mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 14:58:39 +08:00
* 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>
1149 lines
38 KiB
TypeScript
1149 lines
38 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
ACP_SERVER_TAG_KEY,
|
|
buildRuntimeServicesSystemSuffix,
|
|
buildStartConversationRequest,
|
|
getDefaultConversationTitle,
|
|
toAppConversation,
|
|
type DirectConversationInfo,
|
|
} from "#/api/agent-server-adapter";
|
|
import {
|
|
removeStoredConversationMetadata,
|
|
setStoredConversationMetadata,
|
|
} from "#/api/conversation-metadata-store";
|
|
import { DEFAULT_SETTINGS } from "#/services/settings";
|
|
|
|
const {
|
|
mockGetAgentServerWorkingDir,
|
|
mockIsAgentServerToolAvailable,
|
|
mockGetEffectiveLocalBackend,
|
|
} = vi.hoisted(() => ({
|
|
mockGetAgentServerWorkingDir: vi.fn(() => "/workspace/project/agent-canvas"),
|
|
mockIsAgentServerToolAvailable: vi.fn((_toolName: string) => true),
|
|
mockGetEffectiveLocalBackend: vi.fn(() => ({
|
|
id: "default-local",
|
|
name: "Local backend",
|
|
host: "http://127.0.0.1:8000",
|
|
apiKey: "session-key",
|
|
kind: "local" as const,
|
|
})),
|
|
}));
|
|
|
|
vi.mock("#/api/agent-server-config", () => ({
|
|
getAgentServerBaseUrl: vi.fn(() => "http://127.0.0.1:8000"),
|
|
getAgentServerSessionApiKey: vi.fn(() => null),
|
|
getAgentServerWorkingDir: mockGetAgentServerWorkingDir,
|
|
getConfiguredWorkerUrls: vi.fn(() => []),
|
|
shouldLoadPublicSkills: vi.fn(() => true),
|
|
}));
|
|
|
|
vi.mock("#/api/agent-server-compatibility", () => ({
|
|
isAgentServerToolAvailable: mockIsAgentServerToolAvailable,
|
|
}));
|
|
|
|
vi.mock("#/api/backend-registry/active-store", () => ({
|
|
getEffectiveLocalBackend: mockGetEffectiveLocalBackend,
|
|
}));
|
|
|
|
beforeEach(() => {
|
|
mockIsAgentServerToolAvailable.mockReturnValue(true);
|
|
mockGetEffectiveLocalBackend.mockReturnValue({
|
|
id: "default-local",
|
|
name: "Local backend",
|
|
host: "http://127.0.0.1:8000",
|
|
apiKey: "session-key",
|
|
kind: "local",
|
|
});
|
|
});
|
|
|
|
describe("buildStartConversationRequest", () => {
|
|
it("uses nested settings as the source of truth and lets the SDK create the agent", () => {
|
|
const payload = buildStartConversationRequest({
|
|
settings: {
|
|
...DEFAULT_SETTINGS,
|
|
llm_model: "stale-top-level-model",
|
|
agent_settings: {
|
|
...DEFAULT_SETTINGS.agent_settings,
|
|
agent: "CodeActAgent",
|
|
enable_sub_agents: true,
|
|
llm: {
|
|
model: "nested-model",
|
|
api_key: " nested-key ",
|
|
base_url: " https://nested.example.com ",
|
|
},
|
|
condenser: {
|
|
enabled: true,
|
|
max_size: 120,
|
|
},
|
|
enable_switch_llm_tool: true,
|
|
},
|
|
conversation_settings: {
|
|
...DEFAULT_SETTINGS.conversation_settings,
|
|
max_iterations: 123,
|
|
},
|
|
},
|
|
query: "hello",
|
|
}) as {
|
|
agent?: unknown;
|
|
agent_settings: Record<string, unknown> & {
|
|
llm: Record<string, unknown>;
|
|
tools: Array<{ name: string; params: Record<string, unknown> }>;
|
|
agent_context: Record<string, unknown>;
|
|
};
|
|
workspace: { working_dir: string };
|
|
initial_message: { content: Array<{ text: string }> };
|
|
max_iterations: number;
|
|
};
|
|
|
|
expect(payload.agent).toBeUndefined();
|
|
expect(payload.agent_settings.llm).toMatchObject({
|
|
model: "nested-model",
|
|
api_key: "nested-key",
|
|
base_url: "https://nested.example.com",
|
|
});
|
|
expect(payload.agent_settings.condenser).toEqual({
|
|
enabled: true,
|
|
max_size: 120,
|
|
});
|
|
expect(payload.agent_settings.tools).toEqual([
|
|
{ name: "terminal", params: {} },
|
|
{ name: "file_editor", params: {} },
|
|
{ name: "task_tracker", params: {} },
|
|
{ name: "canvas_ui", params: {} },
|
|
{ name: "browser_tool_set", params: {} },
|
|
{ name: "task_tool_set", params: {} },
|
|
]);
|
|
expect(payload.agent_settings.agent_context).toEqual({
|
|
load_public_skills: true,
|
|
load_user_skills: true,
|
|
});
|
|
expect(payload.agent_settings.agent).toBe("CodeActAgent");
|
|
expect(payload.agent_settings.enable_switch_llm_tool).toBe(true);
|
|
expect(payload.workspace.working_dir).toBe(
|
|
"/workspace/project/agent-canvas",
|
|
);
|
|
expect(payload.max_iterations).toBe(123);
|
|
expect(payload.initial_message.content[0]?.text).toBe("hello");
|
|
});
|
|
|
|
it("forwards the switch-LLM setting to SDK agent settings", () => {
|
|
const payload = buildStartConversationRequest({
|
|
settings: {
|
|
...DEFAULT_SETTINGS,
|
|
agent_settings: {
|
|
...DEFAULT_SETTINGS.agent_settings,
|
|
enable_switch_llm_tool: true,
|
|
llm: { model: "nested-model" },
|
|
},
|
|
},
|
|
}) as {
|
|
agent?: unknown;
|
|
agent_settings: {
|
|
enable_switch_llm_tool?: boolean;
|
|
include_default_tools?: unknown;
|
|
};
|
|
};
|
|
|
|
expect(payload.agent).toBeUndefined();
|
|
expect(payload.agent_settings.enable_switch_llm_tool).toBe(true);
|
|
expect(payload.agent_settings.include_default_tools).toBeUndefined();
|
|
});
|
|
|
|
it("omits browser_tool_set and task_tool_set when the server does not advertise them", () => {
|
|
mockIsAgentServerToolAvailable.mockReturnValue(false);
|
|
|
|
const payload = buildStartConversationRequest({
|
|
settings: {
|
|
...DEFAULT_SETTINGS,
|
|
agent_settings: {
|
|
...DEFAULT_SETTINGS.agent_settings,
|
|
llm: { model: "nested-model" },
|
|
},
|
|
},
|
|
}) as {
|
|
agent_settings: {
|
|
tools: Array<{ name: string; params: Record<string, unknown> }>;
|
|
};
|
|
};
|
|
|
|
expect(payload.agent_settings.tools).toEqual([
|
|
{ name: "terminal", params: {} },
|
|
{ name: "file_editor", params: {} },
|
|
{ name: "task_tracker", params: {} },
|
|
{ name: "canvas_ui", params: {} },
|
|
]);
|
|
});
|
|
|
|
it("includes task_tool_set when sub-agents are enabled and the server advertises it but not browser tools", () => {
|
|
mockIsAgentServerToolAvailable.mockImplementation(
|
|
(toolName: string) => toolName === "task_tool_set",
|
|
);
|
|
|
|
const payload = buildStartConversationRequest({
|
|
settings: {
|
|
...DEFAULT_SETTINGS,
|
|
agent_settings: {
|
|
...DEFAULT_SETTINGS.agent_settings,
|
|
enable_sub_agents: true,
|
|
llm: { model: "nested-model" },
|
|
},
|
|
},
|
|
}) as {
|
|
agent_settings: {
|
|
tools: Array<{ name: string; params: Record<string, unknown> }>;
|
|
};
|
|
};
|
|
|
|
expect(payload.agent_settings.tools).toEqual([
|
|
{ name: "terminal", params: {} },
|
|
{ name: "file_editor", params: {} },
|
|
{ name: "task_tracker", params: {} },
|
|
{ name: "canvas_ui", params: {} },
|
|
{ name: "task_tool_set", params: {} },
|
|
]);
|
|
});
|
|
|
|
it("omits task_tool_set when sub-agents are disabled even if the server advertises it", () => {
|
|
const payload = buildStartConversationRequest({
|
|
settings: {
|
|
...DEFAULT_SETTINGS,
|
|
agent_settings: {
|
|
...DEFAULT_SETTINGS.agent_settings,
|
|
enable_sub_agents: false,
|
|
llm: { model: "nested-model" },
|
|
},
|
|
},
|
|
}) as {
|
|
agent_settings: {
|
|
tools: Array<{ name: string; params: Record<string, unknown> }>;
|
|
};
|
|
};
|
|
|
|
const toolNames = payload.agent_settings.tools.map((t) => t.name);
|
|
expect(toolNames).not.toContain("task_tool_set");
|
|
});
|
|
|
|
it("derives confirmation and security settings the same way as OpenHands", () => {
|
|
const payload = buildStartConversationRequest({
|
|
settings: {
|
|
...DEFAULT_SETTINGS,
|
|
agent_settings: {
|
|
...DEFAULT_SETTINGS.agent_settings,
|
|
llm: { model: "nested-model" },
|
|
},
|
|
conversation_settings: {
|
|
...DEFAULT_SETTINGS.conversation_settings,
|
|
confirmation_mode: true,
|
|
security_analyzer: "llm",
|
|
},
|
|
},
|
|
}) as {
|
|
confirmation_policy: Record<string, unknown>;
|
|
security_analyzer: Record<string, unknown>;
|
|
};
|
|
|
|
expect(payload.confirmation_policy).toEqual({
|
|
kind: "ConfirmRisky",
|
|
threshold: "HIGH",
|
|
confirm_unknown: true,
|
|
});
|
|
expect(payload.security_analyzer).toEqual({
|
|
kind: "LLMSecurityAnalyzer",
|
|
});
|
|
});
|
|
|
|
it("uses the supplied conversationId and workingDir overrides", () => {
|
|
const conversationId = "11111111-1111-4111-8111-111111111111";
|
|
const workingDir = `/base/${conversationId}`;
|
|
const payload = buildStartConversationRequest({
|
|
settings: {
|
|
...DEFAULT_SETTINGS,
|
|
agent_settings: {
|
|
...DEFAULT_SETTINGS.agent_settings,
|
|
llm: { model: "nested-model" },
|
|
},
|
|
},
|
|
conversationId,
|
|
workingDir,
|
|
}) as {
|
|
conversation_id?: string;
|
|
workspace: { working_dir: string };
|
|
};
|
|
|
|
expect(payload.conversation_id).toBe(conversationId);
|
|
expect(payload.workspace.working_dir).toBe(workingDir);
|
|
});
|
|
|
|
it("always requests a git worktree for new conversations", () => {
|
|
const payload = buildStartConversationRequest({
|
|
settings: {
|
|
...DEFAULT_SETTINGS,
|
|
agent_settings: {
|
|
...DEFAULT_SETTINGS.agent_settings,
|
|
llm: { model: "nested-model" },
|
|
},
|
|
},
|
|
}) as { worktree: boolean };
|
|
|
|
expect(payload.worktree).toBe(true);
|
|
});
|
|
|
|
it("forwards supported conversation runtime fields from nested settings", () => {
|
|
const payload = buildStartConversationRequest({
|
|
settings: {
|
|
...DEFAULT_SETTINGS,
|
|
agent_settings: {
|
|
...DEFAULT_SETTINGS.agent_settings,
|
|
llm: { model: "nested-model" },
|
|
},
|
|
conversation_settings: {
|
|
...DEFAULT_SETTINGS.conversation_settings,
|
|
hook_config: { on_start: [] },
|
|
tool_module_qualnames: { demo_tool: "pkg.tools.demo" },
|
|
agent_definitions: [
|
|
{ name: "reviewer", system_prompt: "be helpful" },
|
|
],
|
|
},
|
|
},
|
|
conversationInstructions: "Follow the repo conventions.",
|
|
plugins: [
|
|
{ source: "github.com/org/plugin", ref: "main", repo_path: "/" },
|
|
],
|
|
}) as Record<string, unknown>;
|
|
|
|
expect(payload.hook_config).toEqual({ on_start: [] });
|
|
// Canvas-UI tool is auto-injected; user-supplied entries are merged in
|
|
// alongside it. The dedicated canvas_ui describe block below pins the
|
|
// exact merge semantics.
|
|
expect(payload.tool_module_qualnames).toEqual({
|
|
canvas_ui: "canvas_ui_tool",
|
|
demo_tool: "pkg.tools.demo",
|
|
});
|
|
expect(payload.agent_definitions).toEqual([
|
|
{ name: "reviewer", system_prompt: "be helpful" },
|
|
]);
|
|
expect(payload.plugins).toEqual([
|
|
{ source: "github.com/org/plugin", ref: "main", repo_path: "/" },
|
|
]);
|
|
expect(payload.initial_message).toEqual({
|
|
role: "user",
|
|
content: [{ type: "text", text: "Follow the repo conventions." }],
|
|
run: true,
|
|
});
|
|
});
|
|
|
|
it("serializes custom secrets as host-relative LookupSecret entries", () => {
|
|
const payload = buildStartConversationRequest({
|
|
settings: {
|
|
...DEFAULT_SETTINGS,
|
|
agent_settings: {
|
|
...DEFAULT_SETTINGS.agent_settings,
|
|
llm: { model: "nested-model" },
|
|
},
|
|
},
|
|
customSecrets: [
|
|
{ name: "API_KEY", description: "Primary API key" },
|
|
{ name: "folder/name", description: "Nested secret" },
|
|
],
|
|
}) as {
|
|
secrets: Record<
|
|
string,
|
|
{
|
|
kind: string;
|
|
url: string;
|
|
description?: string;
|
|
headers?: Record<string, string>;
|
|
}
|
|
>;
|
|
};
|
|
|
|
expect(payload.secrets).toEqual({
|
|
API_KEY: {
|
|
kind: "LookupSecret",
|
|
url: "/api/settings/secrets/API_KEY",
|
|
description: "Primary API key",
|
|
headers: { "X-Session-API-Key": "session-key" },
|
|
},
|
|
"folder/name": {
|
|
kind: "LookupSecret",
|
|
url: "/api/settings/secrets/folder%2Fname",
|
|
description: "Nested secret",
|
|
headers: { "X-Session-API-Key": "session-key" },
|
|
},
|
|
});
|
|
});
|
|
|
|
it("mirrors conversation secrets onto agent_settings.agent_context.secrets for ACP", () => {
|
|
// Until canvas pins to an agent-server build that includes
|
|
// software-agent-sdk PR #3299, the bare ``payload.secrets`` channel
|
|
// only reaches ``secret_registry`` server-side — ``ACPAgent``'s
|
|
// spawn-time env loop reads from ``agent_context.secrets``, not
|
|
// from the registry, so a Settings → Secrets entry like
|
|
// ``ANTHROPIC_API_KEY`` would silently fail to land in the ACP
|
|
// CLI's environment. Mirror the same LookupSecret map onto
|
|
// ``agent_settings.agent_context.secrets`` so the existing SDK loop picks
|
|
// it up. Mirrors OpenHands' app-server bridging.
|
|
const payload = buildStartConversationRequest({
|
|
settings: {
|
|
...DEFAULT_SETTINGS,
|
|
agent_settings: {
|
|
...DEFAULT_SETTINGS.agent_settings,
|
|
agent_kind: "acp",
|
|
acp_server: "claude-code",
|
|
acp_command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp"],
|
|
},
|
|
},
|
|
customSecrets: [{ name: "ANTHROPIC_API_KEY" }],
|
|
}) as {
|
|
agent_settings: { agent_context?: { secrets?: Record<string, unknown> } };
|
|
secrets: Record<string, unknown>;
|
|
};
|
|
|
|
// Same LookupSecret object lands in both places — the bare-secrets
|
|
// channel (for any non-ACP consumer / for SDK #3299 once it lands)
|
|
// and the agent_context bridge (for current ACPAgent spawns).
|
|
expect(payload.secrets.ANTHROPIC_API_KEY).toBeDefined();
|
|
expect(
|
|
payload.agent_settings.agent_context?.secrets?.ANTHROPIC_API_KEY,
|
|
).toEqual(payload.secrets.ANTHROPIC_API_KEY);
|
|
});
|
|
|
|
it("does not synthesize agent_context.secrets for ACP when no custom secrets are set", () => {
|
|
// Empty/absent customSecrets must not introduce an empty
|
|
// ``agent_context.secrets`` map on the ACPAgent payload — the
|
|
// bridge only fires when there's something to bridge.
|
|
const payload = buildStartConversationRequest({
|
|
settings: {
|
|
...DEFAULT_SETTINGS,
|
|
agent_settings: {
|
|
...DEFAULT_SETTINGS.agent_settings,
|
|
agent_kind: "acp",
|
|
acp_server: "claude-code",
|
|
acp_command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp"],
|
|
},
|
|
},
|
|
}) as {
|
|
agent_settings: { agent_context?: { secrets?: Record<string, unknown> } };
|
|
};
|
|
|
|
expect(payload.agent_settings.agent_context?.secrets).toBeUndefined();
|
|
});
|
|
|
|
it("does not mirror conversation secrets onto agent_context for non-ACP conversations", () => {
|
|
// The OpenHands ``Agent`` reads secrets from ``secret_registry``
|
|
// directly (no spawn-env bridging needed), so the LLM-driven path
|
|
// must not get an extra ``agent_context.secrets`` map — that would
|
|
// be both redundant and a surprise for any code that inspects
|
|
// ``agent_context`` for non-secret payload (skills, suffixes, etc.).
|
|
const payload = buildStartConversationRequest({
|
|
settings: DEFAULT_SETTINGS,
|
|
customSecrets: [{ name: "ANTHROPIC_API_KEY" }],
|
|
}) as {
|
|
agent_settings: { agent_context?: { secrets?: Record<string, unknown> } };
|
|
};
|
|
|
|
expect(payload.agent_settings.agent_context?.secrets).toBeUndefined();
|
|
});
|
|
|
|
describe("canvas_ui tool injection", () => {
|
|
it("always registers canvas_ui_tool in tool_module_qualnames, even when no user settings supply qualnames", () => {
|
|
const payload = buildStartConversationRequest({
|
|
settings: DEFAULT_SETTINGS,
|
|
}) as { tool_module_qualnames: Record<string, string> };
|
|
|
|
expect(payload.tool_module_qualnames).toMatchObject({
|
|
canvas_ui: "canvas_ui_tool",
|
|
});
|
|
});
|
|
|
|
it("merges user-supplied tool_module_qualnames alongside canvas_ui_tool without dropping either side", () => {
|
|
const payload = buildStartConversationRequest({
|
|
settings: {
|
|
...DEFAULT_SETTINGS,
|
|
conversation_settings: {
|
|
...DEFAULT_SETTINGS.conversation_settings,
|
|
tool_module_qualnames: { my_tool: "my_package.my_tool" },
|
|
},
|
|
},
|
|
}) as { tool_module_qualnames: Record<string, string> };
|
|
|
|
expect(payload.tool_module_qualnames).toEqual({
|
|
canvas_ui: "canvas_ui_tool",
|
|
my_tool: "my_package.my_tool",
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("getDefaultConversationTitle", () => {
|
|
it("formats the title using the first 5 characters of the conversation id", () => {
|
|
expect(getDefaultConversationTitle("372eb-1234-5678-9abc")).toBe(
|
|
"Conversation 372eb",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("toAppConversation", () => {
|
|
const baseInfo: DirectConversationInfo = {
|
|
id: "372eb-1234-5678-9abc",
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
updated_at: "2026-01-01T00:00:00Z",
|
|
};
|
|
|
|
it("falls back to the default title when the backend returns null", () => {
|
|
const result = toAppConversation({ ...baseInfo, title: null });
|
|
expect(result.title).toBe("Conversation 372eb");
|
|
});
|
|
|
|
it("falls back to the default title when the backend returns undefined", () => {
|
|
const result = toAppConversation({ ...baseInfo });
|
|
expect(result.title).toBe("Conversation 372eb");
|
|
});
|
|
|
|
it("falls back to the default title when the backend returns an empty string", () => {
|
|
const result = toAppConversation({ ...baseInfo, title: "" });
|
|
expect(result.title).toBe("Conversation 372eb");
|
|
});
|
|
|
|
it("falls back to the default title when the backend returns whitespace only", () => {
|
|
const result = toAppConversation({ ...baseInfo, title: " " });
|
|
expect(result.title).toBe("Conversation 372eb");
|
|
});
|
|
|
|
it("preserves a backend-provided title when one is set", () => {
|
|
const result = toAppConversation({
|
|
...baseInfo,
|
|
title: "My real title",
|
|
});
|
|
expect(result.title).toBe("My real title");
|
|
});
|
|
|
|
it("hydrates selected_workspace from stored metadata so the sidebar can group by it", () => {
|
|
setStoredConversationMetadata(baseInfo.id, {
|
|
selected_repository: null,
|
|
selected_branch: null,
|
|
git_provider: null,
|
|
selected_workspace: "/workspace/agent-server-gui",
|
|
});
|
|
try {
|
|
const result = toAppConversation({
|
|
...baseInfo,
|
|
workspace: { working_dir: "/workspace/agent-server-gui/wt-abc" },
|
|
});
|
|
expect(result.selected_workspace).toBe("/workspace/agent-server-gui");
|
|
} finally {
|
|
removeStoredConversationMetadata(baseInfo.id);
|
|
}
|
|
});
|
|
|
|
it("marks openhands conversations and surfaces the agent.llm.model", () => {
|
|
const result = toAppConversation({
|
|
...baseInfo,
|
|
agent: { kind: "Agent", llm: { model: "claude-sonnet-4-6" } },
|
|
});
|
|
expect(result.agent_kind).toBe("openhands");
|
|
expect(result.llm_model).toBe("claude-sonnet-4-6");
|
|
});
|
|
|
|
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",
|
|
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();
|
|
});
|
|
|
|
it("surfaces acp_server from tags.acpserver for ACP conversations", () => {
|
|
// The ``acpserver`` conversation tag is stamped at create time
|
|
// (``buildStartConversationRequest``) but never previously plumbed
|
|
// through on read — the sidebar chip in agent-canvas#405 needs this
|
|
// value to resolve the human display name ("Claude Code" / "Codex" /
|
|
// "Gemini CLI").
|
|
const result = toAppConversation({
|
|
...baseInfo,
|
|
agent: { kind: "ACPAgent", llm: { model: "acp-managed" } },
|
|
tags: { [ACP_SERVER_TAG_KEY]: "claude-code" },
|
|
});
|
|
expect(result.acp_server).toBe("claude-code");
|
|
});
|
|
|
|
it("leaves acp_server null when an ACP conversation has no tag stamped", () => {
|
|
// Older conversations created before the tag was added, or ACP
|
|
// conversations created via the raw API, won't have the tag. The
|
|
// sidebar should still render a chip ("ACP") — but the resolver gets
|
|
// null here and the UI fallback handles the generic label.
|
|
const result = toAppConversation({
|
|
...baseInfo,
|
|
agent: { kind: "ACPAgent", llm: { model: "acp-managed" } },
|
|
});
|
|
expect(result.agent_kind).toBe("acp");
|
|
expect(result.acp_server).toBeNull();
|
|
});
|
|
|
|
it("ignores tags.acpserver on OpenHands conversations to prevent stray-tag bleed", () => {
|
|
// The agent-server's pydantic model doesn't enforce that ``acpserver``
|
|
// is only stamped on ACP conversations. Defensively gating on
|
|
// ``agent.kind === "ACPAgent"`` keeps a misconfigured tag from
|
|
// turning the sidebar of an OpenHands conversation into "Claude
|
|
// Code". Pairs with the ``llm_model`` null-out for ACP.
|
|
const result = toAppConversation({
|
|
...baseInfo,
|
|
agent: { kind: "Agent", llm: { model: "claude-sonnet-4-6" } },
|
|
tags: { [ACP_SERVER_TAG_KEY]: "claude-code" },
|
|
});
|
|
expect(result.agent_kind).toBe("openhands");
|
|
expect(result.acp_server).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("buildRuntimeServicesSystemSuffix", () => {
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
it("returns undefined when VITE_RUNTIME_SERVICES_INFO is unset", () => {
|
|
expect(buildRuntimeServicesSystemSuffix()).toBeUndefined();
|
|
});
|
|
|
|
it("returns undefined when the env var is malformed JSON", () => {
|
|
vi.stubEnv("VITE_RUNTIME_SERVICES_INFO", "{not valid json");
|
|
expect(buildRuntimeServicesSystemSuffix()).toBeUndefined();
|
|
});
|
|
|
|
it("returns undefined when the JSON has no services", () => {
|
|
vi.stubEnv("VITE_RUNTIME_SERVICES_INFO", JSON.stringify({ mode: "x" }));
|
|
expect(buildRuntimeServicesSystemSuffix()).toBeUndefined();
|
|
});
|
|
|
|
it("renders a <RUNTIME_SERVICES> block when an automation entry is present", () => {
|
|
vi.stubEnv(
|
|
"VITE_RUNTIME_SERVICES_INFO",
|
|
JSON.stringify({
|
|
mode: "dev:automation",
|
|
agent_host_alias: "localhost",
|
|
services: {
|
|
agent_server: {
|
|
description: "self",
|
|
url_from_agent: "http://localhost:18000",
|
|
},
|
|
automation: {
|
|
description: "automations",
|
|
url_from_agent: "http://localhost:18001",
|
|
api_prefix: "/api/automation",
|
|
docs_url: "http://localhost:18001/api/automation/docs",
|
|
openapi_url: "http://localhost:18001/api/automation/openapi.json",
|
|
auth_env_var: "OPENHANDS_AUTOMATION_API_KEY",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
const suffix = buildRuntimeServicesSystemSuffix();
|
|
expect(suffix).toBeDefined();
|
|
expect(suffix).toContain("<RUNTIME_SERVICES>");
|
|
expect(suffix).toContain("dev:automation");
|
|
expect(suffix).toContain("http://localhost:18000");
|
|
expect(suffix).toContain("http://localhost:18001");
|
|
expect(suffix).toContain("http://localhost:18001/api/automation/docs");
|
|
expect(suffix).toContain("X-API-Key: $OPENHANDS_AUTOMATION_API_KEY");
|
|
expect(suffix).toContain("</RUNTIME_SERVICES>");
|
|
// The "don't guess" line should reference the actual agent-server URL
|
|
// for this stack, not a hardcoded port. The assertion anchors on the URL
|
|
// we supplied above.
|
|
expect(suffix).toContain(
|
|
"In particular, http://localhost:18000 inside your sandbox is the Agent Server",
|
|
);
|
|
});
|
|
|
|
it("uses the configured agent-server URL in the don't-guess line (not a hardcoded :8000)", () => {
|
|
// dev:safe runs the agent-server on :18000, not :8000. Make sure the
|
|
// rendered block doesn't lie to the agent about its own URL.
|
|
vi.stubEnv(
|
|
"VITE_RUNTIME_SERVICES_INFO",
|
|
JSON.stringify({
|
|
mode: "dev:safe",
|
|
services: {
|
|
agent_server: { url_from_agent: "http://localhost:18000" },
|
|
},
|
|
}),
|
|
);
|
|
const suffix = buildRuntimeServicesSystemSuffix();
|
|
expect(suffix).toBeDefined();
|
|
expect(suffix).toContain(
|
|
"In particular, http://localhost:18000 inside your sandbox is the Agent Server",
|
|
);
|
|
expect(suffix).not.toContain(
|
|
"In particular, http://localhost:8000 inside your sandbox",
|
|
);
|
|
});
|
|
|
|
it("renders the frontend entry with the new key", () => {
|
|
vi.stubEnv(
|
|
"VITE_RUNTIME_SERVICES_INFO",
|
|
JSON.stringify({
|
|
mode: "dev:static",
|
|
services: {
|
|
agent_server: { url_from_agent: "http://localhost:18000" },
|
|
frontend: {
|
|
kind: "static",
|
|
description: "Static-file server hosting the agent-canvas build.",
|
|
url_from_agent: "http://localhost:3001",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
const suffix = buildRuntimeServicesSystemSuffix();
|
|
expect(suffix).toContain("* Frontend: http://localhost:3001");
|
|
expect(suffix).toContain("Static-file server");
|
|
// Should NOT mislabel a static-build frontend as "Vite frontend".
|
|
expect(suffix).not.toContain("Vite frontend");
|
|
});
|
|
|
|
it("accepts the legacy `vite` service key", () => {
|
|
// Older launchers may still emit `services.vite`. Render it under the
|
|
// new "Frontend" label rather than dropping the entry.
|
|
vi.stubEnv(
|
|
"VITE_RUNTIME_SERVICES_INFO",
|
|
JSON.stringify({
|
|
mode: "dev:safe",
|
|
services: {
|
|
agent_server: { url_from_agent: "http://localhost:18000" },
|
|
vite: {
|
|
description: "Vite dev server",
|
|
url_from_agent: "http://localhost:3001",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
const suffix = buildRuntimeServicesSystemSuffix();
|
|
expect(suffix).toContain("* Frontend: http://localhost:3001");
|
|
});
|
|
|
|
it("explicitly mentions when automation is absent", () => {
|
|
vi.stubEnv(
|
|
"VITE_RUNTIME_SERVICES_INFO",
|
|
JSON.stringify({
|
|
mode: "dev:safe",
|
|
services: {
|
|
agent_server: { url_from_agent: "http://localhost:18000" },
|
|
},
|
|
}),
|
|
);
|
|
const suffix = buildRuntimeServicesSystemSuffix();
|
|
expect(suffix).toBeDefined();
|
|
expect(suffix).toContain("Automation backend: not running");
|
|
});
|
|
});
|
|
|
|
describe("agent_settings runtime services suffix", () => {
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
it("does not set system_message_suffix when no runtime info is provided", () => {
|
|
const payload = buildStartConversationRequest({
|
|
settings: DEFAULT_SETTINGS,
|
|
query: "hello",
|
|
}) as {
|
|
agent_settings: { agent_context: Record<string, unknown> };
|
|
};
|
|
expect(payload.agent_settings.agent_context).toEqual({
|
|
load_public_skills: true,
|
|
load_user_skills: true,
|
|
});
|
|
});
|
|
|
|
it("sets system_message_suffix when runtime info is provided", () => {
|
|
vi.stubEnv(
|
|
"VITE_RUNTIME_SERVICES_INFO",
|
|
JSON.stringify({
|
|
mode: "dev:automation",
|
|
services: {
|
|
agent_server: { url_from_agent: "http://localhost:18000" },
|
|
automation: {
|
|
url_from_agent: "http://localhost:18001",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
const payload = buildStartConversationRequest({
|
|
settings: DEFAULT_SETTINGS,
|
|
query: "hello",
|
|
}) as {
|
|
agent_settings: { agent_context: Record<string, unknown> };
|
|
};
|
|
expect(payload.agent_settings.agent_context).toMatchObject({
|
|
load_public_skills: true,
|
|
load_user_skills: true,
|
|
});
|
|
expect(
|
|
payload.agent_settings.agent_context.system_message_suffix as string,
|
|
).toContain("<RUNTIME_SERVICES>");
|
|
});
|
|
});
|
|
|
|
describe("buildStartConversationRequest — ACP discriminator", () => {
|
|
it("builds ACP agent settings when agent_kind is 'acp'", () => {
|
|
const payload = buildStartConversationRequest({
|
|
settings: {
|
|
...DEFAULT_SETTINGS,
|
|
agent_settings: {
|
|
schema_version: 1,
|
|
agent_kind: "acp",
|
|
acp_server: "claude-code",
|
|
acp_command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp"],
|
|
acp_model: "claude-opus-4-5",
|
|
// These fields are LLM-only and must NOT leak into ACP settings.
|
|
agent: "CodeActAgent",
|
|
llm: { model: "gpt-4", api_key: "should-not-appear" },
|
|
condenser: { enabled: true, max_size: 240 },
|
|
mcp_config: {
|
|
mcpServers: { fake: { command: "x", args: [] } },
|
|
},
|
|
},
|
|
},
|
|
}) as {
|
|
agent?: unknown;
|
|
agent_settings: Record<string, unknown> & {
|
|
acp_command?: string[];
|
|
acp_model?: string | null;
|
|
agent_context?: unknown;
|
|
};
|
|
tags?: Record<string, string>;
|
|
};
|
|
|
|
expect(payload.agent).toBeUndefined();
|
|
expect(payload.agent_settings.agent_kind).toBe("acp");
|
|
expect(payload.agent_settings.acp_command).toEqual([
|
|
"npx",
|
|
"-y",
|
|
"@agentclientprotocol/claude-agent-acp",
|
|
]);
|
|
expect(payload.agent_settings.acp_model).toBe("claude-opus-4-5");
|
|
// LLM-only fields must not leak into the ACP settings payload.
|
|
expect(payload.agent_settings.llm).toBeUndefined();
|
|
expect(payload.agent_settings.condenser).toBeUndefined();
|
|
expect(payload.agent_settings.tools).toBeUndefined();
|
|
expect(payload.agent_settings.agent_context).toEqual({
|
|
load_public_skills: true,
|
|
load_user_skills: true,
|
|
});
|
|
expect(payload.tags).toEqual({ [ACP_SERVER_TAG_KEY]: "claude-code" });
|
|
});
|
|
|
|
it("does not include ACP-only fields in OpenHands agent settings", () => {
|
|
const payload = buildStartConversationRequest({
|
|
settings: {
|
|
...DEFAULT_SETTINGS,
|
|
agent_settings: {
|
|
...DEFAULT_SETTINGS.agent_settings,
|
|
agent_kind: "openhands",
|
|
llm: { model: "gpt-4" },
|
|
acp_command: ["npx", "leftover"],
|
|
acp_server: "claude-code",
|
|
},
|
|
},
|
|
}) as {
|
|
agent?: unknown;
|
|
agent_settings: Record<string, unknown> & {
|
|
llm: Record<string, unknown>;
|
|
};
|
|
tags?: Record<string, string>;
|
|
};
|
|
|
|
expect(payload.agent).toBeUndefined();
|
|
expect(payload.agent_settings.agent_kind).toBe("openhands");
|
|
expect(payload.agent_settings.acp_command).toBeUndefined();
|
|
expect(payload.agent_settings.acp_server).toBeUndefined();
|
|
expect(payload.agent_settings.llm.model).toBe("gpt-4");
|
|
expect(payload.tags).toBeUndefined();
|
|
});
|
|
|
|
it("omits acp_model when the user clears it (null)", () => {
|
|
const payload = buildStartConversationRequest({
|
|
settings: {
|
|
...DEFAULT_SETTINGS,
|
|
agent_settings: {
|
|
schema_version: 1,
|
|
agent_kind: "acp",
|
|
acp_server: "custom",
|
|
acp_command: ["./bin/my-agent"],
|
|
acp_model: null,
|
|
},
|
|
},
|
|
}) as { agent_settings: Record<string, unknown> };
|
|
|
|
expect(payload.agent_settings.agent_kind).toBe("acp");
|
|
expect(payload.agent_settings.acp_model).toBeUndefined();
|
|
});
|
|
|
|
it("resolves an empty acp_command from the registry by acp_server", () => {
|
|
const payload = buildStartConversationRequest({
|
|
settings: {
|
|
...DEFAULT_SETTINGS,
|
|
agent_settings: {
|
|
schema_version: 1,
|
|
agent_kind: "acp",
|
|
acp_server: "claude-code",
|
|
acp_command: [],
|
|
acp_model: null,
|
|
},
|
|
},
|
|
}) as {
|
|
agent_settings: Record<string, unknown> & { acp_command?: unknown[] };
|
|
};
|
|
|
|
expect(payload.agent_settings.acp_command).toEqual([
|
|
"npx",
|
|
"-y",
|
|
"@agentclientprotocol/claude-agent-acp",
|
|
]);
|
|
});
|
|
|
|
it("resolves an absent acp_command for built-in providers too", () => {
|
|
const payload = buildStartConversationRequest({
|
|
settings: {
|
|
...DEFAULT_SETTINGS,
|
|
agent_settings: {
|
|
schema_version: 1,
|
|
agent_kind: "acp",
|
|
acp_server: "codex",
|
|
acp_model: null,
|
|
},
|
|
},
|
|
}) as {
|
|
agent_settings: Record<string, unknown> & { acp_command?: unknown[] };
|
|
};
|
|
|
|
expect(payload.agent_settings.acp_command).toEqual([
|
|
"npx",
|
|
"-y",
|
|
"@zed-industries/codex-acp",
|
|
]);
|
|
});
|
|
|
|
it("leaves acp_command alone when acp_server is 'custom'", () => {
|
|
const payload = buildStartConversationRequest({
|
|
settings: {
|
|
...DEFAULT_SETTINGS,
|
|
agent_settings: {
|
|
schema_version: 1,
|
|
agent_kind: "acp",
|
|
acp_server: "custom",
|
|
acp_command: [],
|
|
acp_model: null,
|
|
},
|
|
},
|
|
}) as {
|
|
agent_settings: Record<string, unknown> & { acp_command?: unknown[] };
|
|
};
|
|
|
|
expect(payload.agent_settings.acp_command).toEqual([]);
|
|
});
|
|
|
|
it("leaves acp_command alone for an unknown acp_server key", () => {
|
|
const payload = buildStartConversationRequest({
|
|
settings: {
|
|
...DEFAULT_SETTINGS,
|
|
agent_settings: {
|
|
schema_version: 1,
|
|
agent_kind: "acp",
|
|
acp_server: "future-provider-not-yet-mirrored",
|
|
acp_command: [],
|
|
acp_model: null,
|
|
},
|
|
},
|
|
}) as {
|
|
agent_settings: Record<string, unknown> & { acp_command?: unknown[] };
|
|
};
|
|
|
|
expect(payload.agent_settings.acp_command).toEqual([]);
|
|
});
|
|
|
|
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,
|
|
agent_settings: {
|
|
schema_version: 1,
|
|
agent_kind: "acp",
|
|
acp_server: "claude-code",
|
|
acp_command: [],
|
|
acp_model: "",
|
|
},
|
|
},
|
|
}) as {
|
|
agent_settings: Record<string, unknown> & { acp_model?: unknown };
|
|
};
|
|
|
|
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", () => {
|
|
const baseAcpSettings = {
|
|
...DEFAULT_SETTINGS,
|
|
agent_settings: {
|
|
schema_version: 1,
|
|
agent_kind: "acp",
|
|
acp_server: "claude-code",
|
|
acp_command: [],
|
|
acp_env: { ANTHROPIC_API_KEY: "user-set-via-api" },
|
|
acp_model: "claude-opus-4-5",
|
|
agent: "CodeActAgent",
|
|
llm: { model: "gpt-4o", api_key: "stale-from-prior-oh-run" },
|
|
condenser: { enabled: true, max_size: 200 },
|
|
},
|
|
};
|
|
|
|
const ohPayload = buildStartConversationRequest({
|
|
settings: {
|
|
...baseAcpSettings,
|
|
agent_settings: {
|
|
...baseAcpSettings.agent_settings,
|
|
agent_kind: "openhands",
|
|
},
|
|
},
|
|
}) as {
|
|
agent_settings: Record<string, unknown> & {
|
|
llm: Record<string, unknown>;
|
|
};
|
|
};
|
|
|
|
expect(ohPayload.agent_settings.agent_kind).toBe("openhands");
|
|
expect(ohPayload.agent_settings.acp_command).toBeUndefined();
|
|
expect(ohPayload.agent_settings.acp_env).toBeUndefined();
|
|
expect(ohPayload.agent_settings.acp_model).toBeUndefined();
|
|
expect(ohPayload.agent_settings.acp_server).toBeUndefined();
|
|
expect(ohPayload.agent_settings.llm.model).toBe("gpt-4o");
|
|
|
|
const acpPayload = buildStartConversationRequest({
|
|
settings: baseAcpSettings,
|
|
}) as {
|
|
agent_settings: Record<string, unknown> & {
|
|
acp_command?: unknown;
|
|
acp_env?: unknown;
|
|
acp_model?: unknown;
|
|
llm?: unknown;
|
|
condenser?: unknown;
|
|
};
|
|
};
|
|
|
|
expect(acpPayload.agent_settings.agent_kind).toBe("acp");
|
|
expect(acpPayload.agent_settings.acp_command).toEqual([
|
|
"npx",
|
|
"-y",
|
|
"@agentclientprotocol/claude-agent-acp",
|
|
]);
|
|
expect(acpPayload.agent_settings.acp_model).toBe("claude-opus-4-5");
|
|
expect(acpPayload.agent_settings.acp_env).toEqual({
|
|
ANTHROPIC_API_KEY: "user-set-via-api",
|
|
});
|
|
expect(acpPayload.agent_settings.llm).toBeUndefined();
|
|
expect(acpPayload.agent_settings.condenser).toBeUndefined();
|
|
});
|
|
});
|