Skip to content

Commit

Permalink
Refactor session management (OpenDevin#1810)
Browse files Browse the repository at this point in the history
* refactor session mgmt

* defer file handling to runtime

* add todo

* refactor sessions a bit more

* remove messages logic from FE

* fix up socket handshake

* refactor frontend auth a bit

* first pass at redoing file explorer

* implement directory suffix

* fix up file tree

* close agent on websocket close

* remove session saving

* move file refresh

* remove getWorkspace

* plumb path/code differently

* fix build issues

* fix the tests

* fix npm build

* add session rehydration

* fix event serialization

* logspam

* fix user message rehydration

* add get_event fn

* agent state restoration

* change history tracking for codeact

* fix responsiveness of init

* fix lint

* lint

* delint

* fix prop

* update tests

* logspam

* lint

* fix test

* revert codeact

* change fileService to use API

* fix up session loading

* delint

* delint

* fix integration tests

* revert test

* fix up access to options endpoints

* fix initial files load

* delint

* fix file initialization

* fix mock server

* fixl int

* fix auth for html

* Update frontend/src/i18n/translation.json

Co-authored-by: Xingyao Wang <xingyao6@illinois.edu>

* refactor sessions and sockets

* avoid reinitializing the same session

* fix reconnect issue

* change up intro message

* more guards on reinit

* rename agent_session

* delint

* fix a bunch of tests

* delint

* fix last test

* remove code editor context

* fix build

* fix any

* fix dot notation

* Update frontend/src/services/api.ts

Co-authored-by: Boxuan Li <liboxuan@connect.hku.hk>

* fix up error handling

* Update opendevin/server/session/agent.py

Co-authored-by: Boxuan Li <liboxuan@connect.hku.hk>

* Update opendevin/server/session/agent.py

Co-authored-by: Boxuan Li <liboxuan@connect.hku.hk>

* Update frontend/src/services/session.ts

Co-authored-by: Boxuan Li <liboxuan@connect.hku.hk>

* fix build errs

* fix else

* add closed state

* delint

* Update opendevin/server/session/session.py

Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>

---------

Co-authored-by: Xingyao Wang <xingyao6@illinois.edu>
Co-authored-by: Graham Neubig <neubig@gmail.com>
Co-authored-by: Boxuan Li <liboxuan@connect.hku.hk>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
  • Loading branch information
5 people authored and super-dainiu committed May 23, 2024
1 parent d64580d commit 7f634bc
Show file tree
Hide file tree
Showing 55 changed files with 773 additions and 1,346 deletions.
31 changes: 7 additions & 24 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useDisclosure } from "@nextui-org/react";
import React, { useEffect, useState } from "react";
import React, { useEffect } from "react";
import { Toaster } from "react-hot-toast";
import CogTooth from "#/assets/cog-tooth";
import ChatInterface from "#/components/chat/ChatInterface";
Expand All @@ -8,15 +8,13 @@ import { Container, Orientation } from "#/components/Resizable";
import Workspace from "#/components/Workspace";
import LoadPreviousSessionModal from "#/components/modals/load-previous-session/LoadPreviousSessionModal";
import SettingsModal from "#/components/modals/settings/SettingsModal";
import { fetchMsgTotal } from "#/services/session";
import Socket from "#/services/socket";
import { ResFetchMsgTotal } from "#/types/ResponseType";
import "./App.css";
import AgentControlBar from "./components/AgentControlBar";
import AgentStatusBar from "./components/AgentStatusBar";
import Terminal from "./components/terminal/Terminal";
import { initializeAgent } from "./services/agent";
import { settingsAreUpToDate } from "./services/settings";
import Session from "#/services/session";
import { getToken } from "#/services/auth";
import { settingsAreUpToDate } from "#/services/settings";

interface Props {
setSettingOpen: (isOpen: boolean) => void;
Expand All @@ -43,8 +41,6 @@ function Controls({ setSettingOpen }: Props): JSX.Element {
let initOnce = false;

function App(): JSX.Element {
const [isWarned, setIsWarned] = useState(false);

const {
isOpen: settingsModalIsOpen,
onOpen: onSettingsModalOpen,
Expand All @@ -57,31 +53,18 @@ function App(): JSX.Element {
onOpenChange: onLoadPreviousSessionModalOpenChange,
} = useDisclosure();

const getMsgTotal = () => {
if (isWarned) return;
fetchMsgTotal()
.then((data: ResFetchMsgTotal) => {
if (data.msg_total > 0) {
onLoadPreviousSessionModalOpen();
setIsWarned(true);
}
})
.catch();
};

useEffect(() => {
if (initOnce) return;
initOnce = true;

if (!settingsAreUpToDate()) {
onSettingsModalOpen();
} else if (getToken()) {
onLoadPreviousSessionModalOpen();
} else {
initializeAgent();
Session.startNewSession();
}

Socket.registerCallback("open", [getMsgTotal]);

getMsgTotal();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

Expand Down
9 changes: 0 additions & 9 deletions frontend/src/api/index.ts

This file was deleted.

3 changes: 0 additions & 3 deletions frontend/src/components/AgentControlBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import ArrowIcon from "#/assets/arrow";
import PauseIcon from "#/assets/pause";
import PlayIcon from "#/assets/play";
import { changeAgentState } from "#/services/agentStateService";
import { clearMsgs } from "#/services/session";
import store, { RootState } from "#/store";
import AgentState from "#/types/AgentState";
import { clearMessages } from "#/state/chatSlice";
Expand Down Expand Up @@ -73,7 +72,6 @@ function AgentControlBar() {
}

if (action === AgentState.STOPPED) {
clearMsgs().then().catch();
store.dispatch(clearMessages());
} else {
setIsLoading(true);
Expand All @@ -86,7 +84,6 @@ function AgentControlBar() {
useEffect(() => {
if (curAgentState === desiredState) {
if (curAgentState === AgentState.STOPPED) {
clearMsgs().then().catch();
store.dispatch(clearMessages());
}
setIsLoading(false);
Expand Down
104 changes: 41 additions & 63 deletions frontend/src/components/CodeEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,24 @@
import Editor, { Monaco } from "@monaco-editor/react";
import { Tab, Tabs } from "@nextui-org/react";
import type { editor } from "monaco-editor";
import React, { useMemo, useState } from "react";
import React, { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { VscCode } from "react-icons/vsc";
import { useDispatch, useSelector } from "react-redux";
import { useSelector } from "react-redux";
import { I18nKey } from "#/i18n/declaration";
import { selectFile } from "#/services/fileService";
import { setCode } from "#/state/codeSlice";
import { RootState } from "#/store";
import FileExplorer from "./file-explorer/FileExplorer";
import { CodeEditorContext } from "./CodeEditorContext";

function CodeEditor(): JSX.Element {
const { t } = useTranslation();
const [selectedFileAbsolutePath, setSelectedFileAbsolutePath] = useState("");
const selectedFileName = useMemo(() => {
const paths = selectedFileAbsolutePath.split("/");
return paths[paths.length - 1];
}, [selectedFileAbsolutePath]);
const codeEditorContext = useMemo(
() => ({ selectedFileAbsolutePath }),
[selectedFileAbsolutePath],
);

const dispatch = useDispatch();
const code = useSelector((state: RootState) => state.code.code);
const activeFilepath = useSelector((state: RootState) => state.code.path);

const selectedFileName = useMemo(() => {
const paths = activeFilepath.split("/");
return paths[paths.length - 1];
}, [activeFilepath]);

const handleEditorDidMount = (
editor: editor.IStandaloneCodeEditor,
monaco: Monaco,
Expand All @@ -46,57 +37,44 @@ function CodeEditor(): JSX.Element {
monaco.editor.setTheme("my-theme");
};

const updateCode = async () => {
const newCode = await selectFile(activeFilepath);
setSelectedFileAbsolutePath(activeFilepath);
dispatch(setCode(newCode));
};

React.useEffect(() => {
// FIXME: we can probably move this out of the component and into state/service
if (activeFilepath) updateCode();
}, [activeFilepath]);

return (
<div className="flex h-full w-full bg-neutral-900 transition-all duration-500 ease-in-out">
<CodeEditorContext.Provider value={codeEditorContext}>
<FileExplorer />
<div className="flex flex-col min-h-0 w-full">
<Tabs
disableCursorAnimation
classNames={{
base: "border-b border-divider border-neutral-600 mb-4",
tabList:
"w-full relative rounded-none bg-neutral-900 p-0 border-divider",
cursor: "w-full bg-neutral-600 rounded-none",
tab: "max-w-fit px-4 h-[36px]",
tabContent: "group-data-[selected=true]:text-white",
}}
aria-label="Options"
>
<Tab
key={selectedFileName.toLocaleLowerCase()}
title={selectedFileName}
<FileExplorer />
<div className="flex flex-col min-h-0 w-full">
<Tabs
disableCursorAnimation
classNames={{
base: "border-b border-divider border-neutral-600 mb-4",
tabList:
"w-full relative rounded-none bg-neutral-900 p-0 border-divider",
cursor: "w-full bg-neutral-600 rounded-none",
tab: "max-w-fit px-4 h-[36px]",
tabContent: "group-data-[selected=true]:text-white",
}}
aria-label="Options"
>
<Tab
key={selectedFileName.toLocaleLowerCase()}
title={selectedFileName}
/>
</Tabs>
<div className="flex grow items-center justify-center">
{selectedFileName === "" ? (
<div className="flex flex-col items-center text-neutral-400">
<VscCode size={100} />
{t(I18nKey.CODE_EDITOR$EMPTY_MESSAGE)}
</div>
) : (
<Editor
height="100%"
path={selectedFileName.toLocaleLowerCase()}
defaultValue=""
value={code}
onMount={handleEditorDidMount}
/>
</Tabs>
<div className="flex grow items-center justify-center">
{selectedFileName === "" ? (
<div className="flex flex-col items-center text-neutral-400">
<VscCode size={100} />
{t(I18nKey.CODE_EDITOR$EMPTY_MESSAGE)}
</div>
) : (
<Editor
height="100%"
path={selectedFileName.toLocaleLowerCase()}
defaultValue=""
value={code}
onMount={handleEditorDidMount}
/>
)}
</div>
)}
</div>
</CodeEditorContext.Provider>
</div>
</div>
);
}
Expand Down
5 changes: 0 additions & 5 deletions frontend/src/components/CodeEditorContext.ts

This file was deleted.

17 changes: 9 additions & 8 deletions frontend/src/components/chat/ChatInterface.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { act } from "react-dom/test-utils";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "test-utils";
import ChatInterface from "./ChatInterface";
import Socket from "#/services/socket";
import Session from "#/services/session";
import ActionType from "#/types/ActionType";
import { addAssistantMessage } from "#/state/chatSlice";
import AgentState from "#/types/AgentState";
Expand All @@ -15,16 +15,17 @@ vi.mock("#/hooks/useTyping", () => ({
useTyping: vi.fn((text: string) => text),
}));

const socketSpy = vi.spyOn(Socket, "send");
const sessionSpy = vi.spyOn(Session, "send");
vi.spyOn(Session, "isConnected").mockImplementation(() => true);

// This is for the scrollview ref in Chat.tsx
// TODO: Move this into test setup
HTMLElement.prototype.scrollTo = vi.fn(() => {});

describe("ChatInterface", () => {
it("should render the messages and input", () => {
it("should render empty message list and input", () => {
renderWithProviders(<ChatInterface />);
expect(screen.queryAllByTestId("message")).toHaveLength(1); // initial welcome message only
expect(screen.queryAllByTestId("message")).toHaveLength(0);
});

it("should render the new message the user has typed", async () => {
Expand Down Expand Up @@ -65,7 +66,7 @@ describe("ChatInterface", () => {
expect(screen.getByText("Hello to you!")).toBeInTheDocument();
});

it("should send the a start event to the Socket", () => {
it("should send the a start event to the Session", () => {
renderWithProviders(<ChatInterface />, {
preloadedState: {
agent: {
Expand All @@ -83,10 +84,10 @@ describe("ChatInterface", () => {
action: ActionType.MESSAGE,
args: { content: "my message" },
};
expect(socketSpy).toHaveBeenCalledWith(JSON.stringify(event));
expect(sessionSpy).toHaveBeenCalledWith(JSON.stringify(event));
});

it("should send the a user message event to the Socket", () => {
it("should send the a user message event to the Session", () => {
renderWithProviders(<ChatInterface />, {
preloadedState: {
agent: {
Expand All @@ -104,7 +105,7 @@ describe("ChatInterface", () => {
action: ActionType.MESSAGE,
args: { content: "my message" },
};
expect(socketSpy).toHaveBeenCalledWith(JSON.stringify(event));
expect(sessionSpy).toHaveBeenCalledWith(JSON.stringify(event));
});

it("should disable the user input if agent is not initialized", () => {
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/components/chat/ChatInterface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import Chat from "./Chat";
import { RootState } from "#/store";
import AgentState from "#/types/AgentState";
import { sendChatMessage } from "#/services/chatService";
import { addUserMessage } from "#/state/chatSlice";
import { addUserMessage, addAssistantMessage } from "#/state/chatSlice";
import { I18nKey } from "#/i18n/declaration";
import { useScrollToBottom } from "#/hooks/useScrollToBottom";

Expand Down Expand Up @@ -58,6 +58,12 @@ function ChatInterface() {
const { scrollDomToBottom, onChatBodyScroll, hitBottom } =
useScrollToBottom(scrollRef);

React.useEffect(() => {
if (curAgentState === AgentState.INIT && messages.length === 0) {
dispatch(addAssistantMessage(t(I18nKey.CHAT_INTERFACE$INITIAL_MESSAGE)));
}
}, [curAgentState]);

return (
<div className="flex flex-col h-full bg-neutral-800">
<div className="flex items-center gap-2 border-b border-neutral-600 text-sm px-4 py-2">
Expand Down
11 changes: 9 additions & 2 deletions frontend/src/components/file-explorer/FileExplorer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import { describe, it, expect, vi, Mock } from "vitest";
import FileExplorer from "./FileExplorer";
import { uploadFiles, listFiles } from "#/services/fileService";
import toast from "#/utils/toast";
import AgentState from "#/types/AgentState";

const toastSpy = vi.spyOn(toast, "stickyError");
const toastSpy = vi.spyOn(toast, "error");

vi.mock("../../services/fileService", async () => ({
listFiles: vi.fn(async (path: string = "/") => {
Expand Down Expand Up @@ -42,7 +43,13 @@ describe("FileExplorer", () => {
it.todo("should render an empty workspace");

it.only("should refetch the workspace when clicking the refresh button", async () => {
const { getByText } = renderWithProviders(<FileExplorer />);
const { getByText } = renderWithProviders(<FileExplorer />, {
preloadedState: {
agent: {
curAgentState: AgentState.RUNNING,
},
},
});
await waitFor(() => {
expect(getByText("folder1")).toBeInTheDocument();
expect(getByText("file2.ts")).toBeInTheDocument();
Expand Down
Loading

0 comments on commit 7f634bc

Please sign in to comment.