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"');
});