Warn on incompatible agent server version (#18)

* Warn on incompatible agent server version

Co-authored-by: openhands <openhands@all-hands.dev>

* Use server version for compatibility guard

Co-authored-by: openhands <openhands@all-hands.dev>

* Tighten compatibility guard typing

Co-authored-by: openhands <openhands@all-hands.dev>

* Update

* Warn when agent server is unavailable

Co-authored-by: openhands <openhands@all-hands.dev>

---------

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Graham Neubig
2026-04-25 14:58:11 -04:00
committed by GitHub
co-authored by openhands
parent 66542e4976
commit 3f84ba07a6
12 changed files with 404 additions and 5827 deletions
+3 -4
View File
@@ -16,6 +16,7 @@
- Verification command: `npm run typecheck && npm run build`.
- `@openhands/typescript-client` is currently consumed via a vendored local file dependency at `vendor/openhands-typescript-client` because anonymous/public GitHub Packages install was not usable in this environment. The vendored package is pinned to `v0.1.2`, keeps built `dist/` output committed, and exposes extra subpath exports for `client/http-client`, `events/remote-events-list`, and `workspace/remote-workspace`.
- Shared TypeScript-client adapters live in `src/api/typescript-client.ts`; prefer those helpers for agent-server-backed REST/workspace/event/VS Code calls before falling back to `open-hands-axios`.
- In this repo state, `src/api/typescript-client.ts` imports the vendored TypeScript client directly from `vendor/openhands-typescript-client/src/*` rather than the package subpath exports, because the file dependency does not currently ship the expected built `dist/` subpath files in this environment.
- Root `tsconfig.json` excludes `vendor/openhands-typescript-client` so the frontend typecheck only covers the app code.
- `npm run dev:mock` needs MSW handlers for the direct agent-server routes used by the adapted frontend, not the original OpenHands mock paths. Key routes that must stay covered are:
@@ -23,6 +24,7 @@
- settings schemas: `/api/settings/agent-schema`, `/api/settings/conversation-schema`
- conversation browsing/loading: `/api/conversations/search`, `/api/conversations?ids=...`, `/api/conversations/:id`, `/api/conversations/:id/events/*`
- runtime git panels: `/api/git/changes`, `/api/git/diff`
- Frontend compatibility guard: `OptionService.getConfig()` now uses `/server_info.version` to block unsupported agent-server versions before the app loads. Git history in `software-agent-sdk` shows `/api/settings/agent-schema` and `/api/settings/conversation-schema` first shipped in tag `v1.17.0`, so the GUI currently treats `< 1.17.0` (or unknown/unparseable versions) as incompatible, `useConfig` stops retrying that case, and `src/root.tsx` renders a blocking unsupported-version notice on every route.
- Useful regression tests for mock mode live in `__tests__/api/option-service.test.ts`, `__tests__/api/mock-conversation-handlers.test.ts`, and `__tests__/api/mock-settings-handlers.test.ts`.
- Browser-verified mock-mode tour artifact was generated at `artifacts/frontend-tour.gif`.
- Live `agent_server` compatibility quirks discovered during browser verification:
@@ -41,12 +43,9 @@
- `task_tracker`
- `browser_tool_set`
Using `TerminalTool` / `FileEditorTool` / `TaskTrackerTool` / `BrowserToolSet` caused live `/api/conversations/{id}/events` runs to fail with `ToolDefinition '<name>' is not registered`.
- The root compatibility bootstrap now treats `/server_info` network/timeout failures as a first-class `AgentServerUnavailableError`, uses a short 5s timeout for that probe, and disables React Query retries/toasts for the initial config fetch so missing backends fail fast with an explicit full-screen notice.
- For local verification in this repo, setting `VITE_WORKING_DIR=/workspace/project/agent-server-gui` avoids initial Changes-tab 500s from pointing conversations at the non-repo parent `/workspace/project`.
- A successful end-to-end live run in this environment required a real LLM config (`LLM_MODEL` + `LLM_API_KEY`). The default `litellm_proxy/...` model with no `llm_api_key` failed at runtime with a `litellm.AuthenticationError`.
- README expectation: the very first section should be a concrete from-scratch quickstart for running this frontend against a real `openhands-agent-server` (clone, install backend, optional `.env`, run `npm run dev`). Keep live-backend instructions ahead of general project overview.
- As an OpenHands incubator **Sandbox** project, the repo should carry the standard sandbox warning badge in `README.md` and include a root `LICENSE` file to satisfy the incubator-program requirements.
+56
View File
@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
import { http, HttpResponse } from "msw";
import {
AgentServerIncompatibilityError,
AgentServerUnavailableError,
MINIMUM_SUPPORTED_AGENT_SERVER_VERSION,
} from "#/api/agent-server-compatibility";
import OptionService from "#/api/option-service/option-service.api";
import { server } from "#/mocks/node";
describe("OptionService", () => {
it("returns config in mock mode without a live backend", async () => {
@@ -10,6 +17,55 @@ describe("OptionService", () => {
expect(config.updated_at).toBeTruthy();
});
it("throws a compatibility error when the agent server version is below the supported minimum", async () => {
server.use(
http.get("/server_info", () =>
HttpResponse.json({ uptime: 0, idle_time: 0, version: "1.16.1" }),
),
);
await expect(OptionService.getConfig()).rejects.toMatchObject({
name: AgentServerIncompatibilityError.name,
serverVersion: "1.16.1",
message: expect.stringContaining(MINIMUM_SUPPORTED_AGENT_SERVER_VERSION),
});
});
it("throws an unavailable error when the agent server cannot be reached", async () => {
server.use(http.get("/server_info", () => HttpResponse.error()));
await expect(OptionService.getConfig()).rejects.toMatchObject({
name: AgentServerUnavailableError.name,
message: expect.stringContaining("Agent server not found"),
details: expect.stringContaining("Request failed"),
});
});
it("uses only server version metadata for compatibility checks", async () => {
server.use(
http.get("/server_info", () =>
HttpResponse.json({
uptime: 0,
idle_time: 0,
version: MINIMUM_SUPPORTED_AGENT_SERVER_VERSION,
}),
),
http.get("/api/settings/agent-schema", () =>
HttpResponse.json({ error: "missing" }, { status: 404 }),
),
http.get("/api/settings/conversation-schema", () =>
HttpResponse.json({ error: "missing" }, { status: 404 }),
),
);
await expect(OptionService.getConfig()).resolves.toMatchObject({
app_mode: "oss",
feature_flags: expect.objectContaining({
deployment_mode: "self_hosted",
}),
});
});
it("returns models from mocked LLM endpoints", async () => {
const models = await OptionService.getModels();
+102
View File
@@ -0,0 +1,102 @@
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRoutesStub } from "react-router";
import { describe, expect, it } from "vitest";
import { http, HttpResponse } from "msw";
import { MINIMUM_SUPPORTED_AGENT_SERVER_VERSION } from "#/api/agent-server-compatibility";
import App from "#/root";
import { server } from "#/mocks/node";
const RouterStub = createRoutesStub([
{
Component: App,
path: "/",
children: [
{
Component: () => <div data-testid="app-outlet">app outlet</div>,
path: "/",
},
],
},
]);
const renderApp = (initialEntries: string[] = ["/"]) =>
render(<RouterStub initialEntries={initialEntries} />, {
wrapper: ({ children }) => (
<QueryClientProvider
client={
new QueryClient({
defaultOptions: { queries: { retry: false } },
})
}
>
{children}
</QueryClientProvider>
),
});
describe("App root compatibility guard", () => {
it("blocks the app on any page when the connected agent server version is too old", async () => {
server.use(
http.get("/server_info", () =>
HttpResponse.json({ uptime: 0, idle_time: 0, version: "1.16.1" }),
),
);
renderApp(["/"]);
await waitFor(() => {
expect(
screen.getByTestId("agent-server-incompatibility-warning"),
).toBeInTheDocument();
});
expect(
screen.getByText(/unsupported agent server version/i),
).toBeInTheDocument();
expect(screen.getAllByText(/1\.16\.1/)).toHaveLength(2);
expect(
screen.getByText(
new RegExp(`${MINIMUM_SUPPORTED_AGENT_SERVER_VERSION} or newer`),
),
).toBeInTheDocument();
expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument();
});
it("fails fast with an agent server not found warning when the backend is unreachable", async () => {
let serverInfoRequests = 0;
server.use(
http.get("/server_info", () => {
serverInfoRequests += 1;
return HttpResponse.error();
}),
);
renderApp(["/"]);
await waitFor(() => {
expect(
screen.getByTestId("agent-server-unavailable-warning"),
).toBeInTheDocument();
});
expect(
screen.getByRole("heading", { name: /agent server not found/i }),
).toBeInTheDocument();
expect(serverInfoRequests).toBe(1);
expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument();
});
it("renders the routed page when the agent server is compatible", async () => {
renderApp(["/"]);
await waitFor(() => {
expect(screen.getByTestId("app-outlet")).toBeInTheDocument();
});
expect(
screen.queryByTestId("agent-server-incompatibility-warning"),
).not.toBeInTheDocument();
});
});
+12 -5818
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -11,7 +11,7 @@
"@heroui/react": "2.8.7",
"@microlink/react-json-view": "^1.27.1",
"@monaco-editor/react": "^4.7.0-rc.0",
"@openhands/typescript-client": "file:vendor/openhands-typescript-client",
"@openhands/typescript-client": "github:OpenHands/typescript-client#4716d2e",
"@react-router/node": "^7.12.0",
"@react-router/serve": "^7.12.0",
"@tailwindcss/vite": "^4.1.18",
+118
View File
@@ -0,0 +1,118 @@
import { createServerClient, type ServerInfo } from "#/api/typescript-client";
import { HttpError } from "@openhands/typescript-client/client/http-client";
export const MINIMUM_SUPPORTED_AGENT_SERVER_VERSION = "1.17.0";
const AGENT_SERVER_INFO_TIMEOUT_MS = 5000;
const SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/;
const getServerVersion = (serverInfo: ServerInfo): string => serverInfo.version;
const parseSemver = (
version: string | null,
): [number, number, number] | null => {
if (!version) {
return null;
}
const match = version.match(SEMVER_PATTERN);
if (!match) {
return null;
}
return match.slice(1, 4).map(Number) as [number, number, number];
};
const isSupportedAgentServerVersion = (serverVersion: string | null) => {
const parsedVersion = parseSemver(serverVersion);
const minimumVersion = parseSemver(MINIMUM_SUPPORTED_AGENT_SERVER_VERSION);
if (!parsedVersion || !minimumVersion) {
return false;
}
for (let index = 0; index < minimumVersion.length; index += 1) {
if (parsedVersion[index] > minimumVersion[index]) {
return true;
}
if (parsedVersion[index] < minimumVersion[index]) {
return false;
}
}
return true;
};
const buildCompatibilityMessage = (serverVersion: string | null) => {
const versionMessage = serverVersion
? `Connected agent server version ${serverVersion} is not compatible with this frontend.`
: "The connected agent server version could not be determined.";
return `${versionMessage} This frontend requires agent server version ${MINIMUM_SUPPORTED_AGENT_SERVER_VERSION} or newer. Upgrade the agent server and reload the page.`;
};
export class AgentServerIncompatibilityError extends Error {
readonly serverVersion: string | null;
constructor(serverVersion: string | null) {
super(buildCompatibilityMessage(serverVersion));
this.name = "AgentServerIncompatibilityError";
this.serverVersion = serverVersion;
}
}
export class AgentServerUnavailableError extends Error {
readonly details: string | null;
constructor(details?: string | null) {
super(
"Agent server not found. Could not connect to the configured agent server. Start a compatible agent server and reload the page.",
);
this.name = "AgentServerUnavailableError";
this.details = details ?? null;
}
}
export const isAgentServerIncompatibilityError = (
error: unknown,
): error is AgentServerIncompatibilityError =>
error instanceof AgentServerIncompatibilityError ||
(typeof error === "object" &&
error !== null &&
"name" in error &&
error.name === "AgentServerIncompatibilityError");
export const isAgentServerUnavailableError = (
error: unknown,
): error is AgentServerUnavailableError =>
error instanceof AgentServerUnavailableError ||
(typeof error === "object" &&
error !== null &&
"name" in error &&
error.name === "AgentServerUnavailableError");
export async function ensureCompatibleAgentServer() {
let serverInfo: ServerInfo;
try {
serverInfo = await createServerClient({
timeout: AGENT_SERVER_INFO_TIMEOUT_MS,
}).getServerInfo();
} catch (error) {
if (error instanceof HttpError) {
throw error;
}
const details = error instanceof Error ? error.message : null;
throw new AgentServerUnavailableError(details);
}
const serverVersion = getServerVersion(serverInfo);
if (!isSupportedAgentServerVersion(serverVersion)) {
throw new AgentServerIncompatibilityError(serverVersion);
}
return serverInfo;
}
+3 -2
View File
@@ -1,4 +1,5 @@
import { createLlmMetadataClient, createServerClient } from "../typescript-client";
import { ensureCompatibleAgentServer } from "../agent-server-compatibility";
import { createLlmMetadataClient } from "../typescript-client";
import { ModelsResponse, WebClientConfig } from "./option.types";
class OptionService {
@@ -30,7 +31,7 @@ class OptionService {
}
static async getConfig(): Promise<WebClientConfig> {
await createServerClient().getServerInfo();
await ensureCompatibleAgentServer();
return {
app_mode: "oss",
+7 -1
View File
@@ -5,6 +5,7 @@ import {
SkillsClient,
VSCodeClient,
} from "@openhands/typescript-client/clients";
export type { ServerInfo } from "@openhands/typescript-client";
import { HttpClient } from "@openhands/typescript-client/client/http-client";
import { RemoteEventsList } from "@openhands/typescript-client/events/remote-events-list";
import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace";
@@ -21,6 +22,7 @@ interface TypeScriptClientOverrides {
sessionApiKey?: string | null;
workingDir?: string;
conversationUrl?: string | null;
timeout?: number;
}
interface ResolvedClientOptions {
@@ -55,7 +57,11 @@ export function createServerClient(
overrides?: TypeScriptClientOverrides,
): ServerClient {
const { host, apiKey } = resolveClientOptions(overrides);
return new ServerClient({ host, ...(apiKey ? { apiKey } : {}) });
return new ServerClient({
host,
...(apiKey ? { apiKey } : {}),
...(overrides?.timeout ? { timeout: overrides.timeout } : {}),
});
}
export function createLlmMetadataClient(
+9
View File
@@ -1,4 +1,8 @@
import { useQuery } from "@tanstack/react-query";
import {
isAgentServerIncompatibilityError,
isAgentServerUnavailableError,
} from "#/api/agent-server-compatibility";
import OptionService from "#/api/option-service/option-service.api";
import { useIsOnIntermediatePage } from "#/hooks/use-is-on-intermediate-page";
import { QUERY_KEYS, CONFIG_CACHE_OPTIONS } from "./query-keys";
@@ -13,6 +17,11 @@ export const useConfig = (options?: UseConfigOptions) => {
return useQuery({
queryKey: QUERY_KEYS.WEB_CLIENT_CONFIG,
queryFn: OptionService.getConfig,
retry: (failureCount, error) =>
!isAgentServerIncompatibilityError(error) &&
!isAgentServerUnavailableError(error) &&
failureCount < 3,
meta: { disableToast: true },
...CONFIG_CACHE_OPTIONS,
enabled: options?.enabled ?? !isOnIntermediatePage,
});
+3
View File
@@ -345,6 +345,9 @@ const MOCK_VERIFIED_MODELS_BY_PROVIDER = MOCK_MODELS.reduce<
export const SETTINGS_HANDLERS = [
http.get("/server_info", async () =>
HttpResponse.json({
uptime: 0,
idle_time: 0,
version: "1.18.1",
agents: ["CodeActAgent"],
default_agent: "CodeActAgent",
models: MOCK_MODELS,
+85
View File
@@ -10,6 +10,13 @@ import "./tailwind.css";
import "./index.css";
import React from "react";
import { Toaster } from "react-hot-toast";
import {
AgentServerIncompatibilityError,
AgentServerUnavailableError,
isAgentServerIncompatibilityError,
isAgentServerUnavailableError,
} from "#/api/agent-server-compatibility";
import { useConfig } from "#/hooks/query/use-config";
import { useInvitation } from "#/hooks/use-invitation";
export function Layout({ children }: { children: React.ReactNode }) {
@@ -32,15 +39,93 @@ export function Layout({ children }: { children: React.ReactNode }) {
);
}
function AgentServerNotice({
testId,
title,
message,
children,
}: {
testId: string;
title: string;
message: string;
children?: React.ReactNode;
}) {
return (
<main className="min-h-screen flex items-center justify-center bg-base p-6 text-white">
<div
data-testid={testId}
className="w-full max-w-2xl rounded-2xl border border-danger/30 bg-neutral-900/80 p-8 shadow-2xl"
>
<p className="text-sm font-medium uppercase tracking-[0.24em] text-danger">
Connection blocked
</p>
<h1 className="mt-3 text-3xl font-semibold text-white">{title}</h1>
<p className="mt-4 text-base leading-7 text-neutral-200">{message}</p>
{children}
</div>
</main>
);
}
function UnsupportedAgentServerNotice({
error,
}: {
error: AgentServerIncompatibilityError;
}) {
return (
<AgentServerNotice
testId="agent-server-incompatibility-warning"
title="Unsupported agent server version"
message={error.message}
>
{error.serverVersion && (
<p className="mt-4 text-sm text-neutral-400">
Detected version: <code>{error.serverVersion}</code>
</p>
)}
</AgentServerNotice>
);
}
function MissingAgentServerNotice({
error,
}: {
error: AgentServerUnavailableError;
}) {
return (
<AgentServerNotice
testId="agent-server-unavailable-warning"
title="Agent server not found"
message={error.message}
>
{error.details && (
<p className="mt-4 text-sm text-neutral-400">
Details: {error.details}
</p>
)}
</AgentServerNotice>
);
}
export const meta: MetaFunction = () => [
{ title: "OpenHands" },
{ name: "description", content: "Let's Start Building!" },
];
export default function App() {
const config = useConfig({ enabled: true });
// Handle invitation token cleanup when invitation flow completes
// This runs on all pages to catch redirects from auth callback
useInvitation();
if (isAgentServerUnavailableError(config.error)) {
return <MissingAgentServerNotice error={config.error} />;
}
if (isAgentServerIncompatibilityError(config.error)) {
return <UnsupportedAgentServerNotice error={config.error} />;
}
return <Outlet />;
}
+5 -1
View File
@@ -120,7 +120,11 @@ export default defineConfig(({ mode }) => {
test: {
environment: "jsdom",
setupFiles: ["vitest.setup.ts"],
exclude: [...configDefaults.exclude, "tests"],
exclude: [
...configDefaults.exclude,
"tests",
"vendor/openhands-typescript-client/**",
],
coverage: {
reporter: ["text", "json", "html", "lcov", "text-summary"],
reportsDirectory: "coverage",