-
Notifications
You must be signed in to change notification settings - Fork 4
Make manual handoff explicit #10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<typeof vi.fn>; | ||
| let identifyVisitorMutationMock: ReturnType<typeof vi.fn>; | ||
| let submitAiFeedbackMutationMock: ReturnType<typeof vi.fn>; | ||
| let handoffToHumanMutationMock: ReturnType<typeof vi.fn>; | ||
| let generateAiResponseActionMock: ReturnType<typeof vi.fn>; | ||
| let searchSuggestionsActionMock: ReturnType<typeof vi.fn>; | ||
|
|
||
| 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<typeof vi.fn>; | ||
| 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); | ||
| }); | ||
|
Comment on lines
90
to
+105
|
||
|
|
||
| const mockedUseAction = useAction as unknown as ReturnType<typeof vi.fn>; | ||
| 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<typeof vi.fn>; | ||
| 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", | ||
| }) | ||
| ); | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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", | ||
|
Comment on lines
662
to
667
|
||
| 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 }; | ||
| }, | ||
| }); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The fallback copy "Reason not provided by handoff trigger" is duplicated in multiple UI spots (banner + AI review empty state). Consider centralizing this string (e.g., a constant/helper) so future copy changes stay consistent across the inbox UI.