mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 16:19:05 +08:00
feat(acp): versioned ACP model picker + per-conversation agent chip (#730)
* feat(acp): save concrete model defaults in canvas
* test(acp): verify provider models from subprocesses
* feat(acp): per-conversation agent chip + versioned Claude model picker
User-visible changes:
- Conversation cards now show a single inline chip `[brand mark] {model}`
on every conversation. ACP cards always render (identity info); OpenHands
native cards render whenever `agent.llm.model` is present. New
`AgentBrandIcon` covers Claude / Codex / Gemini brand marks with a
terminal-glyph fallback; the OpenHands logo is recolored via
`[&_path]:fill-current` so it inherits the muted-grey chip color
(the shipped SVG hardcodes `fill="white"`).
- Settings → Agent dropdown for Claude Code lists 10 versioned options
(Opus 4.7, 4.6, 4.6/1M, 4.5, 4.1; Sonnet 4.6, 4.6/1M, 4.5; Haiku 4.5;
opusplan). Default switched from `sonnet` to `claude-opus-4-7`.
Canonical IDs were verified against the bundled `claude` CLI binary's
model registry — the static SDK `.mjs` shims don't carry the full
registry. `[1m]` aliases used for 1M-context variants (no canonical
`claude-*-1m` IDs ship in the SDK).
- `ConversationCardFooter` chip resolves the ACP model string from
`current_model_name → current_model_id → agent.acp_model → agent.llm.model`
(the `"acp-managed"` sentinel is filtered out), so the chip works
whether or not the agent-server populates the SDK runtime fields.
- Removed dead `showLlmProfiles` plumbing on `ConversationCard` /
`CompactConversationRow` / `ConversationPanel` pass-throughs that
used to gate the OpenHands model line behind a metadata-menu toggle.
Chip is now always shown when a model exists.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(acp): adapter cleanup + drop unauthenticated model-check script
- Extract `resolveAcpDisplayModel(info)` from the triple-nested ternary
in `toAppConversation`. Same precedence chain (runtime name → runtime
id → configured model → llm.model minus sentinel), just expressed once
with a loop and named conditions.
- Name the `"acp-managed"` literal as `ACP_MANAGED_SENTINEL` (exported)
and the `"default" / "default (recommended)"` strings as
`ACP_DEFAULT_PLACEHOLDERS`. The sentinel was referenced from 7 places
across source and tests.
- Trim `DirectConversationInfo.agent.kind` JSDoc to 3 lines (was 7).
- Delete `scripts/check-acp-provider-models.mjs` and revert the CI step +
triggers in `.github/workflows/acp-providers-sync.yml`. The script
spawned each ACP wrapper unauthenticated and validated Canvas's lists
against the wrapper's fallback model set — which is only 3 models for
Claude Code (sonnet / sonnet[1m] / haiku) regardless of what the
wrapper actually accepts. The check flagged every legitimately-added
Opus entry as drift. Removing it; followup tracked in #740 for moving
the lists into `@openhands/typescript-client`.
- Remove `test:acp-models` npm script (only invoked the deleted file).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(acp): plug review-flagged gaps in model resolution + chip path
Five behavioral fixes + two refactors from review feedback on PR #730:
- F1: ``normalizeAgent`` and ``requireDirectConversationInfo`` were
dropping ``agent.acp_model``, ``current_model_id``, and
``current_model_name`` on the way in from the wire — so the chip
resolver only worked in unit tests (which build ``DirectConversationInfo``
in-process) and silently fell back to provider-name labels in production.
Normalizer now preserves all three.
- F2: ChatInputActions gated rendering ChatInputModel on
``isCloud || conversation?.agent_kind === "acp"``, so on a local home
screen with no active conversation it always picked SwitchProfileButton.
SwitchProfileButton then hid itself for ACP — net result: the home-ACP
model label never appeared. Added ``isHomeAcp`` derivation from
``settings.agent_settings.agent_kind`` so the chat input picks
ChatInputModel in that case too.
- F3: Switching the preset dropdown to Custom set ``isCustomAcpModel``
but left ``acpModel`` untouched, so a user moving Claude Code → Custom
+ typing a custom command could save ``acp_model: "claude-opus-4-7"``
on an unrelated wrapper. Now clears ``acpModel`` on Custom selection.
- F4: ``buildConfiguredAcpAgentSettings`` was stripping null / empty
``acp_model`` and not falling back to ``provider.default_model``, so
existing users with ``acp_model: null`` saw the new registered default
in Settings → Agent but their next conversation still started with the
agent-server's own default (UI/runtime mismatch). Conversation creation
now substitutes the provider default for empty values, matching what
the form displays.
- F5: ``isAcpDefaultPlaceholder`` was applied only to runtime fields, not
to the ``configured`` (``agent.acp_model``) or ``sdkLlm``
(``agent.llm.model``) fallback rungs of the precedence chain. Older
settings that persisted the literal ``"Default (recommended)"`` could
surface it on chips. Filter now applies uniformly.
- R1: All five surfaces (Settings form, conversation creation,
ChatInputModel, conversation adapter, chip) now route through one
helper, ``resolveEffectiveAcpModel({ runtimeName, runtimeId,
configured, sdkLlm, providerDefault })`` in ``acp-providers.ts``.
Placeholder + sentinel filtering live in one place. ``providerDefault``
is opt-in — chip omits it (don't lie about what's running), settings
/ creation / chat-input pass it (silently substitute the registry
default).
- R2: ``ACPProviderIcon`` no longer includes ``"openhands"`` — it's
ACP-only again. Reintroduced ``AgentBrandIconKind = "openhands" |
ACPProviderIcon`` for surfaces that can render either harness's mark.
Tests: ``acp-server-conversation-service.test.ts`` now exercises the wire
path with the new fields. ``agent-server-adapter.test.ts`` adds two cases
covering placeholder filtering on configured + sdkLlm rungs.
``agent-settings.test.tsx`` adds a Custom-preset-clears-default
regression. ``chat-input-model.test.tsx`` swaps the
"home + ACP renders nothing" assertion for "home + ACP shows the provider
default" — that's the new correct behavior.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(acp): translate new agent-settings keys + harmonize Gemini labels
Addresses the latest review pass:
- Translates ``SETTINGS$AGENT_CUSTOM_MODEL`` and ``SETTINGS$AGENT_MODEL_HINT``
into all 14 non-English locales (ja, zh-CN, zh-TW, ko-KR, no, it, pt, es,
ar, fr, tr, de, uk, ca). Both keys previously fell back to English text
on every non-English client, blocking the model dropdown's hint copy and
Custom-model label from being legible.
- Harmonizes the Gemini model labels with the Claude / Codex pattern —
raw IDs like ``gemini-3.1-pro-preview`` become ``Gemini 3.1 Pro
(preview)`` so the three providers read consistently in the dropdown.
- Adds provenance notes to ``CODEX_MODELS`` and ``GEMINI_MODELS`` mirroring
the Claude one — naming the upstream source the list was extracted from
and pointing at agent-canvas#740 (the long-term "move ACP model lists to
``@openhands/typescript-client``" plan).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(acp): address review — chip labels, dead toggle, icon de-dup, shared hook
- Chip shows the provider's picker label (e.g. "Claude Opus 4.7") instead of
the raw acp_model ID via new labelForAcpModel(); falls back to the raw ID
for custom overrides and the provider name when no model. (#1)
- Drop the stale version from the 1M labels (opus[1m]/sonnet[1m] →
"Claude Opus (1M)" / "Claude Sonnet (1M)") so the version-agnostic alias
and its label can't disagree. (#2)
- Remove the now no-op "Show LLM profiles" filter-menu row + its wiring
(chip is unconditional now; the preference no longer affects cards). (#4)
- Onboarding AgentOptionIcon delegates to AgentBrandIcon; delete the
duplicated brand-mark path constants and per-kind SVG markup. (#5)
- Name the OpenHands logo aspect ratio (3:2) so the chip and onboarding
tile render identically. (#7)
- Extract useAcpModelContext() shared by chat-input-actions and
chat-input-model to kill the duplicated isHomeAcp / destination-path /
label logic. (#6)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(acp): gate the agent chip behind the restored "LLM model" toggle
The chip was unconditional, which (a) silently flipped OpenHands cards from
main's model-hidden-by-default to always-shown and (b) left the panel's
"LLM model" toggle a no-op (then deleted). Restore one toggle that gates the
unified chip for BOTH ACP and OpenHands cards (default OFF), matching the
sibling "Repo and branch" metadata toggle.
- Footer: chip (ACP brand mark + model, OpenHands logo + model) now renders
only when showAgentChip is set.
- Restore showLlmProfiles plumbing: filter-menu row + ConversationPanel/
ConversationCard/CompactConversationRow pass-throughs (panel + filter-menu
are now byte-identical to main).
- ConversationCard.shouldRenderFooter gates ACP under the toggle too.
Net effect vs main for OpenHands: identical by default (hidden); when the
toggle is on, the only change is the OpenHands logo added next to the model
name. ACP identity chip becomes opt-in via the same toggle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(acp): reconcile model on command edit, unify chip labels, dedupe provider lookup
Review follow-ups for the versioned ACP model picker + agent chip:
- agent-settings: editing the command textarea into a different provider
(or a custom command) now reconciles the model selector, so Save can no
longer silently persist e.g. claude-opus-4-7 against a Codex/custom
wrapper. The preset dropdown already did this; the textarea is the other
way a user switches providers. Also always show the custom-model input
when the dropdown is on "Custom" so a mismatched value is visible/editable
rather than hidden.
- chat input: surface the provider's human label (e.g. "Claude Opus 4.7")
for ACP conversations, matching the conversation-list chip, instead of the
raw acp_model id.
- relabel the conversation-panel "LLM model" toggle to "Agent / model" — it
now governs the ACP brand chip too.
- extract getAcpProvider() and replace the repeated ACP_PROVIDERS.find()
lookups across the adapter, settings, and the constants module.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(acp): stop filling the OpenHands logo's transparent hand paths
AgentBrandIcon recolored the OpenHands logo with a blanket
``[&_path]:fill-current``, which also overrode the two ``fill="transparent"``
hand shapes — turning the mark into a solid white blob on the onboarding tile
(and a filled blob on the conversation chip). The logo asset has 5
``fill="white"`` wordmark paths and 2 ``fill="transparent"`` hands; only the
former should inherit ``currentColor``.
Scope the override to ``[&_path:not([fill=transparent])]:fill-current`` so the
hands stay transparent (negative space), restoring the original two-tone logo.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Debug Agent <debug@example.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
co-authored by
Claude Opus 4.7
Debug Agent
parent
c9616c1153
commit
3d724dd8fd
@@ -546,16 +546,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();
|
||||
@@ -967,7 +1034,14 @@ describe("buildStartConversationRequest — ACP discriminator", () => {
|
||||
expect(payload.agent_settings.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. 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,
|
||||
@@ -983,7 +1057,30 @@ describe("buildStartConversationRequest — ACP discriminator", () => {
|
||||
agent_settings: Record<string, unknown> & { acp_model?: unknown };
|
||||
};
|
||||
|
||||
expect(payload.agent_settings.acp_model).toBe("");
|
||||
expect(payload.agent_settings.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_settings: Record<string, unknown> & { acp_model?: unknown };
|
||||
};
|
||||
|
||||
expect(payload.agent_settings.acp_model).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ACP → OpenHands → ACP round trip leaves no field leakage", () => {
|
||||
|
||||
@@ -493,6 +493,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
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -74,6 +80,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 });
|
||||
|
||||
@@ -62,6 +62,45 @@ 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",
|
||||
acp_server: "claude-code",
|
||||
llm_model: "claude-sonnet-4-6",
|
||||
},
|
||||
});
|
||||
|
||||
renderWithProviders(<ChatInputModel />);
|
||||
|
||||
const model = screen.getByTestId("chat-input-llm-model");
|
||||
// ACP surfaces show the provider's human label (matching the conversation
|
||||
// list chip), resolved from ``acp_server`` + the raw ``acp_model`` id.
|
||||
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.
|
||||
@@ -80,6 +119,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 });
|
||||
@@ -116,27 +175,31 @@ 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 (``claude-opus-4-7``), shown as its
|
||||
// human label to match the conversation list chip. 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,7 +123,7 @@ describe("SwitchProfileButton", () => {
|
||||
expect(screen.getByTestId("switch-profile-button")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("renders nothing for ACP conversations even when profiles exist", () => {
|
||||
it("renders nothing for ACP conversations even when profiles and a display model exist", () => {
|
||||
// ACPAgent conversations route prompts to a CLI subprocess whose model is
|
||||
// set via ``acp_model`` in Settings → Agent, not by the LLM-profile
|
||||
// picker. Letting the user "switch the LLM" here would silently no-op
|
||||
@@ -133,7 +133,7 @@ describe("SwitchProfileButton", () => {
|
||||
data: {
|
||||
id: "conv-1",
|
||||
agent_kind: "acp",
|
||||
llm_model: null,
|
||||
llm_model: "claude-sonnet-4-6",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -597,12 +597,12 @@ describe("ConversationCard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACP agent badge", () => {
|
||||
it("renders the resolved display name for a known ACP server", () => {
|
||||
// ``claude-code`` resolves through the ACP_PROVIDERS registry to the
|
||||
// human display name "Claude Code". The badge always renders for
|
||||
// ACP conversations — it's identity info, not gated by the LLM-
|
||||
// profile preference.
|
||||
describe("Agent chip", () => {
|
||||
// The agent chip is gated by the conversation panel's "Agent / model"
|
||||
// toggle (``showLlmProfiles``), off by default — one control for both ACP and
|
||||
// OpenHands cards. The renders below pass ``showLlmProfiles`` to exercise
|
||||
// the chip; the gating itself is covered by the first two tests.
|
||||
it("hides the chip by default (LLM-model toggle off) for ACP", () => {
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
title="Conversation 1"
|
||||
@@ -610,66 +610,187 @@ describe("ConversationCard", () => {
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
agentKind="acp"
|
||||
acpServer="claude-code"
|
||||
llmModel="claude-opus-4-7"
|
||||
/>,
|
||||
);
|
||||
|
||||
const badge = screen.getByTestId("conversation-card-acp-badge");
|
||||
expect(badge).toHaveTextContent("Claude Code");
|
||||
expect(
|
||||
screen.queryByTestId("conversation-card-agent-chip"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to the generic 'ACP' label when the server key is unknown", () => {
|
||||
// The Custom-command preset uses ``acp_server: "custom"`` (and
|
||||
// future ACP servers Canvas's registry doesn't know about look the
|
||||
// same here) — the resolver returns null and the chip shows the
|
||||
// generic ``CONVERSATION$ACP_AGENT_GENERIC`` translation.
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
agentKind="acp"
|
||||
acpServer="custom"
|
||||
/>,
|
||||
);
|
||||
|
||||
const badge = screen.getByTestId("conversation-card-acp-badge");
|
||||
expect(badge).toHaveTextContent("ACP");
|
||||
});
|
||||
|
||||
it("falls back to the generic 'ACP' label when the server key is null", () => {
|
||||
// ACP conversations missing the ``acpserver`` tag (older clients,
|
||||
// raw API writes) still get a chip — the goal is "this is an ACP
|
||||
// conversation" first, exact provider second.
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
agentKind="acp"
|
||||
acpServer={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const badge = screen.getByTestId("conversation-card-acp-badge");
|
||||
expect(badge).toHaveTextContent("ACP");
|
||||
});
|
||||
|
||||
it("does not render the badge for OpenHands conversations", () => {
|
||||
// The OpenHands rendering path must be untouched — even if a stray
|
||||
// ``acp_server`` value somehow reaches the prop, the chip stays
|
||||
// hidden because ``agentKind !== "acp"``.
|
||||
it("hides the chip by default (LLM-model toggle off) for OpenHands", () => {
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
agentKind="openhands"
|
||||
acpServer="claude-code"
|
||||
llmModel="claude-sonnet-4"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("conversation-card-acp-badge"),
|
||||
screen.queryByTestId("conversation-card-agent-chip"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the brand mark + model for an ACP conversation with a model", () => {
|
||||
// PR 730 wires ``current_model_name``/``current_model_id``/configured
|
||||
// ``acp_model`` into ``llm_model`` on the adapter so ACP conversations
|
||||
// arrive at the card with a concrete model string. With the chip toggle
|
||||
// on, the chip shows the resolved Claude brand mark + that model text.
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
showLlmProfiles
|
||||
agentKind="acp"
|
||||
acpServer="claude-code"
|
||||
llmModel="sonnet"
|
||||
/>,
|
||||
);
|
||||
|
||||
const chip = screen.getByTestId("conversation-card-agent-chip");
|
||||
expect(chip).toHaveTextContent("sonnet");
|
||||
expect(chip).toHaveAttribute("title", "Claude Code · sonnet");
|
||||
expect(
|
||||
within(chip).getByTestId("agent-brand-icon-claude-code"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the provider's picker label for a known model ID", () => {
|
||||
// When ``llm_model`` is a registry-known ID, the chip renders the
|
||||
// human label ("Claude Opus 4.7") instead of the raw ID — matching
|
||||
// what the Settings → Agent picker shows for the same value.
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
showLlmProfiles
|
||||
agentKind="acp"
|
||||
acpServer="claude-code"
|
||||
llmModel="claude-opus-4-7"
|
||||
/>,
|
||||
);
|
||||
|
||||
const chip = screen.getByTestId("conversation-card-agent-chip");
|
||||
expect(chip).toHaveTextContent("Claude Opus 4.7");
|
||||
expect(chip).toHaveAttribute("title", "Claude Code · Claude Opus 4.7");
|
||||
});
|
||||
|
||||
it("falls back to the provider display name for an ACP conversation with no model", () => {
|
||||
// No ``llm_model`` (older agent-server, no SDK runtime fields, no
|
||||
// configured ``acp_model``) — the chip still renders for identity, with
|
||||
// the provider name as the text and the brand mark as the icon.
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
showLlmProfiles
|
||||
agentKind="acp"
|
||||
acpServer="claude-code"
|
||||
/>,
|
||||
);
|
||||
|
||||
const chip = screen.getByTestId("conversation-card-agent-chip");
|
||||
expect(chip).toHaveTextContent("Claude Code");
|
||||
expect(chip).toHaveAttribute("title", "Claude Code");
|
||||
expect(
|
||||
within(chip).getByTestId("agent-brand-icon-claude-code"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to the generic terminal glyph when the server key is unknown", () => {
|
||||
// ``custom`` (and any future ACP server Canvas doesn't know yet) maps
|
||||
// to the fallback ``cli-generic`` icon and the generic "ACP" label.
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
showLlmProfiles
|
||||
agentKind="acp"
|
||||
acpServer="custom"
|
||||
/>,
|
||||
);
|
||||
|
||||
const chip = screen.getByTestId("conversation-card-agent-chip");
|
||||
expect(chip).toHaveTextContent("ACP");
|
||||
expect(
|
||||
within(chip).getByTestId("agent-brand-icon-generic"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to the generic terminal glyph when the server key is null", () => {
|
||||
// ACP conversations missing the ``acpserver`` tag (older clients,
|
||||
// raw API writes) still get a chip — identity first, exact provider
|
||||
// second.
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
showLlmProfiles
|
||||
agentKind="acp"
|
||||
acpServer={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
const chip = screen.getByTestId("conversation-card-agent-chip");
|
||||
expect(chip).toHaveTextContent("ACP");
|
||||
expect(
|
||||
within(chip).getByTestId("agent-brand-icon-generic"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the OpenHands logo + model name for native conversations", () => {
|
||||
// With the chip toggle on, OpenHands native conversations show the
|
||||
// OpenHands logo + the raw ``agent.llm.model`` string. A stray
|
||||
// ``acp_server`` value on an OpenHands card must not flip the icon to
|
||||
// the Claude/Codex/Gemini brand mark.
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
showLlmProfiles
|
||||
agentKind="openhands"
|
||||
acpServer="claude-code"
|
||||
llmModel="claude-sonnet-4"
|
||||
/>,
|
||||
);
|
||||
|
||||
const chip = screen.getByTestId("conversation-card-agent-chip");
|
||||
expect(chip).toHaveTextContent("claude-sonnet-4");
|
||||
expect(chip).toHaveAttribute("title", "claude-sonnet-4");
|
||||
expect(
|
||||
within(chip).getByTestId("agent-brand-icon-openhands"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(chip).queryByTestId("agent-brand-icon-claude-code"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the chip for OpenHands conversations with no model", () => {
|
||||
// Toggle on, but no model string and no ACP server — nothing to
|
||||
// display, so the chip collapses rather than showing a bare logo.
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
showLlmProfiles
|
||||
agentKind="openhands"
|
||||
llmModel={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("conversation-card-agent-chip"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -168,7 +168,7 @@ describe("ChooseAgentStep", () => {
|
||||
// ``acp_args`` can't survive and concatenate onto the spawn
|
||||
// command at conversation-create time.
|
||||
acp_args: [],
|
||||
acp_model: null,
|
||||
acp_model: "claude-opus-4-7",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -191,6 +191,9 @@ describe("ChooseAgentStep", () => {
|
||||
expect(
|
||||
(call.agent_settings_diff as Record<string, unknown>).acp_server,
|
||||
).toBe(expected);
|
||||
expect(
|
||||
(call.agent_settings_diff as Record<string, unknown>).acp_model,
|
||||
).toBe(ACP_PROVIDERS.find(({ key }) => key === expected)?.default_model);
|
||||
});
|
||||
|
||||
it("rebuilds the diff cleanly when the user flips between ACP providers", async () => {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getAcpProviderDisplayName } from "#/constants/acp-providers";
|
||||
import {
|
||||
ACP_CUSTOM_PRESET_KEY,
|
||||
ACP_PROVIDERS,
|
||||
buildAcpAgentSettingsDiff,
|
||||
getAcpProviderDisplayName,
|
||||
} from "#/constants/acp-providers";
|
||||
|
||||
describe("getAcpProviderDisplayName", () => {
|
||||
it("resolves the three built-in registry keys to their human names", () => {
|
||||
@@ -28,3 +33,45 @@ describe("getAcpProviderDisplayName", () => {
|
||||
expect(getAcpProviderDisplayName("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACP provider registry", () => {
|
||||
it("keeps every built-in default model in the UX suggestions", () => {
|
||||
for (const provider of ACP_PROVIDERS) {
|
||||
expect(provider.default_model, provider.key).toBeTruthy();
|
||||
expect(provider.available_models, provider.key).toBeTruthy();
|
||||
expect(
|
||||
provider.available_models?.some(
|
||||
(model) => model.id === provider.default_model,
|
||||
),
|
||||
provider.key,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not suggest generic default model placeholders", () => {
|
||||
for (const provider of ACP_PROVIDERS) {
|
||||
for (const model of provider.available_models ?? []) {
|
||||
expect(model.id.toLowerCase()).not.toBe("default");
|
||||
expect(model.label.toLowerCase()).not.toContain("default");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("seeds built-in ACP diffs with the provider default model", () => {
|
||||
for (const provider of ACP_PROVIDERS) {
|
||||
expect(buildAcpAgentSettingsDiff(provider.key)).toMatchObject({
|
||||
agent_kind: "acp",
|
||||
acp_server: provider.key,
|
||||
acp_model: provider.default_model,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps custom ACP diffs model-optional", () => {
|
||||
expect(buildAcpAgentSettingsDiff(ACP_CUSTOM_PRESET_KEY)).toMatchObject({
|
||||
agent_kind: "acp",
|
||||
acp_server: ACP_CUSTOM_PRESET_KEY,
|
||||
acp_model: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -152,6 +152,10 @@ describe("AgentSettingsScreen", () => {
|
||||
});
|
||||
|
||||
it("shows the ACP form when the active agent_kind is acp", async () => {
|
||||
// Use a model ID that isn't in CLAUDE_MODELS so the form falls through to
|
||||
// the custom-input branch — that's the path this test is asserting (saved
|
||||
// value round-trips into the visible input). Known IDs go through the
|
||||
// dropdown instead and are covered separately.
|
||||
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(
|
||||
buildSettings({
|
||||
agent_settings: {
|
||||
@@ -159,7 +163,7 @@ describe("AgentSettingsScreen", () => {
|
||||
agent_kind: "acp",
|
||||
acp_server: "claude-code",
|
||||
acp_command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp"],
|
||||
acp_model: "claude-opus-4-5",
|
||||
acp_model: "my-pinned-fork-model",
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -174,7 +178,162 @@ describe("AgentSettingsScreen", () => {
|
||||
const modelInput = screen.getByTestId(
|
||||
"agent-model-input",
|
||||
) as HTMLInputElement;
|
||||
expect(modelInput.value).toBe("claude-opus-4-5");
|
||||
expect(modelInput.value).toBe("my-pinned-fork-model");
|
||||
});
|
||||
|
||||
it("defaults built-in ACP providers to a suggested model when none is saved", async () => {
|
||||
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(
|
||||
buildSettings({
|
||||
agent_settings: {
|
||||
schema_version: 1,
|
||||
agent_kind: "acp",
|
||||
acp_server: "claude-code",
|
||||
acp_command: [],
|
||||
acp_model: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
renderAgentSettingsScreen();
|
||||
|
||||
await screen.findByTestId("agent-command-input");
|
||||
expect(screen.getByLabelText("SETTINGS$AGENT_MODEL")).toHaveValue(
|
||||
"Claude Opus 4.7",
|
||||
);
|
||||
});
|
||||
|
||||
it("saves the selected built-in ACP model", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(
|
||||
buildSettings({
|
||||
agent_settings: {
|
||||
schema_version: 1,
|
||||
agent_kind: "acp",
|
||||
acp_server: "claude-code",
|
||||
acp_command: [],
|
||||
acp_model: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const save = vi.spyOn(SettingsService, "saveSettings");
|
||||
|
||||
renderAgentSettingsScreen();
|
||||
await screen.findByTestId("agent-command-input");
|
||||
await user.click(screen.getByLabelText("SETTINGS$AGENT_MODEL"));
|
||||
await user.click(await screen.findByText("Claude Haiku 4.5"));
|
||||
await user.click(screen.getByTestId("agent-save-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const call = save.mock.calls[0]?.[0] as {
|
||||
agent_settings_diff?: Record<string, unknown>;
|
||||
};
|
||||
expect(call.agent_settings_diff?.acp_model).toBe("claude-haiku-4-5");
|
||||
});
|
||||
|
||||
it("clears the model when switching from a built-in provider to Custom", async () => {
|
||||
// F3 from review: built-ins seed ``acp_model`` to their registered
|
||||
// ``default_model`` on load. Picking Custom must not leak that built-in
|
||||
// default into custom settings — otherwise a user choosing Custom from
|
||||
// Claude Code would silently save ``acp_model: "claude-opus-4-7"`` on an
|
||||
// unrelated wrapper.
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(
|
||||
buildSettings({
|
||||
agent_settings: {
|
||||
schema_version: 1,
|
||||
agent_kind: "acp",
|
||||
acp_server: "claude-code",
|
||||
acp_command: [],
|
||||
acp_model: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const save = vi.spyOn(SettingsService, "saveSettings");
|
||||
|
||||
renderAgentSettingsScreen();
|
||||
await screen.findByTestId("agent-command-input");
|
||||
// Form loads with the Claude Code default visible.
|
||||
expect(screen.getByLabelText("SETTINGS$AGENT_MODEL")).toHaveValue(
|
||||
"Claude Opus 4.7",
|
||||
);
|
||||
|
||||
// Switch to the Custom preset, then enter a different command — the
|
||||
// form's ``selectedPreset`` re-derives from the command text, so the
|
||||
// save path only treats it as Custom once the command no longer matches
|
||||
// a built-in provider's default.
|
||||
await user.click(screen.getByTestId("agent-preset-selector"));
|
||||
await user.click(
|
||||
await screen.findByRole("option", { name: "SETTINGS$AGENT_PRESET_CUSTOM" }),
|
||||
);
|
||||
const commandInput = screen.getByTestId(
|
||||
"agent-command-input",
|
||||
) as HTMLTextAreaElement;
|
||||
await user.clear(commandInput);
|
||||
await user.type(commandInput, "my-custom-acp --flag");
|
||||
|
||||
await user.click(screen.getByTestId("agent-save-button"));
|
||||
await waitFor(() => {
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const call = save.mock.calls[0]?.[0] as {
|
||||
agent_settings_diff?: Record<string, unknown>;
|
||||
};
|
||||
// Custom preset has no registered default — saved acp_model must be null,
|
||||
// not the inherited Claude Opus default.
|
||||
expect(call.agent_settings_diff?.acp_server).toBe("custom");
|
||||
expect(call.agent_settings_diff?.acp_model).toBeNull();
|
||||
});
|
||||
|
||||
it("reconciles the model when the command is retyped to a different provider", async () => {
|
||||
// Editing the command textarea (rather than the preset dropdown) into a
|
||||
// different built-in provider must not leave the previous provider's model
|
||||
// selected — otherwise Save would persist e.g. ``claude-opus-4-7`` against
|
||||
// a Codex wrapper. The detected preset changes, so the model reconciles to
|
||||
// the new provider's default.
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(
|
||||
buildSettings({
|
||||
agent_settings: {
|
||||
schema_version: 1,
|
||||
agent_kind: "acp",
|
||||
acp_server: "claude-code",
|
||||
acp_command: [],
|
||||
acp_model: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const save = vi.spyOn(SettingsService, "saveSettings");
|
||||
|
||||
renderAgentSettingsScreen();
|
||||
await screen.findByTestId("agent-command-input");
|
||||
expect(screen.getByLabelText("SETTINGS$AGENT_MODEL")).toHaveValue(
|
||||
"Claude Opus 4.7",
|
||||
);
|
||||
|
||||
const commandInput = screen.getByTestId(
|
||||
"agent-command-input",
|
||||
) as HTMLTextAreaElement;
|
||||
await user.clear(commandInput);
|
||||
await user.type(commandInput, "npx -y @zed-industries/codex-acp");
|
||||
|
||||
// The model field now reflects the Codex default, not the stale Claude one.
|
||||
expect(screen.getByLabelText("SETTINGS$AGENT_MODEL")).toHaveValue(
|
||||
"GPT-5.5 (medium)",
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("agent-save-button"));
|
||||
await waitFor(() => {
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const call = save.mock.calls[0]?.[0] as {
|
||||
agent_settings_diff?: Record<string, unknown>;
|
||||
};
|
||||
expect(call.agent_settings_diff?.acp_server).toBe("codex");
|
||||
expect(call.agent_settings_diff?.acp_model).toBe("gpt-5.5/medium");
|
||||
});
|
||||
|
||||
it("saves an ACP diff when switching to ACP + Claude Code", async () => {
|
||||
@@ -205,6 +364,9 @@ describe("AgentSettingsScreen", () => {
|
||||
expect(commandInput.value).toBe(
|
||||
"npx -y @agentclientprotocol/claude-agent-acp",
|
||||
);
|
||||
expect(screen.getByLabelText("SETTINGS$AGENT_MODEL")).toHaveValue(
|
||||
"Claude Opus 4.7",
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("agent-save-button"));
|
||||
|
||||
@@ -225,7 +387,7 @@ describe("AgentSettingsScreen", () => {
|
||||
// ``acp_args`` can't survive and concatenate onto the spawn
|
||||
// command at conversation-create time.
|
||||
acp_args: [],
|
||||
acp_model: null,
|
||||
acp_model: "claude-opus-4-7",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { ExecutionStatus } from "#/types/agent-server/core";
|
||||
import { Settings, SettingsValue } from "#/types/settings";
|
||||
import { ACP_PROVIDERS } from "#/constants/acp-providers";
|
||||
import {
|
||||
getAcpProvider,
|
||||
resolveEffectiveAcpModel,
|
||||
} from "#/constants/acp-providers";
|
||||
import { getAgentServerClientOptions } from "./agent-server-client-options";
|
||||
import { isAgentServerToolAvailable } from "./agent-server-compatibility";
|
||||
import {
|
||||
@@ -42,19 +45,18 @@ export interface DirectConversationInfo {
|
||||
} | null;
|
||||
agent?: {
|
||||
/**
|
||||
* Pydantic discriminator from the SDK union. ``"ACPAgent"`` means the
|
||||
* conversation runs an ACP CLI subprocess (model selection lives on
|
||||
* the subprocess via ``acp_model``, not on ``agent.llm``); ``"Agent"``
|
||||
* means the conversation drives an LLM directly through litellm.
|
||||
* Used by ``toAppConversation`` to null out ``llm_model`` for ACP
|
||||
* conversations so the chat UI doesn't expose LLM-switch affordances
|
||||
* that would silently no-op against the running ACP subprocess.
|
||||
* Pydantic discriminator from the SDK union: ``"ACPAgent"`` for ACP CLI
|
||||
* subprocesses (model lives on the subprocess via ``acp_model``),
|
||||
* ``"Agent"`` for direct litellm. Read by {@link toAppConversation}.
|
||||
*/
|
||||
kind?: string | null;
|
||||
acp_model?: string | null;
|
||||
llm?: {
|
||||
model?: string | null;
|
||||
} | null;
|
||||
} | null;
|
||||
current_model_id?: string | null;
|
||||
current_model_name?: string | null;
|
||||
workspace?: {
|
||||
working_dir?: string | null;
|
||||
} | null;
|
||||
@@ -247,14 +249,10 @@ export function toAppConversation(
|
||||
info: DirectConversationInfo,
|
||||
): AppConversation {
|
||||
const metadata = getStoredConversationMetadata(info.id);
|
||||
// ACPAgent conversations carry a dummy ``llm`` on the SDK side (the real
|
||||
// model lives on the ACP subprocess via ``acp_model``), so surfacing
|
||||
// ``agent.llm.model`` as the conversation's "active LLM" would lie to
|
||||
// every consumer downstream — most visibly the chat header's
|
||||
// SwitchProfileButton, which would otherwise let the user switch
|
||||
// profiles on a Claude-Code conversation while the running subprocess
|
||||
// keeps its own model. Null at the boundary so no consumer has to
|
||||
// re-derive the rule. Mirrors OpenHands PR #14401.
|
||||
// ACPAgent conversations carry a sentinel ``llm`` on older SDKs. Prefer the
|
||||
// runtime model fields when available, then the configured ``acp_model`` that
|
||||
// Canvas saves for built-in providers. ``agent_kind`` still gates model
|
||||
// switching, so surfacing this string is display-only.
|
||||
const isAcp = info.agent?.kind === "ACPAgent";
|
||||
// Only surface ``acp_server`` for ACP conversations even if the wire
|
||||
// payload accidentally carries an ``acpserver`` tag on an OpenHands
|
||||
@@ -275,8 +273,17 @@ export function toAppConversation(
|
||||
pr_number: [],
|
||||
agent_kind: isAcp ? "acp" : "openhands",
|
||||
acp_server: acpServer,
|
||||
// Chip path: no ``providerDefault`` — the chip must distinguish
|
||||
// "no concrete model" (fall back to the provider display name in
|
||||
// ConversationCardFooter) from "default" (would lie about what's
|
||||
// running on the subprocess).
|
||||
llm_model: isAcp
|
||||
? null
|
||||
? resolveEffectiveAcpModel({
|
||||
runtimeName: info.current_model_name,
|
||||
runtimeId: info.current_model_id,
|
||||
configured: info.agent?.acp_model,
|
||||
sdkLlm: info.agent?.llm?.model,
|
||||
})
|
||||
: (info.agent?.llm?.model ?? DEFAULT_SETTINGS.llm_model),
|
||||
metrics: info.metrics
|
||||
? {
|
||||
@@ -530,7 +537,7 @@ function resolveAcpCommand(agentSettings: SettingsRecord): unknown {
|
||||
typeof agentSettings.acp_server === "string"
|
||||
? agentSettings.acp_server
|
||||
: undefined;
|
||||
const provider = ACP_PROVIDERS.find(({ key }) => key === serverKey);
|
||||
const provider = getAcpProvider(serverKey);
|
||||
return provider ? [...provider.default_command] : cmd;
|
||||
}
|
||||
|
||||
@@ -544,6 +551,9 @@ function buildConfiguredAcpAgentSettings(
|
||||
};
|
||||
|
||||
for (const key of ACP_SETTINGS_KEYS) {
|
||||
// ``acp_model`` is resolved separately below so a saved ``null`` still
|
||||
// falls back to the provider's default rather than being dropped.
|
||||
if (key === "acp_model") continue;
|
||||
const value =
|
||||
key === "acp_command"
|
||||
? resolveAcpCommand(agentSettings)
|
||||
@@ -553,6 +563,25 @@ function buildConfiguredAcpAgentSettings(
|
||||
}
|
||||
}
|
||||
|
||||
// Saved settings may carry ``acp_model: null`` (existing users predating
|
||||
// the default-model registry, or saved fields the agent-server stripped).
|
||||
// Fall back to the provider's ``default_model`` so the conversation starts
|
||||
// with whatever the Settings → Agent UI shows — without that, the form's
|
||||
// displayed default would silently not take effect at runtime until the
|
||||
// user re-saved the page.
|
||||
const serverKey =
|
||||
typeof agentSettings.acp_server === "string"
|
||||
? agentSettings.acp_server
|
||||
: undefined;
|
||||
const provider = getAcpProvider(serverKey);
|
||||
const effectiveModel = resolveEffectiveAcpModel({
|
||||
configured: agentSettings.acp_model as string | null | undefined,
|
||||
providerDefault: provider?.default_model,
|
||||
});
|
||||
if (effectiveModel) {
|
||||
payload.acp_model = effectiveModel;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
|
||||
@@ -137,11 +137,17 @@ function normalizeAgent(value: unknown): DirectConversationInfo["agent"] {
|
||||
? { model: stringOrNull(value.llm.model) }
|
||||
: null;
|
||||
// ``kind`` is the SDK's pydantic discriminator (``"Agent"`` vs ``"ACPAgent"``);
|
||||
// ``toAppConversation`` reads it to derive ``agent_kind`` and to gate the
|
||||
// ACP-server chip + ``llm_model`` null-out. Preserving it here makes the
|
||||
// wire path agree with the unit-test path that builds ``DirectConversationInfo``
|
||||
// ``toAppConversation`` reads it to derive ``agent_kind``. ``acp_model`` is
|
||||
// the Canvas-configured model on the ACPAgent — preserved so the conversation
|
||||
// adapter and the conversation chip can fall back to it when the SDK runtime
|
||||
// model fields aren't populated. Preserving these here makes the wire path
|
||||
// agree with the unit-test path that builds ``DirectConversationInfo``
|
||||
// directly (e.g. ``__tests__/api/agent-server-adapter.test.ts``).
|
||||
return { kind: stringOrNull(value.kind), llm };
|
||||
return {
|
||||
kind: stringOrNull(value.kind),
|
||||
acp_model: stringOrNull(value.acp_model),
|
||||
llm,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWorkspace(
|
||||
@@ -222,6 +228,12 @@ function requireDirectConversationInfo(item: unknown): DirectConversationInfo {
|
||||
agent: normalizeAgent(item.agent),
|
||||
workspace: normalizeWorkspace(item.workspace),
|
||||
tags: normalizeTags(item.tags),
|
||||
// SDK-runtime ACP model fields (populated when the agent-server supports
|
||||
// ``ConversationInfo.current_model_*``). Consumed by the conversation
|
||||
// adapter to drive the per-card chip's model text. Older agent-servers
|
||||
// omit these — adapter handles ``undefined`` / ``null`` gracefully.
|
||||
current_model_id: stringOrNull(item.current_model_id),
|
||||
current_model_name: stringOrNull(item.current_model_name),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -127,8 +127,8 @@ export interface AppConversation {
|
||||
* driven Agent, ``"acp"`` for an ACPAgent that delegates to an external
|
||||
* ACP CLI subprocess. Consumers can use this to gate UI affordances that
|
||||
* only make sense for one kind (e.g. the LLM-profile switcher in the chat
|
||||
* header is a no-op for ACP conversations because model selection lives
|
||||
* on the subprocess via ``acp_model``, not on ``llm_model``).
|
||||
* header is a no-op for ACP conversations even though ``llm_model`` may
|
||||
* carry the ACP subprocess model for display).
|
||||
*/
|
||||
agent_kind?: "openhands" | "acp" | null;
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,8 @@ import { usePauseConversation } from "#/hooks/mutation/use-pause-conversation";
|
||||
import { useResumeConversation } from "#/hooks/mutation/use-resume-conversation";
|
||||
import { useActiveBackend } from "#/contexts/active-backend-context";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useAcpModelContext } from "#/hooks/use-acp-model-context";
|
||||
import { labelForAcpModel } from "#/constants/acp-providers";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
@@ -59,9 +61,19 @@ export function ChatInputActions({
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { backend } = useActiveBackend();
|
||||
const isCloud = backend.kind === "cloud";
|
||||
const llmDestinationLabel = t(
|
||||
isCloud ? I18nKey.SETTINGS$LLM_SETTINGS : I18nKey.SETTINGS$LLM_PROFILES,
|
||||
);
|
||||
// Shared with ChatInputModel: routes the model affordance to ChatInputModel
|
||||
// (which knows how to show the ACP model) instead of SwitchProfileButton for
|
||||
// ACP conversations — and for the home screen when Settings → Agent already
|
||||
// selects an ACP agent, since the next conversation will inherit it.
|
||||
const { isAcpContext, destinationPath, destinationLabel } =
|
||||
useAcpModelContext();
|
||||
// Mirror ChatInputModel: ACP conversations show the provider's human label
|
||||
// (e.g. "Claude Opus 4.7") in the overflow model submenu, not the raw
|
||||
// ``acp_model`` id. OpenHands keeps the raw model string.
|
||||
const overflowModelLabel = isAcpContext
|
||||
? (labelForAcpModel(conversation?.acp_server, conversation?.llm_model) ??
|
||||
conversation?.llm_model)
|
||||
: conversation?.llm_model;
|
||||
const webSocketStatus = useUnifiedWebSocketStatus();
|
||||
const { curAgentState } = useAgentState();
|
||||
const { conversationMode, setConversationMode } = useConversationStore();
|
||||
@@ -360,13 +372,13 @@ export function ChatInputActions({
|
||||
>
|
||||
<li className="text-sm">
|
||||
<div className="p-2 leading-5 text-[var(--oh-foreground)] break-all">
|
||||
{conversation?.llm_model}
|
||||
{overflowModelLabel}
|
||||
</div>
|
||||
</li>
|
||||
<Divider inset="menu" />
|
||||
<li className="text-sm">
|
||||
<NavigationLink
|
||||
to="/settings"
|
||||
to={destinationPath}
|
||||
onClick={closeOverflowMenus}
|
||||
className={cn(
|
||||
"group flex h-[30px] items-center gap-2 rounded p-2 leading-5 text-[var(--oh-foreground)] hover:bg-[var(--oh-interactive-hover)]",
|
||||
@@ -382,7 +394,7 @@ export function ChatInputActions({
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<span>{llmDestinationLabel}</span>
|
||||
<span>{destinationLabel}</span>
|
||||
</NavigationLink>
|
||||
</li>
|
||||
</ContextMenu>
|
||||
@@ -411,7 +423,11 @@ export function ChatInputActions({
|
||||
</div>
|
||||
)}
|
||||
<div ref={modelRef} className={cn(!showModelInline && "hidden")}>
|
||||
{isCloud ? <ChatInputModel /> : <SwitchProfileButton />}
|
||||
{isCloud || isAcpContext ? (
|
||||
<ChatInputModel />
|
||||
) : (
|
||||
<SwitchProfileButton />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasOverflowItems && (
|
||||
|
||||
@@ -1,56 +1,79 @@
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { useAcpModelContext } from "#/hooks/use-acp-model-context";
|
||||
import { ComboboxCaretInline } from "#/ui/combobox-caret";
|
||||
import SettingsGearIcon from "#/icons/settings-gear.svg?react";
|
||||
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
|
||||
import { NavigationLink } from "#/components/shared/navigation-link";
|
||||
import { ContextMenu } from "#/ui/context-menu";
|
||||
import { Divider } from "#/ui/divider";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useActiveBackend } from "#/contexts/active-backend-context";
|
||||
import {
|
||||
getAcpProvider,
|
||||
labelForAcpModel,
|
||||
resolveEffectiveAcpModel,
|
||||
} from "#/constants/acp-providers";
|
||||
import { cn } from "#/utils/utils";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const MODEL_LABEL_MAX_CHARS = 10;
|
||||
// ACP surfaces show the provider's human label (e.g. "Claude Opus 4.7"),
|
||||
// which is longer than a raw model id, so the inline button gets a wider cap
|
||||
// before truncating. The full string still shows in the title + popover.
|
||||
const ACP_MODEL_LABEL_MAX_CHARS = 22;
|
||||
|
||||
function truncateModelLabel(model: string): string {
|
||||
if (model.length <= MODEL_LABEL_MAX_CHARS) {
|
||||
function truncateModelLabel(
|
||||
model: string,
|
||||
maxChars: number = MODEL_LABEL_MAX_CHARS,
|
||||
): string {
|
||||
if (model.length <= maxChars) {
|
||||
return model;
|
||||
}
|
||||
return `${model.slice(0, MODEL_LABEL_MAX_CHARS)}…`;
|
||||
return `${model.slice(0, maxChars)}…`;
|
||||
}
|
||||
|
||||
export function ChatInputModel() {
|
||||
const { t } = useTranslation("openhands");
|
||||
const { backend } = useActiveBackend();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
// Home page has no active conversation; fall back to the user's default
|
||||
// model so the switcher renders consistently across both surfaces.
|
||||
const { data: settings } = useSettings();
|
||||
// ACPAgent conversations have no OpenHands LLM (the model lives on the
|
||||
// ACP subprocess via ``acp_model``), so ``toAppConversation`` writes a
|
||||
// null ``llm_model`` for them. Don't fall back to ``settings.llm_model``
|
||||
// here — that would resurrect the user's *default* OpenHands model on a
|
||||
// Claude-Code conversation and link to /settings, both of which lie
|
||||
// about what model is actually running.
|
||||
const {
|
||||
isActiveAcpConversation,
|
||||
isHomeAcp,
|
||||
isAcpContext,
|
||||
destinationPath,
|
||||
destinationLabel,
|
||||
} = useAcpModelContext();
|
||||
// ACP conversations do not use the OpenHands LLM profile. Resolve the model
|
||||
// label through the shared helper so the displayed value matches what the
|
||||
// conversation-creation path will actually send to the agent-server (the
|
||||
// helper applies provider defaults + filters out the SDK ``"default"``
|
||||
// placeholders + the ``"acp-managed"`` sentinel).
|
||||
//
|
||||
// On the home screen ``conversation`` is undefined, so we also have to
|
||||
// consult ``settings.agent_settings.agent_kind`` — that's the kind the
|
||||
// next-created conversation will inherit. Without the fallback, ACP
|
||||
// users would still see the LLM-profile control on the home page,
|
||||
// contradicting the ACP nav gating elsewhere.
|
||||
const isAcpActive =
|
||||
conversation?.agent_kind === "acp" ||
|
||||
(!conversation && settings?.agent_settings?.agent_kind === "acp");
|
||||
const llmModel = isAcpActive
|
||||
? null
|
||||
: (conversation?.llm_model ?? settings?.llm_model);
|
||||
const llmDestinationLabel = t(
|
||||
backend.kind === "cloud"
|
||||
? I18nKey.SETTINGS$LLM_SETTINGS
|
||||
: I18nKey.SETTINGS$LLM_PROFILES,
|
||||
);
|
||||
// The ACP server key whose registry owns the model label comes off the
|
||||
// active conversation when there is one, else the saved agent settings the
|
||||
// next home-page conversation will inherit.
|
||||
const acpServerKey = isActiveAcpConversation
|
||||
? conversation?.acp_server
|
||||
: isHomeAcp
|
||||
? typeof settings?.agent_settings?.acp_server === "string"
|
||||
? settings.agent_settings.acp_server
|
||||
: null
|
||||
: null;
|
||||
const acpProvider = isHomeAcp ? getAcpProvider(acpServerKey) : undefined;
|
||||
let llmModel: string | null | undefined;
|
||||
if (isActiveAcpConversation) {
|
||||
llmModel = conversation?.llm_model;
|
||||
} else if (isHomeAcp) {
|
||||
llmModel = resolveEffectiveAcpModel({
|
||||
configured:
|
||||
typeof settings?.agent_settings?.acp_model === "string"
|
||||
? settings.agent_settings.acp_model
|
||||
: null,
|
||||
providerDefault: acpProvider?.default_model,
|
||||
});
|
||||
} else {
|
||||
llmModel = conversation?.llm_model ?? settings?.llm_model;
|
||||
}
|
||||
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
|
||||
|
||||
const popoverRef = useClickOutsideElement<HTMLUListElement>(() => {
|
||||
@@ -60,7 +83,16 @@ export function ChatInputModel() {
|
||||
if (!llmModel) {
|
||||
return null;
|
||||
}
|
||||
const truncatedModelLabel = truncateModelLabel(llmModel);
|
||||
// For ACP, surface the provider's human label (matching the conversation
|
||||
// list chip) instead of the raw ``acp_model`` id; falls back to the raw
|
||||
// value for custom / unknown ids. OpenHands keeps the raw model string.
|
||||
const displayModel = isAcpContext
|
||||
? (labelForAcpModel(acpServerKey, llmModel) ?? llmModel)
|
||||
: llmModel;
|
||||
const truncatedModelLabel = truncateModelLabel(
|
||||
displayModel,
|
||||
isAcpContext ? ACP_MODEL_LABEL_MAX_CHARS : MODEL_LABEL_MAX_CHARS,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative min-w-0">
|
||||
@@ -70,7 +102,7 @@ export function ChatInputModel() {
|
||||
"inline-flex items-center gap-1 rounded-[100px] border border-transparent px-1.5 text-sm font-normal leading-5 text-[var(--oh-muted)] whitespace-nowrap min-w-0 transition-[border-color,background-color,box-shadow,opacity] duration-150 motion-reduce:transition-none",
|
||||
"hover:text-white hover:bg-white/10 cursor-pointer",
|
||||
)}
|
||||
title={llmModel}
|
||||
title={displayModel}
|
||||
data-testid="chat-input-llm-model"
|
||||
aria-expanded={isPopoverOpen}
|
||||
aria-haspopup="dialog"
|
||||
@@ -94,12 +126,14 @@ export function ChatInputModel() {
|
||||
className="z-[60] mb-2 min-w-[200px] max-w-[320px]"
|
||||
>
|
||||
<li className="text-sm">
|
||||
<div className="p-2 leading-5 text-white break-all">{llmModel}</div>
|
||||
<div className="p-2 leading-5 text-white break-all">
|
||||
{displayModel}
|
||||
</div>
|
||||
</li>
|
||||
<Divider />
|
||||
<li className="text-sm">
|
||||
<NavigationLink
|
||||
to="/settings"
|
||||
to={destinationPath}
|
||||
onClick={() => setIsPopoverOpen(false)}
|
||||
className="flex h-[30px] items-center gap-2 rounded p-2 leading-5 text-white hover:bg-[var(--oh-interactive-hover)] transition-colors"
|
||||
>
|
||||
@@ -109,7 +143,7 @@ export function ChatInputModel() {
|
||||
className="shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<span>{llmDestinationLabel}</span>
|
||||
<span>{destinationLabel}</span>
|
||||
</NavigationLink>
|
||||
</li>
|
||||
</ContextMenu>
|
||||
|
||||
@@ -34,9 +34,8 @@ export function SwitchProfileButton() {
|
||||
// controlled by ``acp_model`` (set in Settings → Agent), not by the LLM
|
||||
// profile picker. Surfacing the switcher here would let the user "change
|
||||
// the model" while the running subprocess silently keeps its own — a
|
||||
// confusing no-op. Hide the button instead. ``toAppConversation`` also
|
||||
// nulls ``llm_model`` on this boundary so any other consumer that reads
|
||||
// the model directly sees "no model" rather than a misleading value.
|
||||
// confusing no-op. Hide the button even when ``llm_model`` carries an ACP
|
||||
// display model for chips/headers.
|
||||
//
|
||||
// On the home screen ``conversation`` is undefined; fall back to
|
||||
// ``settings.agent_settings.agent_kind`` so the picker also hides when
|
||||
|
||||
@@ -70,7 +70,7 @@ export function CompactConversationRow({
|
||||
workspaceWorkingDir={workspaceWorkingDir}
|
||||
showRepositoryMetadata={showRepositoryMetadata}
|
||||
llmModel={llmModel}
|
||||
showLlmModel={showLlmProfiles}
|
||||
showAgentChip={showLlmProfiles}
|
||||
agentKind={agentKind}
|
||||
acpServer={acpServer}
|
||||
/>
|
||||
|
||||
+57
-32
@@ -5,7 +5,15 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
import { RepositorySelection } from "#/api/open-hands.types";
|
||||
import { ExecutionStatus } from "#/types/agent-server/core/base/common";
|
||||
import { isExecutionPaused } from "#/utils/status";
|
||||
import { getAcpProviderDisplayName } from "#/constants/acp-providers";
|
||||
import {
|
||||
getAcpProviderDisplayName,
|
||||
labelForAcpModel,
|
||||
resolveAcpProviderIcon,
|
||||
} from "#/constants/acp-providers";
|
||||
import {
|
||||
AgentBrandIcon,
|
||||
type AgentBrandIconKind,
|
||||
} from "#/components/shared/agent-brand-icon";
|
||||
import { ConversationRepoLink } from "./conversation-repo-link";
|
||||
import { NoRepository } from "./no-repository";
|
||||
|
||||
@@ -18,13 +26,17 @@ interface ConversationCardFooterProps {
|
||||
showRepositoryMetadata?: boolean;
|
||||
showTimestamp?: boolean;
|
||||
llmModel?: string | null;
|
||||
showLlmModel?: boolean;
|
||||
/**
|
||||
* High-level kind of the conversation's agent. The ACP-agent chip is
|
||||
* only rendered when this is ``"acp"``. The OpenHands rendering path
|
||||
* is intentionally untouched — for OpenHands conversations the chip is
|
||||
* suppressed regardless of any ``acpServer`` value (defensive against
|
||||
* stray wire tags on non-ACP conversations).
|
||||
* Whether to render the agent/model chip. Wired to the conversation
|
||||
* panel's "LLM model" toggle (off by default); gates the chip uniformly
|
||||
* for both ACP and OpenHands cards.
|
||||
*/
|
||||
showAgentChip?: boolean;
|
||||
/**
|
||||
* High-level kind of the conversation's agent. Drives the chip's icon:
|
||||
* the OpenHands logo for native conversations and the resolved ACP brand
|
||||
* mark for ACP conversations. Defensive against stray ``acpServer``
|
||||
* values reaching an OpenHands card.
|
||||
*/
|
||||
agentKind?: "openhands" | "acp" | null;
|
||||
/**
|
||||
@@ -32,9 +44,7 @@ interface ConversationCardFooterProps {
|
||||
* ``"gemini-cli"`` / unknown / null). Resolved to a human display name
|
||||
* via {@link getAcpProviderDisplayName}; unknown / null falls back to
|
||||
* a generic "ACP" label so a Custom-command preset still produces a
|
||||
* useful chip. Always shown for ACP conversations — this is identity
|
||||
* info, not gated by the ``showLlmModel`` preference (which is about
|
||||
* LLM model strings, an orthogonal concern).
|
||||
* useful chip.
|
||||
*/
|
||||
acpServer?: string | null;
|
||||
}
|
||||
@@ -48,7 +58,7 @@ export function ConversationCardFooter({
|
||||
showRepositoryMetadata = true,
|
||||
showTimestamp = true,
|
||||
llmModel,
|
||||
showLlmModel = false,
|
||||
showAgentChip = false,
|
||||
agentKind = null,
|
||||
acpServer = null,
|
||||
}: ConversationCardFooterProps) {
|
||||
@@ -56,11 +66,36 @@ export function ConversationCardFooter({
|
||||
|
||||
const isPaused = isExecutionPaused(executionStatus);
|
||||
|
||||
const acpDisplayName =
|
||||
agentKind === "acp"
|
||||
? (getAcpProviderDisplayName(acpServer) ??
|
||||
t(I18nKey.CONVERSATION$ACP_AGENT_GENERIC))
|
||||
: null;
|
||||
// Single inline chip per conversation: [brand mark] {model text}. Gated by
|
||||
// the conversation panel's "LLM model" toggle (off by default) and applied
|
||||
// uniformly to both kinds — OpenHands shows the logo + ``agent.llm.model``;
|
||||
// ACP shows the provider brand mark + model resolved through PR 730's
|
||||
// adapter chain, falling back to the provider display name when no model is
|
||||
// available so the chip never collapses to icon-only.
|
||||
let chip: {
|
||||
kind: AgentBrandIconKind;
|
||||
text: string;
|
||||
tooltip: string;
|
||||
} | null = null;
|
||||
if (showAgentChip) {
|
||||
if (agentKind === "acp") {
|
||||
const providerName =
|
||||
getAcpProviderDisplayName(acpServer) ??
|
||||
t(I18nKey.CONVERSATION$ACP_AGENT_GENERIC);
|
||||
// Prefer the provider's picker label (e.g. "Claude Opus 4.7") over the
|
||||
// raw ``acp_model`` ID; falls back to the raw ID for custom overrides
|
||||
// and to the provider name when there's no model at all.
|
||||
const modelLabel = labelForAcpModel(acpServer, llmModel);
|
||||
const text = modelLabel ?? providerName;
|
||||
chip = {
|
||||
kind: resolveAcpProviderIcon(acpServer),
|
||||
text,
|
||||
tooltip: modelLabel ? `${providerName} · ${modelLabel}` : providerName,
|
||||
};
|
||||
} else if (llmModel) {
|
||||
chip = { kind: "openhands", text: llmModel, tooltip: llmModel };
|
||||
}
|
||||
}
|
||||
|
||||
// Match title text start: 18px status column + gap-2 (8px).
|
||||
const metadataIndentClass =
|
||||
@@ -73,28 +108,18 @@ export function ConversationCardFooter({
|
||||
isPaused && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{acpDisplayName ? (
|
||||
{chip ? (
|
||||
<div className={metadataIndentClass}>
|
||||
<span
|
||||
data-testid="conversation-card-acp-badge"
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded-full bg-[var(--oh-surface-raised)] text-[var(--oh-muted)] text-xs font-medium max-w-full truncate"
|
||||
title={acpDisplayName}
|
||||
data-testid="conversation-card-agent-chip"
|
||||
className="inline-flex items-center gap-1 text-xs text-[var(--oh-muted)] max-w-full min-w-0"
|
||||
title={chip.tooltip}
|
||||
>
|
||||
{acpDisplayName}
|
||||
<AgentBrandIcon kind={chip.kind} />
|
||||
<span className="truncate">{chip.text}</span>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{showLlmModel && llmModel ? (
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 max-w-full truncate text-xs text-[var(--oh-muted)]",
|
||||
metadataIndentClass,
|
||||
)}
|
||||
title={llmModel}
|
||||
>
|
||||
{llmModel}
|
||||
</span>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-row items-center gap-2 w-full min-w-0",
|
||||
|
||||
@@ -133,8 +133,7 @@ export function ConversationCard({
|
||||
const hasContextMenu = !!(onDelete || onChangeTitle || showOptions);
|
||||
const shouldRenderFooter =
|
||||
showRepositoryMetadata ||
|
||||
(!!llmModel && showLlmProfiles) ||
|
||||
agentKind === "acp";
|
||||
(showLlmProfiles && (agentKind === "acp" || !!llmModel));
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -209,7 +208,7 @@ export function ConversationCard({
|
||||
showRepositoryMetadata={showRepositoryMetadata}
|
||||
showTimestamp={false}
|
||||
llmModel={llmModel}
|
||||
showLlmModel={showLlmProfiles}
|
||||
showAgentChip={showLlmProfiles}
|
||||
agentKind={agentKind}
|
||||
acpServer={acpServer}
|
||||
/>
|
||||
|
||||
@@ -2,8 +2,6 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AxiosError } from "axios";
|
||||
import { Check } from "lucide-react";
|
||||
import OpenHandsLogo from "#/assets/branding/openhands-logo.svg?react";
|
||||
import TerminalIcon from "#/icons/terminal.svg?react";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { cn } from "#/utils/utils";
|
||||
@@ -12,8 +10,11 @@ import {
|
||||
ACP_PROVIDER_FALLBACK_ICON,
|
||||
ACP_PROVIDERS,
|
||||
buildAcpAgentSettingsDiff,
|
||||
type ACPProviderIcon,
|
||||
} from "#/constants/acp-providers";
|
||||
import {
|
||||
AgentBrandIcon,
|
||||
type AgentBrandIconKind,
|
||||
} from "#/components/shared/agent-brand-icon";
|
||||
import {
|
||||
displayErrorToast,
|
||||
displaySuccessToast,
|
||||
@@ -26,16 +27,7 @@ export type OnboardingAgentId =
|
||||
| "codex"
|
||||
| "gemini-cli";
|
||||
|
||||
const CLAUDE_CODE_MARK_PATH =
|
||||
"m19.6 66.5 19.7-11 .3-1-.3-.5h-1l-3.3-.2-11.2-.3L14 53l-9.5-.5-2.4-.5L0 49l.2-1.5 2-1.3 2.9.2 6.3.5 9.5.6 6.9.4L38 49.1h1.6l.2-.7-.5-.4-.4-.4L29 41l-10.6-7-5.6-4.1-3-2-1.5-2-.6-4.2 2.7-3 3.7.3.9.2 3.7 2.9 8 6.1L37 36l1.5 1.2.6-.4.1-.3-.7-1.1L33 25l-6-10.4-2.7-4.3-.7-2.6c-.3-1-.4-2-.4-3l3-4.2L28 0l4.2.6L33.8 2l2.6 6 4.1 9.3L47 29.9l2 3.8 1 3.4.3 1h.7v-.5l.5-7.2 1-8.7 1-11.2.3-3.2 1.6-3.8 3-2L61 2.6l2 2.9-.3 1.8-1.1 7.7L59 27.1l-1.5 8.2h.9l1-1.1 4.1-5.4 6.9-8.6 3-3.5L77 13l2.3-1.8h4.3l3.1 4.7-1.4 4.9-4.4 5.6-3.7 4.7-5.3 7.1-3.2 5.7.3.4h.7l12-2.6 6.4-1.1 7.6-1.3 3.5 1.6.4 1.6-1.4 3.4-8.2 2-9.6 2-14.3 3.3-.2.1.2.3 6.4.6 2.8.2h6.8l12.6 1 3.3 2 1.9 2.7-.3 2-5.1 2.6-6.8-1.6-16-3.8-5.4-1.3h-.8v.4l4.6 4.5 8.3 7.5L89 80.1l.5 2.4-1.3 2-1.4-.2-9.2-7-3.6-3-8-6.8h-.5v.7l1.8 2.7 9.8 14.7.5 4.5-.7 1.4-2.6 1-2.7-.6-5.8-8-6-9-4.7-8.2-.5.4-2.9 30.2-1.3 1.5-3 1.2-2.5-2-1.4-3 1.4-6.2 1.6-8 1.3-6.4 1.2-7.9.7-2.6v-.2H49L43 72l-9 12.3-7.2 7.6-1.7.7-3-1.5.3-2.8L24 86l10-12.8 6-7.9 4-4.6-.1-.5h-.3L17.2 77.4l-4.7.6-2-2 .2-3 1-1 8-5.5Z";
|
||||
|
||||
const CODEX_MARK_PATH =
|
||||
"M4.04286 0.228393C4.52451 0.0304495 5.0488 -0.0409817 5.56586 0.0208928C6.23236 0.0973928 6.82636 0.380893 7.34786 0.870893C7.35488 0.877545 7.36344 0.882351 7.37278 0.884881C7.38212 0.887412 7.39194 0.887588 7.40136 0.885393C8.10536 0.712393 8.78236 0.773393 9.43186 1.06839L9.46336 1.08339L9.54036 1.12139C10.2189 1.47289 10.7054 2.00639 10.9994 2.72039C11.1384 3.05989 11.2084 3.41439 11.2099 3.78339C11.2197 4.05816 11.1893 4.33288 11.1199 4.59889C11.1164 4.61245 11.1165 4.62665 11.12 4.6402C11.1235 4.65374 11.1303 4.66618 11.1399 4.67639C11.5329 5.0749 11.8063 5.57572 11.9289 6.12189C12.1214 7.07239 11.9239 7.92939 11.3374 8.69189L11.2464 8.80189C10.8579 9.24669 10.3481 9.56836 9.77936 9.72739C9.76694 9.73097 9.75556 9.73747 9.74617 9.74634C9.73677 9.75521 9.72964 9.7662 9.72536 9.77839C9.59786 10.1464 9.46986 10.4604 9.23186 10.7744C8.63236 11.5654 7.75086 12.0054 6.75786 11.9999C5.96636 11.9959 5.26486 11.7064 4.65286 11.1319C4.64358 11.1234 4.63225 11.1174 4.61998 11.1146C4.6077 11.1118 4.59491 11.1123 4.58286 11.1159C4.32386 11.1994 4.06286 11.2114 3.78086 11.2084C3.33033 11.2048 2.88658 11.0984 2.48336 10.8974C2.0613 10.688 1.6939 10.3831 1.41036 10.0069C1.30886 9.87239 1.20836 9.74589 1.13486 9.59639C1.03349 9.39033 0.95066 9.17565 0.887357 8.95489C0.754446 8.45324 0.751521 7.92599 0.878857 7.42289C0.882974 7.41102 0.884341 7.39837 0.882857 7.38589C0.88038 7.37348 0.873877 7.36223 0.864357 7.35389C0.556147 7.04213 0.320543 6.66619 0.174357 6.25289C0.0775698 5.99842 0.0213841 5.73031 0.00785682 5.45839C-0.0163229 5.10033 0.0153902 4.74069 0.101857 4.39239C0.326857 3.65039 0.756357 3.06839 1.39036 2.64589C1.53136 2.55189 1.66536 2.47889 1.79136 2.42689C1.93436 2.36689 2.07786 2.31689 2.22186 2.27489C2.23216 2.27184 2.24153 2.26626 2.24913 2.25866C2.25672 2.25107 2.2623 2.24169 2.26536 2.23139C2.37455 1.83888 2.56235 1.47264 2.81736 1.15489C3.15736 0.731893 3.56586 0.422893 4.04286 0.228393ZM3.64086 4.15339C3.58503 4.05573 3.49269 3.98424 3.38415 3.95465C3.27562 3.92507 3.15977 3.93981 3.06211 3.99564C2.96444 4.05147 2.89295 4.14381 2.86337 4.25235C2.83378 4.36088 2.84853 4.47673 2.90436 4.57439L3.75136 6.05689L2.90736 7.48089C2.85561 7.57738 2.84315 7.69014 2.87257 7.7956C2.902 7.90106 2.97104 7.99108 3.06526 8.04684C3.15949 8.1026 3.27162 8.1198 3.37823 8.09484C3.48484 8.06988 3.57768 8.00469 3.63736 7.91289L4.60736 6.27689C4.64561 6.21237 4.66609 6.13887 4.66671 6.06386C4.66732 5.98886 4.64805 5.91503 4.61086 5.84989L3.64086 4.15339ZM6.36386 7.27339C6.25583 7.27982 6.15434 7.32727 6.08012 7.40603C6.00591 7.48479 5.96458 7.58892 5.96458 7.69714C5.96458 7.80536 6.00591 7.90949 6.08012 7.98826C6.15434 8.06702 6.25583 8.11446 6.36386 8.12089H8.78786C8.89675 8.1156 8.99943 8.06862 9.07462 7.98969C9.14982 7.91075 9.19176 7.80591 9.19176 7.69689C9.19176 7.58787 9.14982 7.48303 9.07462 7.4041C8.99943 7.32516 8.89675 7.27818 8.78786 7.27289H6.36386V7.27339Z";
|
||||
|
||||
const GEMINI_MARK_PATH =
|
||||
"M12 0C12.904 6.056 17.944 11.096 24 12C17.944 12.904 12.904 17.944 12 24C11.096 17.944 6.056 12.904 0 12C6.056 11.096 11.096 6.056 12 0Z";
|
||||
|
||||
function getAgentOptionIcon(id: string): ACPProviderIcon {
|
||||
function getAgentOptionIcon(id: string): AgentBrandIconKind {
|
||||
if (id === "openhands") return "openhands";
|
||||
|
||||
return (
|
||||
@@ -45,75 +37,29 @@ function getAgentOptionIcon(id: string): ACPProviderIcon {
|
||||
}
|
||||
|
||||
export function AgentOptionIcon({ id, muted }: { id: string; muted: boolean }) {
|
||||
const iconClass = muted ? "text-[var(--oh-muted)]" : "text-white";
|
||||
const icon = getAgentOptionIcon(id);
|
||||
|
||||
// The OpenHands wordmark is wider than the square brand marks (24×16 vs
|
||||
// 18×18) and dims via opacity rather than a muted text colour — its paths
|
||||
// inherit ``currentColor`` so it stays white on the tile.
|
||||
if (icon === "openhands") {
|
||||
return (
|
||||
<OpenHandsLogo
|
||||
width={24}
|
||||
height={16}
|
||||
className={cn("shrink-0", muted && "opacity-55")}
|
||||
<AgentBrandIcon
|
||||
kind="openhands"
|
||||
size={16}
|
||||
className={cn("text-white", muted && "opacity-55")}
|
||||
data-testid="onboarding-agent-icon-openhands"
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (icon === "claude-code") {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 100 100"
|
||||
className={cn("size-[18px] shrink-0", iconClass)}
|
||||
data-testid="onboarding-agent-icon-claude-code"
|
||||
aria-hidden
|
||||
>
|
||||
<path fill="currentColor" d={CLAUDE_CODE_MARK_PATH} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (icon === "gemini") {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
className={cn("size-[18px] shrink-0", iconClass)}
|
||||
data-testid="onboarding-agent-icon-gemini"
|
||||
aria-hidden
|
||||
>
|
||||
<path fill="currentColor" d={GEMINI_MARK_PATH} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (icon === "cli-generic") {
|
||||
return (
|
||||
<TerminalIcon
|
||||
className={cn("size-[18px] shrink-0", iconClass)}
|
||||
data-testid="onboarding-agent-icon-cli-generic"
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
className={cn("size-[18px] shrink-0", iconClass)}
|
||||
data-testid="onboarding-agent-icon-codex"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d={CODEX_MARK_PATH}
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<AgentBrandIcon
|
||||
kind={icon}
|
||||
size={18}
|
||||
className={muted ? "text-[var(--oh-muted)]" : "text-white"}
|
||||
data-testid={`onboarding-agent-icon-${icon}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import OpenHandsLogo from "#/assets/branding/openhands-logo.svg?react";
|
||||
import TerminalIcon from "#/icons/terminal.svg?react";
|
||||
import {
|
||||
CLAUDE_CODE_MARK_PATH,
|
||||
CLAUDE_CODE_VIEWBOX,
|
||||
CODEX_MARK_PATH,
|
||||
CODEX_VIEWBOX,
|
||||
GEMINI_MARK_PATH,
|
||||
GEMINI_VIEWBOX,
|
||||
} from "#/constants/acp-brand-marks";
|
||||
import type { ACPProviderIcon } from "#/constants/acp-providers";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
/**
|
||||
* Icons the conversation chip + onboarding tiles can render. Strictly broader
|
||||
* than {@link ACPProviderIcon} — that type covers ACP CLI subprocesses only
|
||||
* (Claude Code, Codex, Gemini, generic terminal fallback), whereas this type
|
||||
* additionally includes the native OpenHands harness.
|
||||
*/
|
||||
export type AgentBrandIconKind = "openhands" | ACPProviderIcon;
|
||||
|
||||
// The OpenHands wordmark renders at a 3:2 (width:height) ratio. Kept as a
|
||||
// named constant so the conversation chip and the onboarding tile (24×16)
|
||||
// stay visually identical — see ``AgentOptionIcon`` in choose-agent-step.tsx.
|
||||
const OPENHANDS_LOGO_ASPECT_RATIO = 3 / 2;
|
||||
|
||||
interface AgentBrandIconProps {
|
||||
kind: AgentBrandIconKind;
|
||||
size?: number;
|
||||
className?: string;
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
export function AgentBrandIcon({
|
||||
kind,
|
||||
size = 12,
|
||||
className,
|
||||
"data-testid": testId,
|
||||
}: AgentBrandIconProps) {
|
||||
if (kind === "openhands") {
|
||||
// The shipped SVG draws the wordmark with ``fill="white"`` paths but
|
||||
// leaves the two hand shapes as ``fill="transparent"`` (negative space).
|
||||
// Recolor only the non-transparent paths to ``currentColor`` so the logo
|
||||
// inherits the chip's text color *without* filling in the hands — a
|
||||
// blanket ``[&_path]`` selector turns the whole mark into a solid blob.
|
||||
return (
|
||||
<OpenHandsLogo
|
||||
width={Math.round(size * OPENHANDS_LOGO_ASPECT_RATIO)}
|
||||
height={size}
|
||||
className={cn(
|
||||
"shrink-0 [&_path:not([fill=transparent])]:fill-current",
|
||||
className,
|
||||
)}
|
||||
data-testid={testId ?? "agent-brand-icon-openhands"}
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (kind === "claude-code") {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox={CLAUDE_CODE_VIEWBOX}
|
||||
width={size}
|
||||
height={size}
|
||||
className={cn("shrink-0", className)}
|
||||
data-testid={testId ?? "agent-brand-icon-claude-code"}
|
||||
aria-hidden
|
||||
>
|
||||
<path fill="currentColor" d={CLAUDE_CODE_MARK_PATH} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (kind === "codex") {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox={CODEX_VIEWBOX}
|
||||
width={size}
|
||||
height={size}
|
||||
className={cn("shrink-0", className)}
|
||||
data-testid={testId ?? "agent-brand-icon-codex"}
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d={CODEX_MARK_PATH}
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (kind === "gemini") {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox={GEMINI_VIEWBOX}
|
||||
width={size}
|
||||
height={size}
|
||||
className={cn("shrink-0", className)}
|
||||
data-testid={testId ?? "agent-brand-icon-gemini"}
|
||||
aria-hidden
|
||||
>
|
||||
<path fill="currentColor" d={GEMINI_MARK_PATH} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TerminalIcon
|
||||
width={size}
|
||||
height={size}
|
||||
className={cn("shrink-0", className)}
|
||||
data-testid={testId ?? "agent-brand-icon-generic"}
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Brand-mark SVG path data for the agent harnesses we recognise on a
|
||||
// conversation chip. Kept as raw path strings (drawn with ``currentColor``
|
||||
// so callers can size & colour them via Tailwind) rather than embedded
|
||||
// ``<svg>`` files to avoid an extra asset round-trip and so the icons can
|
||||
// be reused inline at multiple sizes (onboarding tile, conversation chip,
|
||||
// settings preview).
|
||||
|
||||
export const CLAUDE_CODE_MARK_PATH =
|
||||
"m19.6 66.5 19.7-11 .3-1-.3-.5h-1l-3.3-.2-11.2-.3L14 53l-9.5-.5-2.4-.5L0 49l.2-1.5 2-1.3 2.9.2 6.3.5 9.5.6 6.9.4L38 49.1h1.6l.2-.7-.5-.4-.4-.4L29 41l-10.6-7-5.6-4.1-3-2-1.5-2-.6-4.2 2.7-3 3.7.3.9.2 3.7 2.9 8 6.1L37 36l1.5 1.2.6-.4.1-.3-.7-1.1L33 25l-6-10.4-2.7-4.3-.7-2.6c-.3-1-.4-2-.4-3l3-4.2L28 0l4.2.6L33.8 2l2.6 6 4.1 9.3L47 29.9l2 3.8 1 3.4.3 1h.7v-.5l.5-7.2 1-8.7 1-11.2.3-3.2 1.6-3.8 3-2L61 2.6l2 2.9-.3 1.8-1.1 7.7L59 27.1l-1.5 8.2h.9l1-1.1 4.1-5.4 6.9-8.6 3-3.5L77 13l2.3-1.8h4.3l3.1 4.7-1.4 4.9-4.4 5.6-3.7 4.7-5.3 7.1-3.2 5.7.3.4h.7l12-2.6 6.4-1.1 7.6-1.3 3.5 1.6.4 1.6-1.4 3.4-8.2 2-9.6 2-14.3 3.3-.2.1.2.3 6.4.6 2.8.2h6.8l12.6 1 3.3 2 1.9 2.7-.3 2-5.1 2.6-6.8-1.6-16-3.8-5.4-1.3h-.8v.4l4.6 4.5 8.3 7.5L89 80.1l.5 2.4-1.3 2-1.4-.2-9.2-7-3.6-3-8-6.8h-.5v.7l1.8 2.7 9.8 14.7.5 4.5-.7 1.4-2.6 1-2.7-.6-5.8-8-6-9-4.7-8.2-.5.4-2.9 30.2-1.3 1.5-3 1.2-2.5-2-1.4-3 1.4-6.2 1.6-8 1.3-6.4 1.2-7.9.7-2.6v-.2H49L43 72l-9 12.3-7.2 7.6-1.7.7-3-1.5.3-2.8L24 86l10-12.8 6-7.9 4-4.6-.1-.5h-.3L17.2 77.4l-4.7.6-2-2 .2-3 1-1 8-5.5Z";
|
||||
export const CLAUDE_CODE_VIEWBOX = "0 0 100 100";
|
||||
|
||||
export const CODEX_MARK_PATH =
|
||||
"M4.04286 0.228393C4.52451 0.0304495 5.0488 -0.0409817 5.56586 0.0208928C6.23236 0.0973928 6.82636 0.380893 7.34786 0.870893C7.35488 0.877545 7.36344 0.882351 7.37278 0.884881C7.38212 0.887412 7.39194 0.887588 7.40136 0.885393C8.10536 0.712393 8.78236 0.773393 9.43186 1.06839L9.46336 1.08339L9.54036 1.12139C10.2189 1.47289 10.7054 2.00639 10.9994 2.72039C11.1384 3.05989 11.2084 3.41439 11.2099 3.78339C11.2197 4.05816 11.1893 4.33288 11.1199 4.59889C11.1164 4.61245 11.1165 4.62665 11.12 4.6402C11.1235 4.65374 11.1303 4.66618 11.1399 4.67639C11.5329 5.0749 11.8063 5.57572 11.9289 6.12189C12.1214 7.07239 11.9239 7.92939 11.3374 8.69189L11.2464 8.80189C10.8579 9.24669 10.3481 9.56836 9.77936 9.72739C9.76694 9.73097 9.75556 9.73747 9.74617 9.74634C9.73677 9.75521 9.72964 9.7662 9.72536 9.77839C9.59786 10.1464 9.46986 10.4604 9.23186 10.7744C8.63236 11.5654 7.75086 12.0054 6.75786 11.9999C5.96636 11.9959 5.26486 11.7064 4.65286 11.1319C4.64358 11.1234 4.63225 11.1174 4.61998 11.1146C4.6077 11.1118 4.59491 11.1123 4.58286 11.1159C4.32386 11.1994 4.06286 11.2114 3.78086 11.2084C3.33033 11.2048 2.88658 11.0984 2.48336 10.8974C2.0613 10.688 1.6939 10.3831 1.41036 10.0069C1.30886 9.87239 1.20836 9.74589 1.13486 9.59639C1.03349 9.39033 0.95066 9.17565 0.887357 8.95489C0.754446 8.45324 0.751521 7.92599 0.878857 7.42289C0.882974 7.41102 0.884341 7.39837 0.882857 7.38589C0.88038 7.37348 0.873877 7.36223 0.864357 7.35389C0.556147 7.04213 0.320543 6.66619 0.174357 6.25289C0.0775698 5.99842 0.0213841 5.73031 0.00785682 5.45839C-0.0163229 5.10033 0.0153902 4.74069 0.101857 4.39239C0.326857 3.65039 0.756357 3.06839 1.39036 2.64589C1.53136 2.55189 1.66536 2.47889 1.79136 2.42689C1.93436 2.36689 2.07786 2.31689 2.22186 2.27489C2.23216 2.27184 2.24153 2.26626 2.24913 2.25866C2.25672 2.25107 2.2623 2.24169 2.26536 2.23139C2.37455 1.83888 2.56235 1.47264 2.81736 1.15489C3.15736 0.731893 3.56586 0.422893 4.04286 0.228393ZM3.64086 4.15339C3.58503 4.05573 3.49269 3.98424 3.38415 3.95465C3.27562 3.92507 3.15977 3.93981 3.06211 3.99564C2.96444 4.05147 2.89295 4.14381 2.86337 4.25235C2.83378 4.36088 2.84853 4.47673 2.90436 4.57439L3.75136 6.05689L2.90736 7.48089C2.85561 7.57738 2.84315 7.69014 2.87257 7.7956C2.902 7.90106 2.97104 7.99108 3.06526 8.04684C3.15949 8.1026 3.27162 8.1198 3.37823 8.09484C3.48484 8.06988 3.57768 8.00469 3.63736 7.91289L4.60736 6.27689C4.64561 6.21237 4.66609 6.13887 4.66671 6.06386C4.66732 5.98886 4.64805 5.91503 4.61086 5.84989L3.64086 4.15339ZM6.36386 7.27339C6.25583 7.27982 6.15434 7.32727 6.08012 7.40603C6.00591 7.48479 5.96458 7.58892 5.96458 7.69714C5.96458 7.80536 6.00591 7.90949 6.08012 7.98826C6.15434 8.06702 6.25583 8.11446 6.36386 8.12089H8.78786C8.89675 8.1156 8.99943 8.06862 9.07462 7.98969C9.14982 7.91075 9.19176 7.80591 9.19176 7.69689C9.19176 7.58787 9.14982 7.48303 9.07462 7.4041C8.99943 7.32516 8.89675 7.27818 8.78786 7.27289H6.36386V7.27339Z";
|
||||
export const CODEX_VIEWBOX = "0 0 12 12";
|
||||
|
||||
export const GEMINI_MARK_PATH =
|
||||
"M12 0C12.904 6.056 17.944 11.096 24 12C17.944 12.904 12.904 17.944 12 24C11.096 17.944 6.056 12.904 0 12C6.056 11.096 11.096 6.056 12 0Z";
|
||||
export const GEMINI_VIEWBOX = "0 0 24 24";
|
||||
@@ -1,7 +1,6 @@
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export type ACPProviderIcon =
|
||||
| "openhands"
|
||||
| "claude-code"
|
||||
| "codex"
|
||||
| "gemini"
|
||||
@@ -9,6 +8,64 @@ export type ACPProviderIcon =
|
||||
|
||||
export const ACP_PROVIDER_FALLBACK_ICON: ACPProviderIcon = "cli-generic";
|
||||
|
||||
// SDK placeholder strings the ACP wrapper returns before the user has
|
||||
// chosen a real model — surfacing either would lie about what's running.
|
||||
export const ACP_DEFAULT_PLACEHOLDERS = new Set([
|
||||
"default",
|
||||
"default (recommended)",
|
||||
]);
|
||||
|
||||
// Sentinel ``agent.llm.model`` returned by older SDKs for ACP conversations
|
||||
// in lieu of a real model. Suppressed at every consumer that resolves a
|
||||
// display string.
|
||||
export const ACP_MANAGED_SENTINEL = "acp-managed";
|
||||
|
||||
/**
|
||||
* Filter for "real" ACP model strings — non-empty, not the SDK's "default"
|
||||
* placeholder, not the legacy ``acp-managed`` sentinel. Returns the trimmed
|
||||
* value on success, ``null`` otherwise.
|
||||
*/
|
||||
function realAcpModel(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
if (ACP_DEFAULT_PLACEHOLDERS.has(trimmed.toLowerCase())) return null;
|
||||
if (trimmed === ACP_MANAGED_SENTINEL) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for resolving the model string to surface for an
|
||||
* ACP conversation/settings context. Consumed by the conversation adapter
|
||||
* (chip text), the conversation-creation path (concrete ``acp_model``
|
||||
* payload), the Settings → Agent form (initial value), and the chat-input
|
||||
* model label.
|
||||
*
|
||||
* Precedence: SDK runtime fields → user-configured ``acp_model`` →
|
||||
* legacy ``agent.llm.model`` → provider default (when ``providerDefault``
|
||||
* is passed). Pass ``providerDefault`` only on surfaces that should
|
||||
* silently substitute the registry default; omit it for the conversation
|
||||
* chip, which must distinguish "no concrete model" from "default".
|
||||
*/
|
||||
export function resolveEffectiveAcpModel(inputs: {
|
||||
runtimeName?: string | null;
|
||||
runtimeId?: string | null;
|
||||
configured?: string | null;
|
||||
sdkLlm?: string | null;
|
||||
providerDefault?: string | null;
|
||||
}): string | null {
|
||||
for (const candidate of [
|
||||
inputs.runtimeName,
|
||||
inputs.runtimeId,
|
||||
inputs.configured,
|
||||
inputs.sdkLlm,
|
||||
]) {
|
||||
const value = realAcpModel(candidate);
|
||||
if (value) return value;
|
||||
}
|
||||
return inputs.providerDefault ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in ACP (Agent Client Protocol) provider registry.
|
||||
*
|
||||
@@ -43,6 +100,14 @@ export interface ACPProviderConfig {
|
||||
* ``@zed-industries/codex-acp`` (the Zed-shipped wrapper) instead.
|
||||
*/
|
||||
default_command: string[];
|
||||
/**
|
||||
* Canvas-local suggested ACP model IDs. These mirror the current runtime
|
||||
* picker values for the built-in harnesses, but are not authoritative access
|
||||
* checks; users can still enter a custom override in Settings -> Agent.
|
||||
*/
|
||||
available_models?: ACPModelOption[];
|
||||
/** Model ID preselected for built-in providers so Canvas never saves blank. */
|
||||
default_model?: string;
|
||||
/**
|
||||
* i18n key for the one-line provider description rendered under the
|
||||
* onboarding tile. Stored on the registry so adding a new ACP
|
||||
@@ -58,6 +123,86 @@ export interface ACPProviderConfig {
|
||||
icon?: ACPProviderIcon;
|
||||
}
|
||||
|
||||
export interface ACPModelOption {
|
||||
/** Exact model ID sent as ``acp_model``. */
|
||||
id: string;
|
||||
/** Human-readable label shown in Settings -> Agent. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
// Canonical model IDs the Claude Code CLI binary's model registry recognises
|
||||
// (verified by string-scanning v2.1.146 of the bundled ``claude`` native
|
||||
// binary). ``[1m]`` is the SDK-documented 1M-context suffix; we use the
|
||||
// version-agnostic alias so the option auto-tracks the newest 1M-capable
|
||||
// model. ``opusplan`` routes planning to Opus and execution to Sonnet.
|
||||
// Availability for any of these ultimately depends on the user's Anthropic
|
||||
// plan tier — surfacing them here matches what the CLI *accepts*, not what
|
||||
// every account can actually invoke.
|
||||
const CLAUDE_MODELS: ACPModelOption[] = [
|
||||
{ id: "claude-opus-4-7", label: "Claude Opus 4.7" },
|
||||
{ id: "claude-opus-4-6", label: "Claude Opus 4.6" },
|
||||
// The 1M-context entries use the version-agnostic ``[1m]`` aliases, so the
|
||||
// label must stay version-less too — pinning a number here (e.g. "4.6")
|
||||
// would lie the moment the alias resolves to a newer model.
|
||||
{ id: "opus[1m]", label: "Claude Opus (1M)" },
|
||||
{ id: "claude-opus-4-5", label: "Claude Opus 4.5" },
|
||||
{ id: "claude-opus-4-1-20250805", label: "Claude Opus 4.1" },
|
||||
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
|
||||
{ id: "sonnet[1m]", label: "Claude Sonnet (1M)" },
|
||||
{ id: "claude-sonnet-4-5", label: "Claude Sonnet 4.5" },
|
||||
{ id: "claude-haiku-4-5", label: "Claude Haiku 4.5" },
|
||||
{ id: "opusplan", label: "Opus (plan) + Sonnet (execute)" },
|
||||
];
|
||||
|
||||
// Model IDs accepted by the ``@zed-industries/codex-acp`` wrapper, mirroring
|
||||
// the Codex CLI's own ``/model`` picker. Format is ``<base-model>/<effort>``
|
||||
// where the trailing tier (``low``/``medium``/``high``/``xhigh``) hints the
|
||||
// reasoning effort for that turn. Sourced from the Codex CLI's documented
|
||||
// runtime options as of 2026-05-22 — see ``acp_model`` registry tracker
|
||||
// in agent-canvas#740 for the long-term plan.
|
||||
const CODEX_MODELS: ACPModelOption[] = [
|
||||
{ id: "gpt-5.5/low", label: "GPT-5.5 (low)" },
|
||||
{ id: "gpt-5.5/medium", label: "GPT-5.5 (medium)" },
|
||||
{ id: "gpt-5.5/high", label: "GPT-5.5 (high)" },
|
||||
{ id: "gpt-5.5/xhigh", label: "GPT-5.5 (xhigh)" },
|
||||
{ id: "gpt-5.4/low", label: "GPT-5.4 (low)" },
|
||||
{ id: "gpt-5.4/medium", label: "GPT-5.4 (medium)" },
|
||||
{ id: "gpt-5.4/high", label: "GPT-5.4 (high)" },
|
||||
{ id: "gpt-5.4/xhigh", label: "GPT-5.4 (xhigh)" },
|
||||
{ id: "gpt-5.4-mini/low", label: "GPT-5.4 Mini (low)" },
|
||||
{ id: "gpt-5.4-mini/medium", label: "GPT-5.4 Mini (medium)" },
|
||||
{ id: "gpt-5.4-mini/high", label: "GPT-5.4 Mini (high)" },
|
||||
{ id: "gpt-5.4-mini/xhigh", label: "GPT-5.4 Mini (xhigh)" },
|
||||
{ id: "gpt-5.3-codex/low", label: "GPT-5.3 Codex (low)" },
|
||||
{ id: "gpt-5.3-codex/medium", label: "GPT-5.3 Codex (medium)" },
|
||||
{ id: "gpt-5.3-codex/high", label: "GPT-5.3 Codex (high)" },
|
||||
{ id: "gpt-5.3-codex/xhigh", label: "GPT-5.3 Codex (xhigh)" },
|
||||
{ id: "gpt-5.2/low", label: "GPT-5.2 (low)" },
|
||||
{ id: "gpt-5.2/medium", label: "GPT-5.2 (medium)" },
|
||||
{ id: "gpt-5.2/high", label: "GPT-5.2 (high)" },
|
||||
{ id: "gpt-5.2/xhigh", label: "GPT-5.2 (xhigh)" },
|
||||
];
|
||||
|
||||
// Model IDs accepted by ``@google/gemini-cli --acp``. The ``auto-gemini-*``
|
||||
// entries delegate version selection to the CLI's router; the explicit
|
||||
// ``gemini-3.1-*`` / ``gemini-2.5-*`` entries pin to a specific snapshot.
|
||||
// Sourced from the Gemini CLI's documented model list as of 2026-05-22 —
|
||||
// see agent-canvas#740 for the long-term plan to move this registry
|
||||
// upstream.
|
||||
const GEMINI_MODELS: ACPModelOption[] = [
|
||||
{ id: "auto-gemini-3", label: "Auto (Gemini 3)" },
|
||||
{ id: "auto-gemini-2.5", label: "Auto (Gemini 2.5)" },
|
||||
{ id: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro (preview)" },
|
||||
{ id: "gemini-3-flash-preview", label: "Gemini 3 Flash (preview)" },
|
||||
{
|
||||
id: "gemini-3.1-flash-lite-preview",
|
||||
label: "Gemini 3.1 Flash Lite (preview)",
|
||||
},
|
||||
{ id: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
|
||||
{ id: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
|
||||
{ id: "gemini-2.5-flash-lite", label: "Gemini 2.5 Flash Lite" },
|
||||
];
|
||||
|
||||
// Each entry's ``default_command`` is the published-package npx
|
||||
// invocation that speaks the ACP JSON-RPC protocol on stdio. Verified
|
||||
// against the upstream npm registry on the date noted below — if a
|
||||
@@ -71,6 +216,8 @@ export const ACP_PROVIDERS: ACPProviderConfig[] = [
|
||||
// Verified 2026-05-19. Official Anthropic-maintained ACP wrapper
|
||||
// around the Claude Code CLI.
|
||||
default_command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp"],
|
||||
available_models: CLAUDE_MODELS,
|
||||
default_model: "claude-opus-4-7",
|
||||
description_key: I18nKey.ONBOARDING$AGENT_CLAUDE_CODE_DESCRIPTION,
|
||||
icon: "claude-code",
|
||||
},
|
||||
@@ -82,6 +229,8 @@ export const ACP_PROVIDERS: ACPProviderConfig[] = [
|
||||
// OpenAI Codex CLI — NOT ``@openai/codex acp`` (no ``acp``
|
||||
// subcommand on that package).
|
||||
default_command: ["npx", "-y", "@zed-industries/codex-acp"],
|
||||
available_models: CODEX_MODELS,
|
||||
default_model: "gpt-5.5/medium",
|
||||
description_key: I18nKey.ONBOARDING$AGENT_CODEX_DESCRIPTION,
|
||||
icon: "codex",
|
||||
},
|
||||
@@ -92,6 +241,8 @@ export const ACP_PROVIDERS: ACPProviderConfig[] = [
|
||||
// Verified 2026-05-19. Official Google CLI; ``--acp`` switches it
|
||||
// into ACP server mode on stdio.
|
||||
default_command: ["npx", "-y", "@google/gemini-cli", "--acp"],
|
||||
available_models: GEMINI_MODELS,
|
||||
default_model: "gemini-2.5-pro",
|
||||
description_key: I18nKey.ONBOARDING$AGENT_GEMINI_CLI_DESCRIPTION,
|
||||
icon: "gemini",
|
||||
},
|
||||
@@ -99,6 +250,22 @@ export const ACP_PROVIDERS: ACPProviderConfig[] = [
|
||||
|
||||
export const ACP_CUSTOM_PRESET_KEY = "custom";
|
||||
|
||||
/**
|
||||
* Look up a built-in ACP provider config by its registry key.
|
||||
*
|
||||
* Returns ``undefined`` for an empty / null key, for the ``"custom"`` preset
|
||||
* (which has no registry entry), and for any forward-compatible key Canvas's
|
||||
* registry doesn't know about yet. Centralizes the ``ACP_PROVIDERS.find(...)``
|
||||
* lookup shared by the resolvers below and by the adapter / settings surfaces
|
||||
* so the key-comparison shape lives in one place.
|
||||
*/
|
||||
export function getAcpProvider(
|
||||
key: string | null | undefined,
|
||||
): ACPProviderConfig | undefined {
|
||||
if (!key) return undefined;
|
||||
return ACP_PROVIDERS.find((provider) => provider.key === key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an ACP provider registry key (the value stored under
|
||||
* ``tags.acpserver`` on a conversation) to a human display name for the
|
||||
@@ -118,11 +285,45 @@ export const ACP_CUSTOM_PRESET_KEY = "custom";
|
||||
export function getAcpProviderDisplayName(
|
||||
key: string | null | undefined,
|
||||
): string | null {
|
||||
if (!key) return null;
|
||||
const found = ACP_PROVIDERS.find((p) => p.key === key);
|
||||
const found = getAcpProvider(key);
|
||||
return found ? found.display_name : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an ACP provider registry key to the icon discriminator the
|
||||
* conversation chip should render alongside the model text.
|
||||
*
|
||||
* Falls back to {@link ACP_PROVIDER_FALLBACK_ICON} for ``"custom"``,
|
||||
* unknown keys, or a missing key — the chip then shows a neutral
|
||||
* terminal glyph that still communicates "this is an ACP conversation"
|
||||
* without claiming a brand identity we don't know.
|
||||
*/
|
||||
export function resolveAcpProviderIcon(
|
||||
key: string | null | undefined,
|
||||
): ACPProviderIcon {
|
||||
return getAcpProvider(key)?.icon ?? ACP_PROVIDER_FALLBACK_ICON;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a raw ``acp_model`` ID to the human-readable label the provider's
|
||||
* picker shows for it (e.g. ``"claude-opus-4-7"`` → ``"Claude Opus 4.7"``).
|
||||
*
|
||||
* Falls back to the raw ID when the provider is unknown or the ID isn't one
|
||||
* of its registered {@link ACPModelOption}s — so a user's custom override
|
||||
* still renders something meaningful rather than nothing. Returns ``null``
|
||||
* only when there is no model to show, letting the conversation chip decide
|
||||
* to display the provider name instead.
|
||||
*/
|
||||
export function labelForAcpModel(
|
||||
serverKey: string | null | undefined,
|
||||
modelId: string | null | undefined,
|
||||
): string | null {
|
||||
if (!modelId) return null;
|
||||
const provider = getAcpProvider(serverKey);
|
||||
const match = provider?.available_models?.find((m) => m.id === modelId);
|
||||
return match?.label ?? modelId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the ``agent_settings_diff`` payload PATCH /api/settings expects
|
||||
* for the agent-kind/provider choice the user just made.
|
||||
@@ -163,13 +364,16 @@ export function buildAcpAgentSettingsDiff(
|
||||
}
|
||||
|
||||
const isCustom = providerKey === ACP_CUSTOM_PRESET_KEY;
|
||||
const provider = isCustom
|
||||
? undefined
|
||||
: ACP_PROVIDERS.find(({ key }) => key === providerKey);
|
||||
const provider = isCustom ? undefined : getAcpProvider(providerKey);
|
||||
if (!isCustom && !provider && !options.allowUnknownServer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const model =
|
||||
options.model === undefined
|
||||
? (provider?.default_model ?? null)
|
||||
: options.model;
|
||||
|
||||
// ``acp_args: []`` resets any API-set ``acp_args`` that would
|
||||
// otherwise survive and concatenate to ``acp_command`` at spawn time
|
||||
// (the agent-server merges the two before exec). Callers building the
|
||||
@@ -181,6 +385,6 @@ export function buildAcpAgentSettingsDiff(
|
||||
acp_server: providerKey,
|
||||
acp_command: options.command ?? [],
|
||||
acp_args: [],
|
||||
acp_model: options.model ?? null,
|
||||
acp_model: model ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useActiveBackend } from "#/contexts/active-backend-context";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export interface AcpModelContext {
|
||||
/** The active conversation runs an ACP agent. */
|
||||
isActiveAcpConversation: boolean;
|
||||
/**
|
||||
* No active conversation (home page) but the saved agent settings already
|
||||
* select an ACP agent — the next conversation created here inherits it, so
|
||||
* the model UI should reflect that now.
|
||||
*/
|
||||
isHomeAcp: boolean;
|
||||
/** Either of the above: the model affordance should defer to the ACP picker. */
|
||||
isAcpContext: boolean;
|
||||
/** Where the model/settings link should navigate. */
|
||||
destinationPath: "/settings/agent" | "/settings";
|
||||
/** Translated label for that link. */
|
||||
destinationLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared source of truth for "is this an ACP model context, and where does
|
||||
* the model affordance link?". Consumed by both {@link ChatInputActions}
|
||||
* (model render + overflow settings link) and {@link ChatInputModel} (model
|
||||
* label popover) so the two surfaces can't drift on the home-page-ACP rule or
|
||||
* on the destination path/label.
|
||||
*/
|
||||
export function useAcpModelContext(): AcpModelContext {
|
||||
const { t } = useTranslation("openhands");
|
||||
const { backend } = useActiveBackend();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { data: settings } = useSettings();
|
||||
|
||||
const isActiveAcpConversation = conversation?.agent_kind === "acp";
|
||||
const isHomeAcp =
|
||||
!conversation && settings?.agent_settings?.agent_kind === "acp";
|
||||
const isAcpContext = isActiveAcpConversation || isHomeAcp;
|
||||
|
||||
const destinationPath = isAcpContext ? "/settings/agent" : "/settings";
|
||||
const destinationLabel = t(
|
||||
isAcpContext
|
||||
? I18nKey.SETTINGS$NAV_AGENT
|
||||
: backend.kind === "cloud"
|
||||
? I18nKey.SETTINGS$LLM_SETTINGS
|
||||
: I18nKey.SETTINGS$LLM_PROFILES,
|
||||
);
|
||||
|
||||
return {
|
||||
isActiveAcpConversation,
|
||||
isHomeAcp,
|
||||
isAcpContext,
|
||||
destinationPath,
|
||||
destinationLabel,
|
||||
};
|
||||
}
|
||||
+47
-30
@@ -5167,22 +5167,39 @@
|
||||
"uk": "Model",
|
||||
"ca": "Model"
|
||||
},
|
||||
"SETTINGS$AGENT_CUSTOM_MODEL": {
|
||||
"en": "Custom model",
|
||||
"ja": "カスタムモデル",
|
||||
"zh-CN": "自定义模型",
|
||||
"zh-TW": "自訂模型",
|
||||
"ko-KR": "사용자 정의 모델",
|
||||
"no": "Egendefinert modell",
|
||||
"it": "Modello personalizzato",
|
||||
"pt": "Modelo personalizado",
|
||||
"es": "Modelo personalizado",
|
||||
"ar": "نموذج مخصص",
|
||||
"fr": "Modèle personnalisé",
|
||||
"tr": "Özel model",
|
||||
"de": "Benutzerdefiniertes Modell",
|
||||
"uk": "Користувацька модель",
|
||||
"ca": "Model personalitzat"
|
||||
},
|
||||
"SETTINGS$AGENT_MODEL_HINT": {
|
||||
"en": "Optional model override forwarded to the ACP subprocess. Leave blank to use the provider's default.",
|
||||
"ja": "Optional model override forwarded to the ACP subprocess. Leave blank to use the provider's default.",
|
||||
"zh-CN": "Optional model override forwarded to the ACP subprocess. Leave blank to use the provider's default.",
|
||||
"zh-TW": "Optional model override forwarded to the ACP subprocess. Leave blank to use the provider's default.",
|
||||
"ko-KR": "Optional model override forwarded to the ACP subprocess. Leave blank to use the provider's default.",
|
||||
"no": "Optional model override forwarded to the ACP subprocess. Leave blank to use the provider's default.",
|
||||
"it": "Optional model override forwarded to the ACP subprocess. Leave blank to use the provider's default.",
|
||||
"pt": "Optional model override forwarded to the ACP subprocess. Leave blank to use the provider's default.",
|
||||
"es": "Optional model override forwarded to the ACP subprocess. Leave blank to use the provider's default.",
|
||||
"ar": "Optional model override forwarded to the ACP subprocess. Leave blank to use the provider's default.",
|
||||
"fr": "Optional model override forwarded to the ACP subprocess. Leave blank to use the provider's default.",
|
||||
"tr": "Optional model override forwarded to the ACP subprocess. Leave blank to use the provider's default.",
|
||||
"de": "Optional model override forwarded to the ACP subprocess. Leave blank to use the provider's default.",
|
||||
"uk": "Optional model override forwarded to the ACP subprocess. Leave blank to use the provider's default.",
|
||||
"ca": "Optional model override forwarded to the ACP subprocess. Leave blank to use the provider's default."
|
||||
"en": "Choose a suggested ACP model or enter a custom model override. Built-in providers save a concrete model instead of leaving the model blank.",
|
||||
"ja": "推奨される ACP モデルを選択するか、カスタムモデルの上書きを入力してください。組み込みプロバイダーはモデルを空白のままにせず、具体的なモデルを保存します。",
|
||||
"zh-CN": "选择一个建议的 ACP 模型,或输入自定义模型覆盖。内置提供商会保存一个具体的模型,而不是将模型留空。",
|
||||
"zh-TW": "選擇一個建議的 ACP 模型,或輸入自訂模型覆寫。內建供應商會儲存具體的模型,而不是將模型留空。",
|
||||
"ko-KR": "추천 ACP 모델을 선택하거나 사용자 정의 모델 재정의를 입력하세요. 기본 제공 공급자는 모델을 비워두지 않고 구체적인 모델을 저장합니다.",
|
||||
"no": "Velg en foreslått ACP-modell eller skriv inn en egendefinert modelloverstyring. Innebygde leverandører lagrer en konkret modell i stedet for å la modellen være tom.",
|
||||
"it": "Scegli un modello ACP suggerito o inserisci una sostituzione personalizzata. I provider integrati salvano un modello concreto invece di lasciarlo vuoto.",
|
||||
"pt": "Escolha um modelo ACP sugerido ou insira uma substituição de modelo personalizada. Os provedores integrados salvam um modelo concreto em vez de deixá-lo em branco.",
|
||||
"es": "Elige un modelo ACP sugerido o introduce una sustitución de modelo personalizada. Los proveedores integrados guardan un modelo concreto en lugar de dejarlo en blanco.",
|
||||
"ar": "اختر نموذج ACP مقترحًا أو أدخل تجاوزًا لنموذج مخصص. تقوم المزودات المدمجة بحفظ نموذج محدد بدلًا من ترك النموذج فارغًا.",
|
||||
"fr": "Choisissez un modèle ACP suggéré ou saisissez un remplacement de modèle personnalisé. Les fournisseurs intégrés enregistrent un modèle concret plutôt que de laisser le champ vide.",
|
||||
"tr": "Önerilen bir ACP modelini seçin veya özel bir model geçersiz kılma değeri girin. Yerleşik sağlayıcılar, modeli boş bırakmak yerine somut bir model kaydeder.",
|
||||
"de": "Wähle ein vorgeschlagenes ACP-Modell aus oder gib eine benutzerdefinierte Modellüberschreibung ein. Integrierte Anbieter speichern ein konkretes Modell, anstatt das Modellfeld leer zu lassen.",
|
||||
"uk": "Виберіть рекомендовану модель ACP або введіть користувацьке перевизначення моделі. Вбудовані постачальники зберігають конкретну модель замість того, щоб залишати поле порожнім.",
|
||||
"ca": "Tria un model ACP suggerit o introdueix una substitució de model personalitzada. Els proveïdors integrats desen un model concret en lloc de deixar-lo en blanc."
|
||||
},
|
||||
"SETTINGS$AGENT_DISABLED_TOOLTIP": {
|
||||
"en": "Disabled while {{agentName}} is the active agent",
|
||||
@@ -15895,21 +15912,21 @@
|
||||
"uk": "Метадані"
|
||||
},
|
||||
"CONVERSATION_PANEL$LLM_MODEL": {
|
||||
"en": "LLM model",
|
||||
"ja": "LLMモデル",
|
||||
"zh-CN": "大语言模型",
|
||||
"zh-TW": "LLM 模型",
|
||||
"ko-KR": "LLM 모델",
|
||||
"no": "LLM-modell",
|
||||
"ar": "نموذج LLM",
|
||||
"de": "LLM-Modell",
|
||||
"fr": "Modèle LLM",
|
||||
"it": "Modello LLM",
|
||||
"pt": "Modelo LLM",
|
||||
"es": "Modelo LLM",
|
||||
"ca": "Model LLM",
|
||||
"tr": "LLM modeli",
|
||||
"uk": "Модель LLM"
|
||||
"en": "Agent / model",
|
||||
"ja": "エージェント / モデル",
|
||||
"zh-CN": "代理 / 模型",
|
||||
"zh-TW": "代理 / 模型",
|
||||
"ko-KR": "에이전트 / 모델",
|
||||
"no": "Agent / modell",
|
||||
"ar": "الوكيل / النموذج",
|
||||
"de": "Agent / Modell",
|
||||
"fr": "Agent / modèle",
|
||||
"it": "Agente / modello",
|
||||
"pt": "Agente / modelo",
|
||||
"es": "Agente / modelo",
|
||||
"ca": "Agent / model",
|
||||
"tr": "Aracı / model",
|
||||
"uk": "Агент / модель"
|
||||
},
|
||||
"CONVERSATION_PANEL$REPO_BRANCH": {
|
||||
"en": "Repo and branch",
|
||||
|
||||
+114
-20
@@ -26,6 +26,7 @@ import {
|
||||
ACP_PROVIDERS,
|
||||
ACP_CUSTOM_PRESET_KEY,
|
||||
buildAcpAgentSettingsDiff,
|
||||
getAcpProvider,
|
||||
type ACPProviderConfig,
|
||||
} from "#/constants/acp-providers";
|
||||
import { parseCommand, formatCommand } from "#/utils/acp-command";
|
||||
@@ -36,6 +37,7 @@ type AgentType = "openhands" | "acp";
|
||||
|
||||
const ENABLE_SUB_AGENTS_FIELD_KEY = "enable_sub_agents";
|
||||
const COMMAND_PLACEHOLDER_FALLBACK = "npx -y <package-name>";
|
||||
const ACP_CUSTOM_MODEL_KEY = "__custom_model__";
|
||||
|
||||
function toStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
@@ -70,6 +72,15 @@ function getEnableSubAgentsValue(
|
||||
return field?.default === true;
|
||||
}
|
||||
|
||||
function isKnownAcpModel(
|
||||
provider: ACPProviderConfig | undefined,
|
||||
model: string,
|
||||
): boolean {
|
||||
return (
|
||||
provider?.available_models?.some(({ id }) => id === model.trim()) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
function AgentSettingsScreen() {
|
||||
const { t } = useTranslation("openhands");
|
||||
const { data: settings, isLoading } = useSettings();
|
||||
@@ -100,6 +111,7 @@ function AgentSettingsScreen() {
|
||||
const [agentType, setAgentType] = useState<AgentType>("openhands");
|
||||
const [commandText, setCommandText] = useState("");
|
||||
const [acpModel, setAcpModel] = useState("");
|
||||
const [isCustomAcpModel, setIsCustomAcpModel] = useState(false);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
|
||||
const lastInitializedSettingsRef = useRef<unknown>(null);
|
||||
@@ -119,7 +131,7 @@ function AgentSettingsScreen() {
|
||||
const rawAcpServer = settings.agent_settings?.acp_server;
|
||||
const acpServer =
|
||||
typeof rawAcpServer === "string" ? rawAcpServer : undefined;
|
||||
const provider = ACP_PROVIDERS.find(({ key }) => key === acpServer);
|
||||
const provider = getAcpProvider(acpServer);
|
||||
const storedCommand = toStringArray(settings.agent_settings?.acp_command);
|
||||
const effectiveBaseCommand =
|
||||
storedCommand.length > 0
|
||||
@@ -136,13 +148,20 @@ function AgentSettingsScreen() {
|
||||
loadedCommandTextRef.current = renderedCommandText;
|
||||
|
||||
const savedModel = settings.agent_settings?.acp_model;
|
||||
setAcpModel(typeof savedModel === "string" ? savedModel : "");
|
||||
const normalizedSavedModel =
|
||||
typeof savedModel === "string" ? savedModel.trim() : "";
|
||||
setAcpModel(normalizedSavedModel || provider?.default_model || "");
|
||||
setIsCustomAcpModel(
|
||||
!!normalizedSavedModel &&
|
||||
(!provider || !isKnownAcpModel(provider, normalizedSavedModel)),
|
||||
);
|
||||
} else {
|
||||
setAgentType("openhands");
|
||||
setCommandText("");
|
||||
setAcpModel("");
|
||||
loadedAcpServerRef.current = null;
|
||||
loadedCommandTextRef.current = "";
|
||||
setIsCustomAcpModel(false);
|
||||
}
|
||||
setIsDirty(false);
|
||||
}, [settings]);
|
||||
@@ -158,9 +177,14 @@ function AgentSettingsScreen() {
|
||||
const commandTokens = parseCommand(commandText);
|
||||
const isAcpInvalid = isAcp && commandTokens.length === 0;
|
||||
const selectedPreset = detectPreset(commandText, ACP_PROVIDERS);
|
||||
const selectedProvider = ACP_PROVIDERS.find(
|
||||
({ key }) => key === selectedPreset,
|
||||
);
|
||||
const selectedProvider = getAcpProvider(selectedPreset);
|
||||
const modelSuggestions = selectedProvider?.available_models ?? [];
|
||||
const hasModelSuggestions = modelSuggestions.length > 0;
|
||||
const selectedModelIsSuggestion = isKnownAcpModel(selectedProvider, acpModel);
|
||||
const selectedModelKey =
|
||||
isCustomAcpModel || !selectedModelIsSuggestion
|
||||
? ACP_CUSTOM_MODEL_KEY
|
||||
: acpModel;
|
||||
const isDefaultProviderCommand =
|
||||
!!selectedProvider &&
|
||||
commandTokens.join(" ") === selectedProvider.default_command.join(" ");
|
||||
@@ -189,9 +213,12 @@ function AgentSettingsScreen() {
|
||||
: selectedProvider && isDefaultProviderCommand
|
||||
? selectedProvider.key
|
||||
: ACP_CUSTOM_PRESET_KEY;
|
||||
// ``model: undefined`` lets buildAcpAgentSettingsDiff seed the provider's
|
||||
// ``default_model`` for built-in keys; for the custom preset it falls
|
||||
// through to ``null`` since custom has no default.
|
||||
const agentSettingsDiff = buildAcpAgentSettingsDiff(providerKey, {
|
||||
command: useDefault ? [] : commandTokens,
|
||||
model: acpModel.trim() || null,
|
||||
model: acpModel.trim() || undefined,
|
||||
allowUnknownServer: preserveUnknownServer,
|
||||
});
|
||||
|
||||
@@ -279,7 +306,11 @@ function AgentSettingsScreen() {
|
||||
const preferred = ACP_PROVIDERS[0];
|
||||
if (preferred) {
|
||||
setCommandText(formatCommand(preferred.default_command));
|
||||
setAcpModel(preferred.default_model ?? "");
|
||||
setIsCustomAcpModel(false);
|
||||
}
|
||||
} else if (newType === "openhands") {
|
||||
setIsCustomAcpModel(false);
|
||||
}
|
||||
setIsDirty(true);
|
||||
}}
|
||||
@@ -329,9 +360,18 @@ function AgentSettingsScreen() {
|
||||
onSelectionChange={(key) => {
|
||||
if (!key) return;
|
||||
const preset = String(key);
|
||||
const provider = ACP_PROVIDERS.find(({ key: k }) => k === preset);
|
||||
const provider = getAcpProvider(preset);
|
||||
if (provider) {
|
||||
setCommandText(formatCommand(provider.default_command));
|
||||
setAcpModel(provider.default_model ?? "");
|
||||
setIsCustomAcpModel(false);
|
||||
} else if (preset === ACP_CUSTOM_PRESET_KEY) {
|
||||
// Switching to Custom must clear any built-in default the
|
||||
// user just left — otherwise saving the custom command
|
||||
// silently leaks e.g. ``claude-opus-4-7`` into
|
||||
// ``acp_model`` for an unrelated wrapper.
|
||||
setAcpModel("");
|
||||
setIsCustomAcpModel(true);
|
||||
}
|
||||
setIsDirty(true);
|
||||
}}
|
||||
@@ -347,7 +387,25 @@ function AgentSettingsScreen() {
|
||||
value={commandText}
|
||||
placeholder={commandPlaceholder}
|
||||
onChange={(e) => {
|
||||
setCommandText(e.target.value);
|
||||
const nextCommandText = e.target.value;
|
||||
// Keep the model selector in sync with the command being
|
||||
// typed. Editing the command into a *different* provider — or
|
||||
// into a custom command — must drop the previous provider's
|
||||
// model, or Save would silently persist e.g.
|
||||
// ``claude-opus-4-7`` against a Codex / custom wrapper. The
|
||||
// preset dropdown already does this; the textarea is the other
|
||||
// way a user changes provider, so it needs the same
|
||||
// reconciliation. Gated on the *detected preset* actually
|
||||
// changing, so it never clobbers a model the user is editing
|
||||
// within the same provider.
|
||||
const prevPreset = detectPreset(commandText, ACP_PROVIDERS);
|
||||
const nextPreset = detectPreset(nextCommandText, ACP_PROVIDERS);
|
||||
if (nextPreset !== prevPreset) {
|
||||
const nextProvider = getAcpProvider(nextPreset);
|
||||
setAcpModel(nextProvider?.default_model ?? "");
|
||||
setIsCustomAcpModel(false);
|
||||
}
|
||||
setCommandText(nextCommandText);
|
||||
setIsDirty(true);
|
||||
}}
|
||||
/>
|
||||
@@ -357,18 +415,54 @@ function AgentSettingsScreen() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<SettingsInput
|
||||
testId="agent-model-input"
|
||||
label={t(I18nKey.SETTINGS$AGENT_MODEL)}
|
||||
type="text"
|
||||
className="w-full"
|
||||
value={acpModel}
|
||||
showOptionalTag
|
||||
onChange={(value) => {
|
||||
setAcpModel(value);
|
||||
setIsDirty(true);
|
||||
}}
|
||||
/>
|
||||
{hasModelSuggestions && (
|
||||
<SettingsDropdownInput
|
||||
testId="agent-model-selector"
|
||||
name="agent-model"
|
||||
label={t(I18nKey.SETTINGS$AGENT_MODEL)}
|
||||
items={[
|
||||
...modelSuggestions.map((model) => ({
|
||||
key: model.id,
|
||||
label: model.label,
|
||||
})),
|
||||
{
|
||||
key: ACP_CUSTOM_MODEL_KEY,
|
||||
label: t(I18nKey.SETTINGS$AGENT_PRESET_CUSTOM),
|
||||
},
|
||||
]}
|
||||
selectedKey={selectedModelKey}
|
||||
onSelectionChange={(key) => {
|
||||
if (!key) return;
|
||||
const modelKey = String(key);
|
||||
if (modelKey === ACP_CUSTOM_MODEL_KEY) {
|
||||
setIsCustomAcpModel(true);
|
||||
setAcpModel("");
|
||||
} else {
|
||||
setIsCustomAcpModel(false);
|
||||
setAcpModel(modelKey);
|
||||
}
|
||||
setIsDirty(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{selectedModelKey === ACP_CUSTOM_MODEL_KEY && (
|
||||
<SettingsInput
|
||||
testId="agent-model-input"
|
||||
label={
|
||||
hasModelSuggestions
|
||||
? t(I18nKey.SETTINGS$AGENT_CUSTOM_MODEL)
|
||||
: t(I18nKey.SETTINGS$AGENT_MODEL)
|
||||
}
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user