Clean room implementation of the chat process (#5079)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Zane <75694352+zanesq@users.noreply.github.com>
This commit is contained in:
Douwe Osinga
2025-10-11 10:45:16 -04:00
committed by GitHub
parent 73f109237e
commit 0c2127124f
36 changed files with 1024 additions and 541 deletions
+6
View File
@@ -13,6 +13,12 @@ use utoipa::ToSchema;
use crate::conversation::tool_result_serde;
use crate::utils::sanitize_unicode_tags;
#[derive(ToSchema)]
pub enum ToolCallResult<T> {
Success { value: T },
Error { error: String },
}
/// Custom deserializer for MessageContent that sanitizes Unicode Tags in text content
fn deserialize_sanitized_content<'de, D>(deserializer: D) -> Result<Vec<MessageContent>, D::Error>
where
+1 -1
View File
@@ -1,4 +1,4 @@
pub use rmcp::model::ErrorData;
/// Type alias for tool results
pub type ToolResult<T> = std::result::Result<T, ErrorData>;
pub type ToolResult<T> = Result<T, ErrorData>;
-6
View File
@@ -1,6 +0,0 @@
{
"name": "goose",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+11 -2
View File
@@ -44,6 +44,7 @@ import {
useAgent,
} from './hooks/useAgent';
import { useNavigation } from './hooks/useNavigation';
import Pair2 from './components/Pair2';
// Route Components
const HubRouteWrapper = ({
@@ -93,7 +94,16 @@ const PairRouteWrapper = ({
const resumeSessionId = searchParams.get('resumeSessionId') ?? undefined;
return (
return process.env.ALPHA ? (
<Pair2
chat={chat}
setChat={setChat}
setView={setView}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
resumeSessionId={resumeSessionId}
initialMessage={initialMessage}
/>
) : (
<Pair
chat={chat}
setChat={setChat}
@@ -332,7 +342,6 @@ export function AppInner() {
setAgentWaitingMessage,
setIsExtensionsLoading,
});
// Update the chat state with the loaded session to ensure sessionId is available globally
setChat(loadedChat);
} catch (e) {
if (e instanceof NoProviderOrModelError) {
+1 -1
View File
@@ -61,10 +61,10 @@ import { useChatEngine } from '../hooks/useChatEngine';
import { useRecipeManager } from '../hooks/useRecipeManager';
import { useFileDrop } from '../hooks/useFileDrop';
import { useCostTracking } from '../hooks/useCostTracking';
import { Message } from '../types/message';
import { ChatState } from '../types/chatState';
import { ChatType } from '../types/chat';
import { useToolCount } from './alerts/useToolCount';
import { Message } from '../api';
// Context for sharing current model info
const CurrentModelContext = createContext<{ model: string; mode: string } | null>(null);
+463
View File
@@ -0,0 +1,463 @@
import React, { useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { SearchView } from './conversation/SearchView';
import LoadingGoose from './LoadingGoose';
import PopularChatTopics from './PopularChatTopics';
import ProgressiveMessageList from './ProgressiveMessageList';
import { View, ViewOptions } from '../utils/navigationUtils';
import { ContextManagerProvider } from './context_management/ContextManager';
import { MainPanelLayout } from './Layout/MainPanelLayout';
import ChatInput from './ChatInput';
import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area';
import { useFileDrop } from '../hooks/useFileDrop';
import { Message, Session } from '../api';
import { ChatState } from '../types/chatState';
import { ChatType } from '../types/chat';
import { useIsMobile } from '../hooks/use-mobile';
import { useSidebar } from './ui/sidebar';
import { cn } from '../utils';
import { useChatStream } from '../hooks/useChatStream';
import { loadSession } from '../utils/sessionCache';
interface BaseChatProps {
chat: ChatType;
setChat: (chat: ChatType) => void;
setView: (view: View, viewOptions?: ViewOptions) => void;
setIsGoosehintsModalOpen?: (isOpen: boolean) => void;
onMessageStreamFinish?: () => void;
onMessageSubmit?: (message: string) => void;
renderHeader?: () => React.ReactNode;
renderBeforeMessages?: () => React.ReactNode;
renderAfterMessages?: () => React.ReactNode;
customChatInputProps?: Record<string, unknown>;
customMainLayoutProps?: Record<string, unknown>;
contentClassName?: string;
disableSearch?: boolean;
showPopularTopics?: boolean;
suppressEmptyState?: boolean;
autoSubmit?: boolean;
resumeSessionId?: string; // Optional session ID to resume on mount
}
function BaseChatContent({
chat,
setChat,
setView,
setIsGoosehintsModalOpen,
renderHeader,
renderBeforeMessages,
renderAfterMessages,
customChatInputProps = {},
customMainLayoutProps = {},
disableSearch = false,
resumeSessionId,
}: BaseChatProps) {
const location = useLocation();
const scrollRef = useRef<ScrollAreaHandle>(null);
const disableAnimation = location.state?.disableAnimation || false;
// const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false);
// const [currentRecipeTitle, setCurrentRecipeTitle] = React.useState<string | null>(null);
// const { isCompacting, handleManualCompaction } = useContextManager();
const isMobile = useIsMobile();
const { state: sidebarState } = useSidebar();
const contentClassName = cn('pr-1 pb-10', (isMobile || sidebarState === 'collapsed') && 'pt-11');
// Use shared file drop
const { droppedFiles, setDroppedFiles, handleDrop, handleDragOver } = useFileDrop();
// Use shared cost tracking
// const { sessionCosts } = useCostTracking({
// sessionInputTokens,
// sessionOutputTokens,
// localInputTokens,
// localOutputTokens,
// session: sessionMetadata,
// });
// Session loading state
const [sessionLoadError, setSessionLoadError] = useState<string | null>(null);
const hasLoadedSessionRef = useRef(false);
const [messages, setMessages] = useState(chat.messages || []);
// Load session on mount if resumeSessionId is provided
useEffect(() => {
const needsLoad = resumeSessionId && !hasLoadedSessionRef.current;
if (needsLoad) {
hasLoadedSessionRef.current = true;
setSessionLoadError(null);
// Set chat to empty session to indicate loading state
// todo: set to null instead and handle that in other places
const emptyChat: ChatType = {
sessionId: resumeSessionId,
title: 'Loading...',
messageHistoryIndex: 0,
messages: [],
recipe: null,
recipeParameters: null,
};
setChat(emptyChat);
loadSession(resumeSessionId)
.then((session: Session) => {
const conversation = session.conversation || [];
const loadedChat: ChatType = {
sessionId: session.id,
title: session.description || 'Untitled Chat',
messageHistoryIndex: 0,
messages: conversation,
recipe: null,
recipeParameters: null,
};
setChat(loadedChat);
})
.catch((error: Error) => {
const errorMessage = error.message || 'Failed to load session';
setSessionLoadError(errorMessage);
});
}
}, [resumeSessionId, setChat]);
// Update messages when chat changes (e.g., when resuming a session)
useEffect(() => {
if (chat.messages) {
setMessages(chat.messages);
}
}, [chat.messages, chat.sessionId]);
const { chatState, handleSubmit, stopStreaming } = useChatStream({
sessionId: chat.sessionId || '',
messages,
setMessages,
onStreamFinish: () => {},
});
const handleFormSubmit = (e: React.FormEvent) => {
const customEvent = e as unknown as CustomEvent;
const textValue = customEvent.detail?.value || '';
// if (recipe && textValue.trim()) {
// setHasStartedUsingRecipe(true);
// }
//
// if (onMessageSubmit && textValue.trim()) {
// onMessageSubmit(textValue);
// }
handleSubmit(textValue);
};
// TODO(Douwe): send this to the chatbox instead, possibly autosubmit? or backend
const append = (_txt: string) => {};
useEffect(() => {
window.electron.logInfo(
'Initial messages when resuming session: ' + JSON.stringify(messages, null, 2)
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Track if this is the initial render for session resuming
const initialRenderRef = useRef(true);
const recipe = chat?.recipe;
// Auto-scroll when messages are loaded (for session resuming)
const handleRenderingComplete = React.useCallback(() => {
// Only force scroll on the very first render
if (initialRenderRef.current && messages.length > 0) {
initialRenderRef.current = false;
if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
}
} else if (scrollRef.current?.isFollowing) {
if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
}
}
}, [messages.length]);
//const toolCount = useToolCount(chat.sessionId);
// Wrapper for append that tracks recipe usage
// const appendWithTracking = (text: string | Message) => {
// // Mark that user has started using the recipe when they use append
// if (recipe) {
// setHasStartedUsingRecipe(true);
// }
// append(text);
// };
// Listen for global scroll-to-bottom requests (e.g., from MCP UI prompt actions)
useEffect(() => {
const handleGlobalScrollRequest = () => {
// Add a small delay to ensure content has been rendered
setTimeout(() => {
if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
}
}, 200);
};
window.addEventListener('scroll-chat-to-bottom', handleGlobalScrollRequest);
return () => window.removeEventListener('scroll-chat-to-bottom', handleGlobalScrollRequest);
}, []);
const renderProgressiveMessageList = (chat: ChatType) => (
<>
<ProgressiveMessageList
messages={messages}
chat={chat}
// toolCallNotifications={toolCallNotifications}
// appendMessage={(newMessage) => {
// const updatedMessages = [...messages, newMessage];
// setMessages(updatedMessages);
// }}
isUserMessage={(m: Message) => m.role === 'user'}
isStreamingMessage={chatState !== ChatState.Idle}
// onMessageUpdate={onMessageUpdate}
onRenderingComplete={handleRenderingComplete}
/>
</>
);
const showPopularTopics = messages.length === 0;
// TODO(Douwe): get this from the backend
const isCompacting = false;
const initialPrompt = messages.length == 0 && recipe?.prompt ? recipe.prompt : '';
return (
<div className="h-full flex flex-col min-h-0">
<h2>Warning: BaseChat2!</h2>
<MainPanelLayout
backgroundColor={'bg-background-muted'}
removeTopPadding={true}
{...customMainLayoutProps}
>
{/* Custom header */}
{renderHeader && renderHeader()}
{/* Chat container with sticky recipe header */}
<div className="flex flex-col flex-1 mb-0.5 min-h-0 relative">
<ScrollArea
ref={scrollRef}
className={`flex-1 bg-background-default rounded-b-2xl min-h-0 relative ${contentClassName}`}
autoScroll
onDrop={handleDrop}
onDragOver={handleDragOver}
data-drop-zone="true"
paddingX={6}
paddingY={0}
>
{/*/!* Recipe agent header - sticky at top of chat container *!/*/}
{/*{recipe?.title && (*/}
{/* <div className="sticky top-0 z-10 bg-background-default px-0 -mx-6 mb-6 pt-6">*/}
{/* <AgentHeader*/}
{/* title={recipe.title}*/}
{/* profileInfo={*/}
{/* recipe.profile ? `${recipe.profile} - ${recipe.mcps || 12} MCPs` : undefined*/}
{/* }*/}
{/* onChangeProfile={() => {*/}
{/* console.log('Change profile clicked');*/}
{/* }}*/}
{/* showBorder={true}*/}
{/* />*/}
{/* </div>*/}
{/*)}*/}
{/* Custom content before messages */}
{renderBeforeMessages && renderBeforeMessages()}
{/*/!* Recipe Activities - always show when recipe is active and accepted *!/*/}
{/*{recipe && recipeAccepted && !suppressEmptyState && (*/}
{/* <div className={hasStartedUsingRecipe ? 'mb-6' : ''}>*/}
{/* <RecipeActivities*/}
{/* append={(text: string) => appendWithTracking(text)}*/}
{/* activities={Array.isArray(recipe.activities) ? recipe.activities : null}*/}
{/* title={recipe.title}*/}
{/* parameterValues={recipeParameters || {}}*/}
{/* />*/}
{/* </div>*/}
{/*)}*/}
{sessionLoadError && (
<div className="flex flex-col items-center justify-center p-8">
<div className="text-red-700 dark:text-red-300 bg-red-400/50 p-4 rounded-lg mb-4 max-w-md">
<h3 className="font-semibold mb-2">Failed to Load Session</h3>
<p className="text-sm">{sessionLoadError}</p>
</div>
<button
onClick={() => {
setSessionLoadError(null);
hasLoadedSessionRef.current = false;
}}
className="px-4 py-2 text-center cursor-pointer text-textStandard border border-borderSubtle hover:bg-bgSubtle rounded-lg transition-all duration-150"
>
Retry
</button>
</div>
)}
{/* Messages or Popular Topics */}
{
messages.length > 0 || recipe ? (
<>
{disableSearch ? (
renderProgressiveMessageList(chat)
) : (
// Render messages with SearchView wrapper when search is enabled
<SearchView>{renderProgressiveMessageList(chat)}</SearchView>
)}
{/*{error && (*/}
{/* <>*/}
{/* <div className="flex flex-col items-center justify-center p-4">*/}
{/* <div className="text-red-700 dark:text-red-300 bg-red-400/50 p-3 rounded-lg mb-2">*/}
{/* {error.message || 'Honk! Goose experienced an error while responding'}*/}
{/* </div>*/}
{/* /!* Action buttons for all errors including token limit errors *!/*/}
{/* <div className="flex gap-2 mt-2">*/}
{/* <div*/}
{/* className="px-3 py-2 text-center whitespace-nowrap cursor-pointer text-textStandard border border-borderSubtle hover:bg-bgSubtle rounded-full inline-block transition-all duration-150"*/}
{/* onClick={async () => {*/}
{/* clearError();*/}
{/* await handleManualCompaction(*/}
{/* messages,*/}
{/* setMessages,*/}
{/* append,*/}
{/* chat.sessionId*/}
{/* );*/}
{/* }}*/}
{/* >*/}
{/* Summarize Conversation*/}
{/* </div>*/}
{/* <div*/}
{/* className="px-3 py-2 text-center whitespace-nowrap cursor-pointer text-textStandard border border-borderSubtle hover:bg-bgSubtle rounded-full inline-block transition-all duration-150"*/}
{/* onClick={async () => {*/}
{/* // Find the last user message*/}
{/* const lastUserMessage = messages.reduceRight(*/}
{/* (found, m) => found || (m.role === 'user' ? m : null),*/}
{/* null as Message | null*/}
{/* );*/}
{/* if (lastUserMessage) {*/}
{/* await append(lastUserMessage);*/}
{/* }*/}
{/* }}*/}
{/* >*/}
{/* Retry Last Message*/}
{/* </div>*/}
{/* </div>*/}
{/* </div>*/}
{/* </>*/}
{/*)}*/}
<div className="block h-8" />
</>
) : !recipe && showPopularTopics ? (
/* Show PopularChatTopics when no messages, no recipe, and showPopularTopics is true (Pair view) */
<PopularChatTopics append={(text: string) => append(text)} />
) : null /* Show nothing when messages.length === 0 && suppressEmptyState === true */
}
{/* Custom content after messages */}
{renderAfterMessages && renderAfterMessages()}
</ScrollArea>
{/* Fixed loading indicator at bottom left of chat container */}
{(messages.length === 0 || isCompacting) && !sessionLoadError && (
<div className="absolute bottom-1 left-4 z-20 pointer-events-none">
<LoadingGoose
message={
messages.length === 0
? 'loading conversation...'
: isCompacting
? 'goose is compacting the conversation...'
: undefined
}
chatState={chatState}
/>
</div>
)}
</div>
<div
className={`relative z-10 ${disableAnimation ? '' : 'animate-[fadein_400ms_ease-in_forwards]'}`}
>
<ChatInput
sessionId={chat?.sessionId || ''}
handleSubmit={handleFormSubmit}
chatState={chatState}
onStop={stopStreaming}
//commandHistory={commandHistory}
initialValue={initialPrompt}
setView={setView}
// numTokens={sessionTokenCount}
// inputTokens={sessionInputTokens || localInputTokens}
// outputTokens={sessionOutputTokens || localOutputTokens}
droppedFiles={droppedFiles}
onFilesProcessed={() => setDroppedFiles([])} // Clear dropped files after processing
messages={messages}
setMessages={(_m) => {}}
disableAnimation={disableAnimation}
//sessionCosts={sessionCosts}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
recipe={recipe}
//recipeAccepted={recipeAccepted}
initialPrompt={initialPrompt}
//toolCount={toolCount || 0}
toolCount={0}
//autoSubmit={autoSubmit}
autoSubmit={false}
//append={append}
{...customChatInputProps}
/>
</div>
</MainPanelLayout>
{/*/!* Recipe Warning Modal *!/*/}
{/*<RecipeWarningModal*/}
{/* isOpen={isRecipeWarningModalOpen}*/}
{/* onConfirm={handleRecipeAccept}*/}
{/* onCancel={handleRecipeCancel}*/}
{/* recipeDetails={{*/}
{/* title: recipe?.title,*/}
{/* description: recipe?.description,*/}
{/* instructions: recipe?.instructions || undefined,*/}
{/* }}*/}
{/* hasSecurityWarnings={hasSecurityWarnings}*/}
{/*/>*/}
{/*/!* Recipe Parameter Modal *!/*/}
{/*{isParameterModalOpen && filteredParameters.length > 0 && (*/}
{/* <ParameterInputModal*/}
{/* parameters={filteredParameters}*/}
{/* onSubmit={handleParameterSubmit}*/}
{/* onClose={() => setIsParameterModalOpen(false)}*/}
{/* />*/}
{/*)}*/}
{/*/!* Create Recipe from Session Modal *!/*/}
{/*<CreateRecipeFromSessionModal*/}
{/* isOpen={isCreateRecipeModalOpen}*/}
{/* onClose={() => setIsCreateRecipeModalOpen(false)}*/}
{/* sessionId={chat.sessionId}*/}
{/* onRecipeCreated={handleRecipeCreated}*/}
{/*/>*/}
</div>
);
}
export default function BaseChat(props: BaseChatProps) {
return (
<ContextManagerProvider>
<BaseChatContent {...props} />
</ContextManagerProvider>
);
}
+1 -1
View File
@@ -8,7 +8,7 @@ import { Attach, Send, Close, Microphone } from './icons';
import { ChatState } from '../types/chatState';
import debounce from 'lodash/debounce';
import { LocalMessageStorage } from '../utils/localMessageStorage';
import { Message } from '../types/message';
import { Message } from '../api';
import { DirSwitcher } from './bottom_menu/DirSwitcher';
import ModelsBottomBar from './settings/models/bottom_bar/ModelsBottomBar';
import { BottomMenuModeSelection } from './bottom_menu/BottomMenuModeSelection';
+1 -1
View File
@@ -11,13 +11,13 @@ import {
getChainForMessage,
} from '../utils/toolCallChaining';
import {
Message,
getTextContent,
getToolRequests,
getToolResponses,
getToolConfirmationContent,
createToolErrorResponseMessage,
} from '../types/message';
import { Message } from '../api';
import ToolCallConfirmation from './ToolCallConfirmation';
import MessageCopyLink from './MessageCopyLink';
import { NotificationEvent } from '../hooks/useMessageStream';
@@ -7,13 +7,14 @@ import {
UIActionResultToolCall,
} from '@mcp-ui/client';
import { useState, useEffect } from 'react';
import { ResourceContent } from '../types/message';
import { toast } from 'react-toastify';
import { EmbeddedResource } from '../api';
interface MCPUIResourceRendererProps {
content: ResourceContent;
content: EmbeddedResource & { type: 'resource' };
appendPromptToChat?: (value: string) => void;
}
type UISizeChange = {
type: 'ui-size-change';
payload: {
+35
View File
@@ -0,0 +1,35 @@
import { View, ViewOptions } from '../utils/navigationUtils';
import 'react-toastify/dist/ReactToastify.css';
import { ChatType } from '../types/chat';
import BaseChat2 from './BaseChat2';
export interface PairRouteState {
resumeSessionId?: string;
initialMessage?: string;
}
interface PairProps {
chat: ChatType;
setChat: (chat: ChatType) => void;
setView: (view: View, viewOptions?: ViewOptions) => void;
setIsGoosehintsModalOpen: (isOpen: boolean) => void;
}
export default function Pair({
chat,
setChat,
setView,
setIsGoosehintsModalOpen,
resumeSessionId,
}: PairProps & PairRouteState) {
return (
<BaseChat2
chat={chat}
setChat={setChat}
setView={setView}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
resumeSessionId={resumeSessionId}
/>
);
}
@@ -15,7 +15,7 @@
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { Message } from '../types/message';
import { Message } from '../api';
import GooseMessage from './GooseMessage';
import UserMessage from './UserMessage';
import { CompactionMarker } from './context_management/CompactionMarker';
+2 -1
View File
@@ -1,5 +1,6 @@
import { formatMessageTimestamp } from '../utils/timeUtils';
import { Message, getToolRequests } from '../types/message';
import { Message } from '../api';
import { getToolRequests } from '../types/message';
import { NotificationEvent } from '../hooks/useMessageStream';
import ToolCallWithResponse from './ToolCallWithResponse';
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { snakeToTitleCase } from '../utils';
import PermissionModal from './settings/permission/PermissionModal';
import { ChevronRight } from 'lucide-react';
import { confirmPermission } from '../api';
import { confirmPermission, ToolConfirmationRequest } from '../api';
import { Button } from './ui/button';
const ALLOW_ONCE = 'allow_once';
@@ -20,13 +20,11 @@ const toolConfirmationState = new Map<
}
>();
import { ToolConfirmationRequestMessageContent } from '../types/message';
interface ToolConfirmationProps {
sessionId: string;
isCancelledMessage: boolean;
isClicked: boolean;
toolConfirmationContent: ToolConfirmationRequestMessageContent;
toolConfirmationContent: ToolConfirmationRequest & { type: 'toolConfirmationRequest' };
}
export default function ToolConfirmation({
@@ -4,7 +4,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { Button } from './ui/button';
import { ToolCallArguments, ToolCallArgumentValue } from './ToolCallArguments';
import MarkdownContent from './MarkdownContent';
import { Content, ToolRequestMessageContent, ToolResponseMessageContent } from '../types/message';
import { ToolRequestMessageContent, ToolResponseMessageContent } from '../types/message';
import { cn, snakeToTitleCase } from '../utils';
import { LoadingStatus } from './ui/Dot';
import { NotificationEvent } from '../hooks/useMessageStream';
@@ -12,6 +12,7 @@ import { ChevronRight, FlaskConical } from 'lucide-react';
import { TooltipWrapper } from './settings/providers/subcomponents/buttons/TooltipWrapper';
import MCPUIResourceRenderer from './MCPUIResourceRenderer';
import { isUIResource } from '@mcp-ui/client';
import { Content, EmbeddedResource } from '../api';
interface ToolCallWithResponseProps {
isCancelledMessage: boolean;
@@ -22,16 +23,34 @@ interface ToolCallWithResponseProps {
append?: (value: string) => void; // Function to append messages to the chat
}
function getToolResultValue(toolResult: Record<string, unknown>): Content[] | null {
if ('value' in toolResult && Array.isArray(toolResult.value)) {
return toolResult.value as Content[];
}
return null;
}
function isEmbeddedResource(content: Content): content is EmbeddedResource {
return 'resource' in content && typeof (content as Record<string, unknown>).resource === 'object';
}
export default function ToolCallWithResponse({
isCancelledMessage,
toolRequest,
toolResponse,
notifications,
isStreamingMessage = false,
isStreamingMessage,
append,
}: ToolCallWithResponseProps) {
const toolCall = toolRequest.toolCall.status === 'success' ? toolRequest.toolCall.value : null;
if (!toolCall) {
// Handle both the wrapped ToolResult format and the unwrapped format
// The server serializes ToolResult<T> as { status: "success", value: T } or { status: "error", error: string }
const toolCallData = toolRequest.toolCall as Record<string, unknown>;
const toolCall =
toolCallData?.status === 'success'
? (toolCallData.value as { name: string; arguments: Record<string, unknown> })
: (toolCallData as { name: string; arguments: Record<string, unknown> });
if (!toolCall || !toolCall.name) {
return null;
}
@@ -53,12 +72,15 @@ export default function ToolCallWithResponse({
/>
</div>
{/* MCP UI — Inline */}
{toolResponse?.toolResult?.value &&
toolResponse.toolResult.value.map((content, index) => {
if (isUIResource(content)) {
{toolResponse?.toolResult &&
getToolResultValue(toolResponse.toolResult)?.map((content, index) => {
const resourceContent = isEmbeddedResource(content)
? { ...content, type: 'resource' as const }
: null;
if (resourceContent && isUIResource(resourceContent)) {
return (
<div key={`${content.type}-${index}`} className="mt-3">
<MCPUIResourceRenderer content={content} appendPromptToChat={append} />
<div key={index} className="mt-3">
<MCPUIResourceRenderer content={resourceContent} appendPromptToChat={append} />
<div className="mt-3 p-4 py-3 border border-borderSubtle rounded-lg bg-background-muted flex items-center">
<FlaskConical className="mr-2" size={20} />
<div className="text-sm font-sans">
@@ -200,7 +222,7 @@ function ToolCallView({
}
})();
const isToolDetails = Object.entries(toolCall?.arguments).length > 0;
const isToolDetails = toolCall?.arguments && Object.entries(toolCall.arguments).length > 0;
// Check if streaming has finished but no tool response was received
// This is a workaround for cases where the backend doesn't send tool responses
@@ -211,7 +233,9 @@ function ToolCallView({
? shouldShowAsComplete
? 'success'
: 'loading'
: toolResponse.toolResult.status;
: (toolResponse.toolResult as Record<string, unknown>).status === 'error'
? 'error'
: 'success';
// Tool call timing tracking
const [startTime, setStartTime] = useState<number | null>(null);
@@ -547,19 +571,30 @@ interface ToolResultViewProps {
}
function ToolResultView({ result, isStartExpanded }: ToolResultViewProps) {
const hasText = (c: Content): c is Content & { text: string } =>
'text' in c && typeof (c as Record<string, unknown>).text === 'string';
const hasImage = (c: Content): c is Content & { data: string; mimeType: string } => {
if (!('data' in c && 'mimeType' in c)) return false;
const mimeType = (c as Record<string, unknown>).mimeType;
return typeof mimeType === 'string' && mimeType.startsWith('image');
};
const hasResource = (c: Content): c is Content & { resource: unknown } => 'resource' in c;
return (
<ToolCallExpandable
label={<span className="pl-4 py-1 font-sans text-sm">Output</span>}
isStartExpanded={isStartExpanded}
>
<div className="pl-4 pr-4 py-4">
{result.type === 'text' && result.text && (
{hasText(result) && (
<MarkdownContent
content={result.text}
className="whitespace-pre-wrap max-w-full overflow-x-auto"
/>
)}
{result.type === 'image' && (
{hasImage(result) && (
<img
src={`data:${result.mimeType};base64,${result.data}`}
alt="Tool result"
@@ -570,7 +605,7 @@ function ToolResultView({ result, isStartExpanded }: ToolResultViewProps) {
}}
/>
)}
{result.type === 'resource' && (
{hasResource(result) && (
<pre className="font-sans text-sm">{JSON.stringify(result, null, 2)}</pre>
)}
</div>
+3 -2
View File
@@ -1,8 +1,9 @@
import { useRef, useMemo, useState, useEffect, useCallback } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import ImagePreview from './ImagePreview';
import { extractImagePaths, removeImagePathsFromText } from '../utils/imageUtils';
import MarkdownContent from './MarkdownContent';
import { Message, getTextContent } from '../types/message';
import { getTextContent } from '../types/message';
import { Message } from '../api';
import MessageCopyLink from './MessageCopyLink';
import { formatMessageTimestamp } from '../utils/timeUtils';
import Edit from './icons/Edit';
@@ -1,5 +1,5 @@
import React from 'react';
import { Message, SummarizationRequestedContent } from '../../types/message';
import { Message, SummarizationRequested } from '../../api';
interface CompactionMarkerProps {
message: Message;
@@ -7,8 +7,9 @@ interface CompactionMarkerProps {
export const CompactionMarker: React.FC<CompactionMarkerProps> = ({ message }) => {
const compactionContent = message.content.find(
(content) => content.type === 'summarizationRequested'
) as SummarizationRequestedContent | undefined;
(content): content is SummarizationRequested & { type: 'summarizationRequested' } =>
content.type === 'summarizationRequested'
);
const markerText = compactionContent?.msg || 'Conversation compacted';
@@ -1,6 +1,6 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
import { Message } from '../../types/message';
import { manageContextFromBackend, convertApiMessageToFrontendMessage } from './index';
import { manageContextFromBackend } from './index';
import { Message } from '../../api';
// Define the context management interface
interface ContextManagerState {
@@ -53,21 +53,14 @@ export const ContextManagerProvider: React.FC<{ children: React.ReactNode }> = (
sessionId: sessionId,
});
// Convert API messages to frontend messages
// The server now handles all visibility - we just display what we receive
const convertedMessages = summaryResponse.messages.map((apiMessage) =>
convertApiMessageToFrontendMessage(apiMessage)
);
// Replace messages with the server-provided messages
setMessages(convertedMessages);
setMessages(summaryResponse.messages);
// Only automatically submit the continuation message for auto-compaction (context limit reached)
// Manual compaction should just compact without continuing the conversation
if (!isManual) {
// Automatically submit the continuation message to continue the conversation
// This should be the third message (index 2) which contains the "I ran into a context length exceeded error..." text
const continuationMessage = convertedMessages[2];
const continuationMessage = summaryResponse.messages[2];
if (continuationMessage) {
setTimeout(() => {
append(continuationMessage);
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { CompactionMarker } from '../CompactionMarker';
import { Message } from '../../../types/message';
import { Message } from '../../../api';
describe('CompactionMarker', () => {
it('should render default message when no summarizationRequested content found', () => {
@@ -1,20 +1,15 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { ContextManagerProvider, useContextManager } from '../ContextManager';
import { Message } from '../../../types/message';
import * as contextManagement from '../index';
import { ContextManageResponse } from '../../../api';
import { ContextManageResponse, Message } from '../../../api';
// Mock the context management functions
vi.mock('../index', () => ({
manageContextFromBackend: vi.fn(),
convertApiMessageToFrontendMessage: vi.fn(),
}));
const mockManageContextFromBackend = vi.mocked(contextManagement.manageContextFromBackend);
const mockConvertApiMessageToFrontendMessage = vi.mocked(
contextManagement.convertApiMessageToFrontendMessage
);
describe('ContextManager', () => {
const mockMessages: Message[] = [
@@ -32,13 +27,6 @@ describe('ContextManager', () => {
},
];
const mockSummaryMessage: Message = {
id: 'summary-1',
role: 'assistant',
created: 3000,
content: [{ type: 'text', text: 'This is a summary of the conversation.' }],
};
const mockSetMessages = vi.fn();
const mockAppend = vi.fn();
@@ -113,6 +101,7 @@ describe('ContextManager', () => {
describe('handleAutoCompaction', () => {
it('should successfully perform auto compaction with server-provided messages', async () => {
// Mock the backend response with 3 messages: marker, summary, continuation
// Note: Server messages may not have id/created, which will be added by the code
mockManageContextFromBackend.mockResolvedValue({
messages: [
{
@@ -120,11 +109,11 @@ describe('ContextManager', () => {
content: [
{ type: 'summarizationRequested', msg: 'Conversation compacted and summarized' },
],
},
} as Message,
{
role: 'assistant',
content: [{ type: 'text', text: 'Summary content' }],
},
} as Message,
{
role: 'assistant',
content: [
@@ -133,36 +122,11 @@ describe('ContextManager', () => {
text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context',
},
],
},
} as Message,
],
tokenCounts: [8, 100, 50],
});
const mockCompactionMarker: Message = {
id: 'marker-1',
role: 'assistant',
created: 3000,
content: [{ type: 'summarizationRequested', msg: 'Conversation compacted and summarized' }],
};
const mockContinuationMessage: Message = {
id: 'continuation-1',
role: 'assistant',
created: 3000,
content: [
{
type: 'text',
text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context',
},
],
};
// Mock the conversion function to return different messages based on call order
mockConvertApiMessageToFrontendMessage
.mockReturnValueOnce(mockCompactionMarker) // First call - compaction marker
.mockReturnValueOnce(mockSummaryMessage) // Second call - summary
.mockReturnValueOnce(mockContinuationMessage); // Third call - continuation
const { result } = renderContextManager();
await act(async () => {
@@ -180,39 +144,28 @@ describe('ContextManager', () => {
sessionId: 'test-session-id',
});
// Verify conversion calls with correct parameters
expect(mockConvertApiMessageToFrontendMessage).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
content: [
{ type: 'summarizationRequested', msg: 'Conversation compacted and summarized' },
],
})
);
expect(mockConvertApiMessageToFrontendMessage).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
content: [{ type: 'text', text: 'Summary content' }],
})
);
expect(mockConvertApiMessageToFrontendMessage).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
content: [
{
type: 'text',
text: expect.stringContaining('The previous message contains a summary'),
},
],
})
);
// Expect setMessages to be called with all 3 converted messages
expect(mockSetMessages).toHaveBeenCalledWith([
mockCompactionMarker,
mockSummaryMessage,
mockContinuationMessage,
]);
// Expect setMessages to be called with all 3 messages from server
// Note: Server doesn't provide id/created fields, so we don't check for them
expect(mockSetMessages).toHaveBeenCalledTimes(1);
const setMessagesCall = mockSetMessages.mock.calls[0][0];
expect(setMessagesCall).toHaveLength(3);
expect(setMessagesCall[0]).toMatchObject({
role: 'assistant',
content: [{ type: 'summarizationRequested', msg: 'Conversation compacted and summarized' }],
});
expect(setMessagesCall[1]).toMatchObject({
role: 'assistant',
content: [{ type: 'text', text: 'Summary content' }],
});
expect(setMessagesCall[2]).toMatchObject({
role: 'assistant',
content: [
{
type: 'text',
text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context',
},
],
});
// Fast-forward timers to trigger the append call
act(() => {
@@ -221,7 +174,16 @@ describe('ContextManager', () => {
// Should append the continuation message (index 2) for auto-compaction
expect(mockAppend).toHaveBeenCalledTimes(1);
expect(mockAppend).toHaveBeenCalledWith(mockContinuationMessage);
const appendedMessage = mockAppend.mock.calls[0][0];
expect(appendedMessage).toMatchObject({
role: 'assistant',
content: [
{
type: 'text',
text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context',
},
],
});
});
it('should handle compaction errors gracefully', async () => {
@@ -290,8 +252,6 @@ describe('ContextManager', () => {
tokenCounts: [100, 50],
});
mockConvertApiMessageToFrontendMessage.mockReturnValue(mockSummaryMessage);
await act(async () => {
await promise;
});
@@ -363,30 +323,6 @@ describe('ContextManager', () => {
tokenCounts: [8, 100, 50],
});
const mockCompactionMarker: Message = {
id: 'marker-1',
role: 'assistant',
created: 3000,
content: [{ type: 'summarizationRequested', msg: 'Conversation compacted and summarized' }],
};
const mockContinuationMessage: Message = {
id: 'continuation-1',
role: 'assistant',
created: 3000,
content: [
{
type: 'text',
text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context',
},
],
};
mockConvertApiMessageToFrontendMessage
.mockReturnValueOnce(mockCompactionMarker)
.mockReturnValueOnce(mockSummaryMessage)
.mockReturnValueOnce(mockContinuationMessage);
const { result } = renderContextManager();
await act(async () => {
@@ -405,11 +341,26 @@ describe('ContextManager', () => {
});
// Verify all three messages are set
expect(mockSetMessages).toHaveBeenCalledWith([
mockCompactionMarker,
mockSummaryMessage,
mockContinuationMessage,
]);
expect(mockSetMessages).toHaveBeenCalledTimes(1);
const setMessagesCall = mockSetMessages.mock.calls[0][0];
expect(setMessagesCall).toHaveLength(3);
expect(setMessagesCall[0]).toMatchObject({
role: 'assistant',
content: [{ type: 'summarizationRequested', msg: 'Conversation compacted and summarized' }],
});
expect(setMessagesCall[1]).toMatchObject({
role: 'assistant',
content: [{ type: 'text', text: 'Manual summary content' }],
});
expect(setMessagesCall[2]).toMatchObject({
role: 'assistant',
content: [
{
type: 'text',
text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context',
},
],
});
// Fast-forward timers to check if append would be called
act(() => {
@@ -431,8 +382,6 @@ describe('ContextManager', () => {
tokenCounts: [100, 50],
});
mockConvertApiMessageToFrontendMessage.mockReturnValue(mockSummaryMessage);
const { result } = renderContextManager();
await act(async () => {
@@ -481,30 +430,6 @@ describe('ContextManager', () => {
tokenCounts: [8, 100, 50],
});
const mockCompactionMarker: Message = {
id: 'marker-1',
role: 'assistant',
created: 3000,
content: [{ type: 'summarizationRequested', msg: 'Conversation compacted and summarized' }],
};
const mockContinuationMessage: Message = {
id: 'continuation-1',
role: 'assistant',
created: 3000,
content: [
{
type: 'text',
text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context',
},
],
};
mockConvertApiMessageToFrontendMessage
.mockReturnValueOnce(mockCompactionMarker)
.mockReturnValueOnce(mockSummaryMessage)
.mockReturnValueOnce(mockContinuationMessage);
const { result } = renderContextManager();
await act(async () => {
@@ -517,11 +442,26 @@ describe('ContextManager', () => {
});
// Verify all three messages are set
expect(mockSetMessages).toHaveBeenCalledWith([
mockCompactionMarker,
mockSummaryMessage,
mockContinuationMessage,
]);
expect(mockSetMessages).toHaveBeenCalledTimes(1);
const setMessagesCall = mockSetMessages.mock.calls[0][0];
expect(setMessagesCall).toHaveLength(3);
expect(setMessagesCall[0]).toMatchObject({
role: 'assistant',
content: [{ type: 'summarizationRequested', msg: 'Conversation compacted and summarized' }],
});
expect(setMessagesCall[1]).toMatchObject({
role: 'assistant',
content: [{ type: 'text', text: 'Manual summary content' }],
});
expect(setMessagesCall[2]).toMatchObject({
role: 'assistant',
content: [
{
type: 'text',
text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context',
},
],
});
// Fast-forward timers to check if append would be called
act(() => {
@@ -559,20 +499,11 @@ describe('ContextManager', () => {
content: [
{ type: 'toolResponse', id: 'test', toolResult: { content: 'Not text content' } },
],
},
} as Message,
],
tokenCounts: [100, 50],
});
const mockMessageWithoutText: Message = {
id: 'summary-1',
role: 'assistant',
created: 3000,
content: [{ type: 'toolResponse', id: 'test', toolResult: { status: 'success' } }],
};
mockConvertApiMessageToFrontendMessage.mockReturnValue(mockMessageWithoutText);
const { result } = renderContextManager();
await act(async () => {
@@ -588,8 +519,16 @@ describe('ContextManager', () => {
expect(result.current.isCompacting).toBe(false);
expect(result.current.compactionError).toBe(null);
// Should still set messages with the converted message
expect(mockSetMessages).toHaveBeenCalledWith([mockMessageWithoutText]);
// Should still set messages from server
expect(mockSetMessages).toHaveBeenCalledTimes(1);
const setMessagesCall = mockSetMessages.mock.calls[0][0];
expect(setMessagesCall).toHaveLength(1);
expect(setMessagesCall[0]).toMatchObject({
role: 'assistant',
content: [
{ type: 'toolResponse', id: 'test', toolResult: { content: 'Not text content' } },
],
});
});
});
@@ -1,149 +1,29 @@
import {
Message as FrontendMessage,
Content as FrontendContent,
MessageContent as FrontendMessageContent,
ToolCallResult,
ToolCall,
Role,
} from '../../types/message';
import {
ContextManageRequest,
ContextManageResponse,
manageContext,
Message as ApiMessage,
MessageContent as ApiMessageContent,
} from '../../api';
import { generateId } from 'ai';
import { ContextManageRequest, ContextManageResponse, manageContext, Message } from '../../api';
export async function manageContextFromBackend({
messages,
manageAction,
sessionId,
}: {
messages: FrontendMessage[];
messages: Message[];
manageAction: 'truncation' | 'summarize';
sessionId: string;
}): Promise<ContextManageResponse> {
try {
const contextManagementRequest = { manageAction, messages, sessionId };
const contextManagementRequest = { manageAction, messages, sessionId };
// Cast to the API-expected type
const result = await manageContext({
body: contextManagementRequest as unknown as ContextManageRequest,
});
// Cast to the API-expected type
const result = await manageContext({
body: contextManagementRequest as unknown as ContextManageRequest,
});
// Check for errors in the result
if (result.error) {
throw new Error(`Context management failed: ${result.error}`);
}
// Extract the actual data from the result
if (!result.data) {
throw new Error('Context management returned no data');
}
return result.data;
} catch (error) {
console.error(`Context management failed: ${error || 'Unknown error'}`);
throw new Error(
`Context management failed: ${error || 'Unknown error'}\n\nStart a new session.`
);
}
}
// Function to convert API Message to frontend Message
export function convertApiMessageToFrontendMessage(apiMessage: ApiMessage): FrontendMessage {
return {
id: generateId(),
role: apiMessage.role as Role,
created: apiMessage.created ?? Math.floor(Date.now() / 1000),
content: apiMessage.content
.map((apiContent) => mapApiContentToFrontendMessageContent(apiContent))
.filter((content): content is FrontendMessageContent => content !== null),
};
}
// Function to convert API MessageContent to frontend MessageContent
function mapApiContentToFrontendMessageContent(
apiContent: ApiMessageContent
): FrontendMessageContent | null {
// Handle each content type specifically based on its "type" property
if (apiContent.type === 'text') {
return {
type: 'text',
text: apiContent.text,
annotations: apiContent.annotations as Record<string, unknown> | undefined,
};
} else if (apiContent.type === 'image') {
return {
type: 'image',
data: apiContent.data,
mimeType: apiContent.mimeType,
annotations: apiContent.annotations as Record<string, unknown> | undefined,
};
} else if (apiContent.type === 'toolRequest') {
// Ensure the toolCall has the correct type structure
const toolCall = apiContent.toolCall as unknown as ToolCallResult<ToolCall>;
return {
type: 'toolRequest',
id: apiContent.id,
toolCall: toolCall,
};
} else if (apiContent.type === 'toolResponse') {
// Ensure the toolResult has the correct type structure
const toolResult = apiContent.toolResult as unknown as ToolCallResult<FrontendContent[]>;
return {
type: 'toolResponse',
id: apiContent.id,
toolResult: toolResult,
};
} else if (apiContent.type === 'toolConfirmationRequest') {
return {
type: 'toolConfirmationRequest',
id: apiContent.id,
toolName: apiContent.toolName,
arguments: apiContent.arguments as Record<string, unknown>,
prompt: apiContent.prompt === null ? undefined : apiContent.prompt,
};
} else if (apiContent.type === 'contextLengthExceeded') {
return {
type: 'contextLengthExceeded',
msg: apiContent.msg,
};
} else if (apiContent.type === 'summarizationRequested') {
return {
type: 'summarizationRequested',
msg: apiContent.msg,
};
// Check for errors in the result
if (result.error) {
throw new Error(`Context management failed: ${result.error}`);
}
// For types that exist in API but not in frontend, either skip or convert
console.warn(`Skipping unsupported content type: ${apiContent.type}`);
return null;
}
export function createSummarizationRequestMessage(
messages: FrontendMessage[],
requestMessage: string
): FrontendMessage {
// Get the last message
const lastMessage = messages[messages.length - 1];
// Determine the next role (opposite of the last message)
const nextRole: Role = lastMessage.role === 'user' ? 'assistant' : 'user';
// Create the new message with SummarizationRequestedContent
return {
id: generateId(),
role: nextRole,
created: Math.floor(Date.now() / 1000),
content: [
{
type: 'summarizationRequested',
msg: requestMessage,
},
],
};
if (!result.data) {
throw new Error('Context management returned no data');
}
return result.data;
}
@@ -29,11 +29,9 @@ import {
import ProgressiveMessageList from '../ProgressiveMessageList';
import { SearchView } from '../conversation/SearchView';
import { ContextManagerProvider } from '../context_management/ContextManager';
import { Message } from '../../types/message';
import BackButton from '../ui/BackButton';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/Tooltip';
import { Session } from '../../api';
import { convertApiMessageToFrontendMessage } from '../context_management';
import { Message, Session } from '../../api';
// Helper function to determine if a message is a user message (same as useChatEngine)
const isUserMessage = (message: Message): boolean => {
@@ -153,7 +151,7 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
const [isCopied, setIsCopied] = useState(false);
const [canShare, setCanShare] = useState(false);
const messages = (session.conversation || []).map(convertApiMessageToFrontendMessage);
const messages = session.conversation || [];
useEffect(() => {
const savedSessionConfig = localStorage.getItem('session_sharing_config');
@@ -8,13 +8,13 @@ import MarkdownContent from '../MarkdownContent';
import ToolCallWithResponse from '../ToolCallWithResponse';
import ImagePreview from '../ImagePreview';
import {
getTextContent,
ToolRequestMessageContent,
ToolResponseMessageContent,
TextContent,
} from '../../types/message';
import { type Message } from '../../types/message';
import { formatMessageTimestamp } from '../../utils/timeUtils';
import { extractImagePaths, removeImagePathsFromText } from '../../utils/imageUtils';
import { Message } from '../../api';
/**
* Get tool responses map from messages
@@ -111,12 +111,7 @@ export const SessionMessages: React.FC<SessionMessagesProps> = ({
) : messages?.length > 0 ? (
messages
.map((message, index) => {
// Extract text content from the message
let textContent = message.content
.filter((c): c is TextContent => c.type === 'text')
.map((c) => c.text)
.join('\n');
const textContent = getTextContent(message);
// Extract image paths from the message
const imagePaths = extractImagePaths(textContent);
@@ -86,7 +86,9 @@ export function SessionInsights() {
const handleSessionClick = async (session: Session) => {
try {
resumeSession(session);
resumeSession(session, (sessionId: string) => {
navigate(`/pair?resumeSessionId=${sessionId}`);
});
} catch (error) {
console.error('Failed to start session:', error);
navigate('/sessions', {
+2 -12
View File
@@ -6,7 +6,6 @@ import { initializeCostDatabase } from '../utils/costDatabase';
import {
backupConfig,
initConfig,
Message as ApiMessage,
readAllConfig,
Recipe,
recoverConfig,
@@ -15,7 +14,6 @@ import {
validateConfig,
} from '../api';
import { COST_TRACKING_ENABLED } from '../updates';
import { convertApiMessageToFrontendMessage } from '../components/context_management';
export enum AgentState {
UNINITIALIZED = 'uninitialized',
@@ -77,9 +75,7 @@ export function useAgent(): UseAgentReturn {
sessionId: agentSession.id,
title: agentSession.recipe?.title || agentSession.description,
messageHistoryIndex: 0,
messages: messages?.map((message: ApiMessage) =>
convertApiMessageToFrontendMessage(message)
),
messages,
recipe: agentSession.recipe,
recipeParameters: agentSession.user_recipe_values || null,
};
@@ -159,13 +155,7 @@ export function useAgent(): UseAgentReturn {
const conversation = agentSession.conversation || [];
// If we're loading a recipe from initContext (new recipe load), start with empty messages
// Otherwise, use the messages from the session
const messages =
initContext.recipe && !initContext.resumeSessionId
? []
: conversation.map((message: ApiMessage) =>
convertApiMessageToFrontendMessage(message)
);
const messages = initContext.recipe && !initContext.resumeSessionId ? [] : conversation;
let initChat: ChatType = {
sessionId: agentSession.id,
title: agentSession.recipe?.title || agentSession.description,
+6 -5
View File
@@ -1,12 +1,13 @@
/**
* @vitest-environment jsdom
*/
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useChatEngine } from './useChatEngine';
import { Message, getTextContent } from '../types/message';
import { ChatType } from '../types/chat';
import { act, renderHook } from '@testing-library/react';
import type { Mock } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useChatEngine } from './useChatEngine';
import { getTextContent } from '../types/message';
import { Message } from '../api';
import { ChatType } from '../types/chat';
// Mock the useMessageStream hook which is a dependency of useChatEngine
vi.mock('./useMessageStream', () => ({
+7 -26
View File
@@ -2,20 +2,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { getApiUrl } from '../config';
import { useMessageStream } from './useMessageStream';
import { LocalMessageStorage } from '../utils/localMessageStorage';
import {
Message,
createUserMessage,
ToolCall,
ToolCallResult,
ToolRequestMessageContent,
ToolResponseMessageContent,
ToolConfirmationRequestMessageContent,
getTextContent,
TextContent,
} from '../types/message';
import { createUserMessage, getTextContent, ToolResponseMessageContent } from '../types/message';
import { getSession, Message } from '../api';
import { ChatType } from '../types/chat';
import { ChatState } from '../types/chatState';
import { getSession } from '../api';
// Helper function to determine if a message is a user message
const isUserMessage = (message: Message): boolean => {
@@ -308,11 +298,7 @@ export const useChatEngine = ({
// isUserMessage also checks if the message is a toolConfirmationRequest
// check if the last message is a real user's message
if (lastMessage && isUserMessage(lastMessage) && !isToolResponse) {
// Get the text content from the last message before removing it
const textContent = lastMessage.content.find((c): c is TextContent => c.type === 'text');
const textValue = textContent?.text || '';
// Set the text back to the input field
const textValue = getTextContent(lastMessage);
_setInput(textValue);
// Also add to local storage history as a backup so cmd+up can retrieve it
@@ -327,19 +313,15 @@ export const useChatEngine = ({
setMessages([]);
}
} else if (!isUserMessage(lastMessage)) {
// the last message was an assistant message
// check if we have any tool requests or tool confirmation requests
const toolRequests: [string, ToolCallResult<ToolCall>][] = lastMessage.content
const toolRequests: [string, Record<string, unknown>][] = lastMessage.content
.filter(
(content): content is ToolRequestMessageContent | ToolConfirmationRequestMessageContent =>
content.type === 'toolRequest' || content.type === 'toolConfirmationRequest'
(content) => content.type === 'toolRequest' || content.type === 'toolConfirmationRequest'
)
.map((content) => {
if (content.type === 'toolRequest') {
return [content.id, content.toolCall];
} else {
// extract tool call from confirmation
const toolCall: ToolCallResult<ToolCall> = {
const toolCall = {
status: 'success',
value: {
name: content.toolName,
@@ -391,8 +373,7 @@ export const useChatEngine = ({
return filteredMessages
.reduce<string[]>((history, message) => {
if (isUserMessage(message)) {
const textContent = message.content.find((c): c is TextContent => c.type === 'text');
const text = textContent?.text?.trim();
const text = getTextContent(message).trim();
if (text) {
history.push(text);
}
+140
View File
@@ -0,0 +1,140 @@
import { useState, useCallback, useRef } from 'react';
import { ChatState } from '../types/chatState';
import { Message } from '../api';
import { getApiUrl } from '../config';
const TextDecoder = globalThis.TextDecoder;
interface UseChatStreamProps {
sessionId: string;
messages: Message[];
setMessages: (messages: Message[]) => void;
onStreamFinish?: () => void;
}
function pushMessage(currentMessages: Message[], incomingMsg: Message): Message[] {
const lastMsg = currentMessages[currentMessages.length - 1];
if (lastMsg?.id && lastMsg.id === incomingMsg.id) {
const lastContent = lastMsg.content[lastMsg.content.length - 1];
const newContent = incomingMsg.content[incomingMsg.content.length - 1];
if (
lastContent?.type === 'text' &&
newContent?.type === 'text' &&
incomingMsg.content.length === 1
) {
lastContent.text += newContent.text;
} else {
lastMsg.content.push(...incomingMsg.content);
}
return [...currentMessages];
} else {
return [...currentMessages, incomingMsg];
}
}
export function useChatStream({
sessionId,
messages,
setMessages,
onStreamFinish,
}: UseChatStreamProps) {
const [chatState, setChatState] = useState<ChatState>(ChatState.Idle);
const abortControllerRef = useRef<AbortController | null>(null);
const handleSubmit = useCallback(
async (userMessage: string) => {
const newMessage: Message = {
role: 'user',
content: [{ type: 'text', text: userMessage }],
created: Date.now(),
};
let currentMessages = [...messages, newMessage];
setMessages(currentMessages);
setChatState(ChatState.Streaming);
abortControllerRef.current = new AbortController();
try {
// TODO(Douwe): this side steps our API. heyapi does support streaming though which should make
// this all nice & typed
const response = await fetch(getApiUrl('/reply'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Secret-Key': await window.electron.getSecretKey(),
},
body: JSON.stringify({
session_id: sessionId,
messages: currentMessages,
}),
signal: abortControllerRef.current.signal,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
if (!response.body) throw new Error('No response body');
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') continue;
try {
const event = JSON.parse(data);
if (event.message) {
const msg = event.message as Message;
currentMessages = pushMessage(currentMessages, msg);
setMessages(currentMessages);
}
if (event.error) {
console.error('Stream error:', event.error);
setChatState(ChatState.Idle);
return;
}
if (event.finish) {
setChatState(ChatState.Idle);
onStreamFinish?.();
return;
}
} catch (e) {
console.error('Failed to parse SSE:', e);
}
}
}
} catch (error) {
if (error instanceof Error && error.name !== 'AbortError') {
console.error('Stream error:', error);
}
setChatState(ChatState.Idle);
}
},
[sessionId, messages, setMessages, onStreamFinish]
);
const stopStreaming = useCallback(() => {
abortControllerRef.current?.abort();
setChatState(ChatState.Idle);
}, []);
return {
chatState,
handleSubmit,
stopStreaming,
};
}
+3 -1
View File
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useId, useReducer, useRef, useState } from 'react';
import useSWR from 'swr';
import { createUserMessage, hasCompletedToolCalls, Message, Role } from '../types/message';
import { createUserMessage, hasCompletedToolCalls } from '../types/message';
import { Message, Role } from '../api';
import { getSession, Session } from '../api';
import { ChatState } from '../types/chatState';
+3 -1
View File
@@ -1,6 +1,8 @@
import { useEffect, useMemo, useState, useRef } from 'react';
import { Recipe, scanRecipe } from '../recipe';
import { Message, createUserMessage } from '../types/message';
import { createUserMessage } from '../types/message';
import { Message } from '../api';
import {
updateSystemPromptWithParameters,
substituteParameters,
+16 -8
View File
@@ -1,16 +1,24 @@
import { Session } from './api';
export function resumeSession(session: Session) {
console.log('Launching session in new window:', session.description || session.id);
export function resumeSession(
session: Session,
navigateInSameWindow?: (sessionId: string) => void
) {
const workingDir = session.working_dir;
if (!workingDir) {
throw new Error('Cannot resume session: working directory is missing in session');
}
window.electron.createChatWindow(
undefined, // query
workingDir,
undefined, // version
session.id
);
// When ALPHA is true and we have a navigation callback, resume in the same window
// Otherwise, open in a new window (old behavior)
if (process.env.ALPHA && navigateInSameWindow) {
navigateInSameWindow(session.id);
} else {
window.electron.createChatWindow(
undefined, // query
workingDir,
undefined, // version
session.id
);
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { Message } from './types/message';
import { safeJsonParse } from './utils/jsonUtils';
import { Message } from './api';
export interface SharedSessionDetails {
share_token: string;
+1 -1
View File
@@ -1,5 +1,5 @@
import { Message } from './message';
import { Recipe } from '../recipe';
import { Message } from '../api';
export interface ChatType {
sessionId: string;
+12 -134
View File
@@ -1,116 +1,8 @@
/**
* Message types that match the Rust message structures
* for direct serialization between client and server
*/
import { Content, Message, ToolConfirmationRequest, ToolRequest, ToolResponse } from '../api';
export type Role = 'user' | 'assistant';
export type ToolRequestMessageContent = ToolRequest & { type: 'toolRequest' };
export type ToolResponseMessageContent = ToolResponse & { type: 'toolResponse' };
export interface TextContent {
type: 'text';
text: string;
annotations?: Record<string, unknown>;
}
export interface ImageContent {
type: 'image';
data: string;
mimeType: string;
annotations?: Record<string, unknown>;
}
export interface ResourceContent {
type: 'resource';
resource: {
uri: string;
mimeType: string;
text?: string;
blob?: string;
};
annotations?: Record<string, unknown>;
}
export type Content = TextContent | ImageContent | ResourceContent;
export interface ToolCall {
name: string;
arguments: Record<string, unknown>;
}
export interface ToolCallResult<T> {
status: 'success' | 'error';
value?: T;
error?: string;
}
export interface ToolRequest {
id: string;
toolCall: ToolCallResult<ToolCall>;
}
export interface ToolResponse {
id: string;
toolResult: ToolCallResult<Content[]>;
}
export interface ToolRequestMessageContent {
type: 'toolRequest';
id: string;
toolCall: ToolCallResult<ToolCall>;
}
export interface ToolResponseMessageContent {
type: 'toolResponse';
id: string;
toolResult: ToolCallResult<Content[]>;
}
export interface ToolConfirmationRequestMessageContent {
type: 'toolConfirmationRequest';
id: string;
toolName: string;
arguments: Record<string, unknown>;
prompt?: string;
}
export interface ExtensionCall {
name: string;
arguments: Record<string, unknown>;
extensionName: string;
}
export interface ExtensionCallResult<T> {
status: 'success' | 'error';
value?: T;
error?: string;
}
export interface ContextLengthExceededContent {
type: 'contextLengthExceeded';
msg: string;
}
export interface SummarizationRequestedContent {
type: 'summarizationRequested';
msg: string;
}
export type MessageContent =
| TextContent
| ImageContent
| ToolRequestMessageContent
| ToolResponseMessageContent
| ToolConfirmationRequestMessageContent
| ContextLengthExceededContent
| SummarizationRequestedContent;
export interface Message {
id?: string;
role: Role;
created: number;
content: MessageContent[];
}
// Helper functions to create messages
export function createUserMessage(text: string): Message {
return {
id: generateId(),
@@ -190,56 +82,42 @@ export function createToolErrorResponseMessage(id: string, error: string): Messa
};
}
// Generate a unique ID for messages
function generateId(): string {
return Math.random().toString(36).substring(2, 10);
}
// Helper functions to extract content from messages
export function getTextContent(message: Message): string {
return message.content
.filter(
(content): content is TextContent | ContextLengthExceededContent =>
content.type === 'text' || content.type === 'contextLengthExceeded'
)
.map((content) => {
if (content.type === 'text') {
return content.text;
} else if (content.type === 'contextLengthExceeded') {
return content.msg;
}
if (content.type === 'text') return content.text;
if (content.type === 'contextLengthExceeded') return content.msg;
return '';
})
.join('');
}
export function getToolRequests(message: Message): ToolRequestMessageContent[] {
export function getToolRequests(message: Message): (ToolRequest & { type: 'toolRequest' })[] {
return message.content.filter(
(content): content is ToolRequestMessageContent => content.type === 'toolRequest'
(content): content is ToolRequest & { type: 'toolRequest' } => content.type === 'toolRequest'
);
}
export function getToolResponses(message: Message): ToolResponseMessageContent[] {
export function getToolResponses(message: Message): (ToolResponse & { type: 'toolResponse' })[] {
return message.content.filter(
(content): content is ToolResponseMessageContent => content.type === 'toolResponse'
(content): content is ToolResponse & { type: 'toolResponse' } => content.type === 'toolResponse'
);
}
export function getToolConfirmationContent(
message: Message
): ToolConfirmationRequestMessageContent | undefined {
): (ToolConfirmationRequest & { type: 'toolConfirmationRequest' }) | undefined {
return message.content.find(
(content): content is ToolConfirmationRequestMessageContent =>
(content): content is ToolConfirmationRequest & { type: 'toolConfirmationRequest' } =>
content.type === 'toolConfirmationRequest'
);
}
export function hasCompletedToolCalls(message: Message): boolean {
const toolRequests = getToolRequests(message);
if (toolRequests.length === 0) return false;
// For now, we'll assume all tool calls are completed when this is checked
// In a real implementation, you'd need to check if all tool requests have responses
// by looking through subsequent messages
return true;
return toolRequests.length > 0;
}
+130
View File
@@ -0,0 +1,130 @@
import { Session } from '../api';
import { getApiUrl } from '../config';
/**
* In-memory cache for session data
* Maps session ID to Session object
*/
const sessionCache = new Map<string, Session>();
/**
* In-flight request tracking to prevent duplicate fetches
* Maps session ID to Promise of Session
*/
const inFlightRequests = new Map<string, Promise<Session>>();
/**
* Load a session from the server using the /agent/resume endpoint
* Implements caching to avoid redundant fetches
*
* @param sessionId - The unique identifier for the session
* @param forceRefresh - If true, bypass cache and fetch fresh data
* @returns Promise resolving to the Session object
* @throws Error if the request fails or session not found
*/
export async function loadSession(sessionId: string, forceRefresh = false): Promise<Session> {
if (!forceRefresh && sessionCache.has(sessionId)) {
return sessionCache.get(sessionId)!;
}
if (inFlightRequests.has(sessionId)) {
return inFlightRequests.get(sessionId)!;
}
const fetchPromise = (async () => {
try {
const url = getApiUrl('/agent/resume');
const secretKey = await window.electron.getSecretKey();
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Secret-Key': secretKey,
},
body: JSON.stringify({
session_id: sessionId,
}),
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Failed to load session: HTTP ${response.status} - ${errorText}`);
}
const session: Session = await response.json();
sessionCache.set(sessionId, session);
return session;
} catch (error) {
if (error instanceof Error) {
throw new Error(`Error loading session ${sessionId}: ${error.message}`);
}
throw new Error(`Error loading session ${sessionId}: Unknown error`);
} finally {
inFlightRequests.delete(sessionId);
}
})();
inFlightRequests.set(sessionId, fetchPromise);
return fetchPromise;
}
/**
* Clear a specific session from the cache
* Useful when a session has been updated and needs to be refetched
*
* @param sessionId - The unique identifier for the session to clear
*/
export function clearSessionCache(sessionId: string): void {
sessionCache.delete(sessionId);
}
/**
* Clear all sessions from the cache
* Useful for logout or when switching contexts
*/
export function clearAllSessionCache(): void {
sessionCache.clear();
}
/**
* Check if a session is currently cached
*
* @param sessionId - The unique identifier for the session
* @returns true if the session is in cache, false otherwise
*/
export function isSessionCached(sessionId: string): boolean {
return sessionCache.has(sessionId);
}
/**
* Get a session from cache without fetching
* Returns undefined if not cached
*
* @param sessionId - The unique identifier for the session
* @returns The cached Session object or undefined
*/
export function getCachedSession(sessionId: string): Session | undefined {
return sessionCache.get(sessionId);
}
/**
* Preload a session into cache
* Useful when you already have session data from another source
*
* @param session - The Session object to cache
*/
export function preloadSession(session: Session): void {
sessionCache.set(session.id, session);
}
/**
* Get the current cache size
* Useful for debugging and monitoring
*
* @returns The number of sessions currently cached
*/
export function getCacheSize(): number {
return sessionCache.size;
}
+2 -3
View File
@@ -1,6 +1,5 @@
export function formatMessageTimestamp(timestamp: number): string {
// Convert from Unix timestamp (seconds) to milliseconds
const date = new Date(timestamp * 1000);
export function formatMessageTimestamp(timestamp?: number): string {
const date = timestamp ? new Date(timestamp * 1000) : new Date();
const now = new Date();
// Format time as HH:MM AM/PM
+2 -1
View File
@@ -1,4 +1,5 @@
import { Message, getToolRequests, getTextContent, getToolResponses } from '../types/message';
import { getToolRequests, getTextContent, getToolResponses } from '../types/message';
import { Message } from '../api';
export function identifyConsecutiveToolCalls(messages: Message[]): number[][] {
const chains: number[][] = [];