openhands / __tests__ /api /agent-server-adapter.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
3201ca6 verified
Raw History Blame Contribute Delete
66.8 kB
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MockInstance } from "vitest";
import { getAcpProvider as getClientAcpProvider } from "@openhands/typescript-client";
import { CANVAS_UI_CLIENT_TOOL_NAME } from "#/constants/canvas-ui";
import { LAUNCH_CHILD_CONVERSATION_TOOL_NAME } from "#/constants/child-conversation";
import {
ACP_SERVER_TAG_KEY,
AGENT_CANVAS_SOURCE,
CLIENT_SOURCE_TAG_KEY,
buildRuntimeServicesSystemSuffix,
buildStartConversationRequest,
buildStartConversationRequestWithEncryptedSettings,
fetchBackendRuntimeServicesInfo,
getDefaultConversationTitle,
parseRuntimeServicesInfo,
toAppConversation,
type DirectConversationInfo,
} from "#/api/agent-server-adapter";
import SettingsService from "#/api/settings-service/settings-service.api";
import { SecretsService } from "#/api/secrets-service";
import {
removeStoredConversationMetadata,
setStoredConversationMetadata,
} from "#/api/conversation-metadata-store";
import { HookType } from "@openhands/typescript-client";
import type { HookConfig } from "@openhands/typescript-client";
import { ACP_VERTEX_SAFE_MODEL } from "#/constants/acp-providers";
import { DEFAULT_SETTINGS } from "#/services/settings";
import type { SettingsValue } from "#/types/settings";
import {
LLM_AUTH_TYPE_SUBSCRIPTION,
OPENAI_SUBSCRIPTION_VENDOR,
} from "#/constants/llm-subscription";
const {
mockGetAgentServerWorkingDir,
mockIsAgentServerToolAvailable,
mockGetEffectiveLocalBackend,
mockGetCachedAgentServerInfo,
mockGetServerInfo,
mockGetActiveBackend,
mockLoadHooks,
} = 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,
})),
mockGetCachedAgentServerInfo: vi.fn<() => unknown>(() => null),
mockGetServerInfo: vi.fn(),
mockGetActiveBackend: vi.fn(() => ({
backend: {
id: "default-local",
name: "Local backend",
host: "http://127.0.0.1:8000",
apiKey: "session-key",
kind: "local" as const,
},
})),
mockLoadHooks: vi.fn(),
}));
vi.mock("@openhands/typescript-client/clients", () => ({
ServerClient: vi.fn(function ServerClientMock() {
return {
getServerInfo: mockGetServerInfo,
};
}),
HooksClient: vi.fn(function HooksClientMock() {
return { loadHooks: mockLoadHooks };
}),
}));
vi.mock("#/api/agent-server-config", () => ({
getAgentServerBaseUrl: vi.fn(() => "http://127.0.0.1:8000"),
getAgentServerSessionApiKey: vi.fn(() => null),
getAgentServerWorkingDir: mockGetAgentServerWorkingDir,
shouldLoadPublicSkills: vi.fn(() => true),
syncBakedSessionApiKey: vi.fn(),
}));
vi.mock("#/api/agent-server-compatibility", () => ({
isAgentServerToolAvailable: mockIsAgentServerToolAvailable,
getCachedAgentServerInfo: mockGetCachedAgentServerInfo,
}));
vi.mock("#/api/backend-registry/active-store", () => ({
getEffectiveLocalBackend: mockGetEffectiveLocalBackend,
getActiveBackend: mockGetActiveBackend,
isNoBackend: (backend: { id: string }) => backend.id === "no-backend",
}));
// `HookConfig` requires every event key, so build fixtures from a complete base.
const makeHookConfig = (overrides: Partial<HookConfig> = {}): HookConfig => ({
pre_tool_use: [],
post_tool_use: [],
user_prompt_submit: [],
session_start: [],
session_end: [],
stop: [],
...overrides,
});
const WORKSPACE_HOOK_CONFIG = makeHookConfig({
session_start: [
{
matcher: "*",
hooks: [{ command: "cat AGENTS.md", type: HookType.COMMAND }],
},
],
});
const EXPLICIT_HOOK_CONFIG = makeHookConfig({
session_start: [
{
matcher: "*",
hooks: [{ command: "echo explicit", type: HookType.COMMAND }],
},
],
});
beforeEach(() => {
mockIsAgentServerToolAvailable.mockReturnValue(true);
mockGetCachedAgentServerInfo.mockReturnValue(null);
mockGetServerInfo.mockReset();
mockGetEffectiveLocalBackend.mockReturnValue({
id: "default-local",
name: "Local backend",
host: "http://127.0.0.1:8000",
apiKey: "session-key",
kind: "local",
});
});
describe("buildStartConversationRequest", () => {
it("includes the resolved title LLM profile when configured", () => {
const payload = buildStartConversationRequest({
settings: DEFAULT_SETTINGS,
titleLlmProfile: "Titles",
});
expect(payload.title_llm_profile).toBe("Titles");
});
it("omits the title LLM profile in automatic mode without a default", () => {
const payload = buildStartConversationRequest({
settings: DEFAULT_SETTINGS,
});
expect(payload).not.toHaveProperty("title_llm_profile");
});
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",
// Streaming is enabled so the OpenHands agent emits StreamingDeltaEvents.
stream: true,
});
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: "browser_tool_set", params: {} },
{ name: "task_tool_set", params: {} },
]);
expect(payload.agent_settings.agent_context).toMatchObject({
load_public_skills: false,
load_user_skills: true,
load_project_skills: true,
});
// Persistent memory is opt-in: the key must be absent (not false) so the
// wire payload for un-opted users stays byte-identical to before.
expect(payload.agent_settings.agent_context).not.toHaveProperty(
"load_memory",
);
// Bundled public skills are injected into agent_context.skills so the
// SDK can perform trigger matching without cloning the extensions repo.
expect(Array.isArray(payload.agent_settings.agent_context.skills)).toBe(
true,
);
const skills = payload.agent_settings.agent_context.skills as Record<
string,
unknown
>[];
expect(skills.length).toBeGreaterThan(0);
// Every bundled skill must carry the fields the SDK needs for trigger
// matching and system-prompt injection.
for (const skill of skills) {
expect(skill).toHaveProperty("name");
expect(skill).toHaveProperty("content");
// source must be an absolute path to the skill's SKILL.md so the
// Python agent-server can resolve bundled resources (scripts/, references/).
const source = skill.source as string;
expect(source).toMatch(/^\//);
expect(source).toMatch(
new RegExp(`/${skill.name as string}/SKILL\\.md$`),
);
expect(skill).toHaveProperty("is_agentskills_format", true);
// trigger is either null (always-active) or { type, keywords }
if (skill.trigger !== null) {
expect(skill.trigger).toMatchObject({
type: "keyword",
keywords: expect.arrayContaining([expect.any(String)]),
});
}
}
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("preserves base_url for subscription auth while stripping api_key", () => {
const payload = buildStartConversationRequest({
settings: {
...DEFAULT_SETTINGS,
agent_settings: {
...DEFAULT_SETTINGS.agent_settings,
llm: {
model: "gpt-5.2-codex",
api_key: "stale-api-key",
base_url: "https://chatgpt.com/backend-api/codex",
auth_type: LLM_AUTH_TYPE_SUBSCRIPTION,
subscription_vendor: OPENAI_SUBSCRIPTION_VENDOR,
},
},
},
}) as { agent_settings: { llm: Record<string, unknown> } };
expect(payload.agent_settings.llm).toEqual({
model: "gpt-5.2-codex",
stream: true,
base_url: "https://chatgpt.com/backend-api/codex",
auth_type: LLM_AUTH_TYPE_SUBSCRIPTION,
subscription_vendor: OPENAI_SUBSCRIPTION_VENDOR,
});
});
it("passes the stored model through unchanged for subscription auth", () => {
const payload = buildStartConversationRequest({
settings: {
...DEFAULT_SETTINGS,
agent_settings: {
...DEFAULT_SETTINGS.agent_settings,
llm: {
model: "openai/gpt-4o",
auth_type: LLM_AUTH_TYPE_SUBSCRIPTION,
subscription_vendor: OPENAI_SUBSCRIPTION_VENDOR,
},
},
},
}) as { agent_settings: { llm: Record<string, unknown> } };
expect(payload.agent_settings.llm.model).toBe("openai/gpt-4o");
});
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: {} },
]);
});
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: "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("requests a git worktree for new conversations by default", () => {
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("can start a conversation without requesting a git worktree", () => {
const payload = buildStartConversationRequest({
settings: {
...DEFAULT_SETTINGS,
agent_settings: {
...DEFAULT_SETTINGS.agent_settings,
llm: { model: "nested-model" },
},
},
workingDir: "/Users/devin/project-without-git",
worktree: false,
}) as { worktree: boolean; workspace: { working_dir: string } };
expect(payload.worktree).toBe(false);
expect(payload.workspace.working_dir).toBe(
"/Users/devin/project-without-git",
);
});
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: [] });
expect(payload.tool_module_qualnames).toEqual({
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("uses workspaceHookConfig when conversation_settings.hook_config is omitted", () => {
const payload = buildStartConversationRequest({
settings: DEFAULT_SETTINGS,
workspaceHookConfig: WORKSPACE_HOOK_CONFIG,
}) as Record<string, unknown>;
expect(payload.hook_config).toEqual(WORKSPACE_HOOK_CONFIG);
});
it("prioritizes conversation_settings.hook_config over workspaceHookConfig", () => {
const payload = buildStartConversationRequest({
settings: {
...DEFAULT_SETTINGS,
conversation_settings: {
...DEFAULT_SETTINGS.conversation_settings,
hook_config: EXPLICIT_HOOK_CONFIG as unknown as SettingsValue,
},
},
workspaceHookConfig: WORKSPACE_HOOK_CONFIG,
}) as Record<string, unknown>;
expect(payload.hook_config).toEqual(EXPLICIT_HOOK_CONFIG);
});
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("passes a stored agent_context.load_memory through on an inline launch", () => {
// The persistent-memory toggle persists into
// ``agent_settings.agent_context.load_memory``; buildAgentContext spreads
// the stored context, so the flag needs no dedicated adapter wiring.
//
// This is the inline path only. A profile launch sends ``agent_profile_id``
// and no ``agent_settings`` at all (pinned in
// src/api/agent-server-adapter.test.ts), so there the agent-server reads the
// same stored preference itself — covered by the SDK's
// tests/agent_server/test_agent_profile_conv_start.py.
const payload = buildStartConversationRequest({
settings: {
...DEFAULT_SETTINGS,
agent_settings: {
...DEFAULT_SETTINGS.agent_settings,
agent_context: { load_memory: true },
},
},
}) as {
agent_settings: { agent_context: Record<string, unknown> };
};
expect(payload.agent_settings.agent_context.load_memory).toBe(true);
});
it("passes a stored agent_context.load_memory through on an inline ACP launch", () => {
// Inline ACP settings are the fallback used when no agent profile is
// available; a normal ACP conversation launches from a profile (see
// use-create-conversation.test.tsx) and never reaches this branch.
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"],
agent_context: { load_memory: true },
},
},
}) as {
agent_settings: { agent_context: Record<string, unknown> };
};
expect(payload.agent_settings.agent_context.load_memory).toBe(true);
});
it("does NOT mirror conversation secrets onto agent_context for ACP — request.secrets is the sole channel", () => {
// The compatible agent-server line injects the ACP spawn env from
// ``secret_registry``, which is seeded from ``request.secrets``
// (sdk#3299/#3464; the agent_context drain is gone entirely in sdk#3528).
// Mirroring the map onto ``agent_context.secrets`` would keep a second,
// dead credential channel alive (agent-canvas#1039 wants exactly one).
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>;
};
expect(payload.secrets.ANTHROPIC_API_KEY).toBeDefined();
expect(payload.agent_settings.agent_context?.secrets).toBeUndefined();
});
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.
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("ACP secret delivery", () => {
// Settings factory for a containerized ACP conversation.
const acpSettings = (overrides: Record<string, unknown> = {}) => ({
...DEFAULT_SETTINGS,
agent_settings: {
...DEFAULT_SETTINGS.agent_settings,
agent_kind: "acp",
acp_server: "codex",
acp_command: ["npx", "-y", "@zed-industries/codex-acp"],
acp_model: "gpt-5.5/medium",
...overrides,
},
});
it("delivers provider credentials and user secrets uniformly as LookupSecrets in request.secrets only", () => {
// Provider credentials (e.g. a saved CODEX_AUTH_JSON) are no longer special
// on the wire — they ride as LookupSecrets like any custom secret. The SDK
// resolves them off the event loop at spawn, so the loopback fetch is safe
// (software-agent-sdk#3510). "Reserved" is now only an onboarding concept.
const payload = buildStartConversationRequest({
settings: acpSettings(),
customSecrets: [{ name: "CODEX_AUTH_JSON" }, { name: "MY_TOKEN" }],
}) as {
agent_settings: {
acp_model?: string;
agent_context?: { secrets?: Record<string, { kind: string }> };
};
secrets: Record<string, { kind: string }>;
};
expect(payload.secrets.CODEX_AUTH_JSON?.kind).toBe("LookupSecret");
expect(payload.secrets.MY_TOKEN?.kind).toBe("LookupSecret");
// ``request.secrets`` is the sole channel — no agent_context mirror
// (agent-server >=1.25.0 injects the spawn env from secret_registry).
expect(payload.agent_settings.agent_context?.secrets).toBeUndefined();
// The configured model rides along unchanged.
expect(payload.agent_settings.acp_model).toBe("gpt-5.5/medium");
});
it("does NOT set secrets_encrypted for ACP — there is no encrypted payload to decrypt", () => {
// An ACP request carries no encrypted secret (no LLM api_key; credentials
// are LookupSecrets resolved at runtime). Flagging it encrypted hard-fails
// ("cipher not configured") on a fresh ACP container without OH_SECRET_KEY.
const payload = buildStartConversationRequest({
settings: acpSettings(),
secretsEncrypted: true,
customSecrets: [{ name: "CODEX_AUTH_JSON" }],
}) as { secrets_encrypted?: boolean };
expect(payload.secrets_encrypted).toBeUndefined();
});
it("still sets secrets_encrypted for a non-ACP conversation (encrypted LLM key)", () => {
const payload = buildStartConversationRequest({
settings: DEFAULT_SETTINGS,
secretsEncrypted: true,
}) as { secrets_encrypted?: boolean };
expect(payload.secrets_encrypted).toBe(true);
});
});
describe("client tool injection", () => {
it("sends canvas_ui as a client-defined JSON tool", () => {
const payload = buildStartConversationRequest({
settings: DEFAULT_SETTINGS,
});
expect(payload.client_tools[0]).toMatchObject({
name: CANVAS_UI_CLIENT_TOOL_NAME,
parameters: {
type: "object",
properties: {
command: {
enum: ["navigate_to_file", "open_tab", "show_preview"],
},
},
required: ["command"],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
});
expect(
payload.agent_settings?.tools?.map((tool) => tool.name) ?? [],
).not.toContain("canvas_ui");
expect(payload.tool_module_qualnames).toBeUndefined();
});
it("omits canvas_ui and its module qualname when the backend does not advertise canvas_ui", () => {
mockIsAgentServerToolAvailable.mockImplementation(
(toolName: string) => toolName !== "canvas_ui",
);
const payload = buildStartConversationRequest({
settings: DEFAULT_SETTINGS,
}) as {
agent_settings: { tools: Array<{ name: string }> };
tool_module_qualnames?: Record<string, string>;
};
expect(
payload.agent_settings.tools.map((tool) => tool.name),
).not.toContain("canvas_ui");
expect(payload.tool_module_qualnames).toBeUndefined();
});
it("omits the client tool for an inline ACP agent", () => {
const payload = buildStartConversationRequest({
settings: {
...DEFAULT_SETTINGS,
agent_settings: {
...DEFAULT_SETTINGS.agent_settings,
agent_kind: "acp",
acp_server: "custom",
acp_command: ["custom-acp"],
},
},
});
expect(payload.client_tools).toEqual([]);
});
it("omits the client tool for an ACP profile when global settings are stale", () => {
const payload = buildStartConversationRequest({
settings: DEFAULT_SETTINGS,
agentProfileId: "profile-acp",
agentProfileKind: "acp",
});
expect(payload.client_tools).toEqual([]);
});
it("sends the client tool for an OpenHands profile", () => {
const payload = buildStartConversationRequest({
settings: DEFAULT_SETTINGS,
agentProfileId: "profile-openhands",
agentProfileKind: "openhands",
});
expect(payload.client_tools.map((tool) => tool.name)).toEqual([
CANVAS_UI_CLIENT_TOOL_NAME,
LAUNCH_CHILD_CONVERSATION_TOOL_NAME,
]);
});
it("sends the client tool when resuming a conversation", () => {
const payload = buildStartConversationRequest({
settings: DEFAULT_SETTINGS,
conversationId: "legacy-conversation-id",
}) as { conversation_id: string; client_tools: Array<{ name: string }> };
expect(payload.conversation_id).toBe("legacy-conversation-id");
expect(payload.client_tools.map((tool) => tool.name)).toEqual([
CANVAS_UI_CLIENT_TOOL_NAME,
LAUNCH_CHILD_CONVERSATION_TOOL_NAME,
]);
});
it("drops conflicting user-supplied Canvas module qualnames", () => {
const payload = buildStartConversationRequest({
settings: {
...DEFAULT_SETTINGS,
conversation_settings: {
...DEFAULT_SETTINGS.conversation_settings,
tool_module_qualnames: {
canvas_ui: "custom_canvas_ui_tool",
[CANVAS_UI_CLIENT_TOOL_NAME]: "custom_canvas_ui_control_tool",
my_tool: "my_package.my_tool",
},
},
},
}) as { tool_module_qualnames: Record<string, string> };
expect(payload.tool_module_qualnames).toEqual({
my_tool: "my_package.my_tool",
});
});
it("preserves non-Canvas custom tool modules", () => {
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({
my_tool: "my_package.my_tool",
});
});
});
// @spec LLD-001 — Frontend always sends its chosen default model
describe("llm.model fallback — frontend always sends its chosen default", () => {
type ModelPayload = {
agent_settings: Record<string, unknown> & {
llm: Record<string, unknown>;
};
};
function getModelFrom(
options: Parameters<typeof buildStartConversationRequest>[0],
): unknown {
return (buildStartConversationRequest(options) as unknown as ModelPayload)
.agent_settings.llm.model;
}
it("uses the configured model when one is set", () => {
expect(
getModelFrom({
settings: {
...DEFAULT_SETTINGS,
agent_settings: {
...DEFAULT_SETTINGS.agent_settings,
llm: { model: "anthropic/claude-opus-4-5" },
},
},
}),
).toBe("anthropic/claude-opus-4-5");
});
// The agent-server returns '' when no model has been saved yet.
// Without this guard the empty string passes the old typeof check and
// the agent-server falls back to its own SDK default (gpt-5.5).
// SettingsValue includes scalars so inline literals are type-safe here.
it.each([
["undefined", { ...DEFAULT_SETTINGS.agent_settings, llm: {} }],
[
"an empty string",
{ ...DEFAULT_SETTINGS.agent_settings, llm: { model: "" } },
],
[
"whitespace only",
{ ...DEFAULT_SETTINGS.agent_settings, llm: { model: " " } },
],
// No llm key at all — SettingsValue accepts plain scalars.
[
"absent (no llm block)",
{ schema_version: 1, agent_kind: "openhands", agent: "CodeActAgent" },
],
// Mirrors a fresh user who skipped onboarding: server returns {}.
["entirely empty", {}],
])(
"falls back to DEFAULT_SETTINGS.llm_model when agent_settings.llm.model is %s",
(_, agentSettings) => {
expect(
getModelFrom({
settings: {
...DEFAULT_SETTINGS,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
agent_settings: agentSettings as any,
},
}),
).toBe(DEFAULT_SETTINGS.llm_model);
},
);
// encryptedAgentSettings overrides settings.agent_settings at conversation
// start; if the encrypted payload has no model set the frontend default
// must still be sent explicitly.
it.each([
["carries an empty model", { llm: { model: "" } }],
["is empty", {}],
])(
"falls back to DEFAULT_SETTINGS.llm_model when encryptedAgentSettings %s",
(_, encryptedAgentSettings) => {
expect(
getModelFrom({
settings: DEFAULT_SETTINGS,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
encryptedAgentSettings: encryptedAgentSettings as any,
}),
).toBe(DEFAULT_SETTINGS.llm_model);
},
);
});
});
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("combines stats.usage_to_metrics into metrics when the backend doesn't set metrics directly (#16480)", () => {
const result = toAppConversation({
...baseInfo,
stats: {
usage_to_metrics: {
agent: {
model_name: "agent-model",
accumulated_cost: 1.5,
max_budget_per_task: 10,
accumulated_token_usage: {
prompt_tokens: 100,
completion_tokens: 20,
cache_read_tokens: 5,
cache_write_tokens: 1,
context_window: 8000,
per_turn_token: 120,
},
costs: [],
response_latencies: [],
token_usages: [],
},
condenser: {
model_name: "condenser-model",
accumulated_cost: 0.5,
max_budget_per_task: null,
accumulated_token_usage: {
prompt_tokens: 40,
completion_tokens: 10,
cache_read_tokens: 0,
cache_write_tokens: 0,
context_window: 4000,
per_turn_token: 50,
},
costs: [],
response_latencies: [],
token_usages: [],
},
},
},
});
expect(result.metrics).toEqual({
accumulated_cost: 2,
max_budget_per_task: 10,
accumulated_token_usage: {
prompt_tokens: 140,
completion_tokens: 30,
cache_read_tokens: 5,
cache_write_tokens: 1,
context_window: 8000,
per_turn_token: 120,
},
});
});
it("prefers backend-provided metrics over stats.usage_to_metrics when both are present", () => {
const result = toAppConversation({
...baseInfo,
metrics: { accumulated_cost: 3, max_budget_per_task: null },
stats: {
usage_to_metrics: {
agent: {
model_name: "agent-model",
accumulated_cost: 999,
max_budget_per_task: null,
accumulated_token_usage: null,
costs: [],
response_latencies: [],
token_usages: [],
},
},
},
});
expect(result.metrics?.accumulated_cost).toBe(3);
});
it("defaults metrics to a zero-cost snapshot when neither metrics nor stats are present", () => {
const result = toAppConversation({ ...baseInfo });
expect(result.metrics).toEqual({
accumulated_cost: 0,
max_budget_per_task: null,
accumulated_token_usage: null,
});
});
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("hydrates active_profile from stored metadata so the switcher shows the exact profile (#1082)", () => {
setStoredConversationMetadata(baseInfo.id, {
selected_repository: null,
selected_branch: null,
git_provider: null,
active_profile: "claude-sonnet-4.6",
});
try {
const result = toAppConversation({
...baseInfo,
agent: {
kind: "Agent",
llm: { model: "openhands/claude-sonnet-4-6" },
},
});
expect(result.active_profile).toBe("claude-sonnet-4.6");
} 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",
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");
});
it("surfaces the runtime ACP default model over a configured acp_model", () => {
// claude-agent-acp 0.44+ exposes ``default`` ("Default (recommended)")
// as a real, selectable model in its configOptions select. The runtime
// ``current_model_*`` fields take precedence over the configured
// ``acp_model``, so a session actually running on ``default`` must
// surface that on the chip instead of the stale configured value.
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("Default (recommended)");
});
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("surfaces ACP default model surfaced via the configured acp_model", () => {
// ``default`` / ``Default (recommended)`` is a real claude-agent-acp
// model id (not an SDK placeholder), so it must surface like any other
// configured model.
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).toBe("Default (recommended)");
});
it("surfaces ACP default model surfaced via agent.llm.model", () => {
// Same as above, one rung lower in the precedence chain: ``default`` is
// a real model id and should surface as-is.
const result = toAppConversation({
...baseInfo,
agent: { kind: "ACPAgent", llm: { model: "default" } },
});
expect(result.agent_kind).toBe("acp");
expect(result.llm_model).toBe("default");
});
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", () => {
it("returns undefined when runtime services info is absent", () => {
expect(buildRuntimeServicesSystemSuffix()).toBeUndefined();
});
it("parses runtime services JSON strings", () => {
expect(
parseRuntimeServicesInfo(
JSON.stringify({
mode: "dev:automation",
services: {
agent_server: { url_from_agent: "http://localhost:18000" },
},
}),
)?.mode,
).toBe("dev:automation");
});
it("returns null when runtime services JSON is malformed", () => {
expect(parseRuntimeServicesInfo("{not valid json")).toBeNull();
});
it("returns undefined when the JSON has no services", () => {
expect(buildRuntimeServicesSystemSuffix({ mode: "x" })).toBeUndefined();
});
it("renders a <RUNTIME_SERVICES> block when an automation entry is present", () => {
const suffix = buildRuntimeServicesSystemSuffix({
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",
},
},
});
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-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY",
);
expect(suffix).not.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.
const suffix = buildRuntimeServicesSystemSuffix({
mode: "dev:safe",
services: {
agent_server: { url_from_agent: "http://localhost:18000" },
},
});
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", () => {
const suffix = buildRuntimeServicesSystemSuffix({
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",
},
},
});
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("explicitly mentions when automation is absent", () => {
const suffix = buildRuntimeServicesSystemSuffix({
mode: "dev:safe",
services: {
agent_server: { url_from_agent: "http://localhost:18000" },
},
});
expect(suffix).toBeDefined();
expect(suffix).toContain("Automation backend: not running");
});
it("fetches runtime services from cached server_info when available", async () => {
mockGetCachedAgentServerInfo.mockReturnValue({
version: "1.28.0",
runtime_services: {
mode: "docker",
services: {
agent_server: { url_from_agent: "http://127.0.0.1:18000" },
automation: {
url_from_agent: "http://127.0.0.1:8000",
api_prefix: "/api/automation",
auth_env_var: "OPENHANDS_AUTOMATION_API_KEY",
},
},
},
});
const info = await fetchBackendRuntimeServicesInfo();
expect(info?.mode).toBe("docker");
expect(info?.services?.automation?.url_from_agent).toBe(
"http://127.0.0.1:8000",
);
expect(mockGetServerInfo).not.toHaveBeenCalled();
});
it("fetches runtime services from /server_info when there is no cached probe", async () => {
mockGetServerInfo.mockResolvedValue({
version: "1.28.0",
runtime_services: {
mode: "dev:automation",
services: {
agent_server: { url_from_agent: "http://localhost:18000" },
},
},
});
const info = await fetchBackendRuntimeServicesInfo();
expect(info?.mode).toBe("dev:automation");
expect(mockGetServerInfo).toHaveBeenCalledOnce();
});
});
describe("agent_settings runtime services suffix", () => {
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).toMatchObject({
load_public_skills: false,
load_user_skills: true,
load_project_skills: true,
});
expect(Array.isArray(payload.agent_settings.agent_context.skills)).toBe(
true,
);
});
it("sets system_message_suffix when backend runtime info is provided", () => {
const payload = buildStartConversationRequest({
settings: DEFAULT_SETTINGS,
query: "hello",
runtimeServicesInfo: {
mode: "dev:automation",
services: {
agent_server: { url_from_agent: "http://localhost:18000" },
automation: {
url_from_agent: "http://localhost:18001",
},
},
},
}) as {
agent_settings: { agent_context: Record<string, unknown> };
};
expect(payload.agent_settings.agent_context).toMatchObject({
load_public_skills: false,
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: [],
acp_model: "claude-opus-4-5",
// These fields are LLM-only and must NOT leak into ACP settings.
// (mcp_config is handled separately — it IS forwarded for ACP; see
// the dedicated tests below.)
agent: "CodeActAgent",
llm: { model: "gpt-4", api_key: "should-not-appear" },
condenser: { enabled: true, max_size: 240 },
},
},
}) 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([
...getClientAcpProvider("claude-code")!.default_command,
]);
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();
const acpAgentContext = payload.agent_settings.agent_context as Record<
string,
unknown
>;
expect(acpAgentContext).toMatchObject({
load_public_skills: false,
load_user_skills: true,
load_project_skills: true,
});
expect(Array.isArray(acpAgentContext.skills)).toBe(true);
expect(payload.tags).toEqual({
[ACP_SERVER_TAG_KEY]: "claude-code",
[CLIENT_SOURCE_TAG_KEY]: AGENT_CANVAS_SOURCE,
});
});
it("forwards mcp_config to the ACP subprocess when servers are configured", () => {
// mcp_config is a shared field: the SDK's ACPAgent forwards these servers
// to the ACP subprocess at session creation, so the start payload must
// carry it (it is intentionally NOT one of the stripped ACP-only fields).
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"],
mcp_config: {
fetch: { command: "uvx", args: ["mcp-server-fetch"] },
},
},
},
}) as { agent_settings: { mcp_config?: unknown } };
expect(payload.agent_settings.mcp_config).toEqual({
fetch: { command: "uvx", args: ["mcp-server-fetch"] },
});
});
it("omits mcp_config from the ACP payload when it carries no servers", () => {
// An empty / serverless mcp_config must not be sent as ``mcp_config: {}``.
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"],
mcp_config: {},
},
},
}) as { agent_settings: { mcp_config?: unknown } };
expect(payload.agent_settings.mcp_config).toBeUndefined();
});
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).toEqual({
[CLIENT_SOURCE_TAG_KEY]: AGENT_CANVAS_SOURCE,
});
});
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("falls back to the preferred (Vertex-safe) default for a null Gemini acp_model", () => {
// The start-request fallback is the third default-model surface (after
// onboarding and Settings → Agent) — all three must substitute the same
// Vertex-safe model, or a saved ``null`` would silently run gemini-cli's
// 404-prone default on Vertex (software-agent-sdk#3532).
const payload = buildStartConversationRequest({
settings: {
...DEFAULT_SETTINGS,
agent_settings: {
schema_version: 1,
agent_kind: "acp",
acp_server: "gemini-cli",
acp_model: null,
},
},
}) as { agent_settings: Record<string, unknown> };
expect(payload.agent_settings.acp_model).toBe(ACP_VERTEX_SAFE_MODEL);
});
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([
...getClientAcpProvider("claude-code")!.default_command,
]);
});
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([
...getClientAcpProvider("codex")!.default_command,
]);
});
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("opus[1m]");
});
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: [],
// Legacy persisted value: provider creds no longer ride acp_env —
// they flow through the Secrets panel (request.secrets). A stale
// acp_env left on saved settings must be dropped, not forwarded.
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([
...getClientAcpProvider("claude-code")!.default_command,
]);
expect(acpPayload.agent_settings.acp_model).toBe("claude-opus-4-5");
// acp_env is no longer a forwarded ACP setting — a stale value on saved
// settings is dropped rather than leaked into the conversation request.
expect(acpPayload.agent_settings.acp_env).toBeUndefined();
expect(acpPayload.agent_settings.llm).toBeUndefined();
expect(acpPayload.agent_settings.condenser).toBeUndefined();
});
});
describe("buildStartConversationRequestWithEncryptedSettings", () => {
let settingsSpy: MockInstance;
let secretsSpy: MockInstance;
beforeEach(() => {
mockLoadHooks.mockReset();
// Stub only the collaborators; the hooks lookup is what's under test.
settingsSpy = vi
.spyOn(SettingsService, "getSettingsForConversation")
.mockResolvedValue({
agentSettings: (DEFAULT_SETTINGS.agent_settings ?? {}) as Record<
string,
SettingsValue
>,
conversationSettings: (DEFAULT_SETTINGS.conversation_settings ??
{}) as Record<string, SettingsValue>,
secretsEncrypted: true,
skillEnablement: { disabledSkills: [] },
});
secretsSpy = vi.spyOn(SecretsService, "getSecrets").mockResolvedValue([]);
});
// vitest is not configured with `restoreMocks`, so restore explicitly.
afterEach(() => {
settingsSpy.mockRestore();
secretsSpy.mockRestore();
});
it("looks hooks up in hooksProjectDir, not the conversation working dir", async () => {
mockLoadHooks.mockResolvedValue({ hook_config: WORKSPACE_HOOK_CONFIG });
const payload = await buildStartConversationRequestWithEncryptedSettings({
settings: DEFAULT_SETTINGS,
workingDir: "/workspace/my-project/0f1e2d3c",
hooksProjectDir: "/workspace/my-project",
});
expect(mockLoadHooks).toHaveBeenCalledWith({
project_dir: "/workspace/my-project",
});
expect(payload.hook_config).toEqual(WORKSPACE_HOOK_CONFIG);
});
it("falls back to the configured working dir when no hooksProjectDir is given", async () => {
mockLoadHooks.mockResolvedValue({ hook_config: null });
await buildStartConversationRequestWithEncryptedSettings({
settings: DEFAULT_SETTINGS,
workingDir: "/workspace/my-project/0f1e2d3c",
});
expect(mockLoadHooks).toHaveBeenCalledWith({
project_dir: "/workspace/project/agent-canvas",
});
});
it("omits hook_config when the workspace has no hooks", async () => {
mockLoadHooks.mockResolvedValue({ hook_config: null });
const payload = await buildStartConversationRequestWithEncryptedSettings({
settings: DEFAULT_SETTINGS,
hooksProjectDir: "/workspace/my-project",
});
expect(payload.hook_config).toBeUndefined();
});
it("starts the conversation without hooks when the lookup fails", async () => {
mockLoadHooks.mockRejectedValue(new Error("500 Internal Server Error"));
const payload = await buildStartConversationRequestWithEncryptedSettings({
settings: DEFAULT_SETTINGS,
hooksProjectDir: "/workspace/my-project",
});
expect(payload.hook_config).toBeUndefined();
expect(payload.agent_settings).toBeDefined();
});
});