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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/__tests__/ai-choose-rule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ describe.runIf(isAiTest)("aiChooseRule", () => {
emailAccount: getEmailAccount(),
});

expect(result).toEqual({ rules: [], reason: "" });
expect(result).toEqual({ rules: [], reason: "No rules to evaluate" });
});

test("Should return correct rule when only one rule passed", async () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/__tests__/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export function getEmailAccount(
userId: "user1",
email: overrides.email || "user@test.com",
about: null,
multiRuleSelectionEnabled: false,
multiRuleSelectionEnabled: overrides.multiRuleSelectionEnabled ?? false,
user: {
aiModel: null,
aiProvider: null,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ExecutedRule" ADD COLUMN "matchMetadata" JSONB;
17 changes: 9 additions & 8 deletions apps/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -469,14 +469,15 @@ model RuleHistory {
// Rule/Action models represent the rules and actions that the AI can take.
// ExecutedRule/ExecutedAction models represent the rules/actions that have been planned or executed by the AI.
model ExecutedRule {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
threadId String
messageId String
status ExecutedRuleStatus
automated Boolean
reason String?
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
threadId String
messageId String
status ExecutedRuleStatus
automated Boolean
reason String?
matchMetadata Json? // Stores structured match information (e.g., learned patterns, match types)

// may be null if the rule was deleted
ruleId String?
Expand Down
9 changes: 7 additions & 2 deletions apps/web/utils/ai/choose-rule/ai-choose-rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export async function aiChooseRule<
rules: { rule: T; isPrimary?: boolean }[];
reason: string;
}> {
if (!rules.length) return { rules: [], reason: "" };
if (!rules.length) return { rules: [], reason: "No rules to evaluate" };

const { result: aiResponse } = await getAiResponse({
email,
Expand All @@ -38,7 +38,12 @@ export async function aiChooseRule<
modelType,
});

if (aiResponse.noMatchFound) return { rules: [], reason: "" };
if (aiResponse.noMatchFound) {
return {
rules: [],
reason: aiResponse.reasoning || "AI determined no rules matched",
};
}

const rulesWithMetadata = aiResponse.matchedRules
.map((match) => {
Expand Down
9 changes: 8 additions & 1 deletion apps/web/utils/ai/choose-rule/match-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,12 @@ export async function findMatchingRules({
});

return {
matches: [{ rule: coldRule, matchReasons: [] }],
matches: [
{
rule: coldRule,
matchReasons: [{ type: ConditionType.AI }],
},
],
reasoning: coldEmailResult.reason,
};
}
Expand Down Expand Up @@ -345,6 +350,8 @@ function getMatchReason(matchReasons?: MatchReason[]): string | undefined {
return `Matched learned pattern: "${reason.groupItem.type}: ${reason.groupItem.value}"`;
case ConditionType.PRESET:
return "Matched a system preset";
case ConditionType.AI:
return "Matched via AI";
}
})
.join(", ");
Expand Down
3 changes: 3 additions & 0 deletions apps/web/utils/ai/choose-rule/run-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { executeAct } from "@/utils/ai/choose-rule/execute";
import prisma from "@/utils/prisma";
import { createScopedLogger } from "@/utils/logger";
import type { MatchReason } from "@/utils/ai/choose-rule/types";
import { serializeMatchReasons } from "@/utils/ai/choose-rule/types";
import { sanitizeActionFields } from "@/utils/action-item";
import { extractEmailAddress } from "@/utils/email";
import { filterNullProperties } from "@/utils";
Expand Down Expand Up @@ -94,6 +95,7 @@ export async function runRules({
messageId: message.id,
automated: true,
reason,
matchMetadata: undefined,
status: ExecutedRuleStatus.SKIPPED,
emailAccount: { connect: { id: emailAccount.id } },
},
Expand Down Expand Up @@ -259,6 +261,7 @@ async function executeMatchedRule(
automated: true,
status: ExecutedRuleStatus.APPLYING, // Changed from PENDING - rules are now always automated
reason,
matchMetadata: serializeMatchReasons(matchReasons),
rule: rule?.id ? { connect: { id: rule.id } } : undefined,
emailAccount: { connect: { id: emailAccount.id } },
createdAt: batchTimestamp, // Use batch timestamp for grouping
Expand Down
52 changes: 52 additions & 0 deletions apps/web/utils/ai/choose-rule/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,55 @@ export type MatchingRuleResult = {
instructions: string;
})[];
};

/**
* Serializable version of MatchReason for database storage
*/
export type SerializedMatchReason =
| { type: "STATIC" }
| {
type: "LEARNED_PATTERN";
group: { id: string; name: string };
groupItem: {
id: string;
type: string;
value: string;
exclude: boolean;
};
}
| { type: "AI" }
| { type: "PRESET"; systemType: string };

/**
* Serializes match reasons to a JSON-safe format for database storage
*/
export function serializeMatchReasons(
matchReasons?: MatchReason[],
): SerializedMatchReason[] | undefined {
if (!matchReasons || matchReasons.length === 0) return undefined;

return matchReasons.map((reason): SerializedMatchReason => {
switch (reason.type) {
case "STATIC":
return { type: "STATIC" };
case "LEARNED_PATTERN":
return {
type: "LEARNED_PATTERN",
group: {
id: reason.group.id,
name: reason.group.name,
},
groupItem: {
id: reason.groupItem.id,
type: reason.groupItem.type,
value: reason.groupItem.value,
exclude: reason.groupItem.exclude,
},
};
case "AI":
return { type: "AI" };
case "PRESET":
return { type: "PRESET", systemType: reason.systemType };
}
});
}
8 changes: 6 additions & 2 deletions apps/web/utils/date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,12 @@ export function sortByInternalDate<T extends { internalDate?: string | null }>(
direction: "asc" | "desc" = "asc",
) {
return (a: T, b: T): number => {
const aTime = internalDateToDate(a.internalDate).getTime() || 0;
const bTime = internalDateToDate(b.internalDate).getTime() || 0;
const aTime = a.internalDate
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Oct 29, 2025

Choose a reason for hiding this comment

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

Defaulting falsy internalDate values to 0 changes the comparator semantics: previously internalDateToDate(undefined) returned the current time, so records without an internalDate still sorted chronologically. Now they sort as the Unix epoch, pushing them to the front of ascending lists (and the end of descending ones) and breaking message ordering for those cases.

Prompt for AI agents
Address the following comment on apps/web/utils/date.ts at line 98:

<comment>Defaulting falsy internalDate values to 0 changes the comparator semantics: previously internalDateToDate(undefined) returned the current time, so records without an internalDate still sorted chronologically. Now they sort as the Unix epoch, pushing them to the front of ascending lists (and the end of descending ones) and breaking message ordering for those cases.</comment>

<file context>
@@ -95,8 +95,12 @@ export function sortByInternalDate&lt;T extends { internalDate?: string | null }&gt;(
   return (a: T, b: T): number =&gt; {
-    const aTime = internalDateToDate(a.internalDate).getTime() || 0;
-    const bTime = internalDateToDate(b.internalDate).getTime() || 0;
+    const aTime = a.internalDate
+      ? internalDateToDate(a.internalDate).getTime()
+      : 0;
</file context>
Fix with Cubic

? internalDateToDate(a.internalDate).getTime()
: 0;
const bTime = b.internalDate
? internalDateToDate(b.internalDate).getTime()
: 0;
Comment on lines +98 to +103
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify the behavior change for items without internalDate.

This changes sort behavior:

  • Previously: Missing internalDate was treated as current date (via internalDateToDate(undefined) returning new Date())
  • Now: Missing internalDate is treated as epoch (timestamp 0)

Items without internalDate will now sort to the beginning with asc or end with desc, rather than being treated as "now". Ensure this is intentional and that callers expecting the old behavior are updated.


🏁 Script executed:

#!/bin/bash
# Find all usages of sortByInternalDate to assess impact
rg -n "sortByInternalDate" --type=ts -C3

Length of output: 3256




🏁 Script executed:

#!/bin/bash
# Get full context on internalDateToDate function
rg -n "function internalDateToDate|export.*internalDateToDate" --type=ts -A10

Length of output: 729


🏁 Script executed:

#!/bin/bash
# Check what happens with undefined/null internalDate in actual data flow
rg -n "internalDate\?" --type=ts -B2 -A2 apps/web/utils/date.ts

Length of output: 369


🏁 Script executed:

#!/bin/bash
# Get more context around the outbound.ts logic to understand the latestMessage selection
rg -n "actualLatestMessage" --type=ts -B5 -A5 apps/web/utils/reply-tracker/outbound.ts

Length of output: 779


🏁 Script executed:

#!/bin/bash
# Check if there's validation or filtering for internalDate before sorting
rg -n "internalDate" --type=ts apps/web/utils/reply-tracker/ -B2 -A2 | head -60

Length of output: 1811


🏁 Script executed:

#!/bin/bash
# Get the complete sortByInternalDate function to see the exact comparison logic
cat apps/web/utils/date.ts | sed -n '94,110p'

Length of output: 420




🏁 Script executed:

#!/bin/bash
# Check where threadMessages parameter comes from and if internalDate is guaranteed
rg -n "getLatestMessageFromThread|isLatest.*boolean" --type=ts -B10 apps/web/utils/reply-tracker/outbound.ts | head -100

Length of output: 277


🏁 Script executed:

#!/bin/bash
# Look for where messages are queried/loaded and if internalDate is filtered
rg -n "threadMessages|\.include|\.select" --type=ts apps/web/utils/reply-tracker/ -B2 -A2 | head -80

Length of output: 5901


🏁 Script executed:

#!/bin/bash
# Check the ParsedMessage type to understand internalDate requirement
rg -n "type ParsedMessage|interface ParsedMessage" --type=ts -A15

Length of output: 5774


🏁 Script executed:

#!/bin/bash
# Search for any validation or filtering of internalDate before sorting
rg -n "filter.*internalDate|internalDate.*filter" --type=ts -C3

Length of output: 0


Fix the sort logic or validate internalDate presence before sorting.

This change introduces a critical bug in how "latest" messages are identified. Both outbound.ts (line 117-118) and handle-conversation-status.ts (line 50+) assume that in an ascending sort, the last element is the latest message. This assumption breaks when any message lacks internalDate:

  • Old behavior: Missing internalDate → treated as current date → sorted to end (correct latest)
  • New behavior: Missing internalDate → timestamp 0 → sorted to beginning (incorrect latest)

Since ParsedMessage.internalDate is optional and there's no validation ensuring it exists, any missing date causes actualLatestMessage to be wrong, leading to incorrect "not the latest" warnings or skipped processing.

Required fixes:

  1. Either ensure internalDate is always populated before sorting, or
  2. Change the "latest" detection logic to handle missing dates (e.g., use Date field or fallback to date header), or
  3. Revert to treating missing internalDate as current date if that was intentional
🤖 Prompt for AI Agents
In apps/web/utils/date.ts around lines 98 to 103, the comparator sets missing
internalDate to 0 which pushes messages without internalDate to the beginning
(breaking callers that expect the last item to be the latest); fix by computing
a fallback timestamp for missing internalDate instead of 0 — either populate
internalDate before sorting (use the message Date header or parsed date) or
update the comparator to derive time = internalDate ?
internalDateToDate(internalDate).getTime() : (DateHeaderIfPresentParsedToMs) ||
Date.now(); ensure the fallback makes messages with missing internalDate sort as
latest so the last element in an ascending sort is the actual latest message and
update any related callers if you change the contract.

return direction === "asc" ? aTime - bTime : bTime - aTime;
};
}
2 changes: 1 addition & 1 deletion version.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v2.17.11
v2.17.12
Loading