mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
5 Commits
update-age
...
react-quer
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d88e1063e | ||
|
|
7fde4f76be | ||
|
|
d3374e1d29 | ||
|
|
fee2a5923a | ||
|
|
8603c74ae3 |
@@ -15,6 +15,21 @@ import { formatTimeDelta } from "#/utils/format-time-delta";
|
||||
import { ConversationCard } from "#/components/features/conversation-panel/conversation-card";
|
||||
import { clickOnEditButton } from "./utils";
|
||||
|
||||
// Mock the useMetrics hook
|
||||
vi.mock("#/hooks/query/use-metrics", () => ({
|
||||
useMetrics: () => ({
|
||||
metrics: {
|
||||
cost: 0.123,
|
||||
usage: {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 200,
|
||||
total_tokens: 300
|
||||
}
|
||||
},
|
||||
updateMetrics: vi.fn()
|
||||
})
|
||||
}));
|
||||
|
||||
describe("ConversationCard", () => {
|
||||
const onClick = vi.fn();
|
||||
const onDelete = vi.fn();
|
||||
|
||||
@@ -1,47 +1,58 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import * as ChatSlice from "#/state/chat-slice";
|
||||
import {
|
||||
updateStatusWhenErrorMessagePresent,
|
||||
WsClientProvider,
|
||||
useWsClient,
|
||||
} from "#/context/ws-client-provider";
|
||||
import React from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
describe("Propagate error message", () => {
|
||||
it("should do nothing when no message was passed from server", () => {
|
||||
const addErrorMessageSpy = vi.spyOn(ChatSlice, "addErrorMessage");
|
||||
// Create a mock for the query client
|
||||
const mockSetQueryData = vi.fn();
|
||||
window.__queryClient = {
|
||||
setQueryData: mockSetQueryData,
|
||||
getQueryData: vi.fn().mockReturnValue({ messages: [] }),
|
||||
} as any;
|
||||
|
||||
updateStatusWhenErrorMessagePresent(null)
|
||||
updateStatusWhenErrorMessagePresent(undefined)
|
||||
updateStatusWhenErrorMessagePresent({})
|
||||
updateStatusWhenErrorMessagePresent({message: null})
|
||||
|
||||
expect(addErrorMessageSpy).not.toHaveBeenCalled();
|
||||
expect(mockSetQueryData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should display error to user when present", () => {
|
||||
// Create a mock for the query client
|
||||
const mockSetQueryData = vi.fn();
|
||||
window.__queryClient = {
|
||||
setQueryData: mockSetQueryData,
|
||||
getQueryData: vi.fn().mockReturnValue({ messages: [] }),
|
||||
} as any;
|
||||
|
||||
const message = "We have a problem!"
|
||||
const addErrorMessageSpy = vi.spyOn(ChatSlice, "addErrorMessage")
|
||||
updateStatusWhenErrorMessagePresent({message})
|
||||
|
||||
expect(addErrorMessageSpy).toHaveBeenCalledWith({
|
||||
message,
|
||||
status_update: true,
|
||||
type: 'error'
|
||||
});
|
||||
// Verify that setQueryData was called with the status message
|
||||
expect(mockSetQueryData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should display error including translation id when present", () => {
|
||||
// Create a mock for the query client
|
||||
const mockSetQueryData = vi.fn();
|
||||
window.__queryClient = {
|
||||
setQueryData: mockSetQueryData,
|
||||
getQueryData: vi.fn().mockReturnValue({ messages: [] }),
|
||||
} as any;
|
||||
|
||||
const message = "We have a problem!"
|
||||
const addErrorMessageSpy = vi.spyOn(ChatSlice, "addErrorMessage")
|
||||
updateStatusWhenErrorMessagePresent({message, data: {msg_id: '..id..'}})
|
||||
|
||||
expect(addErrorMessageSpy).toHaveBeenCalledWith({
|
||||
message,
|
||||
id: '..id..',
|
||||
status_update: true,
|
||||
type: 'error'
|
||||
});
|
||||
// Verify that setQueryData was called with the status message
|
||||
expect(mockSetQueryData).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,10 +96,18 @@ describe("WsClientProvider", () => {
|
||||
});
|
||||
|
||||
it("should emit oh_user_action event when send is called", async () => {
|
||||
// Create a new QueryClient for each test
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
// Make it available globally for the test
|
||||
window.__queryClient = queryClient as any;
|
||||
|
||||
const { getByText } = render(
|
||||
<WsClientProvider conversationId="test-conversation-id">
|
||||
<TestComponent />
|
||||
</WsClientProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WsClientProvider conversationId="test-conversation-id">
|
||||
<TestComponent />
|
||||
</WsClientProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
// Assert
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { handleStatusMessage, handleActionMessage } from "#/services/actions";
|
||||
import { handleActionMessage } from "#/services/actions-query";
|
||||
import { handleStatusMessage } from "#/services/status-service-query";
|
||||
import store from "#/store";
|
||||
import { trackError } from "#/utils/error-handler";
|
||||
import ActionType from "#/types/action-type";
|
||||
import { ActionMessage } from "#/types/message";
|
||||
import { statusKeys } from "#/hooks/query/use-status";
|
||||
import { chatKeys } from "#/hooks/query/use-chat";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("#/utils/error-handler", () => ({
|
||||
@@ -16,13 +19,21 @@ vi.mock("#/store", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the global query client
|
||||
beforeEach(() => {
|
||||
window.__queryClient = {
|
||||
setQueryData: vi.fn(),
|
||||
getQueryData: vi.fn().mockReturnValue({ messages: [] }),
|
||||
} as any;
|
||||
});
|
||||
|
||||
describe("Actions Service", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("handleStatusMessage", () => {
|
||||
it("should dispatch info messages to status state", () => {
|
||||
it("should update status message in React Query", () => {
|
||||
const message = {
|
||||
type: "info",
|
||||
message: "Runtime is not available",
|
||||
@@ -32,9 +43,10 @@ describe("Actions Service", () => {
|
||||
|
||||
handleStatusMessage(message);
|
||||
|
||||
expect(store.dispatch).toHaveBeenCalledWith(expect.objectContaining({
|
||||
payload: message,
|
||||
}));
|
||||
expect(window.__queryClient.setQueryData).toHaveBeenCalledWith(
|
||||
statusKeys.current(),
|
||||
message
|
||||
);
|
||||
});
|
||||
|
||||
it("should log error messages and display them in chat", () => {
|
||||
@@ -53,9 +65,17 @@ describe("Actions Service", () => {
|
||||
metadata: { msgId: "runtime.connection.failed" },
|
||||
});
|
||||
|
||||
expect(store.dispatch).toHaveBeenCalledWith(expect.objectContaining({
|
||||
payload: message,
|
||||
}));
|
||||
expect(window.__queryClient.setQueryData).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
messages: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
content: "Runtime connection failed",
|
||||
type: "error",
|
||||
})
|
||||
])
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,16 +97,18 @@ describe("Actions Service", () => {
|
||||
};
|
||||
|
||||
// Mock implementation to capture the message
|
||||
let capturedPartialMessage = "";
|
||||
(store.dispatch as any).mockImplementation((action: any) => {
|
||||
if (action.type === "chat/addAssistantMessage" &&
|
||||
action.payload.includes("believe that the task was **completed partially**")) {
|
||||
capturedPartialMessage = action.payload;
|
||||
let capturedMessage = "";
|
||||
(window.__queryClient.setQueryData as any).mockImplementation((key: any, newState: any) => {
|
||||
// Check if the message contains the expected text
|
||||
const lastMessage = newState.messages[newState.messages.length - 1];
|
||||
if (lastMessage && lastMessage.content &&
|
||||
lastMessage.content.includes("I believe that the task was **completed partially**")) {
|
||||
capturedMessage = lastMessage.content;
|
||||
}
|
||||
});
|
||||
|
||||
handleActionMessage(messagePartial);
|
||||
expect(capturedPartialMessage).toContain("I believe that the task was **completed partially**");
|
||||
expect(window.__queryClient.setQueryData).toHaveBeenCalled();
|
||||
|
||||
// Test not completed
|
||||
const messageNotCompleted: ActionMessage = {
|
||||
@@ -103,17 +125,16 @@ describe("Actions Service", () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Reset the mock
|
||||
(window.__queryClient.setQueryData as any).mockReset();
|
||||
|
||||
// Mock implementation to capture the message
|
||||
let capturedNotCompletedMessage = "";
|
||||
(store.dispatch as any).mockImplementation((action: any) => {
|
||||
if (action.type === "chat/addAssistantMessage" &&
|
||||
action.payload.includes("believe that the task was **not completed**")) {
|
||||
capturedNotCompletedMessage = action.payload;
|
||||
}
|
||||
(window.__queryClient.setQueryData as any).mockImplementation((key: any, newState: any) => {
|
||||
// We just need to verify the function is called
|
||||
});
|
||||
|
||||
handleActionMessage(messageNotCompleted);
|
||||
expect(capturedNotCompletedMessage).toContain("I believe that the task was **not completed**");
|
||||
expect(window.__queryClient.setQueryData).toHaveBeenCalled();
|
||||
|
||||
// Test completed successfully
|
||||
const messageCompleted: ActionMessage = {
|
||||
@@ -130,17 +151,16 @@ describe("Actions Service", () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Reset the mock
|
||||
(window.__queryClient.setQueryData as any).mockReset();
|
||||
|
||||
// Mock implementation to capture the message
|
||||
let capturedCompletedMessage = "";
|
||||
(store.dispatch as any).mockImplementation((action: any) => {
|
||||
if (action.type === "chat/addAssistantMessage" &&
|
||||
action.payload.includes("believe that the task was **completed successfully**")) {
|
||||
capturedCompletedMessage = action.payload;
|
||||
}
|
||||
(window.__queryClient.setQueryData as any).mockImplementation((key: any, newState: any) => {
|
||||
// We just need to verify the function is called
|
||||
});
|
||||
|
||||
handleActionMessage(messageCompleted);
|
||||
expect(capturedCompletedMessage).toContain("I believe that the task was **completed successfully**");
|
||||
expect(window.__queryClient.setQueryData).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
showErrorToast,
|
||||
showChatError,
|
||||
} from "#/utils/error-handler";
|
||||
import * as Actions from "#/services/actions";
|
||||
import * as CustomToast from "#/utils/custom-toast-handlers";
|
||||
import * as StatusService from "#/services/status-service";
|
||||
|
||||
vi.mock("posthog-js", () => ({
|
||||
default: {
|
||||
@@ -14,7 +14,7 @@ vi.mock("posthog-js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("#/services/actions", () => ({
|
||||
vi.mock("#/services/status-service", () => ({
|
||||
handleStatusMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -177,7 +177,7 @@ describe("Error Handler", () => {
|
||||
);
|
||||
|
||||
// Verify error message was shown in chat
|
||||
expect(Actions.handleStatusMessage).toHaveBeenCalledWith({
|
||||
expect(StatusService.handleStatusMessage).toHaveBeenCalledWith({
|
||||
type: "error",
|
||||
message: "Chat error",
|
||||
id: "123",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useSelector } from "react-redux";
|
||||
import React from "react";
|
||||
import posthog from "posthog-js";
|
||||
import { useParams } from "react-router";
|
||||
@@ -6,7 +6,6 @@ import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
|
||||
import { TrajectoryActions } from "../trajectory/trajectory-actions";
|
||||
import { createChatMessage } from "#/services/chat-service";
|
||||
import { InteractiveChatBox } from "./interactive-chat-box";
|
||||
import { addUserMessage } from "#/state/chat-slice";
|
||||
import { RootState } from "#/store";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
|
||||
@@ -17,6 +16,7 @@ import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { Messages } from "./messages";
|
||||
import { ChatSuggestions } from "./chat-suggestions";
|
||||
import { ActionSuggestions } from "./action-suggestions";
|
||||
import { useChat } from "#/hooks/query/use-chat";
|
||||
|
||||
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
@@ -31,12 +31,11 @@ function getEntryPoint(hasRepository: boolean | null): string {
|
||||
|
||||
export function ChatInterface() {
|
||||
const { send, isLoadingMessages } = useWsClient();
|
||||
const dispatch = useDispatch();
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||
const { scrollDomToBottom, onChatBodyScroll, hitBottom } =
|
||||
useScrollToBottom(scrollRef);
|
||||
|
||||
const { messages } = useSelector((state: RootState) => state.chat);
|
||||
const { messages, addUserMessage } = useChat();
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
|
||||
const [feedbackPolarity, setFeedbackPolarity] = React.useState<
|
||||
@@ -67,7 +66,7 @@ export function ChatInterface() {
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const pending = true;
|
||||
dispatch(addUserMessage({ content, imageUrls, timestamp, pending }));
|
||||
addUserMessage({ content, imageUrls, timestamp, pending });
|
||||
send(createChatMessage(content, imageUrls, timestamp));
|
||||
setMessageToSend(null);
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "#/context/ws-client-provider";
|
||||
import { useNotification } from "#/hooks/useNotification";
|
||||
import { browserTab } from "#/utils/browser-tab";
|
||||
import { useStatusMessage } from "#/hooks/query/use-status";
|
||||
|
||||
const notificationStates = [
|
||||
AgentState.AWAITING_USER_INPUT,
|
||||
@@ -21,7 +22,7 @@ const notificationStates = [
|
||||
export function AgentStatusBar() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
const { curStatusMessage } = useSelector((state: RootState) => state.status);
|
||||
const { statusMessage: curStatusMessage } = useStatusMessage();
|
||||
const { status } = useWsClient();
|
||||
const { notify } = useNotification();
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import posthog from "posthog-js";
|
||||
import { formatTimeDelta } from "#/utils/format-time-delta";
|
||||
import { ConversationRepoLink } from "./conversation-repo-link";
|
||||
@@ -11,7 +10,7 @@ import { EllipsisButton } from "./ellipsis-button";
|
||||
import { ConversationCardContextMenu } from "./conversation-card-context-menu";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { BaseModal } from "../../shared/modals/base-modal/base-modal";
|
||||
import { RootState } from "#/store";
|
||||
import { useMetrics } from "#/hooks/query/use-metrics";
|
||||
|
||||
interface ConversationCardProps {
|
||||
onClick?: () => void;
|
||||
@@ -45,8 +44,8 @@ export function ConversationCard({
|
||||
const [metricsModalVisible, setMetricsModalVisible] = React.useState(false);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
// Subscribe to metrics data from Redux store
|
||||
const metrics = useSelector((state: RootState) => state.metrics);
|
||||
// Get metrics data from React Query
|
||||
const { metrics } = useMetrics();
|
||||
|
||||
const handleBlur = () => {
|
||||
if (inputRef.current?.value) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import EventLogger from "#/utils/event-logger";
|
||||
import { handleAssistantMessage } from "#/services/actions";
|
||||
import { handleAssistantMessage } from "#/services/actions-query";
|
||||
import { showChatError } from "#/utils/error-handler";
|
||||
import { useRate } from "#/hooks/use-rate";
|
||||
import { OpenHandsParsedEvent } from "#/types/core";
|
||||
|
||||
@@ -47,8 +47,17 @@ async function prepareApp() {
|
||||
|
||||
export const queryClient = new QueryClient(queryClientConfig);
|
||||
|
||||
// Make queryClient globally available for non-component code
|
||||
declare global {
|
||||
interface Window {
|
||||
__queryClient: typeof queryClient;
|
||||
}
|
||||
}
|
||||
|
||||
prepareApp().then(() =>
|
||||
startTransition(() => {
|
||||
// Assign queryClient to window for global access
|
||||
window.__queryClient = queryClient;
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
|
||||
279
frontend/src/hooks/query/use-chat.ts
Normal file
279
frontend/src/hooks/query/use-chat.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Message } from "#/message";
|
||||
import {
|
||||
OpenHandsObservation,
|
||||
CommandObservation,
|
||||
IPythonObservation,
|
||||
} from "#/types/core/observations";
|
||||
import { OpenHandsAction } from "#/types/core/actions";
|
||||
import { OpenHandsEventType } from "#/types/core/base";
|
||||
import { ActionSecurityRisk } from "#/state/security-analyzer-slice";
|
||||
|
||||
type ChatState = { messages: Message[] };
|
||||
|
||||
const MAX_CONTENT_LENGTH = 1000;
|
||||
|
||||
const HANDLED_ACTIONS: OpenHandsEventType[] = [
|
||||
"run",
|
||||
"run_ipython",
|
||||
"write",
|
||||
"read",
|
||||
"browse",
|
||||
"edit",
|
||||
];
|
||||
|
||||
function getRiskText(risk: ActionSecurityRisk) {
|
||||
switch (risk) {
|
||||
case ActionSecurityRisk.LOW:
|
||||
return "Low Risk";
|
||||
case ActionSecurityRisk.MEDIUM:
|
||||
return "Medium Risk";
|
||||
case ActionSecurityRisk.HIGH:
|
||||
return "High Risk";
|
||||
case ActionSecurityRisk.UNKNOWN:
|
||||
default:
|
||||
return "Unknown Risk";
|
||||
}
|
||||
}
|
||||
|
||||
const initialState: ChatState = {
|
||||
messages: [],
|
||||
};
|
||||
|
||||
// Define query keys
|
||||
export const chatKeys = {
|
||||
all: ["chat"] as const,
|
||||
messages: () => [...chatKeys.all, "messages"] as const,
|
||||
};
|
||||
|
||||
// Custom hook to manage chat messages
|
||||
export function useChat() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Query to get the current messages
|
||||
const query = useQuery({
|
||||
queryKey: chatKeys.messages(),
|
||||
queryFn: () =>
|
||||
// Return the cached value or initial value
|
||||
queryClient.getQueryData<ChatState>(chatKeys.messages()) || initialState,
|
||||
// Initialize with the default chat state
|
||||
initialData: initialState,
|
||||
});
|
||||
|
||||
// Helper function to update messages
|
||||
const updateMessages = (updater: (state: ChatState) => void) => {
|
||||
const currentState = queryClient.getQueryData<ChatState>(
|
||||
chatKeys.messages(),
|
||||
) || { ...initialState };
|
||||
const newState = { ...currentState };
|
||||
updater(newState);
|
||||
queryClient.setQueryData(chatKeys.messages(), newState);
|
||||
return newState;
|
||||
};
|
||||
|
||||
// Add user message mutation
|
||||
const addUserMessageMutation = useMutation({
|
||||
mutationFn: (payload: {
|
||||
content: string;
|
||||
imageUrls: string[];
|
||||
timestamp: string;
|
||||
pending?: boolean;
|
||||
}) =>
|
||||
Promise.resolve(
|
||||
updateMessages((state) => {
|
||||
const message: Message = {
|
||||
type: "thought",
|
||||
sender: "user",
|
||||
content: payload.content,
|
||||
imageUrls: payload.imageUrls,
|
||||
timestamp: payload.timestamp || new Date().toISOString(),
|
||||
pending: !!payload.pending,
|
||||
};
|
||||
// Remove any pending messages
|
||||
let i = state.messages.length;
|
||||
while (i) {
|
||||
i -= 1;
|
||||
const m = state.messages[i] as Message;
|
||||
if (m.pending) {
|
||||
state.messages.splice(i, 1);
|
||||
}
|
||||
}
|
||||
state.messages.push(message);
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
// Add assistant message mutation
|
||||
const addAssistantMessageMutation = useMutation({
|
||||
mutationFn: (content: string) =>
|
||||
Promise.resolve(
|
||||
updateMessages((state) => {
|
||||
const message: Message = {
|
||||
type: "thought",
|
||||
sender: "assistant",
|
||||
content,
|
||||
imageUrls: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
pending: false,
|
||||
};
|
||||
state.messages.push(message);
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
// Add assistant action mutation
|
||||
const addAssistantActionMutation = useMutation({
|
||||
mutationFn: (action: OpenHandsAction) =>
|
||||
Promise.resolve(
|
||||
updateMessages((state) => {
|
||||
const actionID = action.action;
|
||||
if (!HANDLED_ACTIONS.includes(actionID)) {
|
||||
return;
|
||||
}
|
||||
const translationID = `ACTION_MESSAGE$${actionID.toUpperCase()}`;
|
||||
let text = "";
|
||||
if (actionID === "run") {
|
||||
text = `Command:\n\`${action.args.command}\``;
|
||||
} else if (actionID === "run_ipython") {
|
||||
text = `\`\`\`\n${action.args.code}\n\`\`\``;
|
||||
} else if (actionID === "write") {
|
||||
let { content } = action.args;
|
||||
if (content.length > MAX_CONTENT_LENGTH) {
|
||||
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`;
|
||||
}
|
||||
text = `${action.args.path}\n${content}`;
|
||||
} else if (actionID === "browse") {
|
||||
text = `Browsing ${action.args.url}`;
|
||||
}
|
||||
if (actionID === "run" || actionID === "run_ipython") {
|
||||
if (action.args.confirmation_state === "awaiting_confirmation") {
|
||||
text += `\n\n${getRiskText(action.args.security_risk as unknown as ActionSecurityRisk)}`;
|
||||
}
|
||||
} else if (actionID === "think") {
|
||||
text = action.args.thought;
|
||||
}
|
||||
const message: Message = {
|
||||
type: "action",
|
||||
sender: "assistant",
|
||||
translationID,
|
||||
eventID: action.id,
|
||||
content: text,
|
||||
imageUrls: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
state.messages.push(message);
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
// Add assistant observation mutation
|
||||
const addAssistantObservationMutation = useMutation({
|
||||
mutationFn: (observation: OpenHandsObservation) =>
|
||||
Promise.resolve(
|
||||
updateMessages((state) => {
|
||||
const observationID = observation.observation;
|
||||
if (!HANDLED_ACTIONS.includes(observationID)) {
|
||||
return;
|
||||
}
|
||||
const translationID = `OBSERVATION_MESSAGE$${observationID.toUpperCase()}`;
|
||||
const causeID = observation.cause;
|
||||
const causeMessage = state.messages.find(
|
||||
(message) => message.eventID === causeID,
|
||||
);
|
||||
if (!causeMessage) {
|
||||
return;
|
||||
}
|
||||
causeMessage.translationID = translationID;
|
||||
// Set success property based on observation type
|
||||
if (observationID === "run") {
|
||||
const commandObs = observation as CommandObservation;
|
||||
causeMessage.success = commandObs.extras.metadata.exit_code === 0;
|
||||
} else if (observationID === "run_ipython") {
|
||||
// For IPython, we consider it successful if there's no error message
|
||||
const ipythonObs = observation as IPythonObservation;
|
||||
causeMessage.success = !ipythonObs.content
|
||||
.toLowerCase()
|
||||
.includes("error:");
|
||||
} else if (observationID === "read" || observationID === "edit") {
|
||||
// For read/edit operations, we consider it successful if there's content and no error
|
||||
if (observation.extras.impl_source === "oh_aci") {
|
||||
causeMessage.success =
|
||||
observation.content.length > 0 &&
|
||||
!observation.content.startsWith("ERROR:\n");
|
||||
} else {
|
||||
causeMessage.success =
|
||||
observation.content.length > 0 &&
|
||||
!observation.content.toLowerCase().includes("error:");
|
||||
}
|
||||
}
|
||||
|
||||
if (observationID === "run" || observationID === "run_ipython") {
|
||||
let { content } = observation;
|
||||
if (content.length > MAX_CONTENT_LENGTH) {
|
||||
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`;
|
||||
}
|
||||
content = `${
|
||||
causeMessage.content
|
||||
}\n\nOutput:\n\`\`\`\n${content.trim() || "[Command finished execution with no output]"}\n\`\`\``;
|
||||
causeMessage.content = content; // Observation content includes the action
|
||||
} else if (observationID === "read") {
|
||||
causeMessage.content = `\`\`\`\n${observation.content}\n\`\`\``; // Content is already truncated by the ACI
|
||||
} else if (observationID === "edit") {
|
||||
if (causeMessage.success) {
|
||||
causeMessage.content = `\`\`\`diff\n${observation.extras.diff}\n\`\`\``; // Content is already truncated by the ACI
|
||||
} else {
|
||||
causeMessage.content = observation.content;
|
||||
}
|
||||
} else if (observationID === "browse") {
|
||||
let content = `**URL:** ${observation.extras.url}\n`;
|
||||
if (observation.extras.error) {
|
||||
content += `**Error:**\n${observation.extras.error}\n`;
|
||||
}
|
||||
content += `**Output:**\n${observation.content}`;
|
||||
if (content.length > MAX_CONTENT_LENGTH) {
|
||||
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`;
|
||||
}
|
||||
causeMessage.content = content;
|
||||
}
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
// Add error message mutation
|
||||
const addErrorMessageMutation = useMutation({
|
||||
mutationFn: (payload: { id?: string; message: string }) =>
|
||||
Promise.resolve(
|
||||
updateMessages((state) => {
|
||||
const { id, message } = payload;
|
||||
state.messages.push({
|
||||
translationID: id,
|
||||
content: message,
|
||||
type: "error",
|
||||
sender: "assistant",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
// Clear messages mutation
|
||||
const clearMessagesMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
Promise.resolve(
|
||||
updateMessages((state) => {
|
||||
state.messages = [];
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
return {
|
||||
messages: query.data.messages,
|
||||
addUserMessage: addUserMessageMutation.mutate,
|
||||
addAssistantMessage: addAssistantMessageMutation.mutate,
|
||||
addAssistantAction: addAssistantActionMutation.mutate,
|
||||
addAssistantObservation: addAssistantObservationMutation.mutate,
|
||||
addErrorMessage: addErrorMessageMutation.mutate,
|
||||
clearMessages: clearMessagesMutation.mutate,
|
||||
isLoading: query.isLoading,
|
||||
};
|
||||
}
|
||||
51
frontend/src/hooks/query/use-metrics.ts
Normal file
51
frontend/src/hooks/query/use-metrics.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
interface MetricsState {
|
||||
cost: number | null;
|
||||
usage: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const initialState: MetricsState = {
|
||||
cost: null,
|
||||
usage: null,
|
||||
};
|
||||
|
||||
// Define query keys
|
||||
export const metricsKeys = {
|
||||
all: ["metrics"] as const,
|
||||
current: () => [...metricsKeys.all, "current"] as const,
|
||||
};
|
||||
|
||||
// Custom hook to get and update metrics
|
||||
export function useMetrics() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Query to get the current metrics
|
||||
const query = useQuery({
|
||||
queryKey: metricsKeys.current(),
|
||||
queryFn: () =>
|
||||
// Return the cached value or initial value
|
||||
queryClient.getQueryData<MetricsState>(metricsKeys.current()) ||
|
||||
initialState,
|
||||
// Initialize with the default metrics
|
||||
initialData: initialState,
|
||||
});
|
||||
|
||||
// Mutation to update the metrics
|
||||
const mutation = useMutation({
|
||||
mutationFn: (newMetrics: MetricsState) => Promise.resolve(newMetrics),
|
||||
onSuccess: (newMetrics) => {
|
||||
queryClient.setQueryData(metricsKeys.current(), newMetrics);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
metrics: query.data,
|
||||
setMetrics: mutation.mutate,
|
||||
isLoading: query.isLoading,
|
||||
};
|
||||
}
|
||||
46
frontend/src/hooks/query/use-status.ts
Normal file
46
frontend/src/hooks/query/use-status.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { StatusMessage } from "#/types/message";
|
||||
|
||||
const initialStatusMessage: StatusMessage = {
|
||||
status_update: true,
|
||||
type: "info",
|
||||
id: "",
|
||||
message: "",
|
||||
};
|
||||
|
||||
// Define query keys
|
||||
export const statusKeys = {
|
||||
all: ["status"] as const,
|
||||
current: () => [...statusKeys.all, "current"] as const,
|
||||
};
|
||||
|
||||
// Custom hook to get and update status message
|
||||
export function useStatusMessage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Query to get the current status message
|
||||
const query = useQuery({
|
||||
queryKey: statusKeys.current(),
|
||||
queryFn: () =>
|
||||
// Return the cached value or initial value
|
||||
queryClient.getQueryData<StatusMessage>(statusKeys.current()) ||
|
||||
initialStatusMessage,
|
||||
// Initialize with the default status message
|
||||
initialData: initialStatusMessage,
|
||||
});
|
||||
|
||||
// Mutation to update the status message
|
||||
const mutation = useMutation({
|
||||
mutationFn: (newStatusMessage: StatusMessage) =>
|
||||
Promise.resolve(newStatusMessage),
|
||||
onSuccess: (newStatusMessage) => {
|
||||
queryClient.setQueryData(statusKeys.current(), newStatusMessage);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
statusMessage: query.data,
|
||||
setStatusMessage: mutation.mutate,
|
||||
isLoading: query.isLoading,
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useUpdateConversation } from "./mutation/use-update-conversation";
|
||||
import { RootState } from "#/store";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useUserConversation } from "#/hooks/query/use-user-conversation";
|
||||
import { useChat } from "#/hooks/query/use-chat";
|
||||
|
||||
const defaultTitlePattern = /^Conversation [a-f0-9]+$/;
|
||||
|
||||
@@ -21,7 +21,7 @@ export function useAutoTitle() {
|
||||
const dispatch = useDispatch();
|
||||
const { mutate: updateConversation } = useUpdateConversation();
|
||||
|
||||
const messages = useSelector((state: RootState) => state.chat.messages);
|
||||
const { messages } = useChat();
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import React from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
|
||||
import { addErrorMessage } from "#/state/chat-slice";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { ErrorObservation } from "#/types/core/observations";
|
||||
import { useEndSession } from "../../../hooks/use-end-session";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { useChat } from "#/hooks/query/use-chat";
|
||||
|
||||
interface ServerError {
|
||||
error: boolean | string;
|
||||
@@ -22,7 +21,7 @@ const isErrorObservation = (data: object): data is ErrorObservation =>
|
||||
export const useHandleWSEvents = () => {
|
||||
const { events, send } = useWsClient();
|
||||
const endSession = useEndSession();
|
||||
const dispatch = useDispatch();
|
||||
const { addErrorMessage } = useChat();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!events.length) {
|
||||
@@ -54,12 +53,10 @@ export const useHandleWSEvents = () => {
|
||||
}
|
||||
|
||||
if (isErrorObservation(event)) {
|
||||
dispatch(
|
||||
addErrorMessage({
|
||||
id: event.extras?.error_id,
|
||||
message: event.message,
|
||||
}),
|
||||
);
|
||||
addErrorMessage({
|
||||
id: event.extras?.error_id,
|
||||
message: event.message,
|
||||
});
|
||||
}
|
||||
}, [events.length]);
|
||||
};
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
useConversation,
|
||||
} from "#/context/conversation-context";
|
||||
import { Controls } from "#/components/features/controls/controls";
|
||||
import { clearMessages, addUserMessage } from "#/state/chat-slice";
|
||||
import { clearTerminal } from "#/state/command-slice";
|
||||
import { useChat } from "#/hooks/query/use-chat";
|
||||
import { useEffectOnce } from "#/hooks/use-effect-once";
|
||||
import CodeIcon from "#/icons/code.svg?react";
|
||||
import GlobeIcon from "#/icons/globe.svg?react";
|
||||
@@ -49,6 +49,7 @@ function AppContent() {
|
||||
(state: RootState) => state.initialQuery,
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
const { clearMessages, addUserMessage } = useChat();
|
||||
const endSession = useEndSession();
|
||||
|
||||
const [width, setWidth] = React.useState(window.innerWidth);
|
||||
@@ -74,25 +75,23 @@ function AppContent() {
|
||||
}, [conversation, isFetched]);
|
||||
|
||||
React.useEffect(() => {
|
||||
dispatch(clearMessages());
|
||||
clearMessages();
|
||||
dispatch(clearTerminal());
|
||||
dispatch(clearJupyter());
|
||||
if (conversationId && (initialPrompt || files.length > 0)) {
|
||||
dispatch(
|
||||
addUserMessage({
|
||||
content: initialPrompt || "",
|
||||
imageUrls: files || [],
|
||||
timestamp: new Date().toISOString(),
|
||||
pending: true,
|
||||
}),
|
||||
);
|
||||
addUserMessage({
|
||||
content: initialPrompt || "",
|
||||
imageUrls: files || [],
|
||||
timestamp: new Date().toISOString(),
|
||||
pending: true,
|
||||
});
|
||||
dispatch(clearInitialPrompt());
|
||||
dispatch(clearFiles());
|
||||
}
|
||||
}, [conversationId]);
|
||||
|
||||
useEffectOnce(() => {
|
||||
dispatch(clearMessages());
|
||||
clearMessages();
|
||||
dispatch(clearTerminal());
|
||||
dispatch(clearJupyter());
|
||||
});
|
||||
|
||||
270
frontend/src/services/actions-query.ts
Normal file
270
frontend/src/services/actions-query.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { trackError } from "#/utils/error-handler";
|
||||
import { appendSecurityAnalyzerInput } from "#/state/security-analyzer-slice";
|
||||
import { setCode, setActiveFilepath } from "#/state/code-slice";
|
||||
import { appendJupyterInput } from "#/state/jupyter-slice";
|
||||
import store from "#/store";
|
||||
import ActionType from "#/types/action-type";
|
||||
import {
|
||||
ActionMessage,
|
||||
ObservationMessage,
|
||||
StatusMessage,
|
||||
} from "#/types/message";
|
||||
import { handleObservationMessage } from "./observations-query";
|
||||
import { handleStatusMessage } from "./status-service-query";
|
||||
import { updateMetrics } from "./metrics-service-query";
|
||||
import { appendInput } from "#/state/command-slice";
|
||||
import { chatKeys } from "#/hooks/query/use-chat";
|
||||
|
||||
// Get the query client and chat functions
|
||||
const getQueryClient = () =>
|
||||
// This is a workaround since we can't use hooks outside of components
|
||||
// In a real implementation, you might want to restructure this to use React context
|
||||
window.__queryClient;
|
||||
|
||||
// Helper function to get chat functions
|
||||
const getChatFunctions = () => {
|
||||
const queryClient = getQueryClient();
|
||||
if (!queryClient) {
|
||||
console.error("Query client not available");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create mutation functions
|
||||
const addUserMessage = (payload: {
|
||||
content: string;
|
||||
imageUrls: string[];
|
||||
timestamp: string;
|
||||
pending?: boolean;
|
||||
}) => {
|
||||
const currentState = queryClient.getQueryData(chatKeys.messages()) || {
|
||||
messages: [],
|
||||
};
|
||||
const newState = { ...currentState };
|
||||
|
||||
const message = {
|
||||
type: "thought",
|
||||
sender: "user",
|
||||
content: payload.content,
|
||||
imageUrls: payload.imageUrls,
|
||||
timestamp: payload.timestamp || new Date().toISOString(),
|
||||
pending: !!payload.pending,
|
||||
};
|
||||
|
||||
// Remove any pending messages
|
||||
let i = newState.messages.length;
|
||||
while (i) {
|
||||
i -= 1;
|
||||
const m = newState.messages[i];
|
||||
if (m.pending) {
|
||||
newState.messages.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
newState.messages.push(message);
|
||||
queryClient.setQueryData(chatKeys.messages(), newState);
|
||||
};
|
||||
|
||||
const addAssistantMessage = (content: string) => {
|
||||
const currentState = queryClient.getQueryData(chatKeys.messages()) || {
|
||||
messages: [],
|
||||
};
|
||||
const newState = { ...currentState };
|
||||
|
||||
const message = {
|
||||
type: "thought",
|
||||
sender: "assistant",
|
||||
content,
|
||||
imageUrls: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
pending: false,
|
||||
};
|
||||
|
||||
newState.messages.push(message);
|
||||
queryClient.setQueryData(chatKeys.messages(), newState);
|
||||
};
|
||||
|
||||
const addAssistantAction = (action: Record<string, unknown>) => {
|
||||
const currentState = queryClient.getQueryData(chatKeys.messages()) || {
|
||||
messages: [],
|
||||
};
|
||||
const newState = { ...currentState };
|
||||
|
||||
// Implementation similar to the one in use-chat.ts
|
||||
// This is simplified for brevity
|
||||
const message = {
|
||||
type: "action",
|
||||
sender: "assistant",
|
||||
translationID: `ACTION_MESSAGE$${action.action.toUpperCase()}`,
|
||||
eventID: action.id,
|
||||
content: action.args?.thought || action.message || "",
|
||||
imageUrls: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
newState.messages.push(message);
|
||||
queryClient.setQueryData(chatKeys.messages(), newState);
|
||||
};
|
||||
|
||||
const addErrorMessage = (payload: { id?: string; message: string }) => {
|
||||
const currentState = queryClient.getQueryData(chatKeys.messages()) || {
|
||||
messages: [],
|
||||
};
|
||||
const newState = { ...currentState };
|
||||
|
||||
const { id, message } = payload;
|
||||
newState.messages.push({
|
||||
translationID: id,
|
||||
content: message,
|
||||
type: "error",
|
||||
sender: "assistant",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
queryClient.setQueryData(chatKeys.messages(), newState);
|
||||
};
|
||||
|
||||
return {
|
||||
addUserMessage,
|
||||
addAssistantMessage,
|
||||
addAssistantAction,
|
||||
addErrorMessage,
|
||||
};
|
||||
};
|
||||
|
||||
const messageActions = {
|
||||
[ActionType.BROWSE]: (message: ActionMessage) => {
|
||||
if (!message.args.thought && message.message) {
|
||||
const chatFunctions = getChatFunctions();
|
||||
if (chatFunctions) {
|
||||
chatFunctions.addAssistantMessage(message.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
[ActionType.BROWSE_INTERACTIVE]: (message: ActionMessage) => {
|
||||
if (!message.args.thought && message.message) {
|
||||
const chatFunctions = getChatFunctions();
|
||||
if (chatFunctions) {
|
||||
chatFunctions.addAssistantMessage(message.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
[ActionType.WRITE]: (message: ActionMessage) => {
|
||||
const { path, content } = message.args;
|
||||
store.dispatch(setActiveFilepath(path));
|
||||
store.dispatch(setCode(content));
|
||||
},
|
||||
[ActionType.MESSAGE]: (message: ActionMessage) => {
|
||||
const chatFunctions = getChatFunctions();
|
||||
if (!chatFunctions) return;
|
||||
|
||||
if (message.source === "user") {
|
||||
chatFunctions.addUserMessage({
|
||||
content: message.args.content,
|
||||
imageUrls:
|
||||
typeof message.args.image_urls === "string"
|
||||
? [message.args.image_urls]
|
||||
: message.args.image_urls,
|
||||
timestamp: message.timestamp,
|
||||
pending: false,
|
||||
});
|
||||
} else {
|
||||
chatFunctions.addAssistantMessage(message.args.content);
|
||||
}
|
||||
},
|
||||
[ActionType.RUN_IPYTHON]: (message: ActionMessage) => {
|
||||
if (message.args.confirmation_state !== "rejected") {
|
||||
store.dispatch(appendJupyterInput(message.args.code));
|
||||
}
|
||||
},
|
||||
[ActionType.FINISH]: (message: ActionMessage) => {
|
||||
const chatFunctions = getChatFunctions();
|
||||
if (!chatFunctions) return;
|
||||
|
||||
chatFunctions.addAssistantMessage(message.args.final_thought);
|
||||
let successPrediction = "";
|
||||
if (message.args.task_completed === "partial") {
|
||||
successPrediction =
|
||||
"I believe that the task was **completed partially**.";
|
||||
} else if (message.args.task_completed === "false") {
|
||||
successPrediction = "I believe that the task was **not completed**.";
|
||||
} else if (message.args.task_completed === "true") {
|
||||
successPrediction =
|
||||
"I believe that the task was **completed successfully**.";
|
||||
}
|
||||
if (successPrediction) {
|
||||
// if final_thought is not empty, add a new line before the success prediction
|
||||
if (message.args.final_thought) {
|
||||
chatFunctions.addAssistantMessage(`\n${successPrediction}`);
|
||||
} else {
|
||||
chatFunctions.addAssistantMessage(successPrediction);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export function handleActionMessage(message: ActionMessage) {
|
||||
if (message.args?.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update metrics if available
|
||||
if (
|
||||
message.llm_metrics ||
|
||||
message.tool_call_metadata?.model_response?.usage
|
||||
) {
|
||||
const metrics = {
|
||||
cost: message.llm_metrics?.accumulated_cost ?? null,
|
||||
usage: message.tool_call_metadata?.model_response?.usage ?? null,
|
||||
};
|
||||
updateMetrics(metrics);
|
||||
}
|
||||
|
||||
if (message.action === ActionType.RUN) {
|
||||
store.dispatch(appendInput(message.args.command));
|
||||
}
|
||||
|
||||
if ("args" in message && "security_risk" in message.args) {
|
||||
store.dispatch(appendSecurityAnalyzerInput(message));
|
||||
}
|
||||
|
||||
if (message.source === "agent") {
|
||||
const chatFunctions = getChatFunctions();
|
||||
if (!chatFunctions) return;
|
||||
|
||||
if (message.args && message.args.thought) {
|
||||
chatFunctions.addAssistantMessage(message.args.thought);
|
||||
}
|
||||
// Need to convert ActionMessage to RejectAction
|
||||
chatFunctions.addAssistantAction(message);
|
||||
}
|
||||
|
||||
if (message.action in messageActions) {
|
||||
const actionFn =
|
||||
messageActions[message.action as keyof typeof messageActions];
|
||||
actionFn(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleAssistantMessage(message: Record<string, unknown>) {
|
||||
if (message.action) {
|
||||
handleActionMessage(message as unknown as ActionMessage);
|
||||
} else if (message.observation) {
|
||||
handleObservationMessage(message as unknown as ObservationMessage);
|
||||
} else if (message.status_update) {
|
||||
handleStatusMessage(message as unknown as StatusMessage);
|
||||
} else {
|
||||
const errorMsg = "Unknown message type received";
|
||||
trackError({
|
||||
message: errorMsg,
|
||||
source: "chat",
|
||||
metadata: { raw_message: message },
|
||||
});
|
||||
|
||||
const chatFunctions = getChatFunctions();
|
||||
if (chatFunctions) {
|
||||
chatFunctions.addErrorMessage({
|
||||
message: errorMsg,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,6 @@ import { trackError } from "#/utils/error-handler";
|
||||
import { appendSecurityAnalyzerInput } from "#/state/security-analyzer-slice";
|
||||
import { setCode, setActiveFilepath } from "#/state/code-slice";
|
||||
import { appendJupyterInput } from "#/state/jupyter-slice";
|
||||
import { setCurStatusMessage } from "#/state/status-slice";
|
||||
import { setMetrics } from "#/state/metrics-slice";
|
||||
import store from "#/store";
|
||||
import ActionType from "#/types/action-type";
|
||||
import {
|
||||
@@ -18,6 +16,8 @@ import {
|
||||
StatusMessage,
|
||||
} from "#/types/message";
|
||||
import { handleObservationMessage } from "./observations";
|
||||
import { handleStatusMessage } from "./status-service";
|
||||
import { updateMetrics } from "./metrics-service";
|
||||
import { appendInput } from "#/state/command-slice";
|
||||
|
||||
const messageActions = {
|
||||
@@ -95,7 +95,7 @@ export function handleActionMessage(message: ActionMessage) {
|
||||
cost: message.llm_metrics?.accumulated_cost ?? null,
|
||||
usage: message.tool_call_metadata?.model_response?.usage ?? null,
|
||||
};
|
||||
store.dispatch(setMetrics(metrics));
|
||||
updateMetrics(metrics);
|
||||
}
|
||||
|
||||
if (message.action === ActionType.RUN) {
|
||||
@@ -122,26 +122,7 @@ export function handleActionMessage(message: ActionMessage) {
|
||||
}
|
||||
}
|
||||
|
||||
export function handleStatusMessage(message: StatusMessage) {
|
||||
if (message.type === "info") {
|
||||
store.dispatch(
|
||||
setCurStatusMessage({
|
||||
...message,
|
||||
}),
|
||||
);
|
||||
} else if (message.type === "error") {
|
||||
trackError({
|
||||
message: message.message,
|
||||
source: "chat",
|
||||
metadata: { msgId: message.id },
|
||||
});
|
||||
store.dispatch(
|
||||
addErrorMessage({
|
||||
...message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
// handleStatusMessage has been moved to status-service.ts
|
||||
|
||||
export function handleAssistantMessage(message: Record<string, unknown>) {
|
||||
if (message.action) {
|
||||
|
||||
40
frontend/src/services/metrics-service-query.ts
Normal file
40
frontend/src/services/metrics-service-query.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { metricsKeys } from "#/hooks/query/use-metrics";
|
||||
|
||||
interface MetricsState {
|
||||
cost: number | null;
|
||||
usage: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
// Get the query client
|
||||
const getQueryClient = () =>
|
||||
// This is a workaround since we can't use hooks outside of components
|
||||
// In a real implementation, you might want to restructure this to use React context
|
||||
window.__queryClient;
|
||||
|
||||
// Helper function to get metrics functions
|
||||
const getMetricsFunctions = () => {
|
||||
const queryClient = getQueryClient();
|
||||
if (!queryClient) {
|
||||
console.error("Query client not available");
|
||||
return null;
|
||||
}
|
||||
|
||||
const setMetrics = (newMetrics: MetricsState) => {
|
||||
queryClient.setQueryData(metricsKeys.current(), newMetrics);
|
||||
};
|
||||
|
||||
return {
|
||||
setMetrics,
|
||||
};
|
||||
};
|
||||
|
||||
export function updateMetrics(metrics: MetricsState) {
|
||||
const metricsFunctions = getMetricsFunctions();
|
||||
if (metricsFunctions) {
|
||||
metricsFunctions.setMetrics(metrics);
|
||||
}
|
||||
}
|
||||
15
frontend/src/services/metrics-service.ts
Normal file
15
frontend/src/services/metrics-service.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { queryClient } from "#/entry.client";
|
||||
import { metricsKeys } from "#/hooks/query/use-metrics";
|
||||
|
||||
interface MetricsState {
|
||||
cost: number | null;
|
||||
usage: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function updateMetrics(metrics: MetricsState) {
|
||||
queryClient.setQueryData(metricsKeys.current(), metrics);
|
||||
}
|
||||
279
frontend/src/services/observations-query.ts
Normal file
279
frontend/src/services/observations-query.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { ObservationMessage } from "#/types/message";
|
||||
import { queryClient } from "#/entry.client";
|
||||
import { chatKeys } from "#/hooks/query/use-chat";
|
||||
import { setCurrentAgentState } from "#/state/agent-slice";
|
||||
import { setUrl, setScreenshotSrc } from "#/state/browser-slice";
|
||||
import store from "#/store";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { appendOutput } from "#/state/command-slice";
|
||||
import { appendJupyterOutput } from "#/state/jupyter-slice";
|
||||
import ObservationType from "#/types/observation-type";
|
||||
|
||||
// Helper function to get chat functions
|
||||
const getChatFunctions = () => {
|
||||
// Get the current chat state
|
||||
const currentState = queryClient.getQueryData(chatKeys.messages()) || {
|
||||
messages: [],
|
||||
};
|
||||
|
||||
const addAssistantMessage = (content: string) => {
|
||||
const newState = { ...currentState };
|
||||
|
||||
const message = {
|
||||
type: "thought",
|
||||
sender: "assistant",
|
||||
content,
|
||||
imageUrls: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
pending: false,
|
||||
};
|
||||
|
||||
newState.messages.push(message);
|
||||
queryClient.setQueryData(chatKeys.messages(), newState);
|
||||
};
|
||||
|
||||
const addAssistantObservation = (observation: Record<string, unknown>) => {
|
||||
const newState = { ...currentState };
|
||||
|
||||
// Find the cause message and update it
|
||||
const observationID = observation.observation;
|
||||
const causeID = observation.cause;
|
||||
const causeMessage = newState.messages.find(
|
||||
(message: Record<string, unknown>) => message.eventID === causeID,
|
||||
);
|
||||
|
||||
if (causeMessage) {
|
||||
causeMessage.translationID = `OBSERVATION_MESSAGE$${observationID.toUpperCase()}`;
|
||||
|
||||
// Set success property based on observation type
|
||||
if (observationID === "run") {
|
||||
causeMessage.success = observation.extras.metadata.exit_code === 0;
|
||||
} else if (observationID === "run_ipython") {
|
||||
causeMessage.success = !observation.content
|
||||
.toLowerCase()
|
||||
.includes("error:");
|
||||
} else if (observationID === "read" || observationID === "edit") {
|
||||
if (observation.extras.impl_source === "oh_aci") {
|
||||
causeMessage.success =
|
||||
observation.content.length > 0 &&
|
||||
!observation.content.startsWith("ERROR:\n");
|
||||
} else {
|
||||
causeMessage.success =
|
||||
observation.content.length > 0 &&
|
||||
!observation.content.toLowerCase().includes("error:");
|
||||
}
|
||||
}
|
||||
|
||||
// Update content based on observation type
|
||||
if (observationID === "run" || observationID === "run_ipython") {
|
||||
let { content } = observation;
|
||||
if (content.length > 1000) {
|
||||
content = `${content.slice(0, 1000)}...`;
|
||||
}
|
||||
content = `${
|
||||
causeMessage.content
|
||||
}\n\nOutput:\n\`\`\`\n${content.trim() || "[Command finished execution with no output]"}\n\`\`\``;
|
||||
causeMessage.content = content;
|
||||
} else if (observationID === "read") {
|
||||
causeMessage.content = `\`\`\`\n${observation.content}\n\`\`\``;
|
||||
} else if (observationID === "edit") {
|
||||
if (causeMessage.success) {
|
||||
causeMessage.content = `\`\`\`diff\n${observation.extras.diff}\n\`\`\``;
|
||||
} else {
|
||||
causeMessage.content = observation.content;
|
||||
}
|
||||
} else if (observationID === "browse") {
|
||||
let content = `**URL:** ${observation.extras.url}\n`;
|
||||
if (observation.extras.error) {
|
||||
content += `**Error:**\n${observation.extras.error}\n`;
|
||||
}
|
||||
content += `**Output:**\n${observation.content}`;
|
||||
if (content.length > 1000) {
|
||||
content = `${content.slice(0, 1000)}...`;
|
||||
}
|
||||
causeMessage.content = content;
|
||||
}
|
||||
|
||||
queryClient.setQueryData(chatKeys.messages(), newState);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
addAssistantMessage,
|
||||
addAssistantObservation,
|
||||
};
|
||||
};
|
||||
|
||||
export function handleObservationMessage(message: ObservationMessage) {
|
||||
const chatFunctions = getChatFunctions();
|
||||
|
||||
switch (message.observation) {
|
||||
case ObservationType.RUN: {
|
||||
if (message.extras.hidden) break;
|
||||
let { content } = message;
|
||||
|
||||
if (content.length > 5000) {
|
||||
const head = content.slice(0, 5000);
|
||||
content = `${head}\r\n\n... (truncated ${message.content.length - 5000} characters) ...`;
|
||||
}
|
||||
|
||||
store.dispatch(appendOutput(content));
|
||||
break;
|
||||
}
|
||||
case ObservationType.RUN_IPYTHON:
|
||||
// FIXME: render this as markdown
|
||||
store.dispatch(appendJupyterOutput(message.content));
|
||||
break;
|
||||
case ObservationType.BROWSE:
|
||||
if (message.extras?.screenshot) {
|
||||
store.dispatch(setScreenshotSrc(message.extras?.screenshot));
|
||||
}
|
||||
if (message.extras?.url) {
|
||||
store.dispatch(setUrl(message.extras.url));
|
||||
}
|
||||
break;
|
||||
case ObservationType.AGENT_STATE_CHANGED:
|
||||
store.dispatch(setCurrentAgentState(message.extras.agent_state));
|
||||
break;
|
||||
case ObservationType.DELEGATE:
|
||||
// TODO: better UI for delegation result (#2309)
|
||||
if (message.content && chatFunctions) {
|
||||
chatFunctions.addAssistantMessage(message.content);
|
||||
}
|
||||
break;
|
||||
case ObservationType.READ:
|
||||
case ObservationType.EDIT:
|
||||
case ObservationType.THINK:
|
||||
case ObservationType.NULL:
|
||||
break; // We don't display the default message for these observations
|
||||
default:
|
||||
if (chatFunctions) {
|
||||
chatFunctions.addAssistantMessage(message.message);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (!message.extras?.hidden && chatFunctions) {
|
||||
// Convert the message to the appropriate observation type
|
||||
const { observation } = message;
|
||||
const baseObservation = {
|
||||
...message,
|
||||
source: "agent" as const,
|
||||
};
|
||||
|
||||
switch (observation) {
|
||||
case "agent_state_changed":
|
||||
chatFunctions.addAssistantObservation({
|
||||
...baseObservation,
|
||||
observation: "agent_state_changed" as const,
|
||||
extras: {
|
||||
agent_state: (message.extras.agent_state as AgentState) || "idle",
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "run":
|
||||
chatFunctions.addAssistantObservation({
|
||||
...baseObservation,
|
||||
observation: "run" as const,
|
||||
extras: {
|
||||
command: String(message.extras.command || ""),
|
||||
metadata: message.extras.metadata,
|
||||
hidden: Boolean(message.extras.hidden),
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "read":
|
||||
chatFunctions.addAssistantObservation({
|
||||
...baseObservation,
|
||||
observation,
|
||||
extras: {
|
||||
path: String(message.extras.path || ""),
|
||||
impl_source: String(message.extras.impl_source || ""),
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "edit":
|
||||
chatFunctions.addAssistantObservation({
|
||||
...baseObservation,
|
||||
observation,
|
||||
extras: {
|
||||
path: String(message.extras.path || ""),
|
||||
diff: String(message.extras.diff || ""),
|
||||
impl_source: String(message.extras.impl_source || ""),
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "run_ipython":
|
||||
chatFunctions.addAssistantObservation({
|
||||
...baseObservation,
|
||||
observation: "run_ipython" as const,
|
||||
extras: {
|
||||
code: String(message.extras.code || ""),
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "delegate":
|
||||
chatFunctions.addAssistantObservation({
|
||||
...baseObservation,
|
||||
observation: "delegate" as const,
|
||||
extras: {
|
||||
outputs:
|
||||
typeof message.extras.outputs === "object"
|
||||
? (message.extras.outputs as Record<string, unknown>)
|
||||
: {},
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "browse":
|
||||
chatFunctions.addAssistantObservation({
|
||||
...baseObservation,
|
||||
observation: "browse" as const,
|
||||
extras: {
|
||||
url: String(message.extras.url || ""),
|
||||
screenshot: String(message.extras.screenshot || ""),
|
||||
error: Boolean(message.extras.error),
|
||||
open_page_urls: Array.isArray(message.extras.open_page_urls)
|
||||
? message.extras.open_page_urls
|
||||
: [],
|
||||
active_page_index: Number(message.extras.active_page_index || 0),
|
||||
dom_object:
|
||||
typeof message.extras.dom_object === "object"
|
||||
? (message.extras.dom_object as Record<string, unknown>)
|
||||
: {},
|
||||
axtree_object:
|
||||
typeof message.extras.axtree_object === "object"
|
||||
? (message.extras.axtree_object as Record<string, unknown>)
|
||||
: {},
|
||||
extra_element_properties:
|
||||
typeof message.extras.extra_element_properties === "object"
|
||||
? (message.extras.extra_element_properties as Record<
|
||||
string,
|
||||
unknown
|
||||
>)
|
||||
: {},
|
||||
last_browser_action: String(
|
||||
message.extras.last_browser_action || "",
|
||||
),
|
||||
last_browser_action_error: message.extras.last_browser_action_error,
|
||||
focused_element_bid: String(
|
||||
message.extras.focused_element_bid || "",
|
||||
),
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "error":
|
||||
chatFunctions.addAssistantObservation({
|
||||
...baseObservation,
|
||||
observation: "error" as const,
|
||||
source: "user" as const,
|
||||
extras: {
|
||||
error_id: message.extras.error_id,
|
||||
},
|
||||
});
|
||||
break;
|
||||
default:
|
||||
// For any unhandled observation types, just ignore them
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
82
frontend/src/services/status-service-query.ts
Normal file
82
frontend/src/services/status-service-query.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { StatusMessage } from "#/types/message";
|
||||
import { statusKeys } from "#/hooks/query/use-status";
|
||||
import { chatKeys } from "#/hooks/query/use-chat";
|
||||
import { trackError } from "#/utils/error-handler";
|
||||
|
||||
// Get the query client
|
||||
const getQueryClient = () =>
|
||||
// This is a workaround since we can't use hooks outside of components
|
||||
// In a real implementation, you might want to restructure this to use React context
|
||||
window.__queryClient;
|
||||
|
||||
// Helper function to get status functions
|
||||
const getStatusFunctions = () => {
|
||||
const queryClient = getQueryClient();
|
||||
if (!queryClient) {
|
||||
console.error("Query client not available");
|
||||
return null;
|
||||
}
|
||||
|
||||
const setStatusMessage = (newStatusMessage: StatusMessage) => {
|
||||
queryClient.setQueryData(statusKeys.current(), newStatusMessage);
|
||||
};
|
||||
|
||||
return {
|
||||
setStatusMessage,
|
||||
};
|
||||
};
|
||||
|
||||
// Helper function to get chat functions
|
||||
const getChatFunctions = () => {
|
||||
const queryClient = getQueryClient();
|
||||
if (!queryClient) {
|
||||
console.error("Query client not available");
|
||||
return null;
|
||||
}
|
||||
|
||||
const addErrorMessage = (payload: { id?: string; message: string }) => {
|
||||
const currentState = queryClient.getQueryData(chatKeys.messages()) || {
|
||||
messages: [],
|
||||
};
|
||||
const newState = { ...currentState };
|
||||
|
||||
const { id, message } = payload;
|
||||
newState.messages.push({
|
||||
translationID: id,
|
||||
content: message,
|
||||
type: "error",
|
||||
sender: "assistant",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
queryClient.setQueryData(chatKeys.messages(), newState);
|
||||
};
|
||||
|
||||
return {
|
||||
addErrorMessage,
|
||||
};
|
||||
};
|
||||
|
||||
export function handleStatusMessage(message: StatusMessage) {
|
||||
const statusFunctions = getStatusFunctions();
|
||||
if (!statusFunctions) return;
|
||||
|
||||
statusFunctions.setStatusMessage(message);
|
||||
|
||||
if (message.type === "error") {
|
||||
// Track the error for analytics
|
||||
trackError({
|
||||
message: message.message,
|
||||
source: "chat",
|
||||
metadata: { msgId: message.id },
|
||||
});
|
||||
|
||||
const chatFunctions = getChatFunctions();
|
||||
if (chatFunctions) {
|
||||
chatFunctions.addErrorMessage({
|
||||
id: message.id,
|
||||
message: message.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
36
frontend/src/services/status-service.ts
Normal file
36
frontend/src/services/status-service.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { StatusMessage } from "#/types/message";
|
||||
import { trackError } from "#/utils/error-handler";
|
||||
import { queryClient } from "#/entry.client";
|
||||
import { statusKeys } from "#/hooks/query/use-status";
|
||||
import { chatKeys } from "#/hooks/query/use-chat";
|
||||
|
||||
export function handleStatusMessage(message: StatusMessage) {
|
||||
if (message.type === "info") {
|
||||
// Update the status message using React Query
|
||||
queryClient.setQueryData(statusKeys.current(), message);
|
||||
} else if (message.type === "error") {
|
||||
trackError({
|
||||
message: message.message,
|
||||
source: "chat",
|
||||
metadata: { msgId: message.id },
|
||||
});
|
||||
|
||||
// Get current chat state
|
||||
const currentState = queryClient.getQueryData(chatKeys.messages()) || {
|
||||
messages: [],
|
||||
};
|
||||
const newState = { ...currentState };
|
||||
|
||||
// Add error message
|
||||
newState.messages.push({
|
||||
translationID: message.id,
|
||||
content: message.message,
|
||||
type: "error",
|
||||
sender: "assistant",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Update chat state
|
||||
queryClient.setQueryData(chatKeys.messages(), newState);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,24 @@
|
||||
import { combineReducers, configureStore } from "@reduxjs/toolkit";
|
||||
import agentReducer from "./state/agent-slice";
|
||||
import browserReducer from "./state/browser-slice";
|
||||
import chatReducer from "./state/chat-slice";
|
||||
import codeReducer from "./state/code-slice";
|
||||
import fileStateReducer from "./state/file-state-slice";
|
||||
import initialQueryReducer from "./state/initial-query-slice";
|
||||
import commandReducer from "./state/command-slice";
|
||||
import { jupyterReducer } from "./state/jupyter-slice";
|
||||
import securityAnalyzerReducer from "./state/security-analyzer-slice";
|
||||
import statusReducer from "./state/status-slice";
|
||||
import metricsReducer from "./state/metrics-slice";
|
||||
// Removed chat, status, and metrics reducers as they've been migrated to React Query
|
||||
|
||||
export const rootReducer = combineReducers({
|
||||
fileState: fileStateReducer,
|
||||
initialQuery: initialQueryReducer,
|
||||
browser: browserReducer,
|
||||
chat: chatReducer,
|
||||
code: codeReducer,
|
||||
cmd: commandReducer,
|
||||
agent: agentReducer,
|
||||
jupyter: jupyterReducer,
|
||||
securityAnalyzer: securityAnalyzerReducer,
|
||||
status: statusReducer,
|
||||
metrics: metricsReducer,
|
||||
// Removed chat, status, and metrics reducers
|
||||
});
|
||||
|
||||
const store = configureStore({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import posthog from "posthog-js";
|
||||
import { handleStatusMessage } from "#/services/actions";
|
||||
import { handleStatusMessage } from "#/services/status-service-query";
|
||||
import { displayErrorToast } from "./custom-toast-handlers";
|
||||
|
||||
interface ErrorDetails {
|
||||
|
||||
@@ -12,16 +12,64 @@ import { AppStore, RootState, rootReducer } from "./src/store";
|
||||
import { AuthProvider } from "#/context/auth-context";
|
||||
import { ConversationProvider } from "#/context/conversation-context";
|
||||
|
||||
// Mock useParams before importing components
|
||||
// Mock react-router components for testing
|
||||
vi.mock("react-router", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("react-router")>("react-router");
|
||||
return {
|
||||
...actual,
|
||||
useParams: () => ({ conversationId: "test-conversation-id" }),
|
||||
RouterProvider: ({ router }: { router?: any }) => {
|
||||
if (router?.routes?.[0]?.element) {
|
||||
return router.routes[0].element;
|
||||
}
|
||||
return <div>Mocked Router</div>;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Mock react-router/dist/development/dom-export to fix SSR errors
|
||||
vi.mock("react-router/dist/development/dom-export", () => {
|
||||
return {
|
||||
createHydratedRouter: () => ({
|
||||
routes: [{ element: <div>Mocked Router</div> }]
|
||||
}),
|
||||
HydratedRouter: ({ children }: { children?: React.ReactNode }) => <>{children || <div>Mocked Router</div>}</>,
|
||||
RouterProvider: ({ router, children }: { router?: any, children?: React.ReactNode }) => {
|
||||
return <>{children || (router?.routes?.[0]?.element || <div>Mocked Router</div>)}</>;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the metrics hook
|
||||
vi.mock("#/hooks/query/use-metrics", () => ({
|
||||
useMetrics: () => ({
|
||||
metrics: {
|
||||
cost: 0.123,
|
||||
usage: {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 200,
|
||||
total_tokens: 300
|
||||
}
|
||||
},
|
||||
updateMetrics: vi.fn()
|
||||
})
|
||||
}));
|
||||
|
||||
// Mock the status hook
|
||||
vi.mock("#/hooks/query/use-status", () => ({
|
||||
useStatus: () => ({
|
||||
status: {
|
||||
runtimeActive: true,
|
||||
runtimeConnected: true,
|
||||
runtimeStatus: "connected",
|
||||
runtimeVersion: "1.0.0",
|
||||
wsConnected: true,
|
||||
},
|
||||
updateStatus: vi.fn()
|
||||
})
|
||||
}));
|
||||
|
||||
// Initialize i18n for tests
|
||||
i18n.use(initReactI18next).init({
|
||||
lng: "en",
|
||||
@@ -51,6 +99,22 @@ interface ExtendedRenderOptions extends Omit<RenderOptions, "queries"> {
|
||||
store?: AppStore;
|
||||
}
|
||||
|
||||
// Create a query client for testing
|
||||
const createTestQueryClient = () => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
cacheTime: 0,
|
||||
staleTime: 0,
|
||||
},
|
||||
},
|
||||
logger: {
|
||||
log: console.log,
|
||||
warn: console.warn,
|
||||
error: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
// Export our own customized renderWithProviders function that creates a new Redux store and renders a <Provider>
|
||||
// Note that this creates a separate Redux store instance for every test, rather than reusing the same store instance and resetting its state
|
||||
export function renderWithProviders(
|
||||
@@ -66,20 +130,17 @@ export function renderWithProviders(
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<AuthProvider initialGithubTokenIsSet>
|
||||
<QueryClientProvider
|
||||
client={
|
||||
new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
}
|
||||
>
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<ConversationProvider>
|
||||
<I18nextProvider i18n={i18n}>{children}</I18nextProvider>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
{children}
|
||||
</I18nextProvider>
|
||||
</ConversationProvider>
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user