feat(acp): minimal generic ACP agent UI (#14401)

Co-authored-by: Debug Agent <debug@example.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Simon Rosenberg <simon@openhands.dev>
This commit is contained in:
simonrosenberg
2026-05-15 16:02:43 -04:00
committed by GitHub
co-authored by Debug Agent Claude Opus 4.7 Simon Rosenberg
parent 4804682920
commit 9aa772b97c
48 changed files with 2098 additions and 705 deletions
@@ -67,6 +67,7 @@ const setupHooks = (
profiles?: LlmProfileSummary[];
activeProfile?: string | null;
conversationModel?: string | null;
agentKind?: "openhands" | "acp";
} = {},
) => {
mockUseLlmProfiles.mockReturnValue({
@@ -76,7 +77,10 @@ const setupHooks = (
},
});
mockUseActiveConversation.mockReturnValue({
data: { llm_model: options.conversationModel ?? null },
data: {
llm_model: options.conversationModel ?? null,
agent_kind: options.agentKind ?? "openhands",
},
});
};
@@ -95,6 +99,13 @@ describe("SwitchProfileButton", () => {
expect(screen.queryByTestId("switch-profile-button")).toBeNull();
});
it("renders nothing for ACP conversations even when profiles exist", () => {
// LLM profiles don't apply to ACP — the sub-agent picks its own model.
setupHooks({ agentKind: "acp" });
renderButton();
expect(screen.queryByTestId("switch-profile-button")).toBeNull();
});
it("shows the matching profile name when conversation.llm_model maps to a profile", () => {
setupHooks({ conversationModel: "openai/gpt-5" });
renderButton();
@@ -296,7 +296,7 @@ describe("ConversationName", () => {
).not.toBeInTheDocument();
});
it("should render the llm model when available", () => {
it("should render the OpenHands brand when an llm_model is set", () => {
useActiveConversationMock.mockReturnValue({
data: {
conversation_id: "test-conversation-id",
@@ -310,14 +310,56 @@ describe("ConversationName", () => {
const model = screen.getByTestId("conversation-name-llm-model");
expect(model).toBeInTheDocument();
expect(model).toHaveTextContent("openai/gpt-4o");
// Visible chip is the harness brand; raw model is preserved on hover.
expect(model).toHaveTextContent("OpenHands");
expect(model).toHaveAttribute("title", "openai/gpt-4o");
expect(model.querySelector("svg")).toBeInTheDocument();
});
// Verify the model name is rendered in full (no truncation)
const textSpan = model.querySelector("span");
expect(textSpan).toBeInTheDocument();
expect(textSpan).toHaveTextContent("openai/gpt-4o");
it("should render plain 'ACP' for ACP-agent conversations", () => {
useActiveConversationMock.mockReturnValue({
data: {
conversation_id: "test-conversation-id",
title: "Test Conversation",
status: "RUNNING",
agent_kind: "acp",
} as unknown as Conversation,
});
renderConversationNameWithRouter();
const model = screen.getByTestId("conversation-name-llm-model");
expect(model).toHaveTextContent("ACP");
expect(model).toHaveAttribute("title", "ACP");
});
it("should render the provider brand label when acp_server matches a known provider", () => {
useActiveConversationMock.mockReturnValue({
data: {
conversation_id: "test-conversation-id",
title: "Test Conversation",
status: "RUNNING",
agent_kind: "acp",
tags: { acp_server: "claude-code" },
} as unknown as Conversation,
});
useConfigMock.mockReturnValue({
data: {
app_mode: "oss",
acp_providers: [
{
key: "claude-code",
display_name: "Claude Code",
default_command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp"],
},
],
},
} as unknown as ReturnType<typeof useConfigMock>);
renderConversationNameWithRouter();
const model = screen.getByTestId("conversation-name-llm-model");
expect(model).toHaveTextContent("Claude Code");
});
it("should not render the llm model when not available", () => {
@@ -54,7 +54,7 @@ const renderRecentConversation = (conversation: V1AppConversation) =>
);
describe("RecentConversation - llm_model", () => {
it("should render the llm model when provided", () => {
it("should render the OpenHands brand label when llm_model is provided", () => {
renderRecentConversation({
...baseConversation,
llm_model: "anthropic/claude-sonnet-4-20250514",
@@ -62,20 +62,32 @@ describe("RecentConversation - llm_model", () => {
const model = screen.getByTestId("recent-conversation-llm-model");
expect(model).toBeInTheDocument();
expect(model).toHaveTextContent("anthropic/claude-sonnet-4-20250514");
// Visible label is the harness brand; raw model is preserved on hover.
expect(model).toHaveTextContent("OpenHands");
expect(model).toHaveAttribute(
"title",
"anthropic/claude-sonnet-4-20250514",
);
expect(model.querySelector("svg")).toBeInTheDocument();
// Verify truncation structure: text is wrapped in a span with truncate class
const textSpan = model.querySelector("span.truncate");
expect(textSpan).toBeInTheDocument();
expect(textSpan).toHaveTextContent("anthropic/claude-sonnet-4-20250514");
expect(textSpan).toHaveTextContent("OpenHands");
});
it("should not render the llm model when not provided", () => {
it("should render plain 'ACP' for ACP-agent conversations", () => {
renderRecentConversation({
...baseConversation,
agent_kind: "acp",
llm_model: null,
});
const model = screen.getByTestId("recent-conversation-llm-model");
expect(model).toHaveTextContent("ACP");
expect(model).toHaveAttribute("title", "ACP");
});
it("should not render the model chip when neither llm_model nor ACP", () => {
renderRecentConversation(baseConversation);
expect(
@@ -207,7 +207,8 @@ describe("UserContextMenu", () => {
item.to !== "/settings/org" &&
item.to !== "/settings/billing" &&
!item.to.startsWith("/settings/org-defaults") &&
!personalLlmPaths.has(item.to),
!personalLlmPaths.has(item.to) &&
true,
);
await waitFor(() => {
@@ -242,7 +243,9 @@ describe("UserContextMenu", () => {
// Wait for config to load and verify that navigation items are rendered (except organization-members/org which are filtered out)
const expectedItems = SAAS_NAV_ITEMS.filter(
(item) =>
item.to !== "/settings/org-members" && item.to !== "/settings/org",
item.to !== "/settings/org-members" &&
item.to !== "/settings/org" &&
true,
);
await waitFor(() => {
@@ -1,8 +1,5 @@
import { describe, it, expect } from "vitest";
import {
getACPToolCallContent,
getACPToolCallTitleKey,
} from "#/components/v1/chat/event-content-helpers/get-acp-tool-call-content";
import { getACPToolCallContent } from "#/components/v1/chat/event-content-helpers/get-acp-tool-call-content";
import { getACPToolCallResult } from "#/components/v1/chat/event-content-helpers/get-observation-result";
import { ACPToolCallEvent } from "#/types/v1/core/events/acp-tool-call-event";
@@ -26,26 +23,6 @@ const makeEvent = (overrides: Partial<ACPToolCallEvent>): ACPToolCallEvent => ({
...overrides,
});
describe("getACPToolCallTitleKey", () => {
it.each([
["execute", "ACTION_MESSAGE$ACP_RUN"],
["edit", "ACTION_MESSAGE$ACP_EDIT"],
["read", "ACTION_MESSAGE$ACP_READ"],
["fetch", "ACTION_MESSAGE$ACP_FETCH"],
["other", "ACTION_MESSAGE$ACP_TOOL"],
] as const)("maps tool_kind=%s to %s", (toolKind, expectedKey) => {
expect(getACPToolCallTitleKey(makeEvent({ tool_kind: toolKind }))).toBe(
expectedKey,
);
});
it("falls back to ACP_TOOL when tool_kind is null", () => {
expect(getACPToolCallTitleKey(makeEvent({ tool_kind: null }))).toBe(
"ACTION_MESSAGE$ACP_TOOL",
);
});
});
describe("getACPToolCallContent", () => {
it("renders execute tool calls with Command: and Output: blocks, matching terminal observations", () => {
const content = getACPToolCallContent(baseEvent);
@@ -6,6 +6,25 @@ import {
createPlanningObservationEvent,
createUserMessageEvent,
} from "test-utils";
import { ACPToolCallEvent } from "#/types/v1/core/events/acp-tool-call-event";
const makeACPEvent = (
overrides: Partial<ACPToolCallEvent> = {},
): ACPToolCallEvent => ({
id: "acp-1",
kind: "ACPToolCallEvent",
timestamp: "2024-01-01T00:00:00Z",
source: "agent",
tool_call_id: "tc-1",
title: "Run command",
status: "completed",
tool_kind: "execute",
raw_input: { command: "ls" },
raw_output: "file.txt",
content: null,
is_error: false,
...overrides,
});
describe("shouldRenderEvent - PlanningFileEditorAction", () => {
it("should return false for PlanningFileEditorAction", () => {
@@ -33,3 +52,33 @@ describe("shouldRenderEvent - PlanningFileEditorAction", () => {
expect(shouldRenderEvent(event)).toBe(true);
});
});
describe("shouldRenderEvent - ACPToolCallEvent", () => {
it("should return false for in_progress events (suppress empty-args flash)", () => {
const event = makeACPEvent({ status: "in_progress", raw_input: {} });
expect(shouldRenderEvent(event)).toBe(false);
});
it("should return true for completed events", () => {
const event = makeACPEvent({ status: "completed" });
expect(shouldRenderEvent(event)).toBe(true);
});
it("should return true for failed events", () => {
const event = makeACPEvent({ status: "failed", is_error: true });
expect(shouldRenderEvent(event)).toBe(true);
});
it("should return false for null status (pre-terminal — no production events yet)", () => {
// ACP feature flag has never shipped to production with the GUI, so
// there are no legacy null-status events in the wild. Treat null as
// pre-terminal and suppress to avoid flashing an empty card during
// the intermediate updates some ACP servers emit before settling.
const event = makeACPEvent({ status: null });
expect(shouldRenderEvent(event)).toBe(false);
});
});
@@ -53,11 +53,10 @@ describe("EventMessage - ACPToolCallEvent dispatch", () => {
/>,
);
// The test-utils i18n instance doesn't load the real translation bundle,
// so createTitleFromKey falls back to the key literal. Assert on the
// key — the integration case (rendered string) is covered by a Storybook
// story + manual verification listed in the PR description.
expect(screen.getByText("ACTION_MESSAGE$ACP_RUN")).toBeInTheDocument();
// The title row renders ``event.title`` verbatim — the upstream ACP
// sub-agent (Claude Code / Codex / Gemini CLI) already emits a
// humanised label, so no translation-key wrapping happens here.
expect(screen.getByText("gh pr diff 490")).toBeInTheDocument();
});
it("shows the success check mark for completed tool calls", () => {
@@ -103,7 +102,10 @@ describe("EventMessage - ACPToolCallEvent dispatch", () => {
await user.click(screen.getByRole("button", { name: "Expand" }));
// Markdown renderer wraps code blocks but the plain text survives.
expect(screen.getByText(/gh pr diff 490/)).toBeInTheDocument();
// ``gh pr diff 490`` appears twice now — once in the title row (the
// verbatim ``event.title``) and once in the ``Command:`` block in the
// expanded details. We just care that the details panel contains it.
expect(screen.getAllByText(/gh pr diff 490/).length).toBeGreaterThanOrEqual(1);
expect(screen.getByText(/diff output here/)).toBeInTheDocument();
});
});
@@ -270,6 +270,66 @@ describe("useNewConversationCommand", () => {
});
});
it("does not pass the ACP display label as an LLM model", async () => {
// ACP conversations store a human-readable label in llm_model (e.g. "ACP: claude-agent-acp")
// rather than a real model identifier. The new-conversation command must not forward this
// label as a model parameter — doing so would break LLM routing on the new conversation.
// conversation_url is null here because the test only exercises the model-parameter path;
// the ACP agent server URL is irrelevant to this assertion.
vi.mocked(V1ConversationService.batchGetAppConversations).mockResolvedValue([
{
id: "conv-123",
title: "ACP Conversation",
sandbox_id: "sandbox-456",
sandbox_status: "RUNNING",
execution_status: "IDLE",
conversation_url: null,
session_api_key: null,
selected_repository: null,
selected_branch: null,
git_provider: null,
trigger: null,
pr_number: [],
agent_kind: "acp",
llm_model: "ACP: claude-agent-acp",
metrics: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
sub_conversation_ids: [],
public: false,
} as never,
]);
const readyTask = makeStartTask();
const createSpy = vi
.spyOn(V1ConversationService, "createConversation")
.mockResolvedValue(readyTask as never);
vi.spyOn(V1ConversationService, "getStartTask").mockResolvedValue(
readyTask as never,
);
const { result } = renderHook(() => useNewConversationCommand(), { wrapper });
await result.current.mutateAsync();
await waitFor(() => {
expect(createSpy).toHaveBeenCalledWith(
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
"sandbox-456",
undefined,
);
});
});
it("shows a loading toast immediately and dismisses it on success", async () => {
const readyTask = makeStartTask();
@@ -129,9 +129,12 @@ describe("useSettingsNavItems", () => {
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
await waitFor(() => {
// OSS mode should return items matching OSS_NAV_ITEMS paths
// OSS mode should return items matching OSS_NAV_ITEMS paths,
// minus any items hidden behind a feature flag that's off by default.
const navItems = getNavItems(result.current);
const ossPaths = OSS_NAV_ITEMS.map((item) => item.to);
const ossPaths = OSS_NAV_ITEMS.map(
(item) => item.to,
);
const resultPaths = navItems.map((item) =>
item.type === "item" ? item.item.to : null,
);
@@ -0,0 +1,280 @@
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router";
import userEvent from "@testing-library/user-event";
import AgentSettingsScreen from "#/routes/agent-settings";
import SettingsService from "#/api/settings-service/settings-service.api";
import OptionService from "#/api/option-service/option-service.api";
import type { ACPProviderConfig } from "#/api/option-service/option.types";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
});
afterEach(() => {
vi.restoreAllMocks();
});
const renderAgentSettings = () =>
render(
<MemoryRouter>
<AgentSettingsScreen />
</MemoryRouter>,
{
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
},
);
// Mirrors the SDK registry-backed web-client config. Frontend tests cannot
// import the Python SDK source directly, but keeping this as a typed fixture
// catches frontend drift in the API shape.
const ACP_PROVIDERS_FIXTURE: ACPProviderConfig[] = [
{
key: "claude-code",
display_name: "Claude Code",
default_command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp"],
},
{
key: "codex",
display_name: "Codex",
default_command: ["npx", "-y", "@zed-industries/codex-acp"],
},
{
key: "gemini-cli",
display_name: "Gemini CLI",
default_command: ["npx", "-y", "@google/gemini-cli", "--acp"],
},
];
const baseConfig = {
app_mode: "oss" as const,
posthog_client_key: null,
feature_flags: {
enable_billing: false,
hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
hide_users_page: false,
hide_billing_page: false,
hide_integrations_page: false,
enable_acp: true,
enable_onboarding: false,
},
providers_configured: [],
maintenance_start_time: null,
auth_url: null,
recaptcha_site_key: null,
faulty_models: [],
error_message: null,
updated_at: "2026-01-01T00:00:00Z",
github_app_slug: null,
acp_providers: ACP_PROVIDERS_FIXTURE,
};
describe("AgentSettingsScreen — minimal generic ACP UX", () => {
it("hydrates the form from saved ACP settings", async () => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue(baseConfig);
vi.spyOn(SettingsService, "getSettings").mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
agent_settings: {
agent_kind: "acp",
acp_server: "custom",
acp_command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp"],
acp_args: [],
acp_env: { ANTHROPIC_API_KEY: "sk-test" },
acp_model: "claude-opus-4",
},
});
renderAgentSettings();
await waitFor(() => {
expect(
(screen.getByTestId("agent-command-input") as HTMLTextAreaElement)
.value,
).toBe("npx -y @agentclientprotocol/claude-agent-acp");
});
expect(
(screen.getByTestId("agent-model-input") as HTMLInputElement).value,
).toBe("claude-opus-4");
});
it("clears acp_* fields when switching back to OpenHands", async () => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue(baseConfig);
vi.spyOn(SettingsService, "getSettings").mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
agent_settings: {
agent_kind: "acp",
acp_server: "custom",
acp_command: ["claude-agent-acp"],
acp_args: [],
acp_env: { ANTHROPIC_API_KEY: "sk-test" },
acp_model: "claude-opus-4",
},
});
const saveSpy = vi
.spyOn(SettingsService, "saveSettings")
.mockResolvedValue(true);
renderAgentSettings();
await waitFor(() => {
expect(
(screen.getByTestId("agent-command-input") as HTMLTextAreaElement)
.value,
).toBe("claude-agent-acp");
});
const dropdown = screen.getByTestId("agent-type-selector");
await userEvent.click(dropdown);
const ohOption = await screen.findByRole("option", {
name: "SETTINGS$AGENT_TYPE_OPENHANDS",
});
await userEvent.click(ohOption);
await userEvent.click(screen.getByTestId("agent-save-button"));
await waitFor(() => {
expect(saveSpy).toHaveBeenCalledTimes(1);
});
// OH-switch payload sends only ``agent_kind`` — the backend
// ``Settings.update()`` starts a fresh base when the kind flips and
// discards any incoming ``acp_*`` fields anyway.
expect(saveSpy.mock.calls[0][0]).toEqual({
agent_settings_diff: { agent_kind: "openhands" },
});
});
it("saves built-in presets via acp_server and lets the SDK resolve defaults", async () => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue(baseConfig);
vi.spyOn(SettingsService, "getSettings").mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
agent_settings: {
agent_kind: "acp",
acp_server: "custom",
acp_command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp"],
acp_args: [],
acp_env: {},
acp_model: null,
},
});
const saveSpy = vi
.spyOn(SettingsService, "saveSettings")
.mockResolvedValue(true);
renderAgentSettings();
await waitFor(() => {
expect(
(screen.getByTestId("agent-command-input") as HTMLTextAreaElement)
.value,
).toBe("npx -y @agentclientprotocol/claude-agent-acp");
});
const commandInput = screen.getByTestId("agent-command-input");
await userEvent.clear(commandInput);
await userEvent.type(
commandInput,
"npx -y{enter}@agentclientprotocol/claude-agent-acp",
);
await userEvent.type(screen.getByTestId("agent-model-input"), "opus");
await userEvent.click(screen.getByTestId("agent-save-button"));
await waitFor(() => {
expect(saveSpy).toHaveBeenCalledTimes(1);
});
expect(saveSpy.mock.calls[0][0]).toMatchObject({
agent_settings_diff: {
agent_kind: "acp",
acp_server: "claude-code",
acp_command: [],
acp_model: "opus",
},
});
});
it("saves as custom when acp_server is built-in but command differs from default", async () => {
// If the persisted command doesn't match the provider default, the UI must
// treat it as "custom" and save it that way — even if acp_server says
// "claude-code". This prevents silent round-trip corruption where the UI
// shows a built-in preset but saving overwrites the custom command with [].
vi.spyOn(OptionService, "getConfig").mockResolvedValue(baseConfig);
vi.spyOn(SettingsService, "getSettings").mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
agent_settings: {
agent_kind: "acp",
acp_server: "claude-code",
acp_command: ["npx", "-y", "@custom/my-agent"],
acp_args: [],
acp_env: {},
acp_model: null,
},
});
const saveSpy = vi
.spyOn(SettingsService, "saveSettings")
.mockResolvedValue(true);
renderAgentSettings();
await waitFor(() => {
expect(
(screen.getByTestId("agent-command-input") as HTMLTextAreaElement)
.value,
).toBe("npx -y @custom/my-agent");
});
// Touch the model field to dirty the form (so save is enabled).
await userEvent.type(screen.getByTestId("agent-model-input"), "x");
await userEvent.clear(screen.getByTestId("agent-model-input"));
await userEvent.click(screen.getByTestId("agent-save-button"));
await waitFor(() => {
expect(saveSpy).toHaveBeenCalledTimes(1);
});
// Since the command doesn't match the claude-code default, it must save as
// custom with the full command — NOT as acp_server="claude-code" + acp_command=[].
expect(saveSpy.mock.calls[0][0]).toMatchObject({
agent_settings_diff: {
acp_server: "custom",
acp_command: ["npx", "-y", "@custom/my-agent"],
},
});
});
it("keeps a useful command placeholder if no provider metadata is available", async () => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
...baseConfig,
acp_providers: [],
});
vi.spyOn(SettingsService, "getSettings").mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
agent_settings: {
agent_kind: "acp",
acp_server: "custom",
acp_command: [],
acp_args: [],
acp_env: {},
acp_model: null,
},
});
renderAgentSettings();
expect(await screen.findByTestId("agent-command-input")).toHaveAttribute(
"placeholder",
"npx -y <package-name>",
);
});
});
@@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MemoryRouter } from "react-router";
import { organizationService } from "#/api/organization-service/organization-service.api";
import ProfilesService from "#/api/settings-service/profiles-service.api";
@@ -248,7 +249,9 @@ async function renderLlmSettingsScreen({
const rendered = render(<LlmSettingsScreen scope={scope} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
<MemoryRouter>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</MemoryRouter>
),
});
@@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MemoryRouter } from "react-router";
import SettingsService from "#/api/settings-service/settings-service.api";
import {
MOCK_DEFAULT_USER_SETTINGS,
@@ -60,7 +61,9 @@ function renderMcpSettingsScreen() {
return render(<MCPSettingsScreen />, {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
<MemoryRouter>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</MemoryRouter>
),
});
}
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { formatCommand, tokenizeCommand } from "#/utils/shell-tokenize";
describe("tokenizeCommand", () => {
it("splits on runs of whitespace including newlines", () => {
expect(tokenizeCommand("npx -y\n@scope/pkg")).toEqual([
"npx",
"-y",
"@scope/pkg",
]);
});
it("preserves arguments wrapped in double quotes", () => {
expect(tokenizeCommand('npx -y "my pkg"')).toEqual(["npx", "-y", "my pkg"]);
});
it("preserves arguments wrapped in single quotes", () => {
expect(tokenizeCommand("node 'path with spaces/cli.js' --acp")).toEqual([
"node",
"path with spaces/cli.js",
"--acp",
]);
});
it("returns an empty array for whitespace-only input", () => {
expect(tokenizeCommand(" \n\t ")).toEqual([]);
});
it("treats backslash as literal inside quotes (no escape semantics)", () => {
// Inside a single-quoted string both backslash and double-quote are
// literal; documents the intentional narrowness of the tokenizer.
expect(tokenizeCommand("'a\\b\"c'")).toEqual(['a\\b"c']);
});
it("concatenates adjacent quoted segments into one token", () => {
expect(tokenizeCommand(`a"b"'c'`)).toEqual(["abc"]);
});
it("yields an empty-string token for an empty quoted segment", () => {
expect(tokenizeCommand('a "" b')).toEqual(["a", "", "b"]);
});
it("leniently absorbs an unterminated quote", () => {
expect(tokenizeCommand('cmd "unclosed arg')).toEqual([
"cmd",
"unclosed arg",
]);
});
});
describe("formatCommand", () => {
it("joins tokens with a single space when none need quoting", () => {
expect(formatCommand(["npx", "-y", "@scope/pkg"])).toBe(
"npx -y @scope/pkg",
);
});
it("wraps tokens containing whitespace in double quotes", () => {
expect(formatCommand(["node", "my path/cli.js", "--acp"])).toBe(
'node "my path/cli.js" --acp',
);
});
it("falls back to single quotes when a whitespace token contains a double quote", () => {
expect(formatCommand(["echo", 'say "hi"'])).toBe(`echo 'say "hi"'`);
});
it("round-trips through tokenizeCommand", () => {
const tokens = [
"node",
"my path/cli.js",
"--acp",
"--flag=value with space",
];
expect(tokenizeCommand(formatCommand(tokens))).toEqual(tokens);
});
});
@@ -478,10 +478,29 @@ class V1ConversationService {
conversationUrl: string | null | undefined,
sessionApiKey?: string | null,
): Promise<V1RuntimeConversationInfo> {
const url = this.buildRuntimeUrl(
conversationUrl,
`/api/conversations/${conversationId}`,
);
// The agent-server provides a full ``conversationUrl`` with a
// ``/api/conversations/{id}`` path (the SDK unified the LLM and ACP
// endpoints onto this single route). We still preserve the URL's path
// verbatim because a proxy deployment may add a prefix (e.g.
// ``/runtime/55313/api/conversations/...``). If the URL is missing or
// malformed we fall back to the default path derived from
// ``window.location``.
//
// Either way we route through ``buildRuntimeUrl`` so its
// ``extractBaseHost`` rewrites localhost/127.0.0.1 to the browser's
// hostname when accessed from a non-local browser (proxy/external host
// deployments). Without this, a conversation_url containing
// ``localhost`` is unreachable from anywhere but the host machine.
let path = `/api/conversations/${conversationId}`;
if (conversationUrl) {
try {
path = new URL(conversationUrl).pathname;
} catch {
// Malformed URL — fall back to the default LLM path; buildRuntimeUrl
// will resolve the host against window.location.
}
}
const url = this.buildRuntimeUrl(conversationUrl, path);
const headers = buildSessionHeaders(sessionApiKey);
const { data } = await axios.get<V1RuntimeConversationInfo>(url, {
@@ -1,4 +1,8 @@
import { ConversationTrigger } from "../open-hands.types";
import {
AgentKind,
ConversationTags,
ConversationTrigger,
} from "../open-hands.types";
import { V1SandboxStatus } from "../sandbox-service/sandbox-service.types";
import { Provider } from "#/types/settings";
import { SuggestedTask } from "#/utils/types";
@@ -111,6 +115,8 @@ export interface V1AppConversation {
trigger: ConversationTrigger | null;
pr_number: number[];
llm_model: string | null;
agent_kind?: AgentKind;
tags?: ConversationTags;
metrics: V1MetricsSnapshot | null;
created_at: string;
updated_at: string;
+15
View File
@@ -2,6 +2,19 @@ import { ConversationStatus } from "#/types/conversation-status";
import { RuntimeStatus } from "#/types/runtime-status";
import { Provider } from "#/types/settings";
/** Backend ``AppConversationInfo.agent_kind`` discriminator. */
export type AgentKind = "openhands" | "acp";
/**
* Conversation tags. The backend stamps ``acp_server`` (the ACP provider
* discriminator key) at conversation-create time; other keys carry automation
* context, skills used, etc., and are open-ended.
*/
export interface ConversationTags {
acp_server?: string;
[key: string]: string | undefined;
}
export interface ErrorResponse {
error: string;
}
@@ -81,6 +94,8 @@ export interface Conversation {
public?: boolean;
sandbox_id?: string | null;
llm_model?: string | null;
agent_kind?: AgentKind;
tags?: ConversationTags;
}
export interface ResultSet<T> {
@@ -28,10 +28,17 @@ export interface WebClientFeatureFlags {
hide_users_page: boolean;
hide_billing_page: boolean;
hide_integrations_page: boolean;
enable_acp?: boolean;
deployment_mode?: DeploymentMode;
enable_onboarding: boolean;
}
export interface ACPProviderConfig {
key: string;
display_name: string;
default_command: string[];
}
export interface WebClientConfig {
app_mode: "saas" | "oss";
posthog_client_key: string | null;
@@ -47,4 +54,5 @@ export interface WebClientConfig {
gitlab_enabled?: boolean;
provider_default_hosts?: Partial<Record<Provider, string>>;
slack_enabled?: boolean;
acp_providers?: ACPProviderConfig[];
}
@@ -28,6 +28,14 @@ export function SwitchProfileButton() {
? (profiles.find((p) => p.model === conversationModel)?.name ?? null)
: (data?.active_profile ?? null);
// LLM profiles don't apply to ACP conversations: the sub-agent
// (Claude Code / Codex / Gemini CLI) drives its own model selection,
// and ``llm_model`` is intentionally null. Hide the toggle so the user
// isn't shown a switch that has no effect.
if (conversation?.agent_kind === "acp") {
return null;
}
if (profiles.length === 0) {
return null;
}
@@ -1,30 +1,56 @@
import React from "react";
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import { Tooltip } from "@heroui/react";
import { I18nKey } from "#/i18n/declaration";
import { SettingsNavItem } from "#/constants/settings-nav";
interface ContextMenuNavLinkProps {
item: SettingsNavItem;
onClick: () => void;
disabled?: boolean;
disabledAgentName?: string;
}
export function ContextMenuNavLink({ item, onClick }: ContextMenuNavLinkProps) {
export function ContextMenuNavLink({
item,
onClick,
disabled,
disabledAgentName,
}: ContextMenuNavLinkProps) {
const { t } = useTranslation();
const { to, icon, text } = item;
const iconEl = React.cloneElement(icon, {
className: "text-white",
width: 16,
height: 16,
size: 16,
} as React.SVGProps<SVGSVGElement>);
if (disabled) {
const tooltip = disabledAgentName
? t(I18nKey.SETTINGS$AGENT_DISABLED_TOOLTIP, {
agentName: disabledAgentName,
})
: undefined;
return (
<Tooltip content={tooltip} placement="right">
<div className="flex items-center gap-2 p-2 opacity-40 cursor-not-allowed rounded w-full text-xs">
{iconEl}
{t(text as I18nKey)}
</div>
</Tooltip>
);
}
return (
<Link
to={to}
onClick={onClick}
className="flex items-center gap-2 p-2 cursor-pointer hover:bg-white/10 hover:text-white rounded w-full text-xs"
>
{React.cloneElement(icon, {
className: "text-white",
width: 16,
height: 16,
size: 16, // For react-icons compatibility
} as React.SVGProps<SVGSVGElement>)}
{iconEl}
{t(text as I18nKey)}
</Link>
);
@@ -15,6 +15,8 @@ import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { Provider } from "#/types/settings";
import { useUpdateConversation } from "#/hooks/mutation/use-update-conversation";
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
import { agentDisplayLabel } from "#/utils/agent-display-label";
import { useConfig } from "#/hooks/query/use-config";
import { ConversationCard } from "./conversation-card/conversation-card";
import { StartTaskCard } from "./start-task-card/start-task-card";
import { ConversationCardSkeleton } from "./conversation-card/conversation-card-skeleton";
@@ -28,6 +30,7 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
const { conversationId: currentConversationId } = useParams();
const ref = useClickOutsideElement<HTMLDivElement>(onClose);
const navigate = useNavigate();
const { data: config } = useConfig();
const [confirmDeleteModalVisible, setConfirmDeleteModalVisible] =
React.useState(false);
@@ -201,7 +204,12 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
onContextMenuToggle={(isOpen) =>
setOpenContextMenuId(isOpen ? conversation.id : null)
}
llmModel={conversation.llm_model}
llmModel={agentDisplayLabel(
conversation.agent_kind,
conversation.llm_model,
conversation.tags,
config?.acp_providers,
)}
/>
</NavLink>
))}
@@ -1,11 +1,14 @@
import React from "react";
import { useParams } from "react-router";
import { useTranslation } from "react-i18next";
import { Typography } from "#/ui/typography";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useConfig } from "#/hooks/query/use-config";
import { useUpdateConversation } from "#/hooks/mutation/use-update-conversation";
import { useConversationNameContextMenu } from "#/hooks/use-conversation-name-context-menu";
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
import { I18nKey } from "#/i18n/declaration";
import { agentDisplayLabel } from "#/utils/agent-display-label";
import { EllipsisButton } from "../conversation-panel/ellipsis-button";
import { ConversationNameContextMenu } from "./conversation-name-context-menu";
import { SystemMessageModal } from "../conversation-panel/system-message-modal";
@@ -20,6 +23,7 @@ export function ConversationName() {
const { t } = useTranslation();
const { conversationId } = useParams<{ conversationId: string }>();
const { data: conversation } = useActiveConversation();
const { data: config } = useConfig();
const { mutate: updateConversation } = useUpdateConversation();
const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view");
@@ -131,6 +135,13 @@ export function ConversationName() {
return null;
}
const agentLabel = agentDisplayLabel(
conversation.agent_kind,
conversation.llm_model,
conversation.tags,
config?.acp_providers,
);
return (
<>
<div
@@ -159,14 +170,16 @@ export function ConversationName() {
</div>
)}
{titleMode !== "edit" && conversation.llm_model && (
{titleMode !== "edit" && agentLabel && (
<span
className="text-xs text-[#A3A3A3] flex items-center gap-1 whitespace-nowrap"
title={conversation.llm_model}
className="text-xs text-[#A3A3A3] max-w-[150px] flex items-center gap-1 overflow-hidden"
title={conversation.llm_model ?? agentLabel}
data-testid="conversation-name-llm-model"
>
<CircuitIcon width={12} height={12} className="shrink-0" />
<span>{conversation.llm_model}</span>
<Typography.Text className="text-xs text-[#A3A3A3] truncate">
{agentLabel}
</Typography.Text>
</span>
)}
@@ -3,12 +3,15 @@ import { Link } from "react-router";
import CodeBranchIcon from "#/icons/u-code-branch.svg?react";
import { V1AppConversation } from "#/api/conversation-service/v1-conversation-service.types";
import { GitProviderIcon } from "#/components/shared/git-provider-icon";
import { useConfig } from "#/hooks/query/use-config";
import { Provider } from "#/types/settings";
import { formatTimeDelta } from "#/utils/format-time-delta";
import { I18nKey } from "#/i18n/declaration";
import { SandboxStatusIndicator } from "./sandbox-status-indicator";
import RepoForkedIcon from "#/icons/repo-forked.svg?react";
import { Typography } from "#/ui/typography";
import CircuitIcon from "#/icons/u-circuit.svg?react";
import { agentDisplayLabel } from "#/utils/agent-display-label";
interface RecentConversationProps {
conversation: V1AppConversation;
@@ -16,9 +19,16 @@ interface RecentConversationProps {
export function RecentConversation({ conversation }: RecentConversationProps) {
const { t } = useTranslation();
const { data: config } = useConfig();
const hasRepository =
conversation.selected_repository && conversation.selected_branch;
const agentLabel = agentDisplayLabel(
conversation.agent_kind,
conversation.llm_model,
conversation.tags,
config?.acp_providers,
);
return (
<Link
@@ -66,14 +76,16 @@ export function RecentConversation({ conversation }: RecentConversationProps) {
) : null}
</div>
<div className="flex items-center gap-2">
{conversation.llm_model && (
{agentLabel && (
<span
className="max-w-[120px] flex items-center gap-1 overflow-hidden"
title={conversation.llm_model}
title={conversation.llm_model ?? agentLabel}
data-testid="recent-conversation-llm-model"
>
<CircuitIcon width={12} height={12} className="shrink-0" />
<span className="truncate">{conversation.llm_model}</span>
<Typography.Text className="text-xs truncate">
{agentLabel}
</Typography.Text>
</span>
)}
{(conversation.created_at || conversation.updated_at) && (
@@ -1,5 +1,6 @@
import { useTranslation } from "react-i18next";
import { NavLink } from "react-router";
import { Tooltip } from "@heroui/react";
import { cn } from "#/utils/utils";
import { Typography } from "#/ui/typography";
import { I18nKey } from "#/i18n/declaration";
@@ -8,12 +9,45 @@ import { SettingsNavItem } from "#/constants/settings-nav";
interface SettingsNavLinkProps {
item: SettingsNavItem;
onClick: () => void;
disabled?: boolean;
disabledAgentName?: string;
}
export function SettingsNavLink({ item, onClick }: SettingsNavLinkProps) {
export function SettingsNavLink({
item,
onClick,
disabled,
disabledAgentName,
}: SettingsNavLinkProps) {
const { t } = useTranslation();
const { to, icon, text } = item;
if (disabled) {
const tooltip = disabledAgentName
? t(I18nKey.SETTINGS$AGENT_DISABLED_TOOLTIP, {
agentName: disabledAgentName,
})
: undefined;
return (
<Tooltip content={tooltip} placement="right">
<div
aria-disabled="true"
data-testid={`settings-nav-disabled-${to}`}
className="group flex items-center gap-3 p-1 sm:px-3.5 sm:py-2 rounded opacity-40 cursor-not-allowed"
>
<Typography.Text className="flex h-5 w-5 shrink-0 items-center justify-center text-[#8C8C8C]">
{icon}
</Typography.Text>
<div className="min-w-0 flex-1 overflow-hidden">
<Typography.Text className="block truncate whitespace-nowrap text-[#8C8C8C]">
{t(text as I18nKey)}
</Typography.Text>
</div>
</div>
</Tooltip>
);
}
return (
<NavLink
end
@@ -84,6 +84,8 @@ export function SettingsNavigation({
key={renderedItem.item.to}
item={renderedItem.item}
onClick={onCloseMobileMenu}
disabled={renderedItem.disabled}
disabledAgentName={renderedItem.disabledAgentName}
/>
);
})}
@@ -135,6 +135,8 @@ export function UserContextMenu({
key={renderedItem.item.to}
item={renderedItem.item}
onClick={onClose}
disabled={renderedItem.disabled}
disabledAgentName={renderedItem.disabledAgentName}
/>
);
})}
@@ -2,26 +2,6 @@ import { ACPToolCallEvent } from "#/types/v1/core/events/acp-tool-call-event";
import i18n from "#/i18n";
import { MAX_CONTENT_LENGTH } from "./shared";
/**
* Pick the translation key used for the ACP tool call title row. Mirrors
* ACTION_MESSAGE$RUN / $EDIT / $READ etc. but with an ``ACP · `` prefix so
* the card is visually distinguishable from regular OpenHands actions.
*/
export const getACPToolCallTitleKey = (event: ACPToolCallEvent): string => {
switch (event.tool_kind) {
case "execute":
return "ACTION_MESSAGE$ACP_RUN";
case "edit":
return "ACTION_MESSAGE$ACP_EDIT";
case "read":
return "ACTION_MESSAGE$ACP_READ";
case "fetch":
return "ACTION_MESSAGE$ACP_FETCH";
default:
return "ACTION_MESSAGE$ACP_TOOL";
}
};
/**
* Stringify an arbitrary raw_input / raw_output payload for markdown
* rendering. Strings pass through; objects are pretty-printed JSON.
@@ -10,10 +10,7 @@ import { MonoComponent } from "../../../features/chat/mono-component";
import { PathComponent } from "../../../features/chat/path-component";
import { getActionContent } from "./get-action-content";
import { getObservationContent } from "./get-observation-content";
import {
getACPToolCallContent,
getACPToolCallTitleKey,
} from "./get-acp-tool-call-content";
import { getACPToolCallContent } from "./get-acp-tool-call-content";
import { TaskTrackingObservationContent } from "../task-tracking/task-tracking-observation-content";
import { TaskTrackerObservation } from "#/types/v1/core/base/observation";
import { SkillReadyEvent, isSkillReadyEvent } from "./create-skill-ready-event";
@@ -272,14 +269,13 @@ export const getEventContent = (
details = getObservationContent(event);
}
} else if (isACPToolCallEvent(event)) {
// ACP sub-agent tool calls reuse the same card shape as observations:
// title is "ACP · Running/Editing/Reading …" via a translation key that
// mirrors ACTION_MESSAGE$RUN etc.; details are markdown built from
// raw_input + raw_output the same way getTerminalObservationContent
// builds "Command: / Output:" blocks.
title = createTitleFromKey(getACPToolCallTitleKey(event), {
title: event.title,
});
// ACP sub-agent tool calls reuse the same card shape as observations.
// ``event.title`` is the upstream sub-agent's own humanised label
// (Claude Code / Codex / Gemini CLI emit things like "Read tests" or
// "Edit foo.py"), so we render it verbatim — same pattern as the OH
// path's ``event.summary`` short-circuit. Wrapping it in a verb-prefix
// translation key (e.g. "Reading {{title}}") would double the verb.
title = event.title;
details = getACPToolCallContent(event);
} else if (
// Lenient fallback for action-like events that fail the strict isActionEvent() guard
@@ -59,9 +59,15 @@ export const shouldRenderEvent = (event: OpenHandsEvent) => {
return true;
}
// Render ACP sub-agent tool call events
// Render ACP sub-agent tool call events only once they've reached a
// terminal status. The ACP server emits multiple events per
// ``tool_call_id`` as the call progresses; ``handleEventForUI`` dedupes
// them into a single in-place card. Showing pre-terminal events flashes
// an empty ``Input: {}`` / ``Output: [no output]`` card while
// ``raw_input`` / ``raw_output`` are still streaming in. Wait for the
// call to settle before rendering anything.
if (isACPToolCallEvent(event)) {
return true;
return event.status === "completed" || event.status === "failed";
}
// Don't render any other event types (system events, etc.)
+24 -11
View File
@@ -23,6 +23,13 @@ export interface SettingsNavItem {
to: string;
text: string;
section?: SettingsNavSection;
// When true, this item is greyed out (and its route redirects to
// ``/settings/agent``) while the personal-scope active agent is ACP.
// The ACP sub-agent manages its own LLM / condenser / MCP, so these
// OpenHands-side surfaces have no useful content. Drives both the
// navigation disable in ``use-settings-nav-items.ts`` and the
// server-side redirect in ``routes/settings.tsx`` from one source.
disabledByAcp?: boolean;
}
export const SAAS_NAV_ITEMS: SettingsNavItem[] = [
@@ -56,23 +63,25 @@ export const SAAS_NAV_ITEMS: SettingsNavItem[] = [
text: "SETTINGS$NAV_VERIFICATION",
section: "org",
},
{
icon: <CircuitIcon width={22} height={22} />,
to: "/settings",
text: "COMMON$LANGUAGE_MODEL_LLM",
section: "personal",
},
{
icon: <RobotIcon width={22} height={22} />,
to: "/settings/agent",
text: "SETTINGS$AGENT",
section: "personal",
},
{
icon: <CircuitIcon width={22} height={22} />,
to: "/settings",
text: "COMMON$LANGUAGE_MODEL_LLM",
section: "personal",
disabledByAcp: true,
},
{
icon: <MemoryIcon width={22} height={22} />,
to: "/settings/condenser",
text: "SETTINGS$NAV_CONDENSER",
section: "personal",
disabledByAcp: true,
},
{
icon: <LockIcon width={22} height={22} />,
@@ -97,6 +106,7 @@ export const SAAS_NAV_ITEMS: SettingsNavItem[] = [
to: "/settings/mcp",
text: "SETTINGS$NAV_MCP",
section: "personal",
disabledByAcp: true,
},
{
icon: <UserIcon width={22} height={22} />,
@@ -131,20 +141,22 @@ export const SAAS_NAV_ITEMS: SettingsNavItem[] = [
];
export const OSS_NAV_ITEMS: SettingsNavItem[] = [
{
icon: <CircuitIcon width={22} height={22} />,
to: "/settings",
text: "SETTINGS$NAV_LLM",
},
{
icon: <RobotIcon width={22} height={22} />,
to: "/settings/agent",
text: "SETTINGS$AGENT",
},
{
icon: <CircuitIcon width={22} height={22} />,
to: "/settings",
text: "SETTINGS$NAV_LLM",
disabledByAcp: true,
},
{
icon: <MemoryIcon width={22} height={22} />,
to: "/settings/condenser",
text: "SETTINGS$NAV_CONDENSER",
disabledByAcp: true,
},
{
icon: <LockIcon width={22} height={22} />,
@@ -155,6 +167,7 @@ export const OSS_NAV_ITEMS: SettingsNavItem[] = [
icon: <ServerProcessIcon width={22} height={22} />,
to: "/settings/mcp",
text: "SETTINGS$NAV_MCP",
disabledByAcp: true,
},
{
icon: <LightbulbIcon width={22} height={22} />,
@@ -26,7 +26,9 @@ export const useNewConversationCommand = () => {
// Fetch V1 conversation data to get llm_model (not available in legacy type)
const v1Conversations =
await V1ConversationService.batchGetAppConversations([conversation.id]);
const llmModel = v1Conversations?.[0]?.llm_model;
const v1Conversation = v1Conversations?.[0];
const llmModel =
v1Conversation?.agent_kind === "acp" ? null : v1Conversation?.llm_model;
// Start a new conversation reusing the existing sandbox directly.
// We pass sandbox_id instead of parent_conversation_id so that the
+30 -3
View File
@@ -11,11 +11,17 @@ import { isSettingsPageHidden } from "#/utils/settings-utils";
import { useMe } from "./query/use-me";
import { usePermission } from "./organizations/use-permissions";
import { useOrgTypeAndAccess } from "./use-org-type-and-access";
import { useSettings } from "./query/use-settings";
import { I18nKey } from "#/i18n/declaration";
// Rendered navigation item types
export type SettingsNavRenderedItem =
| { type: "item"; item: SettingsNavItem }
| {
type: "item";
item: SettingsNavItem;
disabled?: boolean;
disabledAgentName?: string;
}
| { type: "header"; text: I18nKey }
| { type: "divider" };
@@ -36,6 +42,7 @@ const SECTION_HEADERS: Partial<Record<SettingsNavSection, I18nKey>> = {
export function useSettingsNavItems(): SettingsNavRenderedItem[] {
const { data: config } = useConfig();
const { data: user } = useMe();
const { data: settings } = useSettings();
const userRole: OrganizationUserRole = user?.role ?? "member";
const { hasPermission } = usePermission(userRole);
const { isPersonalOrg, isTeamOrg, organizationId } = useOrgTypeAndAccess();
@@ -47,6 +54,12 @@ export function useSettingsNavItems(): SettingsNavRenderedItem[] {
const isSaasMode = config?.app_mode === "saas";
const featureFlags = config?.feature_flags;
const isAdminOrOwner = userRole === "admin" || userRole === "owner";
const isAcpAgent = settings?.agent_settings?.agent_kind === "acp";
const acpServerName = isAcpAgent
? (config?.acp_providers?.find(
({ key }) => key === settings?.agent_settings?.acp_server,
)?.display_name ?? "ACP Agent")
: null;
let items = isSaasMode ? [...SAAS_NAV_ITEMS] : [...OSS_NAV_ITEMS];
@@ -86,9 +99,23 @@ export function useSettingsNavItems(): SettingsNavRenderedItem[] {
items = items.filter((item) => !PERSONAL_LLM_PATHS.has(item.to));
}
const buildRenderedItem = (
item: SettingsNavItem,
): SettingsNavRenderedItem => {
if (isAcpAgent && item.disabledByAcp) {
return {
type: "item",
item,
disabled: true,
disabledAgentName: acpServerName ?? undefined,
};
}
return { type: "item", item };
};
// For OSS mode or non-SaaS, return flat list without sections
if (!isSaasMode) {
return items.map((item) => ({ type: "item", item }));
return items.map(buildRenderedItem);
}
// Build rendered items with headers and dividers for SaaS mode
@@ -129,7 +156,7 @@ export function useSettingsNavItems(): SettingsNavRenderedItem[] {
isFirstSection = false;
}
renderedItems.push({ type: "item", item });
renderedItems.push(buildRenderedItem(item));
}
return renderedItems;
+8 -5
View File
@@ -1289,11 +1289,6 @@ export enum I18nKey {
HOOK$ERROR = "HOOK$ERROR",
HOOK$OUTPUT = "HOOK$OUTPUT",
HOOK$STDERR = "HOOK$STDERR",
ACTION_MESSAGE$ACP_RUN = "ACTION_MESSAGE$ACP_RUN",
ACTION_MESSAGE$ACP_EDIT = "ACTION_MESSAGE$ACP_EDIT",
ACTION_MESSAGE$ACP_READ = "ACTION_MESSAGE$ACP_READ",
ACTION_MESSAGE$ACP_FETCH = "ACTION_MESSAGE$ACP_FETCH",
ACTION_MESSAGE$ACP_TOOL = "ACTION_MESSAGE$ACP_TOOL",
COMMON$TYPE_EMAIL_AND_PRESS_SPACE = "COMMON$TYPE_EMAIL_AND_PRESS_SPACE",
ORG$INVITE_ORG_MEMBERS = "ORG$INVITE_ORG_MEMBERS",
ORG$MANAGE_ORGANIZATION = "ORG$MANAGE_ORGANIZATION",
@@ -1432,6 +1427,14 @@ export enum I18nKey {
ORG$CLAIM_ERROR = "ORG$CLAIM_ERROR",
ORG$DISCONNECT_ERROR = "ORG$DISCONNECT_ERROR",
ORG$NO_GIT_ORGANIZATIONS = "ORG$NO_GIT_ORGANIZATIONS",
SETTINGS$AGENT_PAGE_DESCRIPTION = "SETTINGS$AGENT_PAGE_DESCRIPTION",
SETTINGS$AGENT_TYPE_OPENHANDS = "SETTINGS$AGENT_TYPE_OPENHANDS",
SETTINGS$AGENT_PRESET = "SETTINGS$AGENT_PRESET",
SETTINGS$AGENT_PRESET_CUSTOM = "SETTINGS$AGENT_PRESET_CUSTOM",
SETTINGS$AGENT_TYPE_ACP = "SETTINGS$AGENT_TYPE_ACP",
SETTINGS$AGENT_COMMAND_HINT = "SETTINGS$AGENT_COMMAND_HINT",
SETTINGS$AGENT_MODEL_HINT = "SETTINGS$AGENT_MODEL_HINT",
SETTINGS$AGENT_DISABLED_TOOLTIP = "SETTINGS$AGENT_DISABLED_TOOLTIP",
SETTINGS$PROFILES_DESCRIPTION = "SETTINGS$PROFILES_DESCRIPTION",
SETTINGS$PROFILES_EMPTY = "SETTINGS$PROFILES_EMPTY",
SETTINGS$PROFILES_LOAD_ERROR = "SETTINGS$PROFILES_LOAD_ERROR",
+136 -85
View File
@@ -21912,91 +21912,6 @@
"uk": "Стандартна помилка",
"ca": "Stderr"
},
"ACTION_MESSAGE$ACP_RUN": {
"en": "ACP · Running <cmd>{{title}}</cmd>",
"ja": "ACP · <cmd>{{title}}</cmd> を実行中",
"zh-CN": "ACP · 正在运行 <cmd>{{title}}</cmd>",
"zh-TW": "ACP · 正在執行 <cmd>{{title}}</cmd>",
"ko-KR": "ACP · <cmd>{{title}}</cmd> 실행 중",
"no": "ACP · Kjører <cmd>{{title}}</cmd>",
"ar": "ACP · تشغيل <cmd>{{title}}</cmd>",
"de": "ACP · Führe <cmd>{{title}}</cmd> aus",
"fr": "ACP · Exécution de <cmd>{{title}}</cmd>",
"it": "ACP · Esecuzione di <cmd>{{title}}</cmd>",
"pt": "ACP · Executando <cmd>{{title}}</cmd>",
"es": "ACP · Ejecutando <cmd>{{title}}</cmd>",
"tr": "ACP · <cmd>{{title}}</cmd> çalıştırılıyor",
"uk": "ACP · Виконується <cmd>{{title}}</cmd>",
"ca": "ACP · Executant <cmd>{{title}}</cmd>"
},
"ACTION_MESSAGE$ACP_EDIT": {
"en": "ACP · Editing <cmd>{{title}}</cmd>",
"ja": "ACP · <cmd>{{title}}</cmd> を編集中",
"zh-CN": "ACP · 正在编辑 <cmd>{{title}}</cmd>",
"zh-TW": "ACP · 正在編輯 <cmd>{{title}}</cmd>",
"ko-KR": "ACP · <cmd>{{title}}</cmd> 편집 중",
"no": "ACP · Redigerer <cmd>{{title}}</cmd>",
"ar": "ACP · تحرير <cmd>{{title}}</cmd>",
"de": "ACP · Bearbeite <cmd>{{title}}</cmd>",
"fr": "ACP · Édition de <cmd>{{title}}</cmd>",
"it": "ACP · Modifica di <cmd>{{title}}</cmd>",
"pt": "ACP · Editando <cmd>{{title}}</cmd>",
"es": "ACP · Editando <cmd>{{title}}</cmd>",
"tr": "ACP · <cmd>{{title}}</cmd> düzenleniyor",
"uk": "ACP · Редагується <cmd>{{title}}</cmd>",
"ca": "ACP · Editant <cmd>{{title}}</cmd>"
},
"ACTION_MESSAGE$ACP_READ": {
"en": "ACP · Reading <cmd>{{title}}</cmd>",
"ja": "ACP · <cmd>{{title}}</cmd> を読み取り中",
"zh-CN": "ACP · 正在读取 <cmd>{{title}}</cmd>",
"zh-TW": "ACP · 正在讀取 <cmd>{{title}}</cmd>",
"ko-KR": "ACP · <cmd>{{title}}</cmd> 읽는 중",
"no": "ACP · Leser <cmd>{{title}}</cmd>",
"ar": "ACP · قراءة <cmd>{{title}}</cmd>",
"de": "ACP · Lese <cmd>{{title}}</cmd>",
"fr": "ACP · Lecture de <cmd>{{title}}</cmd>",
"it": "ACP · Lettura di <cmd>{{title}}</cmd>",
"pt": "ACP · Lendo <cmd>{{title}}</cmd>",
"es": "ACP · Leyendo <cmd>{{title}}</cmd>",
"tr": "ACP · <cmd>{{title}}</cmd> okunuyor",
"uk": "ACP · Читається <cmd>{{title}}</cmd>",
"ca": "ACP · Llegint <cmd>{{title}}</cmd>"
},
"ACTION_MESSAGE$ACP_FETCH": {
"en": "ACP · Fetching <cmd>{{title}}</cmd>",
"ja": "ACP · <cmd>{{title}}</cmd> を取得中",
"zh-CN": "ACP · 正在获取 <cmd>{{title}}</cmd>",
"zh-TW": "ACP · 正在取得 <cmd>{{title}}</cmd>",
"ko-KR": "ACP · <cmd>{{title}}</cmd> 가져오는 중",
"no": "ACP · Henter <cmd>{{title}}</cmd>",
"ar": "ACP · جلب <cmd>{{title}}</cmd>",
"de": "ACP · Lade <cmd>{{title}}</cmd>",
"fr": "ACP · Récupération de <cmd>{{title}}</cmd>",
"it": "ACP · Recupero di <cmd>{{title}}</cmd>",
"pt": "ACP · Buscando <cmd>{{title}}</cmd>",
"es": "ACP · Obteniendo <cmd>{{title}}</cmd>",
"tr": "ACP · <cmd>{{title}}</cmd> alınıyor",
"uk": "ACP · Завантажується <cmd>{{title}}</cmd>",
"ca": "ACP · Obtenint <cmd>{{title}}</cmd>"
},
"ACTION_MESSAGE$ACP_TOOL": {
"en": "ACP · <cmd>{{title}}</cmd>",
"ja": "ACP · <cmd>{{title}}</cmd>",
"zh-CN": "ACP · <cmd>{{title}}</cmd>",
"zh-TW": "ACP · <cmd>{{title}}</cmd>",
"ko-KR": "ACP · <cmd>{{title}}</cmd>",
"no": "ACP · <cmd>{{title}}</cmd>",
"ar": "ACP · <cmd>{{title}}</cmd>",
"de": "ACP · <cmd>{{title}}</cmd>",
"fr": "ACP · <cmd>{{title}}</cmd>",
"it": "ACP · <cmd>{{title}}</cmd>",
"pt": "ACP · <cmd>{{title}}</cmd>",
"es": "ACP · <cmd>{{title}}</cmd>",
"tr": "ACP · <cmd>{{title}}</cmd>",
"uk": "ACP · <cmd>{{title}}</cmd>",
"ca": "ACP · <cmd>{{title}}</cmd>"
},
"COMMON$TYPE_EMAIL_AND_PRESS_SPACE": {
"en": "Type email and press Space",
"ja": "メールアドレスを入力してスペースキーを押してください",
@@ -24343,6 +24258,142 @@
"uk": "Організації Git не знайдено.",
"ca": "No s'han trobat organitzacions Git."
},
"SETTINGS$AGENT_PAGE_DESCRIPTION": {
"en": "Choose which agent handles your conversations. ACP runs an external agent subprocess that you configure here.",
"ja": "会話を処理するエージェントを選択します。ACP はここで設定する外部エージェントのサブプロセスを実行します。",
"zh-CN": "选择处理会话的代理。ACP 会运行您在此处配置的外部代理子进程。",
"zh-TW": "選擇處理會話的代理。ACP 會執行您在此處設定的外部代理子程序。",
"ko-KR": "대화를 처리할 에이전트를 선택하세요. ACP는 여기서 설정하는 외부 에이전트 하위 프로세스를 실행합니다.",
"no": "Velg hvilken agent som håndterer samtalene dine. ACP kjører en ekstern agent-underprosess som du konfigurerer her.",
"ar": "اختر الوكيل الذي يدير محادثاتك. يقوم ACP بتشغيل عملية فرعية لوكيل خارجي تقوم بتكوينها هنا.",
"de": "Wählen Sie den Agenten, der Ihre Konversationen verarbeitet. ACP führt einen externen Agenten-Unterprozess aus, den Sie hier konfigurieren.",
"fr": "Choisissez l'agent qui gère vos conversations. ACP exécute un sous-processus d'agent externe que vous configurez ici.",
"it": "Scegli quale agente gestisce le tue conversazioni. ACP esegue un sottoprocesso di agente esterno che configuri qui.",
"pt": "Escolha qual agente gerencia suas conversas. O ACP executa um subprocesso de agente externo configurado aqui.",
"es": "Elige qué agente maneja tus conversaciones. ACP ejecuta un subproceso de agente externo que configuras aquí.",
"ca": "Tria quin agent gestiona les teves converses. L'ACP executa un subprocés d'agent extern que configures aquí.",
"tr": "Konuşmalarınızı hangi ajanın yöneteceğini seçin. ACP, burada yapılandırdığınız harici bir ajan alt sürecini çalıştırır.",
"uk": "Виберіть агента, який обробляє ваші розмови. ACP запускає зовнішній підпроцес агента, який ви налаштовуєте тут."
},
"SETTINGS$AGENT_TYPE_OPENHANDS": {
"en": "OpenHands",
"ja": "OpenHands",
"zh-CN": "OpenHands",
"zh-TW": "OpenHands",
"ko-KR": "OpenHands",
"no": "OpenHands",
"ar": "OpenHands",
"de": "OpenHands",
"fr": "OpenHands",
"it": "OpenHands",
"pt": "OpenHands",
"es": "OpenHands",
"ca": "OpenHands",
"tr": "OpenHands",
"uk": "OpenHands"
},
"SETTINGS$AGENT_PRESET": {
"en": "Preset",
"ja": "プリセット",
"zh-CN": "预设",
"zh-TW": "預設",
"ko-KR": "프리셋",
"no": "Forhåndsinnstilling",
"ar": "إعداد مسبق",
"de": "Voreinstellung",
"fr": "Préréglage",
"it": "Preimpostazione",
"pt": "Predefinição",
"es": "Preajuste",
"ca": "Predefinit",
"tr": "Hazır ayar",
"uk": "Шаблон"
},
"SETTINGS$AGENT_PRESET_CUSTOM": {
"en": "Custom",
"ja": "カスタム",
"zh-CN": "自定义",
"zh-TW": "自訂",
"ko-KR": "사용자 지정",
"no": "Egendefinert",
"ar": "مخصص",
"de": "Benutzerdefiniert",
"fr": "Personnalisé",
"it": "Personalizzato",
"pt": "Personalizado",
"es": "Personalizado",
"ca": "Personalitzat",
"tr": "Özel",
"uk": "Користувацький"
},
"SETTINGS$AGENT_TYPE_ACP": {
"en": "ACP",
"ja": "ACP",
"zh-CN": "ACP",
"zh-TW": "ACP",
"ko-KR": "ACP",
"no": "ACP",
"ar": "ACP",
"de": "ACP",
"fr": "ACP",
"it": "ACP",
"pt": "ACP",
"es": "ACP",
"ca": "ACP",
"tr": "ACP",
"uk": "ACP"
},
"SETTINGS$AGENT_COMMAND_HINT": {
"en": "The full subprocess command to start the ACP agent. Whitespace-separated. Add API keys and credentials via the Secrets panel.",
"ja": "ACP エージェントを起動する完全なサブプロセスコマンドです。空白で区切ります。API キーや認証情報はシークレットパネルから追加してください。",
"zh-CN": "启动 ACP 代理的完整子进程命令,以空格分隔。请通过 Secrets 面板添加 API 密钥和凭据。",
"zh-TW": "啟動 ACP 代理的完整子程序指令,以空白分隔。請透過 Secrets 面板新增 API 金鑰和憑證。",
"ko-KR": "ACP 에이전트를 시작할 전체 하위 프로세스 명령입니다. 공백으로 구분합니다. API 키와 자격 증명은 Secrets 패널에서 추가하세요.",
"no": "Hele underprosesskommandoen for å starte ACP-agenten, atskilt med mellomrom. Legg til API-nøkler og legitimasjon via Secrets-panelet.",
"ar": "الأمر الكامل للعملية الفرعية لبدء وكيل ACP، مفصول بمسافات. أضف مفاتيح API وبيانات الاعتماد عبر لوحة Secrets.",
"de": "Der vollständige Unterprozess-Befehl zum Starten des ACP-Agenten, durch Leerzeichen getrennt. API-Schlüssel und Zugangsdaten fügen Sie über das Secrets-Panel hinzu.",
"fr": "La commande complète du sous-processus pour démarrer l'agent ACP, séparée par des espaces. Ajoutez les clés d'API et les identifiants via le panneau Secrets.",
"it": "Il comando completo del sottoprocesso per avviare l'agente ACP, separato da spazi. Aggiungi chiavi API e credenziali tramite il pannello Secrets.",
"pt": "O comando completo do subprocesso para iniciar o agente ACP, separado por espaços. Adicione chaves de API e credenciais pelo painel Secrets.",
"es": "El comando completo del subproceso para iniciar el agente ACP, separado por espacios. Añade las claves de API y credenciales desde el panel Secrets.",
"ca": "L'ordre completa del subprocés per iniciar l'agent ACP, separada per espais. Afegeix les claus d'API i les credencials des del panell Secrets.",
"tr": "ACP ajanını başlatmak için tam alt süreç komutu, boşlukla ayrılmış. API anahtarlarını ve kimlik bilgilerini Secrets panelinden ekleyin.",
"uk": "Повна команда підпроцесу для запуску агента ACP, розділена пробілами. Додавайте ключі API та облікові дані через панель Secrets."
},
"SETTINGS$AGENT_MODEL_HINT": {
"en": "Optional. Passed through to the ACP agent as its model override.",
"ja": "任意。ACP エージェントにモデルオーバーライドとして渡されます。",
"zh-CN": "可选。作为模型覆盖项传递给 ACP 代理。",
"zh-TW": "選用。作為模型覆寫值傳遞給 ACP 代理。",
"ko-KR": "선택 사항. ACP 에이전트에 모델 오버라이드로 전달됩니다.",
"no": "Valgfritt. Sendes gjennom til ACP-agenten som modelloverstyring.",
"ar": "اختياري. يُمرَّر إلى وكيل ACP كتجاوز للنموذج.",
"de": "Optional. Wird an den ACP-Agenten als Modell-Override weitergegeben.",
"fr": "Facultatif. Transmis à l'agent ACP en tant que remplacement de modèle.",
"it": "Opzionale. Inoltrato all'agente ACP come override del modello.",
"pt": "Opcional. Repassado ao agente ACP como substituição do modelo.",
"es": "Opcional. Se pasa al agente ACP como anulación del modelo.",
"ca": "Opcional. Es passa a l'agent ACP com a substitució de model.",
"tr": "İsteğe bağlı. ACP ajanına model geçersiz kılma olarak iletilir.",
"uk": "Необов'язково. Передається агенту ACP як перевизначення моделі."
},
"SETTINGS$AGENT_DISABLED_TOOLTIP": {
"en": "This setting is disabled when using {{agentName}}",
"ja": "{{agentName}} の使用中はこの設定を変更できません",
"zh-CN": "使用 {{agentName}} 时此设置不可用",
"zh-TW": "使用 {{agentName}} 時無法變更此設定",
"ko-KR": "{{agentName}}을(를) 사용 중일 때는 이 설정을 변경할 수 없습니다",
"no": "Denne innstillingen er deaktivert når du bruker {{agentName}}",
"ar": "تم تعطيل هذا الإعداد عند استخدام {{agentName}}",
"de": "Diese Einstellung ist deaktiviert, wenn {{agentName}} verwendet wird",
"fr": "Ce paramètre est désactivé lorsque vous utilisez {{agentName}}",
"it": "Questa impostazione è disabilitata quando si utilizza {{agentName}}",
"pt": "Esta configuração está desativada ao usar {{agentName}}",
"es": "Este ajuste está deshabilitado al usar {{agentName}}",
"ca": "Aquesta opció està desactivada quan utilitzes {{agentName}}",
"tr": "{{agentName}} kullanılırken bu ayar devre dışıdır",
"uk": "Цей параметр недоступний під час використання {{agentName}}"
},
"SETTINGS$PROFILES_DESCRIPTION": {
"en": "Profiles are created automatically when you save a configuration on the LLM page. The newest one becomes active; switch, rename, or delete saved profiles from this list.",
"ja": "LLMページで設定を保存すると、プロファイルが自動的に作成されます。最新のプロファイルがアクティブになり、このリストから切り替え、名前の変更、削除ができます。",
+272 -64
View File
@@ -1,11 +1,14 @@
import React from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { AxiosError } from "axios";
import { useTranslation } from "react-i18next";
import { BrandButton } from "#/components/features/settings/brand-button";
import { LlmSettingsInputsSkeleton } from "#/components/features/settings/llm-settings/llm-settings-inputs-skeleton";
import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input";
import { SettingsInput } from "#/components/features/settings/settings-input";
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { useAgentSettingsSchema } from "#/hooks/query/use-agent-settings-schema";
import { useConfig } from "#/hooks/query/use-config";
import { useSettings } from "#/hooks/query/use-settings";
import { I18nKey } from "#/i18n/declaration";
import { SettingsFieldSchema } from "#/types/settings";
@@ -20,8 +23,12 @@ import {
resolveSchemaFieldDescription,
resolveSchemaFieldLabel,
} from "#/utils/sdk-settings-field-metadata";
import { formatCommand, tokenizeCommand } from "#/utils/shell-tokenize";
import type { ACPProviderConfig } from "#/api/option-service/option.types";
const ENABLE_SUB_AGENTS_FIELD_KEY = "enable_sub_agents";
const CUSTOM_PRESET = "custom";
const EMPTY_ACP_PROVIDERS: ACPProviderConfig[] = [];
function findEnableSubAgentsField(
fields: SettingsFieldSchema[] | undefined,
@@ -33,116 +40,317 @@ function getEnableSubAgentsValue(
settingsValue: unknown,
field: SettingsFieldSchema | undefined,
) {
if (typeof settingsValue === "boolean") {
return settingsValue;
}
if (typeof settingsValue === "boolean") return settingsValue;
return field?.default === true;
}
function toStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((v): v is string => typeof v === "string")
: [];
}
function detectPreset(
commandText: string,
providers: ACPProviderConfig[],
): string {
const normalized = tokenizeCommand(commandText).join(" ");
for (const provider of providers) {
if (normalized === provider.default_command.join(" ")) {
return provider.key;
}
}
return CUSTOM_PRESET;
}
export const clientLoader = createPermissionGuard("view_llm_settings");
export default function AgentSettingsScreen() {
const { t } = useTranslation();
const { mutate: saveSettings, isPending } = useSaveSettings();
const { data: settings, isLoading: isSettingsLoading } = useSettings();
const { data: config, isLoading: isConfigLoading } = useConfig();
const { data: schema, isLoading: isSchemaLoading } = useAgentSettingsSchema(
settings?.agent_settings_schema,
);
const fields = React.useMemo(
const isAcpEnabled = !!config?.feature_flags?.enable_acp;
const acpProviders = config?.acp_providers ?? EMPTY_ACP_PROVIDERS;
// ── Sub-agents (OpenHands mode) ──────────────────────────────────────────
const fields = useMemo(
() => schema?.sections.flatMap((section) => section.fields),
[schema],
);
const field = findEnableSubAgentsField(fields);
const initialIsEnabled = React.useMemo(
const subAgentsField = findEnableSubAgentsField(fields);
const initialSubAgentsEnabled = useMemo(
() =>
getEnableSubAgentsValue(
settings?.agent_settings?.[ENABLE_SUB_AGENTS_FIELD_KEY],
field,
subAgentsField,
),
[field, settings?.agent_settings],
[subAgentsField, settings?.agent_settings],
);
const [isEnabled, setIsEnabled] = React.useState(initialIsEnabled);
React.useEffect(() => {
setIsEnabled(initialIsEnabled);
}, [initialIsEnabled]);
const isDirty = isEnabled !== initialIsEnabled;
const handleError = React.useCallback(
(error: AxiosError) => {
const msg = retrieveAxiosErrorMessage(error);
displayErrorToast(msg || t(I18nKey.ERROR$GENERIC));
},
[t],
const [isSubAgentsEnabled, setIsSubAgentsEnabled] = useState(
initialSubAgentsEnabled,
);
useEffect(() => {
setIsSubAgentsEnabled(initialSubAgentsEnabled);
}, [initialSubAgentsEnabled]);
// ── ACP (ACP mode) ───────────────────────────────────────────────────────
const [agentType, setAgentType] = useState<"openhands" | "acp">("openhands");
const [commandText, setCommandText] = useState("");
const [acpModel, setAcpModel] = useState("");
const [isDirty, setIsDirty] = useState(false);
// Prevent re-initialising ACP fields on every config refetch; only
// reinitialise when the server returns a new settings object.
const lastInitializedSettingsRef = useRef<unknown>(null);
useEffect(() => {
if (!settings || isConfigLoading) return;
if (lastInitializedSettingsRef.current === settings) return;
lastInitializedSettingsRef.current = settings;
const kind = settings.agent_settings?.agent_kind;
if (kind === "acp") {
setAgentType("acp");
const tokens = [
...toStringArray(settings.agent_settings?.acp_command),
...toStringArray(settings.agent_settings?.acp_args),
];
const joined = tokens.join(" ");
const rawAcpServer = settings.agent_settings?.acp_server;
const acpServer =
typeof rawAcpServer === "string" ? rawAcpServer : undefined;
const provider = acpProviders.find(({ key }) => key === acpServer);
setCommandText(joined || formatCommand(provider?.default_command ?? []));
const savedModel = settings.agent_settings?.acp_model;
setAcpModel(typeof savedModel === "string" ? savedModel : "");
} else {
setAgentType("openhands");
setCommandText("");
setAcpModel("");
}
setIsDirty(false);
}, [settings, acpProviders]);
// ── Derived state ────────────────────────────────────────────────────────
const isAcp = agentType === "acp";
const commandTokens = tokenizeCommand(commandText);
const isAcpInvalid = isAcp && commandTokens.length === 0;
const selectedPreset = detectPreset(commandText, acpProviders);
const selectedProvider = acpProviders.find(
({ key }) => key === selectedPreset,
);
const isDefaultProviderCommand =
!!selectedProvider &&
commandTokens.join(" ") === selectedProvider.default_command.join(" ");
const commandPlaceholder =
formatCommand(acpProviders[0]?.default_command ?? []) ||
"npx -y <package-name>";
const subAgentsDirty = isSubAgentsEnabled !== initialSubAgentsEnabled;
const canSave = isAcp ? isDirty && !isAcpInvalid : isDirty || subAgentsDirty;
// ── Save ─────────────────────────────────────────────────────────────────
const handleSave = () => {
if (!isDirty) return;
let agentSettingsDiff: Record<string, unknown>;
if (isAcp) {
// ``acp_args`` intentionally omitted — the textarea owns everything via
// ``acp_command``; the backend's fresh-base default ``[]`` is correct.
agentSettingsDiff = {
agent_kind: "acp",
acp_server:
selectedProvider && isDefaultProviderCommand
? selectedProvider.key
: CUSTOM_PRESET,
acp_command:
selectedProvider && isDefaultProviderCommand ? [] : commandTokens,
acp_model: acpModel.trim() || null,
};
} else if (isDirty) {
// Agent-kind flip: backend resets the new kind to defaults, so send
// the kind alone (sub-agents toggle resets too — preserved as a
// deferred follow-up).
agentSettingsDiff = { agent_kind: "openhands" };
} else {
// Only sub-agents toggled, no kind change.
agentSettingsDiff = { enable_sub_agents: isSubAgentsEnabled };
}
saveSettings(
{ agent_settings_diff: agentSettingsDiff },
{
agent_settings_diff: {
enable_sub_agents: isEnabled,
onError: (error) => {
const message = retrieveAxiosErrorMessage(error as AxiosError);
displayErrorToast(message || t(I18nKey.ERROR$GENERIC));
},
},
{
onError: handleError,
onSuccess: () => {
displaySuccessToast(t(I18nKey.SETTINGS$SAVED_WARNING));
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
setIsDirty(false);
},
},
);
};
if (isSettingsLoading || isSchemaLoading) {
// ── Loading ──────────────────────────────────────────────────────────────
if (isSettingsLoading || isSchemaLoading || isConfigLoading) {
return <LlmSettingsInputsSkeleton />;
}
if (!field) {
return (
<Typography.Paragraph className="text-tertiary-alt">
{t(I18nKey.SETTINGS$SDK_SCHEMA_UNAVAILABLE)}
</Typography.Paragraph>
);
}
const label = resolveSchemaFieldLabel(t, field.key, field.label);
const description = resolveSchemaFieldDescription(
t,
field.key,
field.description,
);
// ── Render ───────────────────────────────────────────────────────────────
return (
<div data-testid="agent-settings-screen" className="h-full relative">
<div className="flex flex-col gap-8 pb-20">
<section className="grid gap-4 xl:grid-cols-2">
<div className="flex flex-col gap-1.5">
<SettingsSwitch
testId="agent-settings-enable-sub-agents"
isToggled={isEnabled}
onToggle={setIsEnabled}
>
{label}
</SettingsSwitch>
{description ? (
<Typography.Paragraph className="text-tertiary-alt text-xs leading-5">
{description}
{/* Agent-type selector — only when ACP feature flag is on */}
{isAcpEnabled && (
<section className="grid gap-4 xl:grid-cols-2">
<SettingsDropdownInput
testId="agent-type-selector"
name="agent-type"
label={t(I18nKey.SETTINGS$AGENT)}
items={[
{
key: "openhands",
label: t(I18nKey.SETTINGS$AGENT_TYPE_OPENHANDS),
},
{ key: "acp", label: t(I18nKey.SETTINGS$AGENT_TYPE_ACP) },
]}
selectedKey={agentType}
onSelectionChange={(key) => {
if (!key) return;
const newType = key as "openhands" | "acp";
setAgentType(newType);
if (newType === "acp" && !commandText) {
const preferred = acpProviders[0];
if (preferred) {
setCommandText(formatCommand(preferred.default_command));
}
}
setIsDirty(true);
}}
/>
</section>
)}
{/* OpenHands: sub-agents toggle */}
{!isAcp && (
<section className="grid gap-4 xl:grid-cols-2">
{subAgentsField ? (
<div className="flex flex-col gap-1.5">
<SettingsSwitch
testId="agent-settings-enable-sub-agents"
isToggled={isSubAgentsEnabled}
onToggle={setIsSubAgentsEnabled}
>
{resolveSchemaFieldLabel(
t,
subAgentsField.key,
subAgentsField.label,
)}
</SettingsSwitch>
{resolveSchemaFieldDescription(
t,
subAgentsField.key,
subAgentsField.description,
) ? (
<Typography.Paragraph className="text-tertiary-alt text-xs leading-5">
{resolveSchemaFieldDescription(
t,
subAgentsField.key,
subAgentsField.description,
)}
</Typography.Paragraph>
) : null}
</div>
) : (
<Typography.Paragraph className="text-tertiary-alt">
{t(I18nKey.SETTINGS$SDK_SCHEMA_UNAVAILABLE)}
</Typography.Paragraph>
) : null}
</div>
</section>
)}
</section>
)}
{/* ACP: preset, command, model */}
{isAcp && (
<>
<SettingsDropdownInput
testId="agent-preset-selector"
name="agent-preset"
label={t(I18nKey.SETTINGS$AGENT_PRESET)}
items={[
...acpProviders.map((provider) => ({
key: provider.key,
label: provider.display_name,
})),
{
key: CUSTOM_PRESET,
label: t(I18nKey.SETTINGS$AGENT_PRESET_CUSTOM),
},
]}
selectedKey={selectedPreset}
onSelectionChange={(key) => {
if (!key) return;
const preset = String(key);
const provider = acpProviders.find(
({ key: k }) => k === preset,
);
if (provider) {
setCommandText(formatCommand(provider.default_command));
}
setIsDirty(true);
}}
/>
<div className="flex flex-col gap-2.5">
<Typography.Text className="text-sm">
{t(I18nKey.SETTINGS$MCP_COMMAND)}
</Typography.Text>
<textarea
data-testid="agent-command-input"
className="bg-tertiary border border-[#717888] rounded-sm p-2 text-sm font-mono text-white placeholder:italic placeholder:text-[#717888] min-h-[60px] resize-y focus:outline-none focus:border-white"
value={commandText}
placeholder={commandPlaceholder}
onChange={(e) => {
setCommandText(e.target.value);
setIsDirty(true);
}}
/>
<Typography.Text className="text-xs text-[#717888]">
{t(I18nKey.SETTINGS$AGENT_COMMAND_HINT)}
</Typography.Text>
</div>
<div className="flex flex-col gap-1.5">
<SettingsInput
testId="agent-model-input"
label={t(I18nKey.SCHEMA$LLM$MODEL$LABEL)}
type="text"
className="w-full"
value={acpModel}
showOptionalTag
onChange={(value) => {
setAcpModel(value);
setIsDirty(true);
}}
/>
<Typography.Text className="text-xs text-[#717888]">
{t(I18nKey.SETTINGS$AGENT_MODEL_HINT)}
</Typography.Text>
</div>
</>
)}
</div>
<div className="sticky bottom-0 bg-base py-4">
<BrandButton
testId="save-button"
testId="agent-save-button"
type="button"
variant="primary"
isDisabled={isPending || !isDirty}
isDisabled={isPending || !canSave}
onClick={handleSave}
>
{isPending
+1
View File
@@ -49,6 +49,7 @@ function MCPSettingsScreen() {
const { data: config } = useConfig();
const { mutate: saveSettings, isPending: isSavingSearchApiKey } =
useSaveSettings();
const { mutate: deleteMcpServer } = useDeleteMcpServer();
const { mutate: addMcpServer } = useAddMcpServer();
const { mutate: updateMcpServer } = useUpdateMcpServer();
+43 -1
View File
@@ -6,10 +6,16 @@ import OptionService from "#/api/option-service/option-service.api";
import { queryClient } from "#/query-client-config";
import { SettingsLayout } from "#/components/features/settings";
import { WebClientConfig } from "#/api/option-service/option.types";
import { QUERY_KEYS, CONFIG_CACHE_OPTIONS } from "#/hooks/query/query-keys";
import {
QUERY_KEYS,
CONFIG_CACHE_OPTIONS,
SETTINGS_QUERY_KEYS,
} from "#/hooks/query/query-keys";
import { Organization } from "#/types/org";
import { Typography } from "#/ui/typography";
import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav";
import { useSettingsNavItems } from "#/hooks/use-settings-nav-items";
import { getSettingsQueryFn } from "#/hooks/query/use-settings";
import { getActiveOrganizationUser } from "#/utils/org/permission-checks";
import { getSelectedOrganizationIdFromStore } from "#/stores/selected-organization-store";
import { rolePermissions } from "#/utils/org/permissions";
@@ -69,6 +75,42 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
}
}
// Step 3b: ACP guard. The LLM / Condenser / MCP personal-settings screens
// have no useful content while an external ACP subprocess is driving
// conversations (the sub-agent owns its own tools, LLM, condenser, MCP),
// so bounce them to ``/settings/agent``. Driven by the nav-item
// ``disabledByAcp`` flag so this list and the greyed-out nav state
// ([`use-settings-nav-items.ts`]) come from the same source of truth.
//
// Doing the redirect in the loader (instead of a per-route ``useEffect``)
// means the personal LLM/condenser/MCP pages don't paint a one-frame
// flash of their content before the guard fires.
//
// Gated on ``enable_acp`` so the guard is fully off when the feature
// flag is disabled. If we can't fetch settings (unauthed, no org, etc.)
// we fall through and let the page render — same behaviour the
// previous hook-based guard had.
if (featureFlags?.enable_acp) {
const navItems = isSaas ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS;
const currentItem = navItems.find((item) => item.to === pathname);
if (currentItem?.disabledByAcp) {
try {
const orgId = getSelectedOrganizationIdFromStore();
const personalSettings = await queryClient.fetchQuery({
queryKey: SETTINGS_QUERY_KEYS.byScope("personal", orgId),
queryFn: () => getSettingsQueryFn("personal", orgId),
staleTime: 1000 * 60 * 5,
});
if (personalSettings?.agent_settings?.agent_kind === "acp") {
return redirect("/settings/agent");
}
} catch {
// Settings unfetchable (unauthed, no org, network) — let the
// page render rather than redirect-loop on a missing payload.
}
}
}
// Step 4: For routes that need permission checks, get user data
// Only fetch user data for billing and org routes that need permission validation
if (
+39
View File
@@ -0,0 +1,39 @@
import type { ACPProviderConfig } from "#/api/option-service/option.types";
import type { AgentKind, ConversationTags } from "#/api/open-hands.types";
/**
* Tag key on ``AppConversationInfo.tags`` holding the active ACP provider
* discriminator (e.g. ``"claude-code"``, ``"codex"``, ``"gemini-cli"``,
* ``"custom"``). The backend writes this at conversation create-time in
* ``openhands.app_server.app_conversation.agent_server_routing.ACP_SERVER_TAG``;
* keep the two constants in sync.
*/
export const ACP_SERVER_TAG = "acp_server";
/**
* Resolve the short label shown next to a conversation title.
*
* - ACP conversations show the provider brand name ("Claude Code", "Codex",
* "Gemini CLI", …) looked up via the SDK registry that the server exposes
* at ``/api/v1/web-client/config``. Falls back to plain "ACP" when the
* provider key is unknown (custom commands, or registry not yet loaded).
* - OpenHands conversations show the harness brand "OpenHands" — the raw
* ``llm_model`` is preserved on hover via the ``title`` attribute.
*/
export function agentDisplayLabel(
agentKind: AgentKind | undefined,
llmModel: string | null | undefined,
tags?: ConversationTags,
acpProviders?: ACPProviderConfig[],
): string | null {
if (agentKind === "acp") {
const acpServer = tags?.[ACP_SERVER_TAG];
if (acpServer && acpProviders) {
const provider = acpProviders.find((p) => p.key === acpServer);
if (provider) return provider.display_name;
}
return "ACP";
}
if (llmModel) return "OpenHands";
return null;
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Split a command line into tokens, respecting single/double quotes so
* paths and arguments with spaces survive a round-trip — e.g.
* `npx -y "my pkg"` becomes `["npx", "-y", "my pkg"]`.
*
* Intentionally narrow: no shell expansion, no backslash escapes, no
* variable substitution. Just enough to keep the textarea honest about
* what the backend will receive.
*/
export function tokenizeCommand(value: string): string[] {
const tokens: string[] = [];
let current = "";
let inToken = false;
let quote: '"' | "'" | null = null;
for (let i = 0; i < value.length; i += 1) {
const ch = value[i];
if (quote !== null) {
if (ch === quote) {
quote = null;
} else {
current += ch;
}
} else if (ch === '"' || ch === "'") {
quote = ch;
inToken = true;
} else if (/\s/.test(ch)) {
if (inToken) {
tokens.push(current);
current = "";
inToken = false;
}
} else {
current += ch;
inToken = true;
}
}
if (inToken) tokens.push(current);
return tokens;
}
/**
* Format tokens back into a command line. Tokens with whitespace are
* wrapped in double quotes (single quotes if the token already contains
* a double quote) so `tokenizeCommand` parses them back to the same list.
*/
export function formatCommand(command: string[]): string {
return command
.map((part) => {
if (part === "") return '""';
if (!/\s/.test(part)) return part;
return part.includes('"') ? `'${part}'` : `"${part}"`;
})
.join(" ");
}
+13 -9
View File
@@ -39,16 +39,20 @@ export function extractBaseHost(
export function extractPathPrefix(
conversationUrl: string | null | undefined,
): string {
if (conversationUrl && !conversationUrl.startsWith("/")) {
try {
const url = new URL(conversationUrl);
const pathBeforeApi = url.pathname.split("/api/conversations")[0] || "";
return pathBeforeApi.replace(/\/$/, ""); // Remove trailing slash
} catch {
return "";
}
if (!conversationUrl || conversationUrl.startsWith("/")) return "";
try {
const { pathname } = new URL(conversationUrl);
// The SDK serves both LLM and ACP conversations on the unified
// ``/api/conversations`` route, so a single regex anchored at a segment
// boundary is enough. ``$|/`` stops at the ``/{id}`` segment that
// follows so a path that merely *contains* ``/api/conversations`` as a
// substring of some unrelated segment is not misclassified.
const match = pathname.match(/^(.*?)\/api\/conversations(?:\/|$)/);
const prefix = match ? match[1] : "";
return prefix.replace(/\/$/, "");
} catch {
return "";
}
return "";
}
/**
@@ -16,10 +16,8 @@ from fastapi import Request
from pydantic import Field, SecretStr, TypeAdapter
from openhands.agent_server.models import (
ACPConversationInfo,
ConversationInfo,
SendMessageRequest,
StartACPConversationRequest,
StartConversationRequest,
TextContent,
)
@@ -96,7 +94,6 @@ from openhands.app_server.utils.llm_metadata import (
should_set_litellm_extra_body,
)
from openhands.sdk import Agent, AgentContext, LocalWorkspace
from openhands.sdk.agent.acp_agent import ACPAgent
from openhands.sdk.hooks import HookConfig
from openhands.sdk.llm import LLM
from openhands.sdk.plugin import PluginSource
@@ -120,35 +117,9 @@ from openhands.tools.preset.planning import (
)
_conversation_info_type_adapter = TypeAdapter(list[ConversationInfo | None])
_acp_conversation_info_type_adapter = TypeAdapter(list[ACPConversationInfo | None])
_logger = logging.getLogger(__name__)
def _agent_kind_to_router_path(agent_kind: str) -> str:
"""Map agent_kind discriminator to the agent-server router path prefix."""
if agent_kind == 'acp':
return 'acp/conversations'
return 'conversations'
def _split_ids_by_kind(
conversation_ids: list[str],
conversation_kind_by_id: dict[str, str],
) -> tuple[list[str], list[str]]:
"""Split conversation IDs into (openhands_ids, acp_ids) by agent_kind."""
openhands_ids = [
cid
for cid in conversation_ids
if conversation_kind_by_id.get(cid, 'openhands') != 'acp'
]
acp_ids = [
cid
for cid in conversation_ids
if conversation_kind_by_id.get(cid, 'openhands') == 'acp'
]
return openhands_ids, acp_ids
# Planning agent instruction to prevent "Ready to proceed?" behavior
PLANNING_AGENT_INSTRUCTION = """<IMPORTANT_PLANNING_BOUNDARIES>
You are a Planning Agent that can ONLY create plans - you CANNOT execute code or make changes.
@@ -363,34 +334,41 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
if sandbox.session_api_key
else {}
)
is_acp = isinstance(start_conversation_request, StartACPConversationRequest)
router_path = 'acp/conversations' if is_acp else 'conversations'
response = await self.httpx_client.post(
f'{agent_server_url}/api/{router_path}',
f'{agent_server_url}/api/conversations',
json=body_json,
headers=headers,
timeout=self.sandbox_startup_timeout,
)
response.raise_for_status()
if is_acp:
info = ACPConversationInfo.model_validate(response.json())
info = ConversationInfo.model_validate(response.json())
# Determine kind / llm_model from the request we built (its
# ``agent`` is the source of truth here): the response echoes
# the same agent back through the AgentBase discriminator.
request_agent = start_conversation_request.agent
tags: dict[str, str] = {}
if request_agent.agent_kind == 'acp':
llm_model = None
agent_kind = 'acp'
display_model = start_conversation_request.agent.acp_model
# Persist the active ACP provider key so the conversation UI
# can resolve a brand label ("Claude Code", "Codex", …) via
# the SDK registry without keeping a per-conversation column.
acp_user = await self.user_context.get_user_info()
if isinstance(acp_user.agent_settings, ACPAgentSettings):
tags['acp_server'] = acp_user.agent_settings.acp_server
else:
info = ConversationInfo.model_validate(response.json())
llm_model = request_agent.llm.model
agent_kind = 'openhands'
display_model = start_conversation_request.agent.llm.model
# Store info...
user_id = await self.user_context.get_user_id()
app_conversation_info = AppConversationInfo(
id=info.id,
title=f'Conversation {info.id.hex[:5]}',
sandbox_id=sandbox.id,
created_by_user_id=user_id,
llm_model=display_model,
llm_model=llm_model,
agent_kind=agent_kind,
tags=tags,
# Git parameters
selected_repository=request.selected_repository,
selected_branch=request.selected_branch,
@@ -450,13 +428,6 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
app_conversation_infos
)
# Build a mapping from conversation id -> agent_kind for routing
conversation_kind_by_id: dict[str, str] = {
str(info.id): info.agent_kind
for info in app_conversation_infos
if info is not None
}
# Get referenced sandboxes in a single batch operation...
sandboxes = await self.sandbox_service.batch_get_sandboxes(
list(sandbox_id_to_conversation_ids)
@@ -468,7 +439,6 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
self._get_live_conversation_info(
sandbox,
sandbox_id_to_conversation_ids.get(sandbox.id),
conversation_kind_by_id,
)
for sandbox in sandboxes
if sandbox and sandbox.status == SandboxStatus.RUNNING
@@ -503,75 +473,43 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
async def _get_live_conversation_info(
self,
sandbox: SandboxInfo,
conversation_ids: list[str],
conversation_kind_by_id: dict[str, str] | None = None,
) -> list[ConversationInfo | ACPConversationInfo]:
conversation_ids: list[UUID],
) -> list[ConversationInfo]:
"""Get agent status for multiple conversations from the Agent Server.
Splits conversation_ids by agent_kind and fetches from the appropriate
endpoint (/api/conversations for LLM, /api/acp/conversations for ACP).
Uses the unified ``/api/conversations`` endpoint, which accepts both
regular and ACP agents through the ``AgentBase`` discriminated union.
"""
if not conversation_ids:
return []
conversation_kind_by_id = conversation_kind_by_id or {}
openhands_ids, acp_ids = _split_ids_by_kind(
conversation_ids, conversation_kind_by_id
)
agent_server_url = self._get_agent_server_url(sandbox)
headers: dict[str, str] = {}
if sandbox.session_api_key:
headers['X-Session-API-Key'] = sandbox.session_api_key
results: list[ConversationInfo | ACPConversationInfo] = []
# Fetch OpenHands conversations
if openhands_ids:
try:
url = f'{agent_server_url.rstrip("/")}/api/conversations'
response = await self.httpx_client.get(
url, params={'ids': openhands_ids}, headers=headers
)
response.raise_for_status()
data = response.json()
infos = _conversation_info_type_adapter.validate_python(data)
results.extend(c for c in infos if c)
except httpx.HTTPStatusError:
_logger.warning(
f'Error getting OpenHands conversation status from sandbox {sandbox.id}',
exc_info=True,
)
except Exception:
_logger.exception(
f'Error getting OpenHands conversation status from sandbox {sandbox.id}',
stack_info=True,
)
# Fetch ACP conversations
if acp_ids:
try:
url = f'{agent_server_url.rstrip("/")}/api/acp/conversations'
response = await self.httpx_client.get(
url, params={'ids': acp_ids}, headers=headers
)
response.raise_for_status()
data = response.json()
infos = _acp_conversation_info_type_adapter.validate_python(data)
results.extend(c for c in infos if c)
except httpx.HTTPStatusError:
_logger.warning(
f'Error getting ACP conversation status from sandbox {sandbox.id}',
exc_info=True,
)
except Exception:
_logger.exception(
f'Error getting ACP conversation status from sandbox {sandbox.id}',
stack_info=True,
)
return results
try:
url = f'{agent_server_url.rstrip("/")}/api/conversations'
response = await self.httpx_client.get(
url,
params={'ids': [str(c) for c in conversation_ids]},
headers=headers,
)
response.raise_for_status()
data = response.json()
infos = _conversation_info_type_adapter.validate_python(data)
return [c for c in infos if c]
except httpx.HTTPStatusError:
_logger.warning(
f'Error getting conversation status from sandbox {sandbox.id}',
exc_info=True,
)
except Exception:
_logger.exception(
f'Error getting conversation status from sandbox {sandbox.id}',
stack_info=True,
)
return []
def _build_conversation(
self,
@@ -597,10 +535,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
None,
)
if conversation_url:
router_path = _agent_kind_to_router_path(
app_conversation_info.agent_kind
)
conversation_url += f'/api/{router_path}/{app_conversation_info.id.hex}'
conversation_url += f'/api/conversations/{app_conversation_info.id.hex}'
session_api_key = sandbox.session_api_key
return AppConversation(
@@ -1275,7 +1210,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
selected_repository: str | None = None,
plugins: list[PluginSpec] | None = None,
api_secrets: dict[str, SecretStr] | None = None,
) -> StartConversationRequest | StartACPConversationRequest:
) -> StartConversationRequest:
"""Build a complete StartConversationRequest for a user.
Resolves LLM, MCP, tools, secrets and agent context, then
@@ -1307,7 +1242,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
# Route ACP agent settings to the ACP-specific builder
if isinstance(user.agent_settings, ACPAgentSettings):
return await self._build_acp_start_conversation_request(
acp_request = await self._build_acp_start_conversation_request(
sandbox=sandbox,
conversation_id=conversation_id,
initial_message=initial_message,
@@ -1316,6 +1251,16 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
plugins=plugins,
api_secrets=api_secrets,
)
if remote_workspace:
acp_request = await self._load_skills_onto_request(
acp_request,
sandbox,
remote_workspace,
selected_repository,
get_project_dir(working_dir, selected_repository),
user.disabled_skills,
)
return acp_request
project_dir = get_project_dir(working_dir, selected_repository)
workspace = LocalWorkspace(working_dir=project_dir)
@@ -1402,21 +1347,9 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
agent, agent_type, mcp_config, conversation_id, user.id
)
# --- skills + hooks (require remote workspace) ----------------------
# --- hooks (require remote workspace; must precede request build) -----
hook_config: HookConfig | None = None
if remote_workspace:
try:
agent = await self._load_skills_and_update_agent(
sandbox,
agent,
remote_workspace,
selected_repository,
project_dir,
disabled_skills=user.disabled_skills,
)
except Exception as e:
_logger.warning(f'Failed to load skills: {e}', exc_info=True)
try:
_logger.debug(
f'Attempting to load hooks from workspace: '
@@ -1464,57 +1397,49 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
# Pass agent explicitly — it has server-only overrides (system
# prompts, LLM metadata, skills) applied after create_agent().
return conv_settings.create_request(StartConversationRequest, agent=agent)
request = conv_settings.create_request(StartConversationRequest, agent=agent)
@staticmethod
def _acp_provider_env(user: UserInfo) -> dict[str, str]:
"""Translate UI-saved LLM credentials into provider-native env vars.
# --- skills (require remote workspace) ------------------------------
if remote_workspace:
request = await self._load_skills_onto_request(
request,
sandbox,
remote_workspace,
selected_repository,
project_dir,
user.disabled_skills,
)
The ACP subprocess reads provider credentials from environment variables.
Maps the user's LLM API key to the env var expected by the active ACP
server via ``ACPAgentSettings.api_key_env_var``. Custom servers return
``None`` — users manage credentials entirely via ``acp_env``.
return request
Args:
user: User information containing ACP agent settings.
async def _load_skills_onto_request(
self,
request: StartConversationRequest,
sandbox: SandboxInfo,
remote_workspace: AsyncRemoteWorkspace,
selected_repository: str | None,
project_dir: str,
disabled_skills: list[str] | None,
) -> StartConversationRequest:
"""Load workspace skills onto a conversation request's agent.
Returns:
Dict of env var name → value to inject into the ACP subprocess.
Used by both the LLM and ACP arms of
``_build_start_conversation_request_for_user`` so that skill-loading
semantics only need to change in one place.
"""
if not isinstance(user.agent_settings, ACPAgentSettings):
return {}
acp_settings = user.agent_settings
env: dict[str, str] = {}
llm_api_key = acp_settings.llm.api_key
if not llm_api_key:
return env
key_value = (
llm_api_key.get_secret_value()
if isinstance(llm_api_key, SecretStr)
else str(llm_api_key)
)
if not key_value or not key_value.strip():
return env
# TODO: simplify to `acp_settings.api_key_env_var` once OpenHands is
# pinned to an SDK version that includes software-agent-sdk PR #2984.
# The fallback per-server mapping below duplicates that SDK property.
api_key_env: str | None = getattr(acp_settings, 'api_key_env_var', None)
if api_key_env is None:
_SERVER_KEY_MAP = {
'claude-code': 'ANTHROPIC_API_KEY',
'codex': 'OPENAI_API_KEY',
'gemini-cli': 'GEMINI_API_KEY',
}
api_key_env = _SERVER_KEY_MAP.get(acp_settings.acp_server)
if api_key_env:
env[api_key_env] = key_value
return env
try:
updated_agent = await self._load_skills_and_update_agent(
sandbox,
request.agent,
remote_workspace,
selected_repository,
project_dir,
disabled_skills=disabled_skills,
)
return request.model_copy(update={'agent': updated_agent})
except Exception as e:
_logger.warning(f'Failed to load skills: {e}', exc_info=True)
return request
async def _build_acp_start_conversation_request(
self,
@@ -1525,15 +1450,19 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
selected_repository: str | None = None,
plugins: list[PluginSpec] | None = None,
api_secrets: dict[str, SecretStr] | None = None,
) -> StartACPConversationRequest:
"""Build a StartACPConversationRequest for ACP agent conversations.
) -> StartConversationRequest:
"""Build a StartConversationRequest for ACP agent conversations.
Unlike the LLM path, ACP agents run as separate subprocesses; we pass
credentials via environment variables rather than injecting an LLM object.
User secrets (Secrets panel + git provider tokens) are also passed through
``AgentContext.secrets`` so the SDK renders a ``<CUSTOM_SECRETS>`` block
in the ACP prompt and injects values into the subprocess env at start time.
User secrets (Secrets panel + git provider tokens) flow through two
complementary channels: they're rendered into the ACP prompt as a
``<CUSTOM_SECRETS>`` block via ``AgentContext.secrets`` (so the agent
knows the names) and also pre-exported as environment variables on
the ACP subprocess via ``acp_env`` (so plain CLI commands like
``gh`` / ``aws`` / ``git`` pick them up from env without the agent
having to manually export them).
Args:
sandbox: Sandbox information
@@ -1571,42 +1500,20 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
acp_settings = user.agent_settings # already verified to be ACPAgentSettings
assert isinstance(acp_settings, ACPAgentSettings)
# Merge provider env vars (API keys etc.) into acp_env.
# Priority (highest → lowest): acp_env > provider_env
provider_env = self._acp_provider_env(user)
merged_env: dict[str, str] = {
**provider_env,
**dict(acp_settings.acp_env or {}),
}
# Pass user secrets via AgentContext so the SDK renders a
# <CUSTOM_SECRETS> block in the ACP prompt and injects values into
# the subprocess env at start time (SDK PR #2984).
# TODO: remove the _sdk_supports_acp_secrets guard once OpenHands pins
# to an SDK version that includes PR #2984 (secrets acp_compatible=True).
_sdk_supports_acp_secrets = (
AgentContext.model_fields.get('secrets') is not None
and isinstance(AgentContext.model_fields['secrets'].json_schema_extra, dict)
and AgentContext.model_fields['secrets'].json_schema_extra.get(
'acp_compatible'
)
is True
)
agent_context = (
AgentContext(secrets=secrets)
if secrets and _sdk_supports_acp_secrets
else None
)
acp_agent = ACPAgent(
acp_command=acp_settings.acp_command,
acp_args=acp_settings.acp_args,
acp_env=merged_env,
acp_model=acp_settings.acp_model,
acp_session_mode=acp_settings.acp_session_mode,
acp_prompt_timeout=acp_settings.acp_prompt_timeout,
agent_context=agent_context,
# Pass user secrets via AgentContext. The SDK renders them as a
# <CUSTOM_SECRETS> block in the ACP prompt (so the agent knows the
# names) and ``ACPAgent._start_acp_server`` gap-fills any that
# aren't already in ``acp_env`` into the subprocess env at launch
# time — preserving the ``acp_env > provider env > secrets``
# precedence end-to-end. We pass ``SecretSource`` objects through
# verbatim; resolving them here (with ``source.get_value()``) would
# eagerly hit the auth service for every ``LookupSecret`` on every
# conversation start, from the wrong process.
agent_context = AgentContext(secrets=secrets) if secrets else None
settings_update = (
{'agent_context': agent_context} if agent_context is not None else {}
)
acp_agent = acp_settings.model_copy(update=settings_update).create_agent()
sdk_plugins: list[PluginSource] | None = None
if plugins:
@@ -1615,16 +1522,20 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
for p in plugins
]
return StartACPConversationRequest(
workspace=workspace,
conversation_id=conversation_id,
initial_message=self._construct_initial_message_with_plugin_params(
initial_message, plugins
),
secrets=secrets,
plugins=sdk_plugins,
agent=acp_agent,
# Mirror the regular path: populate ConversationSettings and delegate
# to create_request() so that max_iterations, confirmation_mode, and
# security_analyzer flow through to ACP conversations too.
conv_settings = user.conversation_settings.model_copy(
update={
'workspace': workspace,
'conversation_id': conversation_id,
'initial_message': self._construct_initial_message_with_plugin_params(
initial_message, plugins
),
'plugins': sdk_plugins,
}
)
return conv_settings.create_request(StartConversationRequest, agent=acp_agent)
async def _process_pending_messages(
self,
@@ -301,7 +301,12 @@ async def on_conversation_update(
sandbox_info: SandboxInfo = Depends(valid_sandbox),
app_conversation_info_service: AppConversationInfoService = app_conversation_info_service_dependency,
) -> Success:
"""Webhook callback for when a conversation starts, pauses, resumes, or deletes."""
"""Webhook callback for when a conversation starts, pauses, resumes, or deletes.
The ``ConversationInfo.agent`` field is an ``AgentBase`` discriminated
union so both OpenHands (``Agent``) and ACP (``ACPAgent``) payloads are
accepted on this single endpoint.
"""
existing = await valid_conversation(
conversation_info.id, sandbox_info, app_conversation_info_service
)
@@ -326,12 +331,28 @@ async def on_conversation_update(
sandbox_id=sandbox_info.id,
)
# Trust the discriminated-union payload over any stored ``agent_kind``
# on ``existing``: a webhook is always authoritative for the agent
# currently running, and a drifted row (e.g. mid-migration data) must
# not lock us into the wrong branch. Branch on the ``agent_kind``
# discriminator (an ``AgentBase`` property) so we don't import a
# concrete SDK subclass just to do a kind check.
agent = conversation_info.agent
if agent.agent_kind == 'acp':
agent_kind = 'acp'
llm_model = None
else:
# ``AgentBase.llm: LLM`` is non-optional on both arms of the union.
agent_kind = 'openhands'
llm_model = agent.llm.model
app_conversation_info = AppConversationInfo(
id=conversation_info.id,
title=existing.title or f'Conversation {conversation_info.id.hex}',
sandbox_id=sandbox_info.id,
created_by_user_id=sandbox_info.created_by_user_id,
llm_model=conversation_info.agent.llm.model,
llm_model=llm_model,
agent_kind=agent_kind,
# Git parameters
selected_repository=existing.selected_repository,
selected_branch=existing.selected_branch,
@@ -374,11 +395,7 @@ async def on_conversation_update(
ctx=ctx,
conversation_id=str(conversation_info.id),
trigger=existing.trigger.value if existing.trigger else None,
llm_model=(
conversation_info.agent.llm.model
if conversation_info.agent and conversation_info.agent.llm
else None
),
llm_model=llm_model,
agent_type='default',
has_repository=existing.selected_repository is not None,
)
@@ -16,7 +16,10 @@ from sqlalchemy import String, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column
from openhands.agent_server.models import ConversationInfo, EventPage
from openhands.agent_server.models import (
ConversationInfo,
EventPage,
)
from openhands.agent_server.utils import utc_now
from openhands.app_server.app_conversation.app_conversation_info_service import (
AppConversationInfoService,
@@ -208,14 +208,33 @@ class Settings(BaseModel):
replace_mcp_config = 'mcp_config' in agent_update
mcp_config = coerced.pop('mcp_config', None) if replace_mcp_config else None
merged = deep_merge(
self.agent_settings.model_dump(
# acp_env is a flat credential dict that should be replaced wholesale
# when present; deep-merging would make removed keys persist across saves.
replace_acp_env = 'acp_env' in agent_update
acp_env = coerced.pop('acp_env', None) if replace_acp_env else None
new_kind = coerced.get('agent_kind')
current_kind = self.agent_settings.agent_kind
if new_kind and new_kind != current_kind:
# ``agent_settings`` is a discriminated union over
# ``OpenHandsAgentSettings | ACPAgentSettings``. Deep-merging
# the incoming kind's fields onto the outgoing kind's dump
# produces a mongrel (``llm`` plus ``acp_command``) that
# fails validation. Start from a fresh base for the new
# kind. Cross-kind config preservation tracked in
# OpenHands/OpenHands#14370.
base: dict[str, Any] = {'agent_kind': new_kind}
else:
base = self.agent_settings.model_dump(
mode='json', context={'expose_secrets': True}
),
coerced,
)
)
merged = deep_merge(base, coerced)
if replace_mcp_config:
merged['mcp_config'] = mcp_config
if replace_acp_env:
merged['acp_env'] = acp_env or {}
# Use object.__setattr__ to avoid validate_assignment
# side-effects on other fields.
@@ -9,9 +9,11 @@ from openhands.app_server.web_client.web_client_config_injector import (
WebClientConfigInjector,
)
from openhands.app_server.web_client.web_client_models import (
ACPProviderConfig,
WebClientConfig,
WebClientFeatureFlags,
)
from openhands.sdk.settings import ACP_PROVIDERS
def _get_recaptcha_site_key() -> str | None:
@@ -112,8 +114,8 @@ def _get_feature_flags() -> WebClientFeatureFlags:
Reads ENABLE_BILLING, HIDE_LLM_SETTINGS, ENABLE_JIRA, ENABLE_JIRA_DC,
ENABLE_LINEAR, HIDE_USERS_PAGE, HIDE_BILLING_PAGE, HIDE_INTEGRATIONS_PAGE,
and OH_ENABLE_ONBOARDING from environment. Each flag is True only if the
corresponding env var is exactly 'true', otherwise False.
ENABLE_ACP, and OH_ENABLE_ONBOARDING from environment. Each flag is True
only if the corresponding env var is exactly 'true', otherwise False.
"""
return WebClientFeatureFlags(
enable_billing=os.getenv('ENABLE_BILLING', 'false') == 'true',
@@ -124,6 +126,7 @@ def _get_feature_flags() -> WebClientFeatureFlags:
hide_users_page=os.getenv('HIDE_USERS_PAGE', 'false') == 'true',
hide_billing_page=os.getenv('HIDE_BILLING_PAGE', 'false') == 'true',
hide_integrations_page=os.getenv('HIDE_INTEGRATIONS_PAGE', 'false') == 'true',
enable_acp=os.getenv('ENABLE_ACP', 'false') == 'true',
enable_onboarding=os.getenv('OH_ENABLE_ONBOARDING', 'false') == 'true',
)
@@ -158,6 +161,19 @@ class DefaultWebClientConfigInjector(WebClientConfigInjector):
}
)
slack_enabled: bool = Field(default_factory=_get_slack_enabled)
acp_providers: list[ACPProviderConfig] = Field(
default_factory=lambda: [
ACPProviderConfig(
key=provider.key,
display_name=provider.display_name,
# SDK exposes ``default_command`` as ``tuple[str, ...]`` (frozen
# registry record); the API contract uses ``list[str]`` for
# JSON-friendliness.
default_command=list(provider.default_command),
)
for provider in ACP_PROVIDERS.values()
]
)
async def get_web_client_config(self) -> WebClientConfig:
from openhands.app_server.config import get_global_config
@@ -178,5 +194,6 @@ class DefaultWebClientConfigInjector(WebClientConfigInjector):
gitlab_enabled=self.gitlab_enabled,
provider_default_hosts=self.provider_default_hosts,
slack_enabled=self.slack_enabled,
acp_providers=self.acp_providers,
)
return result
@@ -20,6 +20,7 @@ class WebClientFeatureFlags(BaseModel):
hide_users_page: bool = False
hide_billing_page: bool = False
hide_integrations_page: bool = False
enable_acp: bool = False
deployment_mode: DeploymentMode | None = None
enable_onboarding: bool = False
@@ -31,6 +32,12 @@ class WebClientFeatureFlags(BaseModel):
return self
class ACPProviderConfig(BaseModel):
key: str
display_name: str
default_command: list[str]
class WebClientConfig(DiscriminatedUnionMixin):
app_mode: AppMode
posthog_client_key: str | None
@@ -46,3 +53,4 @@ class WebClientConfig(DiscriminatedUnionMixin):
gitlab_enabled: bool = False
provider_default_hosts: dict[str, str] = Field(default_factory=dict)
slack_enabled: bool = False
acp_providers: list[ACPProviderConfig] = Field(default_factory=list)
@@ -42,21 +42,11 @@ from openhands.app_server.settings.settings_models import (
)
from openhands.app_server.user.user_context import UserContext
from openhands.sdk import Agent, Event
from openhands.sdk.context.agent_context import AgentContext as _AgentContext
from openhands.sdk.llm import LLM
from openhands.sdk.secret import LookupSecret, StaticSecret
from openhands.sdk.settings import ConversationSettings, OpenHandsAgentSettings
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
# True only on SDK versions that include PR #2984 (secrets acp_compatible=True).
# When False, _build_acp_start_conversation_request skips the agent_context path.
_SDK_SUPPORTS_ACP_SECRETS = (
_AgentContext.model_fields.get('secrets') is not None
and isinstance(_AgentContext.model_fields['secrets'].json_schema_extra, dict)
and _AgentContext.model_fields['secrets'].json_schema_extra.get('acp_compatible')
is True
)
def _build_test_user_agent_settings(user: SimpleNamespace) -> OpenHandsAgentSettings:
llm_vals: dict = {}
@@ -3105,103 +3095,18 @@ class TestLoadHooksFromWorkspace:
)
class TestAcpProviderEnv:
"""Unit tests for ``LiveStatusAppConversationService._acp_provider_env``.
Tests the helper that translates UI-saved LLM credentials into the
provider env vars the chosen ACP subprocess expects.
"""
@pytest.fixture
def _user_factory(self):
try:
from openhands.sdk.settings import (
ACPAgentSettings, # type: ignore[attr-defined]
)
except ImportError:
pytest.skip('ACPAgentSettings not available in this SDK build')
def _make(
*,
acp_server: str = 'claude-code',
api_key: str | None = None,
base_url: str | None = None,
acp_env: dict[str, str] | None = None,
):
user = _TestUserInfo(
id='user1',
llm_model='',
llm_base_url=None,
llm_api_key=None,
sandbox_grouping_strategy=SandboxGroupingStrategy.ADD_TO_ANY,
confirmation_mode=False,
security_analyzer=None,
search_api_key=None,
mcp_config=None,
disabled_skills=[],
)
user.agent_settings = ACPAgentSettings(
acp_server=acp_server, # type: ignore[arg-type]
llm=LLM(
model='claude-sonnet-4-5',
api_key=SecretStr(api_key) if api_key else None,
base_url=base_url,
),
acp_env=acp_env or {},
)
return user
return _make
def test_claude_code_translates_to_anthropic_vars(self, _user_factory):
user = _user_factory(acp_server='claude-code', api_key='sk-test-anthropic')
env = LiveStatusAppConversationService._acp_provider_env(user)
assert env == {'ANTHROPIC_API_KEY': 'sk-test-anthropic'}
def test_codex_translates_to_openai_vars(self, _user_factory):
user = _user_factory(acp_server='codex', api_key='sk-test-openai')
env = LiveStatusAppConversationService._acp_provider_env(user)
assert env == {'OPENAI_API_KEY': 'sk-test-openai'}
def test_gemini_translates_to_gemini_vars(self, _user_factory):
user = _user_factory(acp_server='gemini-cli', api_key='sk-test-gemini')
env = LiveStatusAppConversationService._acp_provider_env(user)
assert env == {'GEMINI_API_KEY': 'sk-test-gemini'}
def test_custom_server_returns_empty(self, _user_factory):
"""For acp_server='custom', the user is on their own via acp_env."""
user = _user_factory(
acp_server='custom',
api_key='sk-test',
base_url='https://proxy.example.com',
)
env = LiveStatusAppConversationService._acp_provider_env(user)
assert env == {}
def test_no_credentials_returns_empty(self, _user_factory):
"""No api_key + no base_url → nothing synthesized."""
user = _user_factory(acp_server='claude-code')
env = LiveStatusAppConversationService._acp_provider_env(user)
assert env == {}
def test_no_api_key_returns_empty(self, _user_factory):
"""No api_key → nothing synthesized."""
user = _user_factory(
acp_server='claude-code', base_url='https://api.anthropic.com'
)
env = LiveStatusAppConversationService._acp_provider_env(user)
assert env == {}
class TestAgentKindConversationUrl:
"""Regression tests for conversation_url / live-status route dispatch.
``/api/conversations`` for LLM, ``/api/acp/conversations`` for ACP.
Getting this wrong makes ACP conversations look stuck on "Loading"
Both LLM and ACP conversations are served by the unified
``/api/conversations`` endpoint (the SDK's ``AgentBase`` discriminated
union accepts both ``Agent`` and ``ACPAgent`` payloads on that route).
Getting this wrong would make ACP conversations look stuck on "Loading"
because the frontend polls the wrong route and 404s.
"""
def test_build_conversation_url_llm(self):
@pytest.mark.parametrize('agent_kind', ['openhands', 'acp'])
def test_build_conversation_url_uses_unified_path(self, agent_kind):
from uuid import UUID
from openhands.app_server.app_conversation.app_conversation_models import (
@@ -3223,7 +3128,7 @@ class TestAgentKindConversationUrl:
id=UUID('11111111-1111-1111-1111-111111111111'),
created_by_user_id=None,
sandbox_id='sandbox-a',
agent_kind='openhands',
agent_kind=agent_kind,
)
sandbox = SandboxInfo(
id='sandbox-a',
@@ -3241,69 +3146,6 @@ class TestAgentKindConversationUrl:
'http://localhost:8000/api/conversations/11111111111111111111111111111111'
)
def test_build_conversation_url_acp(self):
from uuid import UUID
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationInfo,
)
from openhands.app_server.sandbox.sandbox_models import (
AGENT_SERVER,
ExposedUrl,
SandboxInfo,
SandboxStatus,
)
service = LiveStatusAppConversationService.__new__(
LiveStatusAppConversationService
)
info = AppConversationInfo(
id=UUID('22222222-2222-2222-2222-222222222222'),
created_by_user_id=None,
sandbox_id='sandbox-a',
agent_kind='acp',
)
sandbox = SandboxInfo(
id='sandbox-a',
created_by_user_id=None,
sandbox_spec_id='spec',
status=SandboxStatus.RUNNING,
session_api_key='sk',
exposed_urls=[
ExposedUrl(name=AGENT_SERVER, url='http://localhost:8000', port=8000),
],
)
result = service._build_conversation(info, sandbox, None)
assert result is not None
assert result.conversation_url == (
'http://localhost:8000/api/acp/conversations/'
'22222222222222222222222222222222'
)
def test_agent_kind_to_router_path_known_kinds(self):
"""``'openhands'`` routes to standard conversations; ``'acp'`` to ACP."""
from openhands.app_server.app_conversation.live_status_app_conversation_service import ( # noqa: E501
_agent_kind_to_router_path,
)
assert _agent_kind_to_router_path('openhands') == 'conversations'
assert _agent_kind_to_router_path('acp') == 'acp/conversations'
def test_agent_kind_to_router_path_unknown_falls_back(self):
"""Any value that is not 'acp' routes to 'conversations'.
This includes the legacy ``'llm'`` value that the old default emitted
before the rename, so rows stored with ``agent_kind='llm'`` continue to
route correctly without a migration.
"""
from openhands.app_server.app_conversation.live_status_app_conversation_service import ( # noqa: E501
_agent_kind_to_router_path,
)
assert _agent_kind_to_router_path('llm') == 'conversations'
assert _agent_kind_to_router_path('future-variant') == 'conversations'
class TestBuildAcpStartConversationRequestSecrets:
"""Tests for user-secret injection in ``_build_acp_start_conversation_request``.
@@ -3391,14 +3233,10 @@ class TestBuildAcpStartConversationRequestSecrets:
request = await self._call_build(service, user, tmp_path)
if _SDK_SUPPORTS_ACP_SECRETS:
assert request.agent.agent_context is not None
ctx = request.agent.agent_context.secrets
assert ctx.get('GITHUB_TOKEN') is github_secret
assert ctx.get('MY_API_KEY') is api_secret
else:
# Pinned SDK doesn't support ACP secrets yet; agent_context stays unset.
assert request.agent.agent_context is None
assert request.agent.agent_context is not None
ctx = request.agent.agent_context.secrets
assert ctx.get('GITHUB_TOKEN') is github_secret
assert ctx.get('MY_API_KEY') is api_secret
@pytest.mark.asyncio
async def test_lookup_secret_forwarded_as_source(self, service, tmp_path):
@@ -3411,11 +3249,8 @@ class TestBuildAcpStartConversationRequestSecrets:
request = await self._call_build(service, user, tmp_path)
if _SDK_SUPPORTS_ACP_SECRETS:
assert request.agent.agent_context is not None
assert request.agent.agent_context.secrets.get('GITHUB_TOKEN') is lookup
else:
assert request.agent.agent_context is None
assert request.agent.agent_context is not None
assert request.agent.agent_context.secrets.get('GITHUB_TOKEN') is lookup
@pytest.mark.asyncio
async def test_explicit_acp_env_preserved(self, service, tmp_path):
@@ -3443,14 +3278,10 @@ class TestBuildAcpStartConversationRequestSecrets:
request = await self._call_build(service, user, tmp_path)
assert request.agent.acp_env.get('ANTHROPIC_API_KEY') == 'sk-ui-key'
if _SDK_SUPPORTS_ACP_SECRETS:
assert request.agent.agent_context is not None
assert (
request.agent.agent_context.secrets.get('ANTHROPIC_API_KEY')
is panel_secret
)
else:
assert request.agent.agent_context is None
assert request.agent.agent_context is not None
assert (
request.agent.agent_context.secrets.get('ANTHROPIC_API_KEY') is panel_secret
)
@pytest.mark.asyncio
async def test_no_secrets_no_agent_context(self, service, tmp_path):
@@ -3467,8 +3298,8 @@ class TestBuildAcpStartConversationRequestSecrets:
"""Explicit acp_env entries take priority over auto-generated provider_env.
When a user sets ANTHROPIC_API_KEY explicitly in acp_env, it must win
over the same key that _acp_provider_env derives from the UI-saved LLM
credentials. This exercises the merge priority:
over the same key the SDK derives from the UI-saved LLM credentials.
This exercises the merge priority:
acp_env > provider_env > agent_context.secrets
"""
user = self._make_acp_user(
@@ -3482,3 +3313,69 @@ class TestBuildAcpStartConversationRequestSecrets:
# acp_env must win; the UI-saved key must NOT overwrite it
assert request.agent.acp_env.get('ANTHROPIC_API_KEY') == 'sk-explicit-override'
@pytest.mark.asyncio
async def test_secrets_forwarded_via_agent_context(self, service, tmp_path):
"""Panel secrets flow through ``agent_context.secrets`` only.
The SDK's ``ACPAgent._start_acp_server`` gap-fills ``agent_context.secrets``
into the subprocess env at launch time. Pre-resolving here would
eagerly hit external auth services (e.g. ``LookupSecret``) on every
conversation start from the wrong process, so we forward the
``SecretSource`` objects untouched and let the SDK resolve them
at the right boundary.
"""
gh_secret = StaticSecret(value=SecretStr('ghp_test123'))
user = self._make_acp_user()
service._setup_secrets_for_git_providers = AsyncMock(
return_value={'GH_TOKEN': gh_secret}
)
request = await self._call_build(service, user, tmp_path)
# NOT pre-resolved into acp_env — the SDK does that at subprocess start.
assert request.agent.acp_env.get('GH_TOKEN') is None
# Surfaced as a SecretSource in agent_context.
assert request.agent.agent_context is not None
assert request.agent.agent_context.secrets.get('GH_TOKEN') is gh_secret
@pytest.mark.asyncio
async def test_panel_secret_does_not_override_provider_env(self, service, tmp_path):
"""Provider env (from ``llm.api_key``) keeps priority over panel secrets.
If a user has both a UI-saved Claude Code LLM key AND a same-named
``ANTHROPIC_API_KEY`` in the Secrets panel, the LLM-saved one ends
up driving the subprocess: ``acp_env`` carries it (via the SDK's
``resolve_acp_env`` → ``resolve_provider_env``), and the SDK's
subprocess-launch gap-fill skips ``agent_context.secrets`` keys
already present in env.
"""
user = self._make_acp_user(acp_server='claude-code', api_key='sk-ui-key')
panel_secret = StaticSecret(value=SecretStr('sk-from-secrets-panel'))
service._setup_secrets_for_git_providers = AsyncMock(
return_value={'ANTHROPIC_API_KEY': panel_secret}
)
request = await self._call_build(service, user, tmp_path)
# llm.api_key-derived provider env wins in acp_env.
assert request.agent.acp_env.get('ANTHROPIC_API_KEY') == 'sk-ui-key'
# The panel secret is still forwarded in agent_context.secrets; the
# SDK's gap-fill will see ANTHROPIC_API_KEY already in env and skip
# it, preserving the priority.
assert request.agent.agent_context is not None
assert (
request.agent.agent_context.secrets.get('ANTHROPIC_API_KEY') is panel_secret
)
@pytest.mark.asyncio
async def test_explicit_acp_env_wins_over_panel_secret(self, service, tmp_path):
"""Same-named explicit acp_env overrides a panel secret of the same name."""
user = self._make_acp_user(acp_env={'GH_TOKEN': 'explicit-token'})
service._setup_secrets_for_git_providers = AsyncMock(
return_value={'GH_TOKEN': StaticSecret(value=SecretStr('panel-token'))}
)
request = await self._call_build(service, user, tmp_path)
assert request.agent.acp_env.get('GH_TOKEN') == 'explicit-token'
@@ -0,0 +1,119 @@
"""Unit tests for ``Settings.update`` agent-kind switch behaviour.
The discriminated ``OpenHandsAgentSettings | ACPAgentSettings`` union means a
naive deep-merge of the incoming kind's fields onto the outgoing kind's dump
produces a mongrel (e.g. ``llm`` plus ``acp_command``) that fails validation
and 500s the settings endpoint. The fix is to start from a fresh base for
the new kind.
This PR ships the minimum-viable switch — the new kind comes up at defaults.
Cross-kind config preservation (snapshot/restore in ``saved_agent_configs``)
is tracked as a follow-up.
"""
from __future__ import annotations
from openhands.app_server.settings.settings_models import Settings
def _set_acp(
command: list[str] | None = None,
*,
acp_env: dict | None = None,
) -> dict:
return {
'agent_settings_diff': {
'agent_kind': 'acp',
'acp_command': command
or ['npx', '-y', '@agentclientprotocol/claude-agent-acp'],
'acp_args': [],
**({'acp_env': acp_env} if acp_env is not None else {}),
}
}
def _set_openhands(
*,
llm_model: str | None = None,
mcp_config: dict | None = None,
) -> dict:
diff: dict = {'agent_kind': 'openhands'}
if llm_model is not None:
diff['llm'] = {'model': llm_model}
if mcp_config is not None:
diff['mcp_config'] = mcp_config
return {'agent_settings_diff': diff}
def test_kind_switch_does_not_raise():
"""OH → ACP → OH must not 500.
Regression guard for the discriminated-union mongrel: deep-merging the
OH dump onto an ``acp_command`` payload would produce a dict carrying
both ``llm`` and ``acp_command``, which neither branch of
``AgentSettingsConfig`` accepts.
"""
s = Settings()
s.update(_set_openhands(llm_model='anthropic/claude-sonnet-4-5'))
s.update(_set_acp())
assert s.agent_settings.agent_kind == 'acp'
s.update(_set_openhands())
assert s.agent_settings.agent_kind == 'openhands'
def test_kind_switch_resets_new_kind_to_defaults():
"""Switching to a new kind starts from a fresh base.
The user's outgoing-kind config is intentionally not carried into the
new kind — preserving it across switches is the follow-up feature.
"""
s = Settings()
s.update(_set_openhands(llm_model='anthropic/claude-sonnet-4-5'))
s.update(_set_acp())
# ACP base — ``llm`` defaults to the ACP sentinel, not the OH model.
assert s.agent_settings.agent_kind == 'acp'
assert s.agent_settings.llm.model != 'anthropic/claude-sonnet-4-5'
def test_acp_env_replaced_wholesale():
"""``acp_env`` is replaced wholesale (not deep-merged) so removed keys
don't leak across saves.
This is independent of the kind switch: any ``acp_env`` in the update
payload replaces the stored dict in full.
"""
s = Settings()
s.update(_set_acp(acp_env={'FOO': '1', 'BAR': '2'}))
assert s.agent_settings.acp_env == {'FOO': '1', 'BAR': '2'}
s.update({'agent_settings_diff': {'acp_env': {'FOO': '9'}}})
assert s.agent_settings.acp_env == {'FOO': '9'}
def test_kind_switch_with_inline_field_override():
"""An ``agent_kind`` switch alongside other fields in the same payload
must apply those fields on top of the fresh base.
e.g. switching to OH and setting an LLM model in one call: the LLM
override must land on the fresh OH base.
"""
s = Settings()
s.update(_set_acp())
s.update(_set_openhands(llm_model='model-c'))
assert s.agent_settings.agent_kind == 'openhands'
assert s.agent_settings.llm.model == 'model-c'
def test_replace_mcp_config_in_kind_switch():
"""``mcp_config`` replace-wholesale also works alongside a kind switch."""
s = Settings()
s.update(_set_acp())
s.update(_set_openhands(mcp_config={'mcpServers': {'foo': {'command': 'foo-bin'}}}))
assert s.agent_settings.mcp_config is not None
assert 'foo' in s.agent_settings.mcp_config.mcpServers
@@ -0,0 +1,300 @@
"""Tests for ACP agent discrimination in webhook_router.
Verifies that a ``ConversationInfo`` payload carrying an ``ACPAgent`` is
correctly discriminated from one carrying a regular ``Agent`` (via the
``AgentBase`` discriminated union the SDK exposes on the unified
``/api/conversations`` endpoint), and that ``agent_kind`` / ``llm_model``
are populated accordingly.
"""
from typing import AsyncGenerator
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from openhands.agent_server.models import ConversationInfo, Success
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationInfo,
)
from openhands.app_server.app_conversation.sql_app_conversation_info_service import (
SQLAppConversationInfoService,
)
from openhands.app_server.event_callback.webhook_router import on_conversation_update
from openhands.app_server.user.specifiy_user_context import SpecifyUserContext
from openhands.app_server.utils.sql_utils import Base
from openhands.sdk import Agent
from openhands.sdk.agent.acp_agent import ACPAgent
from openhands.sdk.llm import LLM
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
async def async_engine():
engine = create_async_engine(
'sqlite+aiosqlite:///:memory:',
poolclass=StaticPool,
connect_args={'check_same_thread': False},
echo=False,
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest.fixture
async def async_session(async_engine) -> AsyncGenerator[AsyncSession, None]:
async_session_maker = async_sessionmaker(
async_engine, class_=AsyncSession, expire_on_commit=False
)
async with async_session_maker() as db_session:
yield db_session
@pytest.fixture
def service(async_session) -> SQLAppConversationInfoService:
return SQLAppConversationInfoService(
db_session=async_session, user_context=SpecifyUserContext(user_id=None)
)
@pytest.fixture
def sandbox_info():
sandbox = MagicMock()
sandbox.id = 'sandbox_acp_test'
sandbox.created_by_user_id = 'user_123'
sandbox.session_api_key = None
return sandbox
def _make_llm_conversation_info() -> ConversationInfo:
"""Build a real ``ConversationInfo`` with a real ``Agent``.
Using real Pydantic models (rather than ``MagicMock(spec=...)``) exercises
the discriminator (``isinstance(.agent, ACPAgent)``) and the serialization
paths the production code relies on. The webhook only touches ``.id``,
``.execution_status``, ``.agent``, ``.stats``, ``.tags`` — everything else
can ride on defaults.
"""
agent = Agent(llm=LLM(model='anthropic/claude-sonnet-4-6', usage_id='test-usage'))
return ConversationInfo.model_validate(
{
'id': str(uuid4()),
'workspace': {'kind': 'LocalWorkspace', 'working_dir': '/tmp'},
'persistence_dir': '/tmp/persist',
'agent': agent.model_dump(mode='json'),
'execution_status': 'running',
}
)
def _make_acp_conversation_info(acp_command: list[str]) -> ConversationInfo:
"""Build a real ``ConversationInfo`` with a real ``ACPAgent`` payload."""
acp_agent = ACPAgent(acp_command=acp_command)
return ConversationInfo.model_validate(
{
'id': str(uuid4()),
'workspace': {'kind': 'LocalWorkspace', 'working_dir': '/tmp'},
'persistence_dir': '/tmp/persist',
'agent': acp_agent.model_dump(mode='json'),
'execution_status': 'running',
}
)
# ---------------------------------------------------------------------------
# Webhook endpoint tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_llm_conversation_stores_llm_model(async_session, service, sandbox_info):
"""LLM path stores the real model in llm_model and sets agent_kind='openhands'."""
llm_info = _make_llm_conversation_info()
conversation_id = llm_info.id
existing = AppConversationInfo(
id=conversation_id,
title='Test',
sandbox_id=sandbox_info.id,
created_by_user_id=sandbox_info.created_by_user_id,
)
with patch(
'openhands.app_server.event_callback.webhook_router.valid_conversation',
return_value=existing,
):
result = await on_conversation_update(
conversation_info=llm_info,
sandbox_info=sandbox_info,
app_conversation_info_service=service,
)
assert isinstance(result, Success)
saved = await service.get_app_conversation_info(conversation_id)
assert saved is not None
assert saved.llm_model == 'anthropic/claude-sonnet-4-6'
assert saved.agent_kind == 'openhands'
@pytest.mark.asyncio
async def test_acp_conversation_sets_agent_kind(async_session, service, sandbox_info):
"""ACP path sets agent_kind='acp' and leaves llm_model null."""
acp_info = _make_acp_conversation_info(
acp_command=['npx', '-y', '@agentclientprotocol/claude-agent-acp']
)
conversation_id = acp_info.id
existing = AppConversationInfo(
id=conversation_id,
title='Test',
sandbox_id=sandbox_info.id,
created_by_user_id=sandbox_info.created_by_user_id,
)
with patch(
'openhands.app_server.event_callback.webhook_router.valid_conversation',
return_value=existing,
):
result = await on_conversation_update(
conversation_info=acp_info,
sandbox_info=sandbox_info,
app_conversation_info_service=service,
)
assert isinstance(result, Success)
saved = await service.get_app_conversation_info(conversation_id)
assert saved is not None
assert saved.llm_model is None
assert saved.agent_kind == 'acp'
@pytest.mark.asyncio
async def test_acp_server_tag_preserved_on_webhook_update(
async_session, service, sandbox_info
):
"""``tags['acp_server']`` set during creation must survive a webhook update.
The live-status service stamps the active ACP provider key into
``tags['acp_server']`` when the conversation is first stored. Subsequent
webhook updates merge incoming tags onto existing ones, so the provider
key must still be present after a state-change webhook fires.
"""
acp_info = _make_acp_conversation_info(acp_command=['my-acp'])
conversation_id = acp_info.id
existing = AppConversationInfo(
id=conversation_id,
title='Test',
sandbox_id=sandbox_info.id,
created_by_user_id=sandbox_info.created_by_user_id,
tags={'acp_server': 'claude-code'},
)
with patch(
'openhands.app_server.event_callback.webhook_router.valid_conversation',
return_value=existing,
):
await on_conversation_update(
conversation_info=acp_info,
sandbox_info=sandbox_info,
app_conversation_info_service=service,
)
saved = await service.get_app_conversation_info(conversation_id)
assert saved is not None
assert saved.tags.get('acp_server') == 'claude-code'
# ---------------------------------------------------------------------------
# Analytics — llm_model must not leak the ACP sentinel
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_acp_conversation_analytics_llm_model_is_null(
async_session, service, sandbox_info
):
"""``track_conversation_created`` must receive ``llm_model=None`` for ACP.
Regression guard: ``ACPAgent.llm`` defaults to a dummy ``LLM(model='acp-managed')``
sentinel, so reading ``conversation_info.agent.llm.model`` directly would
record the literal string ``"acp-managed"`` in BIZZ-04 dashboards. The
handler must use the agent-kind-aware ``llm_model`` variable instead.
"""
acp_info = _make_acp_conversation_info(acp_command=['my-acp'])
existing = AppConversationInfo(
id=acp_info.id,
title='Test',
sandbox_id=sandbox_info.id,
created_by_user_id=sandbox_info.created_by_user_id,
)
analytics = MagicMock()
with (
patch(
'openhands.app_server.event_callback.webhook_router.valid_conversation',
return_value=existing,
),
patch(
'openhands.app_server.event_callback.webhook_router.get_analytics_service',
return_value=analytics,
),
patch(
'openhands.app_server.event_callback.webhook_router.resolve_analytics_context',
new=AsyncMock(return_value=MagicMock()),
),
):
await on_conversation_update(
conversation_info=acp_info,
sandbox_info=sandbox_info,
app_conversation_info_service=service,
)
analytics.track_conversation_created.assert_called_once()
kwargs = analytics.track_conversation_created.call_args.kwargs
assert kwargs['llm_model'] is None
# ---------------------------------------------------------------------------
# Backward compatibility — discriminated union deserialisation
# ---------------------------------------------------------------------------
def test_legacy_llm_payload_deserialises_as_agent():
"""A legacy LLM webhook payload still routes to the ``Agent`` branch.
The webhook signature accepts ``ConversationInfo`` whose ``agent`` field
is the ``AgentBase`` discriminated union (``Agent | ACPAgent``). This
test proves that an old-style payload (no ACP fields, ``kind='Agent'``)
deserialises into the LLM branch and that ``isinstance(.agent, ACPAgent)``
correctly returns ``False`` — i.e. the on_conversation_update branch
that writes ``llm_model`` is selected.
"""
from openhands.sdk.agent.agent import Agent
from openhands.sdk.llm import LLM
agent = Agent(llm=LLM(model='anthropic/claude-sonnet-4-6', usage_id='test-usage'))
legacy_payload: dict = {
'id': str(uuid4()),
'workspace': {'kind': 'LocalWorkspace', 'working_dir': '/tmp'},
'persistence_dir': '/tmp/persist',
'agent': agent.model_dump(mode='json'),
'execution_status': 'running',
}
parsed = ConversationInfo.model_validate(legacy_payload)
assert parsed.agent.kind == 'Agent'
assert not isinstance(parsed.agent, ACPAgent)
# And the LLM model survived the round-trip — proves the field is reachable
# the same way the webhook handler reads it (``conversation_info.agent.llm.model``).
assert parsed.agent.llm.model == 'anthropic/claude-sonnet-4-6'