From 28809fed32e42bfaee87bdb423a80ad779ae49f6 Mon Sep 17 00:00:00 2001 From: Jack D Date: Thu, 5 Mar 2026 10:12:34 +0000 Subject: [PATCH 1/3] Make manual handoff explicit --- apps/web/src/app/inbox/page.tsx | 41 +++++++++++-- .../src/components/ConversationView.test.tsx | 58 ++++++++++++++++++- .../src/components/ConversationView.tsx | 2 + packages/convex/convex/aiAgent.ts | 13 +++++ .../convex/tests/aiAgentHandoffPath.test.ts | 3 + 5 files changed, 108 insertions(+), 9 deletions(-) diff --git a/apps/web/src/app/inbox/page.tsx b/apps/web/src/app/inbox/page.tsx index 3df42de..ddb84ef 100644 --- a/apps/web/src/app/inbox/page.tsx +++ b/apps/web/src/app/inbox/page.tsx @@ -1029,6 +1029,19 @@ function InboxContent(): React.JSX.Element | null { )} + {selectedConversation?.aiWorkflow?.state === "handoff" && ( +
+ + AI handoff + + {selectedConversation.aiWorkflow.handoffReason ?? + "Reason not provided by handoff trigger"} + +
+ )} {isCompactViewport && (
) : !orderedAiResponses || orderedAiResponses.length === 0 ? ( -

- No AI responses in this conversation yet. -

+ selectedConversation?.aiWorkflow?.state === "handoff" ? ( +
+

+ Conversation was handed off before an AI response record was stored. +

+

+ Handoff reason:{" "} + {selectedConversation.aiWorkflow.handoffReason ?? + "Reason not provided by handoff trigger"} +

+
+ ) : ( +

+ No AI responses in this conversation yet. +

+ ) ) : ( orderedAiResponses.map( (response: NonNullable[number]) => { diff --git a/apps/widget/src/components/ConversationView.test.tsx b/apps/widget/src/components/ConversationView.test.tsx index 625a9d7..f143e8d 100644 --- a/apps/widget/src/components/ConversationView.test.tsx +++ b/apps/widget/src/components/ConversationView.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { useAction, useMutation, useQuery } from "convex/react"; import { ConversationView } from "./ConversationView"; @@ -64,6 +64,12 @@ describe("ConversationView personas", () => { let messagesResult: MessageFixture[]; let aiResponsesResult: AiResponseFixture[]; let conversationResult: { aiWorkflowState?: "none" | "ai_handled" | "handoff" } | null; + let sendMessageMutationMock: ReturnType; + let identifyVisitorMutationMock: ReturnType; + let submitAiFeedbackMutationMock: ReturnType; + let handoffToHumanMutationMock: ReturnType; + let generateAiResponseActionMock: ReturnType; + let searchSuggestionsActionMock: ReturnType; beforeEach(() => { vi.clearAllMocks(); @@ -73,11 +79,40 @@ describe("ConversationView personas", () => { aiResponsesResult = []; conversationResult = { aiWorkflowState: "none" }; + sendMessageMutationMock = vi.fn().mockResolvedValue(undefined); + identifyVisitorMutationMock = vi.fn().mockResolvedValue(undefined); + submitAiFeedbackMutationMock = vi.fn().mockResolvedValue(undefined); + handoffToHumanMutationMock = vi.fn().mockResolvedValue(undefined); + generateAiResponseActionMock = vi.fn().mockResolvedValue(undefined); + searchSuggestionsActionMock = vi.fn().mockResolvedValue([]); + const mockedUseMutation = useMutation as unknown as ReturnType; - mockedUseMutation.mockReturnValue(vi.fn().mockResolvedValue(undefined)); + mockedUseMutation.mockImplementation((mutationRef: unknown) => { + if (mutationRef === "messages.send") { + return sendMessageMutationMock; + } + if (mutationRef === "visitors.identify") { + return identifyVisitorMutationMock; + } + if (mutationRef === "aiAgent.submitFeedback") { + return submitAiFeedbackMutationMock; + } + if (mutationRef === "aiAgent.handoffToHuman") { + return handoffToHumanMutationMock; + } + return vi.fn().mockResolvedValue(undefined); + }); const mockedUseAction = useAction as unknown as ReturnType; - mockedUseAction.mockReturnValue(vi.fn().mockResolvedValue([])); + mockedUseAction.mockImplementation((actionRef: unknown) => { + if (actionRef === "aiAgentActions.generateResponse") { + return generateAiResponseActionMock; + } + if (actionRef === "suggestions.searchForWidget") { + return searchSuggestionsActionMock; + } + return vi.fn().mockResolvedValue([]); + }); const mockedUseQuery = useQuery as unknown as ReturnType; mockedUseQuery.mockImplementation((queryRef: unknown, args: unknown) => { @@ -277,4 +312,21 @@ describe("ConversationView personas", () => { expect(screen.queryByTestId("widget-waiting-human-divider")).toBeNull(); }); + + it("passes explicit handoff reason when visitor clicks talk to human", async () => { + renderSubject(); + + fireEvent.click(screen.getByTestId("widget-talk-to-human")); + + await waitFor(() => { + expect(handoffToHumanMutationMock).toHaveBeenCalledWith( + expect.objectContaining({ + conversationId: "conv_1", + visitorId: "visitor_1", + sessionToken: "wst_test", + reason: "Visitor clicked Talk to human button", + }) + ); + }); + }); }); diff --git a/apps/widget/src/components/ConversationView.tsx b/apps/widget/src/components/ConversationView.tsx index 106f9a3..91ca516 100644 --- a/apps/widget/src/components/ConversationView.tsx +++ b/apps/widget/src/components/ConversationView.tsx @@ -9,6 +9,7 @@ import { useDebouncedValue } from "../hooks/useDebouncedValue"; import { parseMarkdown } from "../utils/parseMarkdown"; const DEFAULT_HUMAN_AGENT_NAME = "Support"; +const MANUAL_HANDOFF_REASON = "Visitor clicked Talk to human button"; function resolveHumanAgentName(senderName?: string): string { const normalized = senderName?.trim(); @@ -392,6 +393,7 @@ export function ConversationView({ conversationId, visitorId, sessionToken: sessionTokenRef.current ?? undefined, + reason: MANUAL_HANDOFF_REASON, }); } catch (error) { console.error("Failed to handoff to human:", error); diff --git a/packages/convex/convex/aiAgent.ts b/packages/convex/convex/aiAgent.ts index 2b367dc..08f07b6 100644 --- a/packages/convex/convex/aiAgent.ts +++ b/packages/convex/convex/aiAgent.ts @@ -7,6 +7,7 @@ import { type MutationCtx, type QueryCtx, } from "./_generated/server"; +import { internal } from "./_generated/api"; import { Doc, Id } from "./_generated/dataModel"; import { getAuthenticatedUserFromSession } from "./auth"; import { getWorkspaceMembership, requirePermission } from "./permissions"; @@ -660,11 +661,23 @@ export const handoffToHuman = mutation({ await ctx.db.patch(args.conversationId, { status: "open", updatedAt: now, + lastMessageAt: now, + unreadByAgent: (conversation.unreadByAgent || 0) + 1, aiWorkflowState: "handoff", aiHandoffReason: args.reason, aiLastResponseAt: now, }); + await ctx.scheduler.runAfter(0, internal.notifications.notifyNewMessage, { + conversationId: args.conversationId, + messageContent: handoffMessage, + senderType: "bot", + messageId, + senderId: "ai-agent", + sentAt: now, + channel: "chat", + }); + return { messageId, handoffMessage }; }, }); diff --git a/packages/convex/tests/aiAgentHandoffPath.test.ts b/packages/convex/tests/aiAgentHandoffPath.test.ts index 74096fc..31ae31d 100644 --- a/packages/convex/tests/aiAgentHandoffPath.test.ts +++ b/packages/convex/tests/aiAgentHandoffPath.test.ts @@ -7,5 +7,8 @@ describe("aiAgent.handoffToHuman persistence path", () => { expect(source).toContain('senderId: "ai-agent"'); expect(source).not.toContain('senderId: "system"'); + expect(source).toContain("lastMessageAt: now"); + expect(source).toContain("unreadByAgent: (conversation.unreadByAgent || 0) + 1"); + expect(source).toContain("internal.notifications.notifyNewMessage"); }); }); From 6fb872f796b7c74760446396b4bf313e2a408632 Mon Sep 17 00:00:00 2001 From: Jack D Date: Thu, 5 Mar 2026 10:58:48 +0000 Subject: [PATCH 2/3] address small comments --- apps/web/src/app/inbox/page.tsx | 22 +++++++++++++------ .../src/components/ConversationView.test.tsx | 1 + packages/convex/convex/aiAgent.ts | 20 +++++++++++------ .../convex/tests/aiAgentHandoffPath.test.ts | 3 ++- 4 files changed, 31 insertions(+), 15 deletions(-) diff --git a/apps/web/src/app/inbox/page.tsx b/apps/web/src/app/inbox/page.tsx index ddb84ef..41df5ed 100644 --- a/apps/web/src/app/inbox/page.tsx +++ b/apps/web/src/app/inbox/page.tsx @@ -62,6 +62,8 @@ type ConversationUiPatch = { optimisticLastMessage?: string; }; +const HANDOFF_REASON_FALLBACK = "Reason not provided by handoff trigger"; + function sortInboxConversations( left: { _id: string; createdAt: number; lastMessageAt?: number }, right: { _id: string; createdAt: number; lastMessageAt?: number } @@ -86,6 +88,13 @@ function getConversationIdentityLabel(conversation: { }); } +function getHandoffReasonLabel(reason: string | null | undefined): string { + const normalizedReason = reason?.trim(); + return normalizedReason && normalizedReason.length > 0 + ? normalizedReason + : HANDOFF_REASON_FALLBACK; +} + function InboxContent(): React.JSX.Element | null { const router = useRouter(); const searchParams = useSearchParams(); @@ -1037,8 +1046,7 @@ function InboxContent(): React.JSX.Element | null { AI handoff - {selectedConversation.aiWorkflow.handoffReason ?? - "Reason not provided by handoff trigger"} + {getHandoffReasonLabel(selectedConversation.aiWorkflow.handoffReason)}
)} @@ -1523,8 +1531,7 @@ function InboxContent(): React.JSX.Element | null {

Handoff reason:{" "} - {selectedConversation.aiWorkflow.handoffReason ?? - "Reason not provided by handoff trigger"} + {getHandoffReasonLabel(selectedConversation.aiWorkflow.handoffReason)}

) : ( @@ -1656,9 +1663,10 @@ function InboxContent(): React.JSX.Element | null { {response.handedOff && (

Handoff reason:{" "} - {response.handoffReason ?? - selectedConversation?.aiWorkflow?.handoffReason ?? - "Not specified"} + {getHandoffReasonLabel( + response.handoffReason ?? + selectedConversation?.aiWorkflow?.handoffReason + )}

)} diff --git a/apps/widget/src/components/ConversationView.test.tsx b/apps/widget/src/components/ConversationView.test.tsx index f143e8d..e33bf88 100644 --- a/apps/widget/src/components/ConversationView.test.tsx +++ b/apps/widget/src/components/ConversationView.test.tsx @@ -13,6 +13,7 @@ vi.mock("@opencom/convex", () => ({ api: { messages: { list: "messages.list", + send: "messages.send", }, conversations: { get: "conversations.get", diff --git a/packages/convex/convex/aiAgent.ts b/packages/convex/convex/aiAgent.ts index 08f07b6..abb10a1 100644 --- a/packages/convex/convex/aiAgent.ts +++ b/packages/convex/convex/aiAgent.ts @@ -668,14 +668,20 @@ export const handoffToHuman = mutation({ aiLastResponseAt: now, }); - await ctx.scheduler.runAfter(0, internal.notifications.notifyNewMessage, { + await ctx.scheduler.runAfter(0, internal.notifications.routeEvent, { + eventType: "chat_message", + domain: "chat", + audience: "agent", + workspaceId: conversation.workspaceId, + actorType: "bot", conversationId: args.conversationId, - messageContent: handoffMessage, - senderType: "bot", - messageId, - senderId: "ai-agent", - sentAt: now, - channel: "chat", + title: "AI handoff", + body: handoffMessage, + data: { + conversationId: args.conversationId, + type: "ai_handoff", + }, + eventKey: `chat_handoff:${messageId}`, }); return { messageId, handoffMessage }; diff --git a/packages/convex/tests/aiAgentHandoffPath.test.ts b/packages/convex/tests/aiAgentHandoffPath.test.ts index 31ae31d..f2a4ff6 100644 --- a/packages/convex/tests/aiAgentHandoffPath.test.ts +++ b/packages/convex/tests/aiAgentHandoffPath.test.ts @@ -9,6 +9,7 @@ describe("aiAgent.handoffToHuman persistence path", () => { expect(source).not.toContain('senderId: "system"'); expect(source).toContain("lastMessageAt: now"); expect(source).toContain("unreadByAgent: (conversation.unreadByAgent || 0) + 1"); - expect(source).toContain("internal.notifications.notifyNewMessage"); + expect(source).toContain("internal.notifications.routeEvent"); + expect(source).toContain('audience: "agent"'); }); }); From ba8017b7e13eaa19c524daee1dcb9ffc81c9b08c Mon Sep 17 00:00:00 2001 From: Jack D Date: Thu, 5 Mar 2026 11:17:44 +0000 Subject: [PATCH 3/3] Fixes --- packages/convex/convex/aiAgent.ts | 9 +++++---- packages/convex/tests/aiAgentHandoffPath.test.ts | 6 +++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/convex/convex/aiAgent.ts b/packages/convex/convex/aiAgent.ts index abb10a1..b92666b 100644 --- a/packages/convex/convex/aiAgent.ts +++ b/packages/convex/convex/aiAgent.ts @@ -647,22 +647,23 @@ export const handoffToHuman = mutation({ const handoffMessage = settings?.handoffMessage ?? "Let me connect you with a human agent who can help you better."; + const now = Date.now(); + const messageId: Id<"messages"> = await ctx.db.insert("messages", { conversationId: args.conversationId, senderId: "ai-agent", content: handoffMessage, senderType: "bot", - createdAt: Date.now(), + createdAt: now, }); - const now = Date.now(); - // Update conversation to ensure it's open and visible to agents await ctx.db.patch(args.conversationId, { status: "open", updatedAt: now, lastMessageAt: now, - unreadByAgent: (conversation.unreadByAgent || 0) + 1, + // Preserve existing visitor-unread count while ensuring handoff-only threads surface to agents. + unreadByAgent: Math.max(conversation.unreadByAgent || 0, 1), aiWorkflowState: "handoff", aiHandoffReason: args.reason, aiLastResponseAt: now, diff --git a/packages/convex/tests/aiAgentHandoffPath.test.ts b/packages/convex/tests/aiAgentHandoffPath.test.ts index f2a4ff6..94d4db0 100644 --- a/packages/convex/tests/aiAgentHandoffPath.test.ts +++ b/packages/convex/tests/aiAgentHandoffPath.test.ts @@ -7,8 +7,12 @@ describe("aiAgent.handoffToHuman persistence path", () => { expect(source).toContain('senderId: "ai-agent"'); expect(source).not.toContain('senderId: "system"'); + expect(source).toContain("const now = Date.now();"); + expect(source).toContain("createdAt: now"); expect(source).toContain("lastMessageAt: now"); - expect(source).toContain("unreadByAgent: (conversation.unreadByAgent || 0) + 1"); + expect(source).toMatch( + /unreadByAgent:\s*Math\.max\(\s*conversation\.unreadByAgent\s*\|\|\s*0,\s*1\s*\)/ + ); expect(source).toContain("internal.notifications.routeEvent"); expect(source).toContain('audience: "agent"'); });