diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/chatgpt-app-renderer.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/chatgpt-app-renderer.tsx index 3c1686125..a2b7ba057 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/chatgpt-app-renderer.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/chatgpt-app-renderer.tsx @@ -884,6 +884,7 @@ export function ChatGPTAppRenderer({ toolName, protocol: "openai-apps", widgetState: initialWidgetState ?? null, + prefersBorder, globals: { theme: themeMode, displayMode: effectiveDisplayMode, @@ -908,6 +909,7 @@ export function ChatGPTAppRenderer({ capabilities, safeAreaInsets, initialWidgetState, + prefersBorder, ]); useEffect(() => { diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx index a9232bfad..3e34d62e8 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx @@ -528,6 +528,7 @@ export function MCPAppsRenderer({ toolName, protocol: "mcp-apps", widgetState: null, // MCP Apps don't have widget state in the same way + prefersBorder, globals: { theme: themeMode, displayMode: effectiveDisplayMode, @@ -547,6 +548,7 @@ export function MCPAppsRenderer({ timeZone, deviceCapabilities, safeAreaInsets, + prefersBorder, ]); // Update globals in debug store when they change diff --git a/mcpjam-inspector/client/src/components/connection/ShareServerDialog.tsx b/mcpjam-inspector/client/src/components/connection/ShareServerDialog.tsx index 90ae0f5b4..c6904a3c8 100644 --- a/mcpjam-inspector/client/src/components/connection/ShareServerDialog.tsx +++ b/mcpjam-inspector/client/src/components/connection/ShareServerDialog.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { + BarChart3, Clock, Copy, Globe, @@ -46,6 +47,7 @@ import { } from "@/hooks/useServerShares"; import { slugify } from "@/lib/shared-server-session"; import { HOSTED_MODE } from "@/lib/config"; +import { ShareUsageDialog } from "./share-usage/ShareUsageDialog"; interface ShareServerDialogProps { isOpen: boolean; @@ -82,12 +84,14 @@ export function ShareServerDialog({ const [email, setEmail] = useState(""); const [isLoading, setIsLoading] = useState(false); const [isMutating, setIsMutating] = useState(false); + const [view, setView] = useState<"settings" | "usage">("settings"); useEffect(() => { if (!isOpen) { setEmail(""); setIsLoading(false); setIsMutating(false); + setView("settings"); return; } @@ -223,227 +227,254 @@ export function ShareServerDialog({ }; return ( - !open && onClose()}> - - - Share “{serverName}” - + <> + !open && onClose()} + > + + + Share “{serverName}” + - {!isAuthenticated ? ( -

- Sign in to manage shared server access. -

- ) : isLoading && !settings ? ( -
- - Loading share settings... -
- ) : !settings ? ( -

- Unable to load sharing settings. -

- ) : ( - <> - {/* Email invite input — top, prominent */} -
- setEmail(e.target.value)} - placeholder="Add people by email" - className="flex-1" - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - void handleInvite(); - } - }} - /> - + {!isAuthenticated ? ( +

+ Sign in to manage shared server access. +

+ ) : isLoading && !settings ? ( +
+ + Loading share settings...
+ ) : !settings ? ( +

+ Unable to load sharing settings. +

+ ) : ( + <> + {/* Email invite input — top, prominent */} +
+ setEmail(e.target.value)} + placeholder="Add people by email" + className="flex-1" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleInvite(); + } + }} + /> + +
- {/* People with access */} -
-

People with access

-
- {/* Current user (owner) */} -
- - - - {displayInitials} - - -
-
-

{displayName}

- - (you) - + {/* People with access */} +
+

People with access

+
+ {/* Current user (owner) */} +
+ + + + {displayInitials} + + +
+
+

{displayName}

+ + (you) + +
+

+ {user?.email} +

-

- {user?.email} -

+ + Owner +
- - Owner - -
- {activeMembers.length === 0 ? ( -

- No one has been invited yet. -

- ) : ( - activeMembers.map((member) => { - const name = member.user?.name || member.email; - const isPending = !member.userId; - const initials = getInitials(name); + {activeMembers.length === 0 ? ( +

+ No one has been invited yet. +

+ ) : ( + activeMembers.map((member) => { + const name = member.user?.name || member.email; + const isPending = !member.userId; + const initials = getInitials(name); - return ( -
- {isPending ? ( -
- -
- ) : ( - - - - {initials} - - - )} -
-

{name}

-

- {member.email} -

-
-
- {isPending && ( - - Pending - + return ( +
+ {isPending ? ( +
+ +
+ ) : ( + + + + {initials} + + )} - +
+

{name}

+

+ {member.email} +

+
+
+ {isPending && ( + + Pending + + )} + +
-
- ); - }) - )} -
-
- - {/* General access */} -
-

General access

-
-
- {settings.mode === "any_signed_in_with_link" ? ( - - ) : ( - + ); + }) )}
-
- -

- {settings.mode === "any_signed_in_with_link" - ? "Anyone signed in with the link can chat" - : "Only invited people can chat"} -

+ {settings.mode === "any_signed_in_with_link" ? ( + + ) : ( + + )} +
+
+ +

+ {settings.mode === "any_signed_in_with_link" + ? "Anyone signed in with the link can chat" + : "Only invited people can chat"} +

+
-
- + - {/* Footer: Copy link + Done */} -
-
- - - - - - - - Reset link (invalidates current link) - - - + {/* Footer: Copy link + Usage + Done */} +
+
+ + + + + + + + Reset link (invalidates current link) + + + +
+
+ {settings?.shareId && ( + + )} + +
- -
- - )} - -
+ + )} +
+
+ {settings?.shareId && ( + setView("settings")} + shareId={settings.shareId} + serverName={serverName} + /> + )} + ); } diff --git a/mcpjam-inspector/client/src/components/connection/share-usage/ShareUsageDialog.tsx b/mcpjam-inspector/client/src/components/connection/share-usage/ShareUsageDialog.tsx new file mode 100644 index 000000000..33bc03b14 --- /dev/null +++ b/mcpjam-inspector/client/src/components/connection/share-usage/ShareUsageDialog.tsx @@ -0,0 +1,96 @@ +import { useEffect, useState } from "react"; +import { ArrowLeft, MessageSquare } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable"; +import { ShareUsageThreadList } from "./ShareUsageThreadList"; +import { ShareUsageThreadDetail } from "./ShareUsageThreadDetail"; + +interface ShareUsageDialogProps { + isOpen: boolean; + onClose: () => void; + onBackToSettings: () => void; + shareId: string; + serverName: string; +} + +export function ShareUsageDialog({ + isOpen, + onClose, + onBackToSettings, + shareId, + serverName, +}: ShareUsageDialogProps) { + const [selectedThreadId, setSelectedThreadId] = useState(null); + + useEffect(() => { + setSelectedThreadId(null); + }, [shareId]); + + return ( + { + if (!open) onClose(); + }} + > + + +
+ + + Usage — {serverName} + +
+
+ +
+ + +
+ +
+
+ + +
+ {selectedThreadId ? ( + + ) : ( +
+
+ +

+ Select a conversation to view +

+
+
+ )} +
+
+
+
+
+
+ ); +} diff --git a/mcpjam-inspector/client/src/components/connection/share-usage/ShareUsageThreadDetail.tsx b/mcpjam-inspector/client/src/components/connection/share-usage/ShareUsageThreadDetail.tsx new file mode 100644 index 000000000..34f81538b --- /dev/null +++ b/mcpjam-inspector/client/src/components/connection/share-usage/ShareUsageThreadDetail.tsx @@ -0,0 +1,225 @@ +import { useEffect, useMemo, useState } from "react"; +import { formatDistanceToNow } from "date-fns"; +import { Loader2, MessageSquare } from "lucide-react"; +import type { ModelDefinition, ModelProvider } from "@/shared/types"; +import { MessageView } from "@/components/chat-v2/thread/message-view"; +import { + adaptTraceToUiMessages, + type TraceWidgetSnapshot, +} from "@/components/evals/trace-viewer-adapter"; +import { + useSharedChatThread, + useSharedChatWidgetSnapshots, +} from "@/hooks/useSharedChatThreads"; + +const NOOP = (..._args: unknown[]) => {}; + +interface ShareUsageThreadDetailProps { + threadId: string; +} + +export function ShareUsageThreadDetail({ + threadId, +}: ShareUsageThreadDetailProps) { + const { thread } = useSharedChatThread({ threadId }); + const { snapshots } = useSharedChatWidgetSnapshots({ threadId }); + const [messages, setMessages] = useState(null); + const [isLoadingMessages, setIsLoadingMessages] = useState(false); + const [error, setError] = useState(null); + + // Fetch messages from blob URL + useEffect(() => { + if (!thread?.messagesBlobUrl) { + setMessages(null); + return; + } + + let isActive = true; + const controller = new AbortController(); + + async function fetchMessages() { + setIsLoadingMessages(true); + setError(null); + try { + const response = await fetch(thread!.messagesBlobUrl!, { + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`Failed to fetch messages: ${response.status}`); + } + const data = await response.json(); + if (isActive) { + setMessages(data); + } + } catch (err) { + if (!isActive) return; + if (err instanceof DOMException && err.name === "AbortError") return; + console.error("Failed to load thread messages:", err); + setError( + err instanceof Error ? err.message : "Failed to load messages", + ); + } finally { + if (isActive) { + setIsLoadingMessages(false); + } + } + } + + void fetchMessages(); + return () => { + isActive = false; + controller.abort(); + }; + }, [thread?.messagesBlobUrl]); + + // Transform snapshots to TraceWidgetSnapshot format + const widgetSnapshots: TraceWidgetSnapshot[] = useMemo(() => { + if (!snapshots || !thread) return []; + return snapshots.map((snap) => { + // Reconstruct toolMetadata so detectUIType returns the correct widget type. + // Without this, PartSwitch won't enter the widget rendering path. + const toolMetadata: Record = + snap.uiType === "mcp-apps" && snap.resourceUri + ? { ui: { resourceUri: snap.resourceUri } } + : snap.uiType === "openai-apps" + ? { "openai/outputTemplate": "__cached__" } + : {}; + + return { + toolCallId: snap.toolCallId, + toolName: snap.toolName, + protocol: snap.uiType, + serverId: thread.serverId, + resourceUri: snap.resourceUri ?? "", + toolMetadata, + widgetCsp: snap.widgetCsp, + widgetPermissions: snap.widgetPermissions, + widgetPermissive: snap.widgetPermissive, + prefersBorder: snap.prefersBorder, + widgetHtmlUrl: snap.widgetHtmlUrl, + }; + }); + }, [snapshots, thread]); + + // Adapt trace to UI messages + const adaptedTrace = useMemo(() => { + if (!messages) return null; + return adaptTraceToUiMessages({ + trace: { messages: messages as any, widgetSnapshots }, + }); + }, [messages, widgetSnapshots]); + + const resolvedModel: ModelDefinition = useMemo( + () => ({ + id: thread?.modelId ?? "unknown", + name: thread?.modelId ?? "Unknown", + provider: "custom" as ModelProvider, + }), + [thread?.modelId], + ); + + // Loading state: thread query or messages fetch + if (thread === undefined || isLoadingMessages) { + return ( +
+ +
+ ); + } + + if (thread === null) { + return ( +
+

Thread not found

+
+ ); + } + + if (error) { + return ( +
+

{error}

+
+ ); + } + + if (!adaptedTrace || adaptedTrace.messages.length === 0) { + return ( +
+

No messages in thread

+
+ ); + } + + const duration = + thread.lastActivityAt && thread.startedAt + ? thread.lastActivityAt - thread.startedAt + : 0; + const durationStr = + duration > 0 + ? duration < 60000 + ? `${Math.round(duration / 1000)}s` + : `${Math.round(duration / 60000)}m` + : null; + + return ( +
+ {/* Thread header */} +
+
+
+

+ {thread.visitorDisplayName} +

+
+ {thread.modelId} + · + + + {thread.messageCount} messages + + {durationStr && ( + <> + · + {durationStr} + + )} + · + + {formatDistanceToNow(new Date(thread.startedAt), { + addSuffix: true, + })} + +
+
+
+
+ + {/* Messages */} +
+
+ {adaptedTrace.messages.map((message) => ( + + ))} +
+
+
+ ); +} diff --git a/mcpjam-inspector/client/src/components/connection/share-usage/ShareUsageThreadList.tsx b/mcpjam-inspector/client/src/components/connection/share-usage/ShareUsageThreadList.tsx new file mode 100644 index 000000000..4b313d1d8 --- /dev/null +++ b/mcpjam-inspector/client/src/components/connection/share-usage/ShareUsageThreadList.tsx @@ -0,0 +1,118 @@ +import { formatDistanceToNow } from "date-fns"; +import { MessageSquare } from "lucide-react"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + useSharedChatThreadList, + type SharedChatThread, +} from "@/hooks/useSharedChatThreads"; + +interface ShareUsageThreadListProps { + shareId: string; + selectedThreadId: string | null; + onSelectThread: (threadId: string) => void; +} + +export function ShareUsageThreadList({ + shareId, + selectedThreadId, + onSelectThread, +}: ShareUsageThreadListProps) { + const { threads } = useSharedChatThreadList({ shareId }); + + if (threads === undefined) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+
+
+ ))} +
+ ); + } + + if (threads.length === 0) { + return ( +
+
+ +

+ No conversations yet +

+

+ Visitor conversations will appear here +

+
+
+ ); + } + + return ( + +
+ {threads.map((thread) => ( + onSelectThread(thread._id)} + /> + ))} +
+
+ ); +} + +function ThreadCard({ + thread, + isSelected, + onSelect, +}: { + thread: SharedChatThread; + isSelected: boolean; + onSelect: () => void; +}) { + return ( + + ); +} diff --git a/mcpjam-inspector/client/src/hooks/__tests__/use-chat-session.hosted.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/use-chat-session.hosted.test.tsx new file mode 100644 index 000000000..17413c80d --- /dev/null +++ b/mcpjam-inspector/client/src/hooks/__tests__/use-chat-session.hosted.test.tsx @@ -0,0 +1,150 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useChatSession } from "../use-chat-session"; + +const mockState = vi.hoisted(() => ({ + sendMessage: vi.fn(), + stop: vi.fn(), + setMessages: vi.fn(), + addToolApprovalResponse: vi.fn(), + getAccessToken: vi.fn(async () => "access-token"), + hasToken: vi.fn(() => false), + getToken: vi.fn(() => ""), + getOpenRouterSelectedModels: vi.fn(() => []), + getOllamaBaseUrl: vi.fn(() => "http://127.0.0.1:11434"), + getAzureBaseUrl: vi.fn(() => ""), + getCustomProviderByName: vi.fn(), + setSelectedModelId: vi.fn(), + useSharedChatWidgetCapture: vi.fn(), + detectOllamaModels: vi.fn(async () => ({ + isRunning: false, + availableModels: [], + })), + detectOllamaToolCapableModels: vi.fn(async () => []), + getToolsMetadata: vi.fn(async () => ({ + metadata: {}, + toolServerMap: {}, + tokenCounts: null, + })), + countTextTokens: vi.fn(async () => null), +})); +let lastTransportOptions: any; + +const baseModel = { + id: "gpt-4.1-mini", + name: "GPT-4.1 Mini", + provider: "openai" as const, +}; + +vi.mock("@/lib/config", () => ({ + HOSTED_MODE: true, +})); + +vi.mock("@/components/chat-v2/shared/model-helpers", () => ({ + buildAvailableModels: vi.fn(() => [baseModel]), + getDefaultModel: vi.fn(() => baseModel), +})); + +vi.mock("@/hooks/use-ai-provider-keys", () => ({ + useAiProviderKeys: () => ({ + hasToken: mockState.hasToken, + getToken: mockState.getToken, + getOpenRouterSelectedModels: mockState.getOpenRouterSelectedModels, + getOllamaBaseUrl: mockState.getOllamaBaseUrl, + getAzureBaseUrl: mockState.getAzureBaseUrl, + }), +})); + +vi.mock("@/hooks/use-custom-providers", () => ({ + useCustomProviders: () => ({ + customProviders: [], + getCustomProviderByName: mockState.getCustomProviderByName, + }), +})); + +vi.mock("@/hooks/use-persisted-model", () => ({ + usePersistedModel: () => ({ + selectedModelId: "gpt-4.1-mini", + setSelectedModelId: mockState.setSelectedModelId, + }), +})); + +vi.mock("@/hooks/useSharedChatWidgetCapture", () => ({ + useSharedChatWidgetCapture: mockState.useSharedChatWidgetCapture, +})); + +vi.mock("@/lib/ollama-utils", () => ({ + detectOllamaModels: mockState.detectOllamaModels, + detectOllamaToolCapableModels: mockState.detectOllamaToolCapableModels, +})); + +vi.mock("@/lib/apis/mcp-tools-api", () => ({ + getToolsMetadata: mockState.getToolsMetadata, +})); + +vi.mock("@/lib/apis/mcp-tokenizer-api", () => ({ + countTextTokens: mockState.countTextTokens, +})); + +vi.mock("@/lib/session-token", () => ({ + getAuthHeaders: vi.fn(() => ({})), +})); + +vi.mock("@workos-inc/authkit-react", () => ({ + useAuth: () => ({ + getAccessToken: mockState.getAccessToken, + }), +})); + +vi.mock("convex/react", () => ({ + useConvexAuth: () => ({ + isAuthenticated: true, + isLoading: false, + }), +})); + +vi.mock("@ai-sdk/react", () => ({ + useChat: vi.fn(() => ({ + messages: [], + sendMessage: mockState.sendMessage, + stop: mockState.stop, + status: "ready", + error: undefined, + setMessages: mockState.setMessages, + addToolApprovalResponse: mockState.addToolApprovalResponse, + })), +})); + +vi.mock("ai", () => ({ + DefaultChatTransport: class MockTransport { + constructor(options: unknown) { + lastTransportOptions = options; + } + }, + generateId: vi.fn(() => "chat-session-id"), + lastAssistantMessageIsCompleteWithApprovalResponses: vi.fn(), +})); + +describe("useChatSession hosted mode", () => { + it("includes chatSessionId in the hosted transport body", async () => { + const { result, unmount } = renderHook(() => + useChatSession({ + selectedServers: ["server-1"], + hostedWorkspaceId: "workspace-1", + hostedSelectedServerIds: ["server-id-1"], + hostedShareToken: "share-token", + }), + ); + + const body = lastTransportOptions.body(); + expect(result.current.chatSessionId).toBe("chat-session-id"); + expect(body).toMatchObject({ + workspaceId: "workspace-1", + chatSessionId: "chat-session-id", + selectedServerIds: ["server-id-1"], + shareToken: "share-token", + accessScope: "chat_v2", + }); + unmount(); + }); +}); diff --git a/mcpjam-inspector/client/src/hooks/__tests__/use-chat-session.minimal-mode.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/use-chat-session.minimal-mode.test.tsx index af37c09ba..1fca8cfc1 100644 --- a/mcpjam-inspector/client/src/hooks/__tests__/use-chat-session.minimal-mode.test.tsx +++ b/mcpjam-inspector/client/src/hooks/__tests__/use-chat-session.minimal-mode.test.tsx @@ -68,6 +68,10 @@ vi.mock("@/lib/session-token", () => ({ getAuthHeaders: vi.fn(() => ({})), })); +vi.mock("@/hooks/useSharedChatWidgetCapture", () => ({ + useSharedChatWidgetCapture: vi.fn(), +})); + vi.mock("@workos-inc/authkit-react", () => ({ useAuth: () => ({ getAccessToken: vi.fn(async () => null), diff --git a/mcpjam-inspector/client/src/hooks/__tests__/useSharedChatWidgetCapture.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/useSharedChatWidgetCapture.test.tsx new file mode 100644 index 000000000..27a08aab4 --- /dev/null +++ b/mcpjam-inspector/client/src/hooks/__tests__/useSharedChatWidgetCapture.test.tsx @@ -0,0 +1,271 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { useSharedChatWidgetCapture } from "../useSharedChatWidgetCapture"; +import { useWidgetDebugStore } from "@/stores/widget-debug-store"; + +const mockGenerateSnapshotUploadUrl = vi.fn(); +const mockCreateWidgetSnapshot = vi.fn(); + +vi.mock("convex/react", () => ({ + useMutation: (name: string) => { + if (name === "sharedChatThreads:generateSnapshotUploadUrl") { + return mockGenerateSnapshotUploadUrl; + } + if (name === "sharedChatThreads:createWidgetSnapshot") { + return mockCreateWidgetSnapshot; + } + throw new Error(`Unexpected mutation: ${name}`); + }, +})); + +const originalFetch = global.fetch; + +async function flushMicrotasks() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe("useSharedChatWidgetCapture", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + useWidgetDebugStore.setState({ widgets: new Map() }); + + let uploadCounter = 0; + mockGenerateSnapshotUploadUrl.mockImplementation(async () => { + uploadCounter += 1; + return `https://upload.example.com/${uploadCounter}`; + }); + mockCreateWidgetSnapshot.mockResolvedValue("snapshot-1"); + + global.fetch = vi.fn(async () => { + uploadCounter += 1; + return new Response( + JSON.stringify({ storageId: `blob-${uploadCounter}` }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + }) as typeof fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.useRealTimers(); + }); + + it("uploads widget html and tool payloads for shared chat widgets", async () => { + const { unmount } = renderHook(() => + useSharedChatWidgetCapture({ + enabled: true, + chatSessionId: "chat-session-1", + hostedShareToken: "share-token", + messages: [ + { + id: "assistant-1", + role: "assistant", + parts: [ + { + type: "tool-search", + toolCallId: "call-1", + input: { q: "hello" }, + output: { + result: "world", + _meta: { "openai/outputTemplate": "ui://widget.html" }, + }, + }, + ], + } as any, + ], + }), + ); + + act(() => { + useWidgetDebugStore.setState({ + widgets: new Map([ + [ + "call-1", + { + toolCallId: "call-1", + toolName: "search", + protocol: "openai-apps", + widgetState: null, + prefersBorder: false, + globals: { + theme: "light", + displayMode: "inline", + locale: "en-US", + timeZone: "America/Los_Angeles", + userAgent: { + device: { type: "desktop" }, + capabilities: { hover: true, touch: false }, + }, + safeAreaInsets: { top: 0, right: 0, bottom: 0, left: 0 }, + }, + csp: { + mode: "widget-declared", + connectDomains: ["https://api.example.com"], + resourceDomains: ["https://cdn.example.com"], + violations: [], + }, + widgetHtml: "
Widget
", + updatedAt: Date.now(), + }, + ], + ]), + }); + }); + + act(() => { + vi.advanceTimersByTime(500); + }); + + await flushMicrotasks(); + + expect(mockCreateWidgetSnapshot).toHaveBeenCalledTimes(1); + expect(mockGenerateSnapshotUploadUrl).toHaveBeenCalledTimes(3); + expect(global.fetch).toHaveBeenCalledTimes(3); + expect(mockCreateWidgetSnapshot).toHaveBeenCalledWith({ + shareToken: "share-token", + chatSessionId: "chat-session-1", + toolCallId: "call-1", + toolName: "search", + widgetHtmlBlobId: expect.stringMatching(/^blob-/), + uiType: "openai-apps", + resourceUri: "ui://widget.html", + toolInputBlobId: expect.stringMatching(/^blob-/), + toolOutputBlobId: expect.stringMatching(/^blob-/), + widgetCsp: { + connectDomains: ["https://api.example.com"], + resourceDomains: ["https://cdn.example.com"], + frameDomains: undefined, + baseUriDomains: undefined, + }, + widgetPermissions: undefined, + widgetPermissive: false, + prefersBorder: false, + displayContext: { + theme: "light", + displayMode: "inline", + deviceType: "desktop", + viewport: undefined, + locale: "en-US", + timeZone: "America/Los_Angeles", + capabilities: { hover: true, touch: false }, + safeAreaInsets: { top: 0, right: 0, bottom: 0, left: 0 }, + }, + }); + + unmount(); + }); + + it("dedupes identical widget html and retries when the thread is not ready yet", async () => { + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); + mockCreateWidgetSnapshot + .mockRejectedValueOnce(new Error("Thread not found for chat session")) + .mockResolvedValueOnce("snapshot-1"); + + try { + const { unmount } = renderHook(() => + useSharedChatWidgetCapture({ + enabled: true, + chatSessionId: "chat-session-1", + hostedShareToken: "share-token", + messages: [ + { + id: "assistant-1", + role: "assistant", + parts: [ + { + type: "tool-search", + toolCallId: "call-1", + input: { q: "hello" }, + output: { result: "world" }, + }, + ], + } as any, + ], + }), + ); + + act(() => { + useWidgetDebugStore.setState({ + widgets: new Map([ + [ + "call-1", + { + toolCallId: "call-1", + toolName: "search", + protocol: "mcp-apps", + widgetState: null, + globals: { + theme: "dark", + displayMode: "inline", + }, + widgetHtml: "
Widget
", + updatedAt: Date.now(), + }, + ], + ]), + }); + }); + + act(() => { + vi.advanceTimersByTime(500); + }); + + await flushMicrotasks(); + expect(mockCreateWidgetSnapshot).toHaveBeenCalledTimes(1); + + // Blobs were uploaded on the first attempt + const uploadsAfterFirstAttempt = ( + global.fetch as ReturnType + ).mock.calls.length; + expect(uploadsAfterFirstAttempt).toBe(3); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + await flushMicrotasks(); + expect(mockCreateWidgetSnapshot).toHaveBeenCalledTimes(2); + + // Retry reuses cached blobs — no new uploads + expect(global.fetch).toHaveBeenCalledTimes(uploadsAfterFirstAttempt); + expect(mockGenerateSnapshotUploadUrl).toHaveBeenCalledTimes(3); + + // Same blob IDs should be passed on the retry + const firstCall = mockCreateWidgetSnapshot.mock.calls[0][0]; + const retryCall = mockCreateWidgetSnapshot.mock.calls[1][0]; + expect(retryCall.widgetHtmlBlobId).toBe(firstCall.widgetHtmlBlobId); + expect(retryCall.toolInputBlobId).toBe(firstCall.toolInputBlobId); + expect(retryCall.toolOutputBlobId).toBe(firstCall.toolOutputBlobId); + + act(() => { + useWidgetDebugStore.setState((state) => ({ + widgets: new Map(state.widgets).set("call-1", { + ...state.widgets.get("call-1")!, + csp: { + mode: "permissive", + connectDomains: [], + resourceDomains: [], + violations: [], + }, + }), + })); + }); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(mockCreateWidgetSnapshot).toHaveBeenCalledTimes(2); + unmount(); + } finally { + randomSpy.mockRestore(); + } + }); +}); diff --git a/mcpjam-inspector/client/src/hooks/use-chat-session.ts b/mcpjam-inspector/client/src/hooks/use-chat-session.ts index 854e8656e..32a369946 100644 --- a/mcpjam-inspector/client/src/hooks/use-chat-session.ts +++ b/mcpjam-inspector/client/src/hooks/use-chat-session.ts @@ -49,6 +49,7 @@ import { getToolsMetadata, ToolServerMap } from "@/lib/apis/mcp-tools-api"; import { countTextTokens } from "@/lib/apis/mcp-tokenizer-api"; import { getAuthHeaders as getSessionAuthHeaders } from "@/lib/session-token"; import { HOSTED_MODE } from "@/lib/config"; +import { useSharedChatWidgetCapture } from "@/hooks/useSharedChatWidgetCapture"; export interface UseChatSessionOptions { /** Server names to connect to */ @@ -143,6 +144,38 @@ export interface UseChatSessionReturn { inputDisabled: boolean; } +function isTransientMessage(message: UIMessage): boolean { + if ( + message.role === "system" && + (message as { metadata?: { source?: string } }).metadata?.source === + "server-instruction" + ) { + return true; + } + + return message.id?.startsWith("widget-state-") ?? false; +} + +function shouldForkChatSession( + previousMessages: UIMessage[], + nextMessages: UIMessage[], +): boolean { + const previousPersistentIds = previousMessages + .filter((message) => !isTransientMessage(message)) + .map((message) => message.id); + const nextPersistentIds = nextMessages + .filter((message) => !isTransientMessage(message)) + .map((message) => message.id); + + if (nextPersistentIds.length >= previousPersistentIds.length) { + return false; + } + + return nextPersistentIds.every( + (messageId, index) => messageId === previousPersistentIds[index], + ); +} + function isAuthDeniedError(error: unknown): boolean { if (!error || typeof error !== "object") return false; const withStatus = error as { status?: unknown; message?: unknown }; @@ -205,6 +238,7 @@ export function useChatSession({ const [requireToolApproval, setRequireToolApproval] = useState(false); const requireToolApprovalRef = useRef(requireToolApproval); requireToolApprovalRef.current = requireToolApproval; + const skipNextForkDetectionRef = useRef(false); // Build available models const availableModels = useMemo(() => { @@ -285,6 +319,7 @@ export function useChatSession({ ...(HOSTED_MODE ? { workspaceId: hostedWorkspaceId, + chatSessionId, selectedServerIds: hostedSelectedServerIds, accessScope: "chat_v2" as const, ...(hostedShareToken ? { shareToken: hostedShareToken } : {}), @@ -311,6 +346,7 @@ export function useChatSession({ systemPrompt, selectedServers, hostedWorkspaceId, + chatSessionId, hostedSelectedServerIds, hostedOAuthTokens, hostedShareToken, @@ -324,7 +360,7 @@ export function useChatSession({ stop, status, error, - setMessages, + setMessages: baseSetMessages, addToolApprovalResponse, } = useChat({ id: chatSessionId, @@ -334,6 +370,38 @@ export function useChatSession({ : undefined, }); + useSharedChatWidgetCapture({ + enabled: HOSTED_MODE && !!hostedShareToken && isAuthenticated, + chatSessionId, + hostedShareToken, + messages, + }); + + const setMessages = useCallback< + React.Dispatch> + >( + (updater) => { + baseSetMessages((previousMessages) => { + const nextMessages = + typeof updater === "function" ? updater(previousMessages) : updater; + const shouldSkipForkDetection = skipNextForkDetectionRef.current; + skipNextForkDetectionRef.current = false; + + if ( + !shouldSkipForkDetection && + shouldForkChatSession(previousMessages, nextMessages) + ) { + queueMicrotask(() => { + setChatSessionId(generateId()); + }); + } + + return nextMessages; + }); + }, + [baseSetMessages], + ); + // Wrapped sendMessage that accepts FileUIPart[] const sendMessage = useCallback( (options: { @@ -358,6 +426,7 @@ export function useChatSession({ // Reset chat const resetChat = useCallback(() => { + skipNextForkDetectionRef.current = true; setChatSessionId(generateId()); setMessages([]); onResetRef.current?.(); @@ -383,6 +452,7 @@ export function useChatSession({ // Reset chat to force new session with updated auth headers // This ensures the transport is recreated with the correct headers if (active) { + skipNextForkDetectionRef.current = true; setChatSessionId(generateId()); setMessages([]); onResetRef.current?.(); diff --git a/mcpjam-inspector/client/src/hooks/useSharedChatThreads.ts b/mcpjam-inspector/client/src/hooks/useSharedChatThreads.ts new file mode 100644 index 000000000..e05b7aa0e --- /dev/null +++ b/mcpjam-inspector/client/src/hooks/useSharedChatThreads.ts @@ -0,0 +1,65 @@ +import { useQuery } from "convex/react"; + +export interface SharedChatThread { + _id: string; + shareId: string; + chatSessionId: string; + serverId: string; + visitorUserId: string; + visitorDisplayName: string; + modelId: string; + messageCount: number; + firstMessagePreview: string; + startedAt: number; + lastActivityAt: number; + messagesBlobUrl?: string; +} + +export interface SharedChatWidgetSnapshot { + _id: string; + threadId: string; + toolCallId: string; + toolName: string; + uiType: "mcp-apps" | "openai-apps"; + resourceUri?: string; + widgetCsp: Record | null; + widgetPermissions: Record | null; + widgetPermissive: boolean; + prefersBorder: boolean; + widgetHtmlUrl?: string | null; +} + +export function useSharedChatThreadList({ + shareId, +}: { + shareId: string | null; +}) { + const threads = useQuery( + "sharedChatThreads:listByShare" as any, + shareId ? ({ shareId, limit: 50 } as any) : "skip", + ) as SharedChatThread[] | undefined; + + return { threads }; +} + +export function useSharedChatThread({ threadId }: { threadId: string | null }) { + const thread = useQuery( + "sharedChatThreads:getThread" as any, + threadId ? ({ threadId } as any) : "skip", + ) as SharedChatThread | null | undefined; + + return { thread }; +} + +export function useSharedChatWidgetSnapshots({ + threadId, +}: { + threadId: string | null; +}) { + const snapshots = useQuery( + "sharedChatThreads:getWidgetSnapshots" as any, + threadId ? ({ threadId } as any) : "skip", + ) as SharedChatWidgetSnapshot[] | undefined; + + return { snapshots }; +} diff --git a/mcpjam-inspector/client/src/hooks/useSharedChatWidgetCapture.ts b/mcpjam-inspector/client/src/hooks/useSharedChatWidgetCapture.ts new file mode 100644 index 000000000..ea842c6c5 --- /dev/null +++ b/mcpjam-inspector/client/src/hooks/useSharedChatWidgetCapture.ts @@ -0,0 +1,376 @@ +import { useEffect, useRef } from "react"; +import { useMutation } from "convex/react"; +import type { UIMessage } from "@ai-sdk/react"; +import type { DisplayContext, WidgetCsp } from "./useViews"; +import { detectUIType, getUIResourceUri } from "@/lib/mcp-ui/mcp-apps-utils"; +import { readToolResultMeta } from "@/lib/tool-result-utils"; +import { + useWidgetDebugStore, + type WidgetDebugInfo, +} from "@/stores/widget-debug-store"; + +interface UseSharedChatWidgetCaptureOptions { + enabled: boolean; + chatSessionId: string; + hostedShareToken?: string; + messages: UIMessage[]; +} + +interface ToolSnapshotSource { + toolName: string; + input: unknown; + rawOutput: unknown; + resourceUri?: string; +} + +function hashString(value: string): string { + let hash = 5381; + for (let i = 0; i < value.length; i++) { + hash = (hash * 33) ^ value.charCodeAt(i); + } + return `${hash >>> 0}`; +} + +function isToolLikePart(part: unknown): part is { + type: string; + toolCallId?: string; + toolName?: string; + input?: unknown; + output?: unknown; +} { + if (!part || typeof part !== "object") { + return false; + } + + const type = (part as { type?: unknown }).type; + return ( + type === "dynamic-tool" || + (typeof type === "string" && type.startsWith("tool-")) + ); +} + +function getToolNameFromPart(part: { + type: string; + toolName?: string; +}): string { + if (part.type === "dynamic-tool" && part.toolName) { + return part.toolName; + } + return part.type.replace(/^tool-/, "") || "unknown"; +} + +function buildToolSourceMap( + messages: UIMessage[], +): Map { + const toolSources = new Map(); + + for (const message of messages) { + for (const part of message.parts ?? []) { + if (!isToolLikePart(part) || typeof part.toolCallId !== "string") { + continue; + } + + const rawOutput = part.output; + const toolMeta = readToolResultMeta(rawOutput); + toolSources.set(part.toolCallId, { + toolName: getToolNameFromPart(part), + input: part.input ?? null, + rawOutput, + resourceUri: + getUIResourceUri(detectUIType(toolMeta, rawOutput), toolMeta) ?? + undefined, + }); + } + } + + return toolSources; +} + +function toDisplayContext( + globals: WidgetDebugInfo["globals"], +): DisplayContext | undefined { + if (!globals) { + return undefined; + } + + const deviceType = globals.userAgent?.device?.type; + const capabilities = + globals.userAgent?.capabilities ?? globals.deviceCapabilities; + const safeAreaInsets = globals.safeAreaInsets ?? globals.safeArea?.insets; + + return { + theme: globals.theme, + displayMode: globals.displayMode, + deviceType: + deviceType === "mobile" || + deviceType === "tablet" || + deviceType === "desktop" + ? deviceType + : undefined, + viewport: + typeof globals.maxWidth === "number" && + typeof globals.maxHeight === "number" + ? { width: globals.maxWidth, height: globals.maxHeight } + : undefined, + locale: globals.locale, + timeZone: globals.timeZone, + capabilities: capabilities + ? { + hover: capabilities.hover, + touch: capabilities.touch, + } + : undefined, + safeAreaInsets: safeAreaInsets + ? { + top: safeAreaInsets.top, + right: safeAreaInsets.right, + bottom: safeAreaInsets.bottom, + left: safeAreaInsets.left, + } + : undefined, + }; +} + +function toWidgetCsp(widget: WidgetDebugInfo): WidgetCsp | undefined { + const csp = widget.csp; + if (!csp) { + return undefined; + } + + return { + connectDomains: csp.connectDomains, + resourceDomains: csp.resourceDomains, + frameDomains: csp.frameDomains, + baseUriDomains: csp.baseUriDomains, + }; +} + +export function useSharedChatWidgetCapture({ + enabled, + chatSessionId, + hostedShareToken, + messages, +}: UseSharedChatWidgetCaptureOptions): void { + const widgets = useWidgetDebugStore((state) => state.widgets); + const generateSnapshotUploadUrl = useMutation( + "sharedChatThreads:generateSnapshotUploadUrl" as any, + ); + const createWidgetSnapshot = useMutation( + "sharedChatThreads:createWidgetSnapshot" as any, + ); + + const uploadedHashesRef = useRef(new Map()); + const inFlightRef = useRef(new Set()); + const pendingTimersRef = useRef( + new Map>(), + ); + const cachedBlobsRef = useRef( + new Map< + string, + { + htmlHash: string; + widgetHtmlBlobId: string; + toolInputBlobId: string; + toolOutputBlobId: string; + } + >(), + ); + const retryCountRef = useRef(new Map()); + const toolSourcesRef = useRef(buildToolSourceMap(messages)); + const widgetsRef = useRef(widgets); + const sessionIdRef = useRef(chatSessionId); + const shareTokenRef = useRef(hostedShareToken); + const uploadAttemptRef = useRef<(toolCallId: string) => Promise>( + async () => {}, + ); + + useEffect(() => { + toolSourcesRef.current = buildToolSourceMap(messages); + }, [messages]); + + useEffect(() => { + widgetsRef.current = widgets; + }, [widgets]); + + useEffect(() => { + sessionIdRef.current = chatSessionId; + shareTokenRef.current = hostedShareToken; + uploadedHashesRef.current.clear(); + cachedBlobsRef.current.clear(); + retryCountRef.current.clear(); + + for (const timer of pendingTimersRef.current.values()) { + clearTimeout(timer); + } + pendingTimersRef.current.clear(); + inFlightRef.current.clear(); + }, [chatSessionId, hostedShareToken]); + + useEffect(() => { + return () => { + for (const timer of pendingTimersRef.current.values()) { + clearTimeout(timer); + } + pendingTimersRef.current.clear(); + inFlightRef.current.clear(); + }; + }, []); + + uploadAttemptRef.current = async (toolCallId: string) => { + const shareToken = shareTokenRef.current; + if (!enabled || !shareToken || inFlightRef.current.has(toolCallId)) { + return; + } + + const widget = widgetsRef.current.get(toolCallId); + const toolSource = toolSourcesRef.current.get(toolCallId); + + if (!widget?.widgetHtml || !toolSource) { + return; + } + + const htmlHash = hashString(widget.widgetHtml); + if (uploadedHashesRef.current.get(toolCallId) === htmlHash) { + return; + } + + inFlightRef.current.add(toolCallId); + + const uploadBlob = async ( + content: BlobPart, + contentType: string, + ): Promise => { + const uploadUrl = await generateSnapshotUploadUrl({ shareToken }); + const response = await fetch(uploadUrl, { + method: "POST", + body: new Blob([content], { type: contentType }), + }); + + if (!response.ok) { + throw new Error(`Failed to upload snapshot blob (${response.status})`); + } + + const result = (await response.json()) as { storageId?: string }; + if (!result.storageId) { + throw new Error("Snapshot upload did not return a storageId"); + } + + return result.storageId; + }; + + try { + // Reuse cached blobs if the HTML hash matches (avoids orphaned blobs on retry) + let cached = cachedBlobsRef.current.get(toolCallId); + if (!cached || cached.htmlHash !== htmlHash) { + const [widgetHtmlBlobId, toolInputBlobId, toolOutputBlobId] = + await Promise.all([ + uploadBlob(widget.widgetHtml, "text/html"), + uploadBlob( + JSON.stringify(toolSource.input ?? null), + "application/json", + ), + uploadBlob( + JSON.stringify(toolSource.rawOutput ?? null), + "application/json", + ), + ]); + cached = { + htmlHash, + widgetHtmlBlobId, + toolInputBlobId, + toolOutputBlobId, + }; + cachedBlobsRef.current.set(toolCallId, cached); + } + + await createWidgetSnapshot({ + shareToken, + chatSessionId: sessionIdRef.current, + toolCallId, + toolName: toolSource.toolName, + widgetHtmlBlobId: cached.widgetHtmlBlobId, + uiType: widget.protocol, + resourceUri: toolSource.resourceUri, + toolInputBlobId: cached.toolInputBlobId, + toolOutputBlobId: cached.toolOutputBlobId, + widgetCsp: toWidgetCsp(widget), + widgetPermissions: widget.csp?.permissions, + widgetPermissive: widget.csp?.mode === "permissive", + prefersBorder: widget.prefersBorder, + displayContext: toDisplayContext(widget.globals), + }); + + uploadedHashesRef.current.set(toolCallId, htmlHash); + cachedBlobsRef.current.delete(toolCallId); + retryCountRef.current.delete(toolCallId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("Thread not found")) { + const retries = retryCountRef.current.get(toolCallId) ?? 0; + if (retries >= 15) { + console.warn( + "[useSharedChatWidgetCapture] Giving up on snapshot for", + toolCallId, + "after", + retries, + "retries", + ); + cachedBlobsRef.current.delete(toolCallId); + retryCountRef.current.delete(toolCallId); + } else { + retryCountRef.current.set(toolCallId, retries + 1); + const existingTimer = pendingTimersRef.current.get(toolCallId); + if (existingTimer) { + clearTimeout(existingTimer); + } + const baseDelay = Math.min(1000 * Math.pow(1.5, retries), 10000); + const delay = baseDelay + Math.random() * baseDelay * 0.5; + const retryTimer = setTimeout(() => { + pendingTimersRef.current.delete(toolCallId); + void uploadAttemptRef.current(toolCallId); + }, delay); + pendingTimersRef.current.set(toolCallId, retryTimer); + } + } else { + console.warn( + "[useSharedChatWidgetCapture] Failed to save snapshot:", + error, + ); + } + } finally { + inFlightRef.current.delete(toolCallId); + } + }; + + useEffect(() => { + if (!enabled || !hostedShareToken) { + return; + } + + for (const [toolCallId, widget] of widgets) { + if (!widget.widgetHtml) { + continue; + } + if (!toolSourcesRef.current.has(toolCallId)) { + continue; + } + + const htmlHash = hashString(widget.widgetHtml); + if (uploadedHashesRef.current.get(toolCallId) === htmlHash) { + continue; + } + + const existingTimer = pendingTimersRef.current.get(toolCallId); + if (existingTimer) { + clearTimeout(existingTimer); + } + + const timer = setTimeout(() => { + pendingTimersRef.current.delete(toolCallId); + void uploadAttemptRef.current(toolCallId); + }, 500); + + pendingTimersRef.current.set(toolCallId, timer); + } + }, [enabled, hostedShareToken, widgets, messages]); +} diff --git a/mcpjam-inspector/client/src/stores/widget-debug-store.ts b/mcpjam-inspector/client/src/stores/widget-debug-store.ts index 9fda9a6c1..7b9dea5d3 100644 --- a/mcpjam-inspector/client/src/stores/widget-debug-store.ts +++ b/mcpjam-inspector/client/src/stores/widget-debug-store.ts @@ -84,6 +84,7 @@ export interface WidgetDebugInfo { protocol: "openai-apps" | "mcp-apps"; widgetState: unknown; globals: WidgetGlobals; + prefersBorder?: boolean; updatedAt: number; /** CSP configuration and violation tracking */ csp?: WidgetCspInfo; @@ -169,6 +170,10 @@ export const useWidgetDebugStore = create((set, get) => ({ theme: "dark", displayMode: "inline", }, + prefersBorder: + info.prefersBorder !== undefined + ? info.prefersBorder + : existing?.prefersBorder, csp: existing?.csp, // Preserve CSP violations across updates widgetHtml: existing?.widgetHtml, // Preserve cached HTML for save view feature modelContext: existing?.modelContext, // Preserve model context across updates @@ -318,6 +323,7 @@ export const useWidgetDebugStore = create((set, get) => ({ protocol: existing?.protocol ?? "mcp-apps", widgetState: existing?.widgetState ?? null, globals: existing?.globals ?? { theme: "dark", displayMode: "inline" }, + prefersBorder: existing?.prefersBorder, csp: existing?.csp, modelContext: existing?.modelContext, widgetHtml: html, diff --git a/mcpjam-inspector/server/routes/mcp/chat-v2.ts b/mcpjam-inspector/server/routes/mcp/chat-v2.ts index 220b40df1..87b08f3c6 100644 --- a/mcpjam-inspector/server/routes/mcp/chat-v2.ts +++ b/mcpjam-inspector/server/routes/mcp/chat-v2.ts @@ -127,7 +127,7 @@ chatV2.post("/", async (c) => { const modelMessages = await convertToModelMessages(messages); return handleMCPJamFreeChatModel({ - messages: scrubMessages(modelMessages as ModelMessage[]), + messages: modelMessages as ModelMessage[], modelId: String(modelDefinition.id), systemPrompt: enhancedSystemPrompt, temperature: resolvedTemperature, diff --git a/mcpjam-inspector/server/routes/web/auth.ts b/mcpjam-inspector/server/routes/web/auth.ts index a4cb71a6e..8251f8407 100644 --- a/mcpjam-inspector/server/routes/web/auth.ts +++ b/mcpjam-inspector/server/routes/web/auth.ts @@ -66,6 +66,7 @@ export const hostedChatSchema = z .object({ workspaceId: z.string().min(1), selectedServerIds: z.array(z.string().min(1)), + chatSessionId: z.string().min(1).optional(), oauthTokens: z.record(z.string(), z.string()).optional(), accessScope: z.enum(["workspace_member", "chat_v2"]).optional(), shareToken: z.string().min(1).optional(), diff --git a/mcpjam-inspector/server/routes/web/chat-v2.ts b/mcpjam-inspector/server/routes/web/chat-v2.ts index 63298b9fd..9361caa97 100644 --- a/mcpjam-inspector/server/routes/web/chat-v2.ts +++ b/mcpjam-inspector/server/routes/web/chat-v2.ts @@ -7,6 +7,7 @@ import { handleMCPJamFreeChatModel } from "../../utils/mcpjam-stream-handler.js" import { isMCPJamProvidedModel } from "@/shared/types"; import { WEB_STREAM_TIMEOUT_MS } from "../../config.js"; import { prepareChatV2 } from "../../utils/chat-v2-orchestration.js"; +import { saveThreadToConvex } from "../../utils/shared-chat-persistence.js"; import { hostedChatSchema, createAuthorizedManager, @@ -98,12 +99,7 @@ chatV2.post("/", async (c) => { throw error; } - const { - allTools, - enhancedSystemPrompt, - resolvedTemperature, - scrubMessages, - } = prepared; + const { allTools, enhancedSystemPrompt, resolvedTemperature } = prepared; if (modelDefinition.id && isMCPJamProvidedModel(modelDefinition.id)) { if (!process.env.CONVEX_HTTP_URL) { @@ -123,7 +119,7 @@ chatV2.post("/", async (c) => { const modelMessages = await convertToModelMessages(messages); return handleMCPJamFreeChatModel({ - messages: scrubMessages(modelMessages as ModelMessage[]), + messages: modelMessages as ModelMessage[], modelId: String(modelDefinition.id), systemPrompt: enhancedSystemPrompt, temperature: resolvedTemperature, @@ -132,6 +128,19 @@ chatV2.post("/", async (c) => { mcpClientManager: manager, selectedServers: selectedServerIds, requireToolApproval, + onConversationComplete: + shareToken && body.chatSessionId + ? async (fullHistory) => { + await saveThreadToConvex({ + chatSessionId: body.chatSessionId!, + shareToken, + bearerToken, + messages: fullHistory, + messageCount: fullHistory.length, + modelId: String(modelDefinition.id), + }); + } + : undefined, onStreamComplete: () => manager.disconnectAllServers(), }); } catch (error) { diff --git a/mcpjam-inspector/server/utils/__tests__/mcpjam-stream-handler.test.ts b/mcpjam-inspector/server/utils/__tests__/mcpjam-stream-handler.test.ts new file mode 100644 index 000000000..fa6a02577 --- /dev/null +++ b/mcpjam-inspector/server/utils/__tests__/mcpjam-stream-handler.test.ts @@ -0,0 +1,264 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { handleMCPJamFreeChatModel } from "../mcpjam-stream-handler"; + +let lastExecution: Promise | null = null; + +const buildSsePayload = (events: any[]) => + `${events + .map((event) => `data: ${JSON.stringify(event)}\n\n`) + .join("")}data: [DONE]\n\n`; + +const createSseResponse = (events: any[]) => { + const encoder = new TextEncoder(); + const payload = buildSsePayload(events); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(payload)); + controller.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); +}; + +vi.mock("ai", async () => { + const actual = await vi.importActual("ai"); + return { + ...actual, + createUIMessageStream: vi.fn(({ execute, onFinish }) => { + const writer = { write: vi.fn() }; + lastExecution = Promise.resolve(execute({ writer })).then(async () => { + await onFinish?.(); + }); + return { getReader: vi.fn() }; + }), + createUIMessageStreamResponse: vi.fn().mockReturnValue( + new Response("{}", { + headers: { "Content-Type": "text/event-stream" }, + }), + ), + }; +}); + +vi.mock("@/shared/http-tool-calls", () => ({ + hasUnresolvedToolCalls: vi.fn().mockReturnValue(false), + executeToolCallsFromMessages: vi.fn(), +})); + +vi.mock("../chat-helpers", () => ({ + scrubMcpAppsToolResultsForBackend: vi.fn((messages) => messages), + scrubChatGPTAppsToolResultsForBackend: vi.fn((messages) => messages), +})); + +vi.mock("../mcpjam-tool-helpers", () => ({ + serializeToolsForConvex: vi.fn(() => []), +})); + +vi.mock("../logger", () => ({ + logger: { + error: vi.fn(), + }, +})); + +describe("mcpjam-stream-handler", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + vi.clearAllMocks(); + lastExecution = null; + process.env.CONVEX_HTTP_URL = "https://test-convex.example.com"; + global.fetch = vi.fn().mockResolvedValue( + createSseResponse([ + { + type: "finish", + finishReason: "stop", + totalUsage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + }, + ]), + ); + }); + + afterEach(() => { + global.fetch = originalFetch; + delete process.env.CONVEX_HTTP_URL; + }); + + it("scrubs backend-only approval parts while preserving full history for completion callbacks", async () => { + const onConversationComplete = vi.fn(); + const messages = [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-1", + toolName: "search", + input: { q: "hello" }, + }, + { + type: "tool-approval-request", + approvalId: "approval-1", + toolCallId: "call-1", + }, + ], + }, + ] as any; + + await handleMCPJamFreeChatModel({ + messages, + modelId: "gpt-4.1-mini", + systemPrompt: "You are helpful", + tools: {}, + mcpClientManager: { + getAllToolsMetadata: vi.fn().mockReturnValue({}), + } as any, + onConversationComplete, + }); + + await lastExecution; + + const fetchBody = JSON.parse( + ((global.fetch as any).mock.calls[0]?.[1]?.body as string) ?? "{}", + ); + const scrubbedMessages = JSON.parse(fetchBody.messages); + + expect(scrubbedMessages).toEqual([ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-1", + toolName: "search", + input: { q: "hello" }, + }, + ], + }, + ]); + expect(onConversationComplete).toHaveBeenCalledWith(messages); + }); + + it("preserves spliced denial tool results in the completed conversation history", async () => { + const onConversationComplete = vi.fn(); + + await handleMCPJamFreeChatModel({ + messages: [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-1", + toolName: "search", + input: { q: "hello" }, + }, + { + type: "tool-approval-request", + approvalId: "approval-1", + toolCallId: "call-1", + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-approval-response", + approvalId: "approval-1", + approved: false, + }, + ], + }, + ] as any, + modelId: "gpt-4.1-mini", + systemPrompt: "You are helpful", + tools: {}, + mcpClientManager: { + getAllToolsMetadata: vi.fn().mockReturnValue({}), + } as any, + requireToolApproval: true, + onConversationComplete, + }); + + await lastExecution; + + const fullHistory = onConversationComplete.mock.calls[0]?.[0]; + expect(fullHistory).toHaveLength(3); + expect(fullHistory[1]).toMatchObject({ + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-1", + toolName: "search", + output: { + type: "error-text", + value: "Tool execution denied by user.", + }, + }, + ], + }); + expect(fullHistory[2]).toMatchObject({ + role: "tool", + content: [ + { + type: "tool-approval-response", + approvalId: "approval-1", + approved: false, + }, + ], + }); + }); + + it("runs teardown even when conversation persistence fails", async () => { + const onConversationComplete = vi + .fn() + .mockRejectedValue(new Error("persist failed")); + const onStreamComplete = vi.fn(); + + await handleMCPJamFreeChatModel({ + messages: [] as any, + modelId: "gpt-4.1-mini", + systemPrompt: "You are helpful", + tools: {}, + mcpClientManager: { + getAllToolsMetadata: vi.fn().mockReturnValue({}), + } as any, + onConversationComplete, + onStreamComplete, + }); + + await lastExecution; + + expect(onConversationComplete).toHaveBeenCalledTimes(1); + expect(onStreamComplete).toHaveBeenCalledTimes(1); + expect(onStreamComplete.mock.invocationCallOrder[0]).toBeGreaterThan( + onConversationComplete.mock.invocationCallOrder[0], + ); + }); + + it("skips conversation persistence after stream errors but still runs teardown", async () => { + const onConversationComplete = vi.fn(); + const onStreamComplete = vi.fn(); + global.fetch = vi.fn().mockRejectedValue(new Error("stream failed")); + + await handleMCPJamFreeChatModel({ + messages: [] as any, + modelId: "gpt-4.1-mini", + systemPrompt: "You are helpful", + tools: {}, + mcpClientManager: { + getAllToolsMetadata: vi.fn().mockReturnValue({}), + } as any, + onConversationComplete, + onStreamComplete, + }); + + await lastExecution; + + expect(onConversationComplete).not.toHaveBeenCalled(); + expect(onStreamComplete).toHaveBeenCalledTimes(1); + }); +}); diff --git a/mcpjam-inspector/server/utils/mcpjam-stream-handler.ts b/mcpjam-inspector/server/utils/mcpjam-stream-handler.ts index 54a116b7f..1984413ac 100644 --- a/mcpjam-inspector/server/utils/mcpjam-stream-handler.ts +++ b/mcpjam-inspector/server/utils/mcpjam-stream-handler.ts @@ -49,6 +49,9 @@ export interface MCPJamHandlerOptions { mcpClientManager: MCPClientManager; selectedServers?: string[]; requireToolApproval?: boolean; + onConversationComplete?: ( + fullHistory: ModelMessage[], + ) => Promise | void; onStreamComplete?: () => Promise | void; } @@ -711,6 +714,7 @@ export async function handleMCPJamFreeChatModel( mcpClientManager, selectedServers, requireToolApproval, + onConversationComplete, onStreamComplete, } = options; @@ -718,6 +722,7 @@ export async function handleMCPJamFreeChatModel( const messageHistory = [...messages]; const usedToolCallIds = collectUsedToolCallIds(messageHistory); let steps = 0; + let runSucceeded = false; const stream = createUIMessageStream({ execute: async ({ writer }) => { @@ -774,6 +779,8 @@ export async function handleMCPJamFreeChatModel( totalUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, } as unknown as UIMessageChunk); } + + runSucceeded = true; } catch (error) { logger.error("[mcpjam-stream-handler] Error in agentic loop", error); writer.write({ @@ -784,12 +791,25 @@ export async function handleMCPJamFreeChatModel( }, onFinish: async () => { try { - await onStreamComplete?.(); - } catch (cleanupError) { - logger.error( - "[mcpjam-stream-handler] Error while running stream cleanup", - cleanupError, - ); + if (runSucceeded) { + try { + await onConversationComplete?.([...messageHistory]); + } catch (persistenceError) { + logger.error( + "[mcpjam-stream-handler] Error while persisting conversation", + persistenceError, + ); + } + } + } finally { + try { + await onStreamComplete?.(); + } catch (cleanupError) { + logger.error( + "[mcpjam-stream-handler] Error while running stream cleanup", + cleanupError, + ); + } } }, }); diff --git a/mcpjam-inspector/server/utils/shared-chat-persistence.ts b/mcpjam-inspector/server/utils/shared-chat-persistence.ts new file mode 100644 index 000000000..a5161fe4b --- /dev/null +++ b/mcpjam-inspector/server/utils/shared-chat-persistence.ts @@ -0,0 +1,108 @@ +import type { ModelMessage } from "@ai-sdk/provider-utils"; +import { logger } from "./logger"; + +const PREVIEW_MAX_LENGTH = 200; + +interface SaveThreadToConvexOptions { + chatSessionId: string; + shareToken: string; + bearerToken: string; + messages: ModelMessage[]; + messageCount: number; + modelId?: string; +} + +function extractTextPreview(content: unknown): string { + if (typeof content === "string") { + return content; + } + if (!Array.isArray(content)) { + return ""; + } + + return content + .map((part) => + part && + typeof part === "object" && + (part as { type?: unknown }).type === "text" && + typeof (part as { text?: unknown }).text === "string" + ? (part as { text: string }).text + : "", + ) + .join(" ") + .trim(); +} + +function getFirstMessagePreview(messages: ModelMessage[]): string { + for (const message of messages) { + if (message.role !== "user") { + continue; + } + + const preview = extractTextPreview( + (message as { content?: unknown }).content, + ) + .trim() + .slice(0, PREVIEW_MAX_LENGTH); + if (preview.length > 0) { + return preview; + } + } + + return ""; +} + +export async function saveThreadToConvex({ + chatSessionId, + shareToken, + bearerToken, + messages, + messageCount, + modelId, +}: SaveThreadToConvexOptions): Promise { + const convexUrl = process.env.CONVEX_HTTP_URL; + if (!convexUrl) { + logger.error( + "[shared-chat-persistence] Missing CONVEX_HTTP_URL while saving thread", + undefined, + { chatSessionId }, + ); + return; + } + + try { + const response = await fetch(`${convexUrl}/shared-chat/save-thread`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${bearerToken}`, + }, + body: JSON.stringify({ + chatSessionId, + shareToken, + messages, + messageCount, + firstMessagePreview: getFirstMessagePreview(messages), + ...(modelId ? { modelId } : {}), + }), + }); + + if (!response.ok) { + logger.error( + "[shared-chat-persistence] Failed to persist shared chat thread", + undefined, + { + chatSessionId, + status: response.status, + responseText: await response.text().catch(() => ""), + }, + ); + } + } catch (error) { + logger.error( + "[shared-chat-persistence] Error while saving shared chat thread", + error, + { chatSessionId }, + ); + } +} diff --git a/mcpjam-inspector/shared/chat-v2.ts b/mcpjam-inspector/shared/chat-v2.ts index f18725e3a..7876b4da8 100644 --- a/mcpjam-inspector/shared/chat-v2.ts +++ b/mcpjam-inspector/shared/chat-v2.ts @@ -3,6 +3,7 @@ import type { ModelDefinition } from "./types"; export interface ChatV2Request { messages: UIMessage[]; + chatSessionId?: string; model?: ModelDefinition; modelId?: string; systemPrompt?: string;