Skip to content
Open
134 changes: 108 additions & 26 deletions __tests__/api/agent-server-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -554,16 +554,83 @@ describe("toAppConversation", () => {
expect(result.llm_model).toBe("claude-sonnet-4-6");
});

it("marks ACP conversations and nulls llm_model so the chat UI can't mislead", () => {
// The SDK's ACPAgent carries a sentinel ``llm`` (``acp-managed``) for
// cost-attribution only; the *real* model lives on the ACP subprocess via
// ``acp_model`` and isn't surfaced on ``agent.llm.model``. Surfacing the
// sentinel as ``llm_model`` would let SwitchProfileButton render an
// affordance to "change the model" on a Claude-Code conversation while
// the running subprocess kept its own — a confusing silent no-op.
it("marks ACP conversations and surfaces the configured acp_model", () => {
// The SDK's ACPAgent may still carry a sentinel ``llm`` (``acp-managed``)
// for cost-attribution. Consumers should see the concrete ACP model Canvas
// configured, while SwitchProfileButton remains gated by agent_kind.
const result = toAppConversation({
...baseInfo,
agent: { kind: "ACPAgent", llm: { model: "acp-managed" } },
agent: {
kind: "ACPAgent",
acp_model: "claude-sonnet-4-6",
llm: { model: "acp-managed" },
},
});
expect(result.agent_kind).toBe("acp");
expect(result.llm_model).toBe("claude-sonnet-4-6");
});

it("prefers ACP runtime model fields over configured acp_model", () => {
const result = toAppConversation({
...baseInfo,
current_model_id: "claude-sonnet-4-6",
current_model_name: "Claude Sonnet 4.6",
agent: {
kind: "ACPAgent",
acp_model: "claude-opus-4-7",
llm: { model: "acp-managed" },
},
});
expect(result.agent_kind).toBe("acp");
expect(result.llm_model).toBe("Claude Sonnet 4.6");
});

it("does not surface ACP default placeholders when a configured model exists", () => {
const result = toAppConversation({
...baseInfo,
current_model_id: "default",
current_model_name: "Default (recommended)",
agent: {
kind: "ACPAgent",
acp_model: "claude-sonnet-4-6",
llm: { model: "acp-managed" },
},
});
expect(result.agent_kind).toBe("acp");
expect(result.llm_model).toBe("claude-sonnet-4-6");
});

it("falls back to a non-sentinel ACP llm.model for SDKs that mirror acp_model there", () => {
const result = toAppConversation({
...baseInfo,
agent: { kind: "ACPAgent", llm: { model: "claude-sonnet-4-6" } },
});
expect(result.agent_kind).toBe("acp");
expect(result.llm_model).toBe("claude-sonnet-4-6");
});

it("filters ACP default placeholders surfaced via the configured acp_model", () => {
// Older settings may have persisted the SDK's literal "default" string
// into ``acp_model``. Surfacing it on the chip would lie about what's
// running — the placeholder filter is applied to every candidate, not
// just the runtime fields.
const result = toAppConversation({
...baseInfo,
agent: {
kind: "ACPAgent",
acp_model: "Default (recommended)",
llm: { model: "acp-managed" },
},
});
expect(result.agent_kind).toBe("acp");
expect(result.llm_model).toBeNull();
});

it("filters ACP default placeholders surfaced via agent.llm.model", () => {
// Same defense, one rung lower in the precedence chain.
const result = toAppConversation({
...baseInfo,
agent: { kind: "ACPAgent", llm: { model: "default" } },
});
expect(result.agent_kind).toBe("acp");
expect(result.llm_model).toBeNull();
Expand Down Expand Up @@ -647,8 +714,7 @@ describe("buildRuntimeServicesSystemSuffix", () => {
url_from_agent: "http://localhost:18001",
api_prefix: "/api/automation",
docs_url: "http://localhost:18001/api/automation/docs",
openapi_url:
"http://localhost:18001/api/automation/openapi.json",
openapi_url: "http://localhost:18001/api/automation/openapi.json",
auth_env_var: "OPENHANDS_AUTOMATION_API_KEY",
},
},
Expand All @@ -660,9 +726,7 @@ describe("buildRuntimeServicesSystemSuffix", () => {
expect(suffix).toContain("dev:automation");
expect(suffix).toContain("http://localhost:18000");
expect(suffix).toContain("http://localhost:18001");
expect(suffix).toContain(
"http://localhost:18001/api/automation/docs",
);
expect(suffix).toContain("http://localhost:18001/api/automation/docs");
expect(suffix).toContain("X-API-Key: $OPENHANDS_AUTOMATION_API_KEY");
expect(suffix).toContain("</RUNTIME_SERVICES>");
// The "don't guess" line should reference the actual agent-server URL
Expand Down Expand Up @@ -1007,12 +1071,14 @@ describe("buildStartConversationRequest — ACP discriminator", () => {
expect(payload.agent.acp_command).toEqual([]);
});

it("treats acp_model: '' (empty string) as 'no override'", () => {
it("seeds the provider default when settings contains an empty acp_model", () => {
// The form may carry an empty string after a user clears the model
// input; the agent-server expects ``null`` for "use provider default."
// Empty strings would pass the spawn but bias model selection on
// some providers (e.g. claude-agent-acp's _meta would set
// ``model: ''``).
// input. Older behavior left ``acp_model`` absent and relied on the
// agent-server's own default; the registry-default path
// (resolveEffectiveAcpModel) is now authoritative on Canvas's side,
// so an empty string resolves to the provider's ``default_model``
// before the request leaves the client. Keeps the displayed Settings
// → Agent default in sync with what the runtime actually starts.
const payload = buildStartConversationRequest({
settings: {
...DEFAULT_SETTINGS,
Expand All @@ -1028,14 +1094,30 @@ describe("buildStartConversationRequest — ACP discriminator", () => {
agent: Record<string, unknown> & { acp_model?: unknown };
};

// ``buildConfiguredAcpAgentSettings`` already filters undefined +
// null; the empty string falls through, which is a known nit. The
// adapter's contract is "forward what settings says"; the
// canonicalisation belongs in the save path
// (``agent-settings.tsx::handleSave`` already does
// ``acpModel.trim() || null``). Pin the current behaviour so a
// future change to either side is a deliberate decision.
expect(payload.agent.acp_model).toBe("");
expect(payload.agent.acp_model).toBe("claude-opus-4-7");
});

it("omits acp_model for the custom preset when none is configured", () => {
// The Custom preset has no registered ``default_model``, so an empty
// ``acp_model`` falls through to ``undefined`` — the agent-server then
// applies its own default. Distinct from the built-in providers
// which substitute their registry default.
const payload = buildStartConversationRequest({
settings: {
...DEFAULT_SETTINGS,
agent_settings: {
schema_version: 1,
agent_kind: "acp",
acp_server: "custom",
acp_command: ["my-custom-acp"],
acp_model: "",
},
},
}) as {
agent: Record<string, unknown> & { acp_model?: unknown };
};

expect(payload.agent.acp_model).toBeUndefined();
});

it("ACP → OpenHands → ACP round trip leaves no field leakage", () => {
Expand Down
63 changes: 63 additions & 0 deletions __tests__/api/agent-server-conversation-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,69 @@ describe("AgentServerConversationService", () => {
);
});

it("preserves the new ACP model fields through the wire normalizer", async () => {
// Direct adapter tests pass DirectConversationInfo objects in-process
// and so can't catch the case where the wire-format normalizer
// (``normalizeAgent`` + ``requireDirectConversationInfo``) drops the
// newly-added ACP fields. Exercises the full HTTP -> AppConversation
// path so the chip's model resolution actually has the inputs it
// needs on a real local-backend fetch.
mockHttpGet.mockResolvedValue({
data: [
{
id: "conv-acp-model-wire",
created_at: "2024-01-01",
updated_at: "2024-01-01",
agent: {
kind: "ACPAgent",
acp_model: "claude-opus-4-7",
llm: { model: "acp-managed" },
},
current_model_id: "claude-opus-4-7",
current_model_name: "Claude Opus 4.7",
tags: { acpserver: "claude-code" },
},
],
});

const [conversation] =
await AgentServerConversationService.batchGetAppConversations([
"conv-acp-model-wire",
]);

// ``current_model_name`` wins the precedence chain in the adapter.
expect(conversation?.agent_kind).toBe("acp");
expect(conversation?.llm_model).toBe("Claude Opus 4.7");
});

it("falls back to acp_model when SDK runtime fields are absent on the wire", async () => {
// Older agent-servers don't populate ``current_model_*``. The
// adapter must still surface a model on the chip — falling through
// to ``agent.acp_model`` (the Canvas-configured value).
mockHttpGet.mockResolvedValue({
data: [
{
id: "conv-acp-fallback",
created_at: "2024-01-01",
updated_at: "2024-01-01",
agent: {
kind: "ACPAgent",
acp_model: "claude-sonnet-4-6",
llm: { model: "acp-managed" },
},
tags: { acpserver: "claude-code" },
},
],
});

const [conversation] =
await AgentServerConversationService.batchGetAppConversations([
"conv-acp-fallback",
]);

expect(conversation?.llm_model).toBe("claude-sonnet-4-6");
});

it("extracts the acpserver tag from the wire payload for the sidebar chip", async () => {
// The agent-server stamps ``tags.acpserver`` at conversation create
// time (see ``buildStartConversationRequest``); the read path
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@ import type { Backend } from "#/api/backend-registry/types";

const useActiveConversationMock = vi.fn<
() => {
data: { conversation_id: string; llm_model: string | null } | undefined;
data:
| {
conversation_id: string;
agent_kind?: "openhands" | "acp";
llm_model: string | null;
}
| undefined;
}
>(() => ({ data: undefined }));

Expand Down Expand Up @@ -78,6 +84,26 @@ describe("ChatInputActions", () => {
).not.toBeInTheDocument();
});

it("renders the static model label for local ACP conversations", () => {
useActiveConversationMock.mockReturnValue({
data: {
conversation_id: "test-conversation-id",
agent_kind: "acp",
llm_model: "claude-sonnet-4-6",
},
});

renderWithProviders(<ChatInputActions disabled={false} />);

expect(screen.getByTestId("chat-input-llm-model")).toHaveAttribute(
"title",
"claude-sonnet-4-6",
);
expect(
screen.queryByTestId("switch-profile-button-stub"),
).not.toBeInTheDocument();
});

it("renders the active conversation model on a cloud backend", () => {
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,42 @@ describe("ChatInputModel", () => {
).not.toBeInTheDocument();
});

it("renders an ACP conversation model and links to Agent settings", () => {
useActiveConversationMock.mockReturnValue({
data: {
conversation_id: "test-conversation-id",
agent_kind: "acp",
llm_model: "claude-sonnet-4-6",
},
});

renderWithProviders(<ChatInputModel />);

const model = screen.getByTestId("chat-input-llm-model");
expect(model).toHaveAttribute("title", "claude-sonnet-4-6");
fireEvent.click(model);
expect(screen.getByRole("link")).toHaveAttribute("href", "/settings/agent");
});

it("does not fall back to the OpenHands settings model for active ACP conversations", () => {
useActiveConversationMock.mockReturnValue({
data: {
conversation_id: "test-conversation-id",
agent_kind: "acp",
llm_model: null,
},
});
useSettingsMock.mockReturnValue({
data: { llm_model: "openai/gpt-4o" },
});

renderWithProviders(<ChatInputModel />);

expect(
screen.queryByTestId("chat-input-llm-model"),
).not.toBeInTheDocument();
});

it("falls back to the user's default model from settings when there is no active conversation", () => {
// Arrange — home page render: no conversation yet, but the user has
// a default model configured. The switcher should still show.
Expand All @@ -80,6 +116,26 @@ describe("ChatInputModel", () => {
);
});

it("uses the ACP settings model on the home page when ACP is active", () => {
useActiveConversationMock.mockReturnValue({ data: undefined });
useSettingsMock.mockReturnValue({
data: {
llm_model: "openai/gpt-4o",
agent_settings: {
agent_kind: "acp",
acp_model: "gemini-2.5-pro",
},
},
});

renderWithProviders(<ChatInputModel />);

const model = screen.getByTestId("chat-input-llm-model");
expect(model).toHaveAttribute("title", "gemini-2.5-pro");
fireEvent.click(model);
expect(screen.getByRole("link")).toHaveAttribute("href", "/settings/agent");
});

it("renders nothing when neither the conversation nor settings provide an llm_model", () => {
useActiveConversationMock.mockReturnValue({ data: undefined });
useSettingsMock.mockReturnValue({ data: undefined });
Expand Down Expand Up @@ -116,27 +172,29 @@ describe("ChatInputModel", () => {
).not.toBeInTheDocument();
});

it("renders nothing on the home page when ACP is the default agent", () => {
// Home-screen gating: no active conversation, so the
// per-conversation ``agent_kind`` check can't catch this case.
// Fall back to ``settings.agent_settings.agent_kind`` — that's
// the kind the next-created conversation will inherit. Showing
// the LLM picker here would put up a control that becomes a
// silent no-op the moment the user sends their first message.
it("shows the provider default on the home page when ACP is the default agent and no model is saved", () => {
// Home-screen gating: no active conversation and no saved ``acp_model``.
// The next-created conversation will inherit the provider's
// ``default_model`` (see buildConfiguredAcpAgentSettings), so the picker
// shows that same default — matching what the runtime will actually
// start. Picker links to /settings/agent (not /settings) since
// ``settings.llm_model`` doesn't apply to ACP.
useActiveConversationMock.mockReturnValue({ data: undefined });
useSettingsMock.mockReturnValue({
data: {
agent_settings: { agent_kind: "acp", acp_server: "claude-code" },
// settings.llm_model is still set (the user has an OpenHands
// default configured), but agent_kind=acp wins.
// settings.llm_model is set (user has an OpenHands default
// configured), but agent_kind=acp suppresses it.
llm_model: "anthropic/claude-sonnet-4-20250514",
},
});

renderWithProviders(<ChatInputModel />);

expect(
screen.queryByTestId("chat-input-llm-model"),
).not.toBeInTheDocument();
const model = screen.getByTestId("chat-input-llm-model");
// Claude Code's registered default; see CLAUDE_MODELS in acp-providers.ts.
expect(model).toHaveAttribute("title", "claude-opus-4-7");
fireEvent.click(model);
expect(screen.getByRole("link")).toHaveAttribute("href", "/settings/agent");
});
});
Loading