Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
- runtime git panels: `/api/git/changes`, `/api/git/diff`
- Static mock verification needs a build created with `VITE_MOCK_API=true` (use `npm run build:mock`); the client must start MSW whenever that flag is enabled, even in production/static builds, otherwise routes like `/settings` and the conversations pane fall through to the static server and crash on undefined `.filter`/`.map` assumptions.
- 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.
- `/server_info` tool capability metadata from `software-agent-sdk` PR #3028 ended up shipping as `usable_tools` (not `available_tools`). Frontend browser-tool gating should key off `usable_tools`, and still default to allowing tools when the server does not advertise tool metadata.

- 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:
Expand Down
49 changes: 43 additions & 6 deletions __tests__/api/agent-server-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { buildStartConversationRequest } from "#/api/agent-server-adapter";
import { DEFAULT_SETTINGS } from "#/services/settings";

const { mockGetAgentServerWorkingDir } = vi.hoisted(() => ({
mockGetAgentServerWorkingDir: vi.fn(
() => "/workspace/project/agent-server-gui",
),
}));
const { mockGetAgentServerWorkingDir, mockIsAgentServerToolAvailable } = vi.hoisted(
() => ({
mockGetAgentServerWorkingDir: vi.fn(
() => "/workspace/project/agent-server-gui",
),
mockIsAgentServerToolAvailable: vi.fn(() => true),
}),
);

vi.mock("#/api/agent-server-config", () => ({
getAgentServerBaseUrl: vi.fn(() => "http://127.0.0.1:8000"),
Expand All @@ -16,6 +19,15 @@ vi.mock("#/api/agent-server-config", () => ({
getConfiguredWorkerUrls: vi.fn(() => []),
}));

vi.mock("#/api/agent-server-compatibility", () => ({
isAgentServerToolAvailable: mockIsAgentServerToolAvailable,
}));

beforeEach(() => {
mockIsAgentServerToolAvailable.mockReturnValue(true);
});


describe("buildStartConversationRequest", () => {
it("uses nested settings as the source of truth and keeps SDK tool names", () => {
const payload = buildStartConversationRequest({
Expand Down Expand Up @@ -80,6 +92,31 @@ describe("buildStartConversationRequest", () => {
expect(payload.initial_message.content[0]?.text).toBe("hello");
});


it("omits browser_tool_set when the server does not advertise browser support", () => {
mockIsAgentServerToolAvailable.mockReturnValue(false);

const payload = buildStartConversationRequest({
settings: {
...DEFAULT_SETTINGS,
agent_settings: {
...DEFAULT_SETTINGS.agent_settings,
llm: { model: "nested-model" },
},
},
}) as {
agent: {
tools: Array<{ name: string; params: Record<string, unknown> }>;
};
};

expect(payload.agent.tools).toEqual([
{ name: "terminal", params: {} },
{ name: "file_editor", params: {} },
{ name: "task_tracker", params: {} },
]);
});

it("derives confirmation and security settings the same way as OpenHands", () => {
const payload = buildStartConversationRequest({
settings: {
Expand Down
44 changes: 43 additions & 1 deletion __tests__/api/option-service.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it } from "vitest";
import { http, HttpResponse } from "msw";
import {
AgentServerIncompatibilityError,
AgentServerUnavailableError,
clearCachedAgentServerInfo,
isAgentServerToolAvailable,
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", () => {
beforeEach(() => {
clearCachedAgentServerInfo();
});


it("returns config in mock mode without a live backend", async () => {
const config = await OptionService.getConfig();

Expand Down Expand Up @@ -67,6 +74,41 @@ describe("OptionService", () => {
});
});

it("caches usable_tools from server_info for later tool gating", async () => {
server.use(
http.get("/server_info", () =>
HttpResponse.json({
uptime: 0,
idle_time: 0,
version: MINIMUM_SUPPORTED_AGENT_SERVER_VERSION,
usable_tools: ["terminal", "file_editor", "task_tracker"],
}),
),
);

await OptionService.getConfig();

expect(isAgentServerToolAvailable("browser_tool_set")).toBe(false);
expect(isAgentServerToolAvailable("terminal")).toBe(true);
});

it("allows all tools when the server does not advertise tool metadata", async () => {
server.use(
http.get("/server_info", () =>
HttpResponse.json({
uptime: 0,
idle_time: 0,
version: MINIMUM_SUPPORTED_AGENT_SERVER_VERSION,
}),
),
);

await OptionService.getConfig();

expect(isAgentServerToolAvailable("browser_tool_set")).toBe(true);
expect(isAgentServerToolAvailable("terminal")).toBe(true);
});

it("returns models from mocked LLM endpoints", async () => {
const models = await OptionService.getModels();

Expand Down
6 changes: 5 additions & 1 deletion src/api/agent-server-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { DEFAULT_SETTINGS } from "#/services/settings";
import { Settings } from "#/types/settings";
import { V1ExecutionStatus } from "#/types/v1/core";
import { isAgentServerToolAvailable } from "./agent-server-compatibility";
import {
getAgentServerBaseUrl,
getAgentServerSessionApiKey,
Expand Down Expand Up @@ -202,7 +203,10 @@ function getConversationSecurityAnalyzer(conversationSettings: SettingsRecord) {

function getAgentTools() {
const tools = DEFAULT_TOOL_NAMES.map((name) => ({ name, params: {} }));
if (browserToolsEnabled()) {
if (
browserToolsEnabled() &&
isAgentServerToolAvailable(BROWSER_TOOL_SET_NAME)
) {
tools.push({ name: BROWSER_TOOL_SET_NAME, params: {} });
}
return tools;
Expand Down
40 changes: 35 additions & 5 deletions src/api/agent-server-compatibility.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
import { createServerClient, type ServerInfo } from "#/api/typescript-client";
import {
createServerClient,
type ServerInfo as BaseServerInfo,
} 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;
export interface AgentServerInfo extends BaseServerInfo {
usable_tools?: string[] | null;
}

let cachedAgentServerInfo: AgentServerInfo | null = null;

const getServerVersion = (serverInfo: AgentServerInfo): string => serverInfo.version;

const getAdvertisedTools = (serverInfo: AgentServerInfo | null) => {
if (Array.isArray(serverInfo?.usable_tools)) {
return serverInfo.usable_tools;
}
return null;
};

const parseSemver = (
version: string | null,
Expand Down Expand Up @@ -92,14 +108,27 @@ export const isAgentServerUnavailableError = (
"name" in error &&
error.name === "AgentServerUnavailableError");

export function clearCachedAgentServerInfo() {
cachedAgentServerInfo = null;
}

export function isAgentServerToolAvailable(toolName: string) {
const availableTools = getAdvertisedTools(cachedAgentServerInfo);
if (!Array.isArray(availableTools)) {
return true;
}
return availableTools.includes(toolName);
}

export async function ensureCompatibleAgentServer() {
let serverInfo: ServerInfo;
let serverInfo: AgentServerInfo;

try {
serverInfo = await createServerClient({
serverInfo = (await createServerClient({
timeout: AGENT_SERVER_INFO_TIMEOUT_MS,
}).getServerInfo();
}).getServerInfo()) as AgentServerInfo;
} catch (error) {
clearCachedAgentServerInfo();
if (error instanceof HttpError) {
throw error;
}
Expand All @@ -108,6 +137,7 @@ export async function ensureCompatibleAgentServer() {
throw new AgentServerUnavailableError(details);
}

cachedAgentServerInfo = serverInfo;
const serverVersion = getServerVersion(serverInfo);

if (!isSupportedAgentServerVersion(serverVersion)) {
Expand Down
6 changes: 6 additions & 0 deletions src/mocks/settings-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,12 @@ export const SETTINGS_HANDLERS = [
uptime: 0,
idle_time: 0,
version: "1.18.1",
usable_tools: [
"terminal",
"file_editor",
"task_tracker",
"browser_tool_set",
],
agents: ["CodeActAgent"],
default_agent: "CodeActAgent",
models: MOCK_MODELS,
Expand Down