Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions e2e-chatbot-app-next/client/src/components/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { useSearchParams } from 'react-router-dom';
import { useChatVisibility } from '@/hooks/use-chat-visibility';
import { ChatSDKError } from '@chat-template/core/errors';
import { useDataStream } from './data-stream-provider';
import { isCredentialErrorMessage } from '@/lib/oauth-error-utils';
import { ChatTransport } from '../lib/ChatTransport';
import type { ClientSession } from '@chat-template/auth';
import { softNavigateToChatId } from '@/lib/navigation';
Expand Down Expand Up @@ -96,6 +97,7 @@ export function Chat({
sendMessage,
status,
resumeStream,
clearError,
addToolResult,
regenerate,
} = useChat<ChatMessage>({
Expand Down Expand Up @@ -171,7 +173,12 @@ export function Chat({
setUsage(dataPart.data as LanguageModelUsage);
}
},
onFinish: ({ isAbort, isDisconnect, isError }) => {
onFinish: ({
isAbort,
isDisconnect,
isError,
messages: finishedMessages,
}) => {
// Reset state for next message
didFetchHistoryOnNewChat.current = false;

Expand All @@ -183,6 +190,26 @@ export function Chat({
return;
}

// Check if the last message contains an OAuth credential error
// If so, don't try to resume - the user needs to authenticate first
const lastMessage = finishedMessages?.at(-1);
const hasOAuthError = lastMessage?.parts?.some(
(part) =>
part.type === 'data-error' &&
typeof part.data === 'string' &&
isCredentialErrorMessage(part.data),
);

if (hasOAuthError) {
console.log(
'[Chat onFinish] OAuth credential error detected, not resuming',
);
setStreamCursor(0);
fetchChatHistory();
clearError();
return;
}

// Determine if we should attempt to resume:
// 1. Stream didn't end with a 'finish' part (incomplete)
// 2. It was a disconnect/error that terminated the stream
Expand Down Expand Up @@ -259,8 +286,8 @@ export function Chat({
messages={messages}
setMessages={setMessages}
addToolResult={addToolResult}
sendMessage={sendMessage}
regenerate={regenerate}
sendMessage={sendMessage}
isReadonly={isReadonly}
selectedModelId={initialChatModel}
/>
Expand Down
118 changes: 118 additions & 0 deletions e2e-chatbot-app-next/client/src/components/message-oauth-error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { motion } from 'framer-motion';
import { useState } from 'react';
import {
LogIn,
RefreshCw,
ChevronDown,
ChevronUp,
KeyRound,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { UseChatHelpers } from '@ai-sdk/react';
import type { ChatMessage } from '@chat-template/core';
import {
findLoginURLFromCredentialErrorMessage,
findConnectionNameFromCredentialErrorMessage,
} from '@/lib/oauth-error-utils';

interface MessageOAuthErrorProps {
error: string;
allMessages: ChatMessage[];
setMessages: UseChatHelpers<ChatMessage>['setMessages'];
sendMessage: UseChatHelpers<ChatMessage>['sendMessage'];
}

export const MessageOAuthError = ({
error,
allMessages,
setMessages,
sendMessage,
}: MessageOAuthErrorProps) => {
const [showDetails, setShowDetails] = useState(false);

const loginUrl = findLoginURLFromCredentialErrorMessage(error);
const connectionName = findConnectionNameFromCredentialErrorMessage(error);

// If we can't extract the required info, don't render
if (!loginUrl || !connectionName) {
return null;
}

const handleLogin = () => {
window.open(loginUrl, '_blank', 'noopener,noreferrer');
};

const handleRetry = () => {
// Remove the OAuth error part from the last message in an immutable way
const lastMessage = allMessages.at(-1);
if (lastMessage) {
const updatedLastMessageParts = lastMessage.parts.filter(
(p) => p.type !== 'data-error'
);
setMessages([
...allMessages.slice(0, -1),
{ ...lastMessage, parts: updatedLastMessageParts },
]);
}
sendMessage();
};

return (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="relative w-full"
>
<div className="flex w-fit items-center gap-1.5 rounded-t-md border border-amber-500/20 border-b-0 bg-amber-500/5 px-2.5 py-1.5">
<div className="text-amber-600 dark:text-amber-500">
<KeyRound size={14} />
</div>
<span className="font-medium text-amber-600 text-xs dark:text-amber-500">
Login Required
</span>
</div>

<div className="rounded-b-lg rounded-tr-lg border border-amber-500/20 bg-amber-500/5 p-3">
<p className="mb-3 text-foreground text-sm">
To continue, please login to{' '}
<span className="font-medium">{connectionName}</span>
</p>

<div className="mb-2 flex items-center gap-2">
<Button onClick={handleLogin} size="sm" className="gap-1.5">
<LogIn size={14} />
Login
</Button>
<Button
onClick={handleRetry}
variant="outline"
size="sm"
className="gap-1.5"
>
<RefreshCw size={14} />
Retry
</Button>
</div>

<button
onClick={() => setShowDetails(!showDetails)}
className="flex items-center gap-1 text-muted-foreground text-xs transition-colors hover:text-foreground"
type="button"
>
{showDetails ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
{showDetails ? 'Hide details' : 'Show details'}
</button>

{showDetails && (
<motion.pre
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
className="mt-2 overflow-x-auto whitespace-pre-wrap break-words rounded-md bg-muted p-2.5 font-mono text-foreground text-xs"
>
{error}
</motion.pre>
)}
</div>
</motion.div>
);
};
36 changes: 32 additions & 4 deletions e2e-chatbot-app-next/client/src/components/message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import {
joinMessagePartSegments,
} from './databricks-message-part-transformers';
import { MessageError } from './message-error';
import { MessageOAuthError } from './message-oauth-error';
import { isCredentialErrorMessage } from '@/lib/oauth-error-utils';
import { Streamdown } from 'streamdown';
import { DATABRICKS_TOOL_CALL_ID } from '@chat-template/ai-sdk-providers/tools';
import {
Expand All @@ -46,6 +48,7 @@ import { useApproval } from '@/hooks/use-approval';

const PurePreviewMessage = ({
message,
allMessages,
isLoading,
setMessages,
addToolResult,
Expand All @@ -56,6 +59,7 @@ const PurePreviewMessage = ({
}: {
chatId: string;
message: ChatMessage;
allMessages: ChatMessage[];
isLoading: boolean;
setMessages: UseChatHelpers<ChatMessage>['setMessages'];
addToolResult: UseChatHelpers<ChatMessage>['addToolResult'];
Expand All @@ -77,9 +81,15 @@ const PurePreviewMessage = ({
(part) => part.type === 'file',
);

// Extract error parts separately
// Extract non-OAuth error parts separately (OAuth errors are rendered inline)
const errorParts = React.useMemo(
() => message.parts.filter((part) => part.type === 'data-error'),
() =>
message.parts
.filter((part) => part.type === 'data-error')
.filter((part) => {
// OAuth errors are rendered inline, not in the error section
return !isCredentialErrorMessage(part.data);
}),
[message.parts],
);

Expand All @@ -89,19 +99,24 @@ const PurePreviewMessage = ({
/**
* We segment message parts into segments that can be rendered as a single component.
* Used to render citations as part of the associated text.
* Note: OAuth errors are included here for inline rendering, non-OAuth errors are filtered out.
*/
() =>
createMessagePartSegments(
message.parts.filter((part) => part.type !== 'data-error'),
message.parts.filter(
(part) =>
part.type !== 'data-error' || isCredentialErrorMessage(part.data),
),
),
[message.parts],
);

// Check if message only contains errors (no other content)
// Check if message only contains non-OAuth errors (no other content)
const hasOnlyErrors = React.useMemo(() => {
const nonErrorParts = message.parts.filter(
(part) => part.type !== 'data-error',
);
// Only consider non-OAuth errors for this check
return errorParts.length > 0 && nonErrorParts.length === 0;
}, [message.parts, errorParts.length]);

Expand Down Expand Up @@ -342,6 +357,19 @@ const PurePreviewMessage = ({
</a>
);
}

// Render OAuth errors inline
if (type === 'data-error' && isCredentialErrorMessage(part.data)) {
return (
<MessageOAuthError
key={key}
error={part.data}
allMessages={allMessages}
setMessages={setMessages}
sendMessage={sendMessage}
/>
);
}
})}

{!isReadonly && !hasOnlyErrors && (
Expand Down
2 changes: 2 additions & 0 deletions e2e-chatbot-app-next/client/src/components/messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,15 @@ function PureMessages({
key={message.id}
chatId={chatId}
message={message}
allMessages={messages}
isLoading={
status === 'streaming' && messages.length - 1 === index
}
setMessages={setMessages}
addToolResult={addToolResult}
sendMessage={sendMessage}
regenerate={regenerate}
sendMessage={sendMessage}
isReadonly={isReadonly}
requiresScrollPadding={
hasSentMessage && index === messages.length - 1
Expand Down
47 changes: 47 additions & 0 deletions e2e-chatbot-app-next/client/src/lib/oauth-error-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Utility functions for OAuth credential error detection and parsing.
*
* These functions detect and parse error messages returned when a tool call
* requires OAuth authentication that the user hasn't completed yet.
*
* Expected error format:
* "Failed request to https://... Error: Credential for user identity('___') is not found
* for the connection 'CONNECTION_NAME'. Please login first to the connection by visiting https://LOGIN_URL"
*/

/**
* Checks if an error message indicates a credential/OAuth error.
* Pattern: "Credential for user identity('___') is not found for the connection '___'"
*/
export function isCredentialErrorMessage(errorMessage: string): boolean {
const pattern =
/Credential for user identity\([^)]*\) is not found for the connection/i;
return pattern.test(errorMessage);
}

/**
* Extracts the login URL from a credential error message.
* Pattern: "please login first to the connection by visiting https://..."
* @returns The login URL or undefined if not found
*/
export function findLoginURLFromCredentialErrorMessage(
errorMessage: string,
): string | undefined {
const pattern =
/please login first to the connection by visiting\s+(https?:\/\/[^\s]+)/i;
const match = errorMessage.match(pattern);
return match?.[1];
}

/**
* Extracts the connection name from a credential error message.
* Pattern: "for the connection 'connection_name'"
* @returns The connection name or undefined if not found
*/
export function findConnectionNameFromCredentialErrorMessage(
errorMessage: string,
): string | undefined {
const pattern = /for the connection\s+'([^']+)'/i;
const match = errorMessage.match(pattern);
return match?.[1];
}
5 changes: 5 additions & 0 deletions e2e-chatbot-app-next/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ export default defineConfig({
testMatch: /ai-sdk-provider\/.*.test.ts/,
use: { ...devices['Desktop Chrome'] },
},
{
name: 'oauth',
testMatch: /oauth\/.*.test.ts/,
use: { ...devices['Desktop Chrome'] },
},
{
name: 'e2e',
testMatch: /e2e\/.*.test.ts/,
Expand Down
Loading