diff --git a/apps/web/src/app/inbox/page.tsx b/apps/web/src/app/inbox/page.tsx index 3df42de..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(); @@ -1029,6 +1038,18 @@ function InboxContent(): React.JSX.Element | null { )} + {selectedConversation?.aiWorkflow?.state === "handoff" && ( +
+ + AI handoff + + {getHandoffReasonLabel(selectedConversation.aiWorkflow.handoffReason)} + +
+ )} {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:{" "} + {getHandoffReasonLabel(selectedConversation.aiWorkflow.handoffReason)} +

+
+ ) : ( +

+ No AI responses in this conversation yet. +

+ ) ) : ( orderedAiResponses.map( (response: NonNullable[number]) => { @@ -1627,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 625a9d7..e33bf88 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"; @@ -13,6 +13,7 @@ vi.mock("@opencom/convex", () => ({ api: { messages: { list: "messages.list", + send: "messages.send", }, conversations: { get: "conversations.get", @@ -64,6 +65,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 +80,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 +313,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..b92666b 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"; @@ -646,25 +647,44 @@ 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, + // 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, }); + await ctx.scheduler.runAfter(0, internal.notifications.routeEvent, { + eventType: "chat_message", + domain: "chat", + audience: "agent", + workspaceId: conversation.workspaceId, + actorType: "bot", + conversationId: args.conversationId, + 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 74096fc..94d4db0 100644 --- a/packages/convex/tests/aiAgentHandoffPath.test.ts +++ b/packages/convex/tests/aiAgentHandoffPath.test.ts @@ -7,5 +7,13 @@ 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).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"'); }); });