mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
Keep VSCode accessible when agent errors (#13492)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
64
frontend/__tests__/hooks/use-runtime-is-ready.test.tsx
Normal file
64
frontend/__tests__/hooks/use-runtime-is-ready.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Conversation } from "#/api/open-hands.types";
|
||||
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
|
||||
vi.mock("#/hooks/use-agent-state");
|
||||
vi.mock("#/hooks/query/use-active-conversation");
|
||||
|
||||
function asMockReturnValue<T>(value: Partial<T>): T {
|
||||
return value as T;
|
||||
}
|
||||
|
||||
function makeConversation(): Conversation {
|
||||
return {
|
||||
conversation_id: "conv-123",
|
||||
title: "Test Conversation",
|
||||
selected_repository: null,
|
||||
selected_branch: null,
|
||||
git_provider: null,
|
||||
last_updated_at: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
status: "RUNNING",
|
||||
runtime_status: null,
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useRuntimeIsReady", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
vi.mocked(useActiveConversation).mockReturnValue(
|
||||
asMockReturnValue<ReturnType<typeof useActiveConversation>>({
|
||||
data: makeConversation(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats agent errors as not ready by default", () => {
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: AgentState.ERROR,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRuntimeIsReady());
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("allows runtime-backed tabs to stay ready when the agent errors", () => {
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: AgentState.ERROR,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useRuntimeIsReady({ allowAgentError: true }),
|
||||
);
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
65
frontend/__tests__/routes/vscode-tab.test.tsx
Normal file
65
frontend/__tests__/routes/vscode-tab.test.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import VSCodeTab from "#/routes/vscode-tab";
|
||||
import { useUnifiedVSCodeUrl } from "#/hooks/query/use-unified-vscode-url";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
|
||||
vi.mock("#/hooks/query/use-unified-vscode-url");
|
||||
vi.mock("#/hooks/use-agent-state");
|
||||
vi.mock("#/utils/feature-flags", () => ({
|
||||
VSCODE_IN_NEW_TAB: () => false,
|
||||
}));
|
||||
|
||||
function mockVSCodeUrlHook(
|
||||
value: Partial<ReturnType<typeof useUnifiedVSCodeUrl>>,
|
||||
) {
|
||||
vi.mocked(useUnifiedVSCodeUrl).mockReturnValue({
|
||||
data: { url: "http://localhost:3000/vscode", error: null },
|
||||
error: null,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
status: "success",
|
||||
refetch: vi.fn(),
|
||||
...value,
|
||||
} as ReturnType<typeof useUnifiedVSCodeUrl>);
|
||||
}
|
||||
|
||||
describe("VSCodeTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("keeps VSCode accessible when the agent is in an error state", () => {
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: AgentState.ERROR,
|
||||
});
|
||||
mockVSCodeUrlHook({});
|
||||
|
||||
renderWithProviders(<VSCodeTab />);
|
||||
|
||||
expect(
|
||||
screen.queryByText("DIFF_VIEWER$WAITING_FOR_RUNTIME"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTitle("VSCODE$TITLE")).toHaveAttribute(
|
||||
"src",
|
||||
"http://localhost:3000/vscode",
|
||||
);
|
||||
});
|
||||
|
||||
it("still waits while the runtime is starting", () => {
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: AgentState.LOADING,
|
||||
});
|
||||
mockVSCodeUrlHook({});
|
||||
|
||||
renderWithProviders(<VSCodeTab />);
|
||||
|
||||
expect(
|
||||
screen.getByText("DIFF_VIEWER$WAITING_FOR_RUNTIME"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTitle("VSCODE$TITLE")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,15 @@
|
||||
import { FaExternalLinkAlt } from "react-icons/fa";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useUnifiedVSCodeUrl } from "#/hooks/query/use-unified-vscode-url";
|
||||
import { RUNTIME_STARTING_STATES } from "#/types/agent-state";
|
||||
|
||||
export function VSCodeTooltipContent() {
|
||||
const { curAgentState } = useAgentState();
|
||||
const { t } = useTranslation();
|
||||
const { data, refetch } = useUnifiedVSCodeUrl();
|
||||
const isRuntimeStarting = RUNTIME_STARTING_STATES.includes(curAgentState);
|
||||
|
||||
const handleVSCodeClick = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -29,7 +30,7 @@ export function VSCodeTooltipContent() {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t(I18nKey.COMMON$CODE)}</span>
|
||||
{!RUNTIME_INACTIVE_STATES.includes(curAgentState) ? (
|
||||
{!isRuntimeStarting ? (
|
||||
<FaExternalLinkAlt
|
||||
className="w-3 h-3 text-inherit cursor-pointer"
|
||||
onClick={handleVSCodeClick}
|
||||
|
||||
@@ -23,7 +23,7 @@ export const useUnifiedVSCodeUrl = () => {
|
||||
const { t } = useTranslation();
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const runtimeIsReady = useRuntimeIsReady();
|
||||
const runtimeIsReady = useRuntimeIsReady({ allowAgentError: true });
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { useActiveConversation } from "./query/use-active-conversation";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import {
|
||||
RUNTIME_INACTIVE_STATES,
|
||||
RUNTIME_STARTING_STATES,
|
||||
} from "#/types/agent-state";
|
||||
import { useActiveConversation } from "./query/use-active-conversation";
|
||||
|
||||
interface UseRuntimeIsReadyOptions {
|
||||
allowAgentError?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to determine if the runtime is ready for operations
|
||||
*
|
||||
* @returns boolean indicating if the runtime is ready
|
||||
*/
|
||||
export const useRuntimeIsReady = (): boolean => {
|
||||
export const useRuntimeIsReady = ({
|
||||
allowAgentError = false,
|
||||
}: UseRuntimeIsReadyOptions = {}): boolean => {
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { curAgentState } = useAgentState();
|
||||
const inactiveStates = allowAgentError
|
||||
? RUNTIME_STARTING_STATES
|
||||
: RUNTIME_INACTIVE_STATES;
|
||||
|
||||
return (
|
||||
conversation?.status === "RUNNING" &&
|
||||
!RUNTIME_INACTIVE_STATES.includes(curAgentState)
|
||||
!inactiveStates.includes(curAgentState)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { useUnifiedVSCodeUrl } from "#/hooks/query/use-unified-vscode-url";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { RUNTIME_STARTING_STATES } from "#/types/agent-state";
|
||||
import { VSCODE_IN_NEW_TAB } from "#/utils/feature-flags";
|
||||
import { WaitingForRuntimeMessage } from "#/components/features/chat/waiting-for-runtime-message";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
|
||||
function VSCodeTab() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, error } = useUnifiedVSCodeUrl();
|
||||
const { curAgentState } = useAgentState();
|
||||
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
|
||||
const isRuntimeStarting = RUNTIME_STARTING_STATES.includes(curAgentState);
|
||||
const iframeRef = React.useRef<HTMLIFrameElement>(null);
|
||||
const [isCrossProtocol, setIsCrossProtocol] = useState(false);
|
||||
const [iframeError, setIframeError] = useState<string | null>(null);
|
||||
@@ -39,7 +39,7 @@ function VSCodeTab() {
|
||||
}
|
||||
};
|
||||
|
||||
if (isRuntimeInactive) {
|
||||
if (isRuntimeStarting) {
|
||||
return <WaitingForRuntimeMessage />;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +14,10 @@ export enum AgentState {
|
||||
USER_REJECTED = "user_rejected",
|
||||
}
|
||||
|
||||
export const RUNTIME_STARTING_STATES = [AgentState.INIT, AgentState.LOADING];
|
||||
|
||||
export const RUNTIME_INACTIVE_STATES = [
|
||||
AgentState.INIT,
|
||||
AgentState.LOADING,
|
||||
...RUNTIME_STARTING_STATES,
|
||||
// Removed AgentState.STOPPED to allow tabs to remain visible when agent is stopped
|
||||
AgentState.ERROR,
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user