Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,53 @@ describe.runIf(shouldRunEval)(
TIMEOUT,
);

test(
"pauses multiple rules through existing rule updates",
async () => {
const { toolCalls, actual } = await runAssistantChat({
emailAccount,
messages: [
{
role: "user",
content:
"Pause both my Marketing and Newsletter rules for now.",
},
],
});

const statusUpdates = toolCalls.filter(
(toolCall) =>
toolCall.toolName === "updateRule" &&
isUpdateRuleInput(toolCall.input) &&
isStatusOnlyEnabledUpdateInput(toolCall.input, false) &&
isSuccessfulOutput(toolCall.output),
);
const updatedRuleNames = statusUpdates.flatMap((toolCall) =>
isUpdateRuleInput(toolCall.input)
? [toolCall.input.ruleName]
: [],
);
const firstUpdateIndex = toolCalls.findIndex(
(toolCall) => toolCall.toolName === "updateRule",
);
const pass =
statusUpdates.length === 2 &&
sameValues(updatedRuleNames, ["Marketing", "Newsletter"]) &&
hasRuleReadBeforeUpdate(toolCalls, firstUpdateIndex) &&
hasNoCreateDeleteOrLegacyRuleMutations(toolCalls);

evalReporter.record({
testName: "multiple pauses use existing rule updates",
model: model.label,
pass,
actual,
});

expect(pass).toBe(true);
},
TIMEOUT,
);

test(
"deletes a rule through the confirmation-gated delete tool",
async () => {
Expand Down Expand Up @@ -753,6 +800,13 @@ function isDeleteRuleInput(input: unknown): input is { ruleName: string } {
return typeof value.ruleName === "string";
}

function sameValues(actual: string[], expected: string[]) {
return (
actual.length === expected.length &&
expected.every((value) => actual.includes(value))
);
}

function patchHasActionType(input: UpdateRuleInput, actionType: ActionType) {
return input.updates.actions?.some((action) => action.type === actionType);
}
Expand Down
90 changes: 88 additions & 2 deletions apps/web/providers/ChatProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { act, render, waitFor } from "@testing-library/react";
import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ASSISTANT_CHAT_MAX_TEXT_LENGTH } from "@/utils/actions/assistant-chat.validation";
import type { MessageContext } from "@/utils/ai/assistant/chat-context-validation";
import { ChatProvider, useChat } from "./ChatProvider";

const {
Expand Down Expand Up @@ -46,8 +47,8 @@ vi.mock("@ai-sdk/react", () => ({
messages: [],
status: "ready",
setMessages: mockSetMessages,
sendMessage: (...args: unknown[]) =>
mockSendMessage(...args).catch((error) => {
sendMessage: (message: unknown, requestOptions?: { body?: unknown }) =>
mockSendMessage(message, requestOptions).catch((error) => {
options.onError?.(error);
throw error;
}),
Expand Down Expand Up @@ -148,6 +149,71 @@ describe("ChatProvider", () => {
mockSendMessage.mockResolvedValue(undefined);
});

it("uses attached fix context only for the next message", async () => {
const fixContext = buildFixRuleContext("first-thread");
let latestContext: ReturnType<typeof useChat> | undefined;

function Consumer() {
latestContext = useChat();
return null;
}

renderWithProvider(<Consumer />);

act(() => {
latestContext?.setContext(fixContext);
});
await waitFor(() => {
expect(latestContext?.context).toEqual(fixContext);
});

await act(async () => {
await latestContext?.submitTextMessage("Fix this rule");
});

expect(mockSendMessage.mock.calls[0]?.[1]).toEqual({
body: { context: fixContext },
});
expect(latestContext?.context).toBeNull();

await act(async () => {
await latestContext?.submitTextMessage("Now answer a new question");
});

expect(mockSendMessage.mock.calls[1]?.[1]).toBeUndefined();
});

it("restores consumed fix context when sending fails", async () => {
const fixContext = buildFixRuleContext("failed-thread");
mockSendMessage.mockRejectedValueOnce(new Error("Network request failed"));
let latestContext: ReturnType<typeof useChat> | undefined;

function Consumer() {
latestContext = useChat();
return null;
}

renderWithProvider(<Consumer />);

act(() => {
latestContext?.setContext(fixContext);
});
await waitFor(() => {
expect(latestContext?.context).toEqual(fixContext);
});

await act(async () => {
await expect(
latestContext?.submitTextMessage("Fix this rule"),
).rejects.toThrow("Network request failed");
});

expect(mockSendMessage.mock.calls[0]?.[1]).toEqual({
body: { context: fixContext },
});
expect(latestContext?.context).toEqual(fixContext);
});

it("clears the active chat when the selected email account changes", async () => {
let latestContext: ReturnType<typeof useChat> | undefined;

Expand Down Expand Up @@ -267,3 +333,23 @@ describe("ChatProvider", () => {
function renderWithProvider(children: React.ReactNode) {
return render(<ChatProvider>{children}</ChatProvider>);
}

function buildFixRuleContext(threadId: string): MessageContext {
return {
type: "fix-rule",
message: {
id: `message-${threadId}`,
threadId,
snippet: "Example message",
textPlain: "Example message body",
headers: {
from: "sender@example.com",
to: "recipient@example.com",
subject: "Example subject",
date: "2026-07-31T10:00:00.000Z",
},
},
results: [],
expected: "none",
};
}
29 changes: 25 additions & 4 deletions apps/web/providers/ChatProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export function ChatProvider({ children }: { children: React.ReactNode }) {
const inlineActionsRef = useRef(inlineActions);
const pendingInlineActionsRef = useRef<InlineEmailAction[] | null>(null);
const pendingRequestRef = useRef<PendingChatRequest | null>(null);
const pendingRequestContextRef = useRef<MessageContext | null>(null);
const previousChatIdRef = useRef(chatId);
const previousEmailAccountIdRef = useRef<string | null>(null);

Expand Down Expand Up @@ -118,7 +119,6 @@ export function ChatProvider({ children }: { children: React.ReactNode }) {
body: {
id,
message: messages.at(-1),
context: context ?? undefined,
inlineActions: pendingInlineActionsRef.current ?? undefined,
...body,
},
Expand All @@ -131,6 +131,7 @@ export function ChatProvider({ children }: { children: React.ReactNode }) {
onFinish: async () => {
pendingInlineActionsRef.current = null;
pendingRequestRef.current = null;
pendingRequestContextRef.current = null;
await Promise.all([
mutate("/api/user/rules"),
chatId ? mutate(`/api/chats/${chatId}`) : Promise.resolve(),
Expand All @@ -139,6 +140,10 @@ export function ChatProvider({ children }: { children: React.ReactNode }) {
onError: (error) => {
const pendingRequest = pendingRequestRef.current;
const pendingInlineActions = pendingInlineActionsRef.current;
const pendingContext = pendingRequestContextRef.current;
if (pendingContext) {
setContext((current) => current ?? pendingContext);
}
if (pendingInlineActions?.length) {
setInlineActions((current) =>
pendingInlineActions.reduce(
Expand All @@ -156,6 +161,7 @@ export function ChatProvider({ children }: { children: React.ReactNode }) {
request: pendingRequest,
});
pendingRequestRef.current = null;
pendingRequestContextRef.current = null;
},
});

Expand All @@ -171,8 +177,11 @@ export function ChatProvider({ children }: { children: React.ReactNode }) {
if (previousChatIdRef.current === chatId) return;

previousChatIdRef.current = chatId;
if (pendingRequestRef.current?.chatId === chatId) return;

pendingInlineActionsRef.current = null;
pendingRequestRef.current = null;
pendingRequestContextRef.current = null;
setInlineActions([]);
}, [chatId]);

Expand All @@ -189,6 +198,7 @@ export function ChatProvider({ children }: { children: React.ReactNode }) {
previousEmailAccountIdRef.current = emailAccountId;
pendingInlineActionsRef.current = null;
pendingRequestRef.current = null;
pendingRequestContextRef.current = null;
setChatId(null);
chat.setMessages([]);
setInput("");
Expand Down Expand Up @@ -221,11 +231,14 @@ export function ChatProvider({ children }: { children: React.ReactNode }) {

if (!chatId) setChatId(chat.id);

const requestChatId = chatId ?? chat.id;
const requestContext = context;
pendingRequestRef.current = {
attachmentCount,
chatId: chatId ?? chat.id,
chatId: requestChatId,
textLength,
};
pendingRequestContextRef.current = requestContext;
pendingInlineActionsRef.current = inlineActionsRef.current.length
? inlineActionsRef.current
: null;
Expand All @@ -234,9 +247,17 @@ export function ChatProvider({ children }: { children: React.ReactNode }) {
setInlineActions([]);
}

return chat.sendMessage({ role: "user", parts });
const sendPromise = chat.sendMessage(
{ role: "user", parts },
requestContext ? { body: { context: requestContext } } : undefined,
);
if (requestContext) {
setContext((current) => (current === requestContext ? null : current));
}

return sendPromise;
},
[chat.id, chat.sendMessage, chatId, emailAccountId, setChatId],
[chat.id, chat.sendMessage, chatId, context, emailAccountId, setChatId],
);

const submitTextMessage = useCallback(
Expand Down
53 changes: 41 additions & 12 deletions apps/web/utils/ai/assistant/chat-folder-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,11 @@ describe("chat folder tools", () => {
});
});

it("does not treat folder names containing slashes as paths", async () => {
it.each([
"Client / invoices",
String.raw`Client\invoices`,
`Client${FOLDER_SEPARATOR}invoices`,
])("does not create a root folder from path-like input: %s", async (name) => {
const getOrCreateFolderIdByName = vi.fn().mockResolvedValue("folder-1");

vi.mocked(createEmailProvider).mockResolvedValue(
Expand All @@ -149,17 +153,12 @@ describe("chat folder tools", () => {
const toolInstance = createOrGetFolderTool(toolOptions);

const result = await (toolInstance.execute as any)({
name: "Client / invoices",
name,
});

expect(getOrCreateFolderIdByName).toHaveBeenCalledWith("Client / invoices");
expect(getOrCreateFolderIdByName).not.toHaveBeenCalled();
expect(result).toEqual({
created: true,
folder: {
name: "Client / invoices",
path: "Client / invoices",
childFolderCount: 0,
},
error: "Folder path not found. Use an existing path from listFolders.",
});
});

Expand Down Expand Up @@ -218,7 +217,11 @@ describe("chat folder tools", () => {
});
});

it("accepts slash path aliases for nested folders after exact name matching", async () => {
it.each([
"Operations / Reports",
"Operations/Reports",
String.raw`Operations\Reports`,
])("accepts a path alias for an existing nested folder: %s", async (folderName) => {
const getOrCreateFolderIdByName = vi.fn();
const moveThreadToFolder = vi.fn().mockResolvedValue(undefined);

Expand Down Expand Up @@ -248,7 +251,7 @@ describe("chat folder tools", () => {

const result = await (toolInstance.execute as any)({
threadIds: ["thread-1"],
folderName: "Operations / Reports",
folderName,
});

expect(getOrCreateFolderIdByName).not.toHaveBeenCalled();
Expand All @@ -259,14 +262,40 @@ describe("chat folder tools", () => {
);
expect(result).toEqual({
success: true,
folderName: "Operations / Reports",
folderName,
requestedCount: 1,
successCount: 1,
failedCount: 0,
failedThreadIds: [],
});
});

it("does not create a root folder while moving to an unresolved path", async () => {
const getOrCreateFolderIdByName = vi.fn().mockResolvedValue("folder-1");
const moveThreadToFolder = vi.fn();

vi.mocked(createEmailProvider).mockResolvedValue(
createMockEmailProvider({
getFolders: vi.fn().mockResolvedValue([]),
getOrCreateFolderIdByName,
moveThreadToFolder,
}),
);

const toolInstance = moveThreadsToFolderTool(toolOptions);

const result = await (toolInstance.execute as any)({
threadIds: ["thread-1"],
folderName: String.raw`Operations\Reports`,
});

expect(getOrCreateFolderIdByName).not.toHaveBeenCalled();
expect(moveThreadToFolder).not.toHaveBeenCalled();
expect(result).toEqual({
error: "Folder path not found. Use an existing path from listFolders.",
});
});

it("moves deduped Outlook thread IDs to the resolved folder", async () => {
const getOrCreateFolderIdByName = vi.fn().mockResolvedValue("folder-1");
const moveThreadToFolder = vi.fn().mockResolvedValue(undefined);
Expand Down
Loading
Loading