mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 16:08:23 +08:00
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:
co-authored by
Debug Agent
Claude Opus 4.7
Simon Rosenberg
parent
4804682920
commit
9aa772b97c
@@ -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
-24
@@ -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);
|
||||
|
||||
+49
@@ -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;
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ページで設定を保存すると、プロファイルが自動的に作成されます。最新のプロファイルがアクティブになり、このリストから切り替え、名前の変更、削除ができます。",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(" ");
|
||||
}
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user