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 => ({ 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 & { llm: Record; tools: Array<{ name: string; params: Record }>; agent_context: Record; }; 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 } }; 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 } }; 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 }>; }; }; 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 }>; }; }; 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 }>; }; }; 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; security_analyzer: Record; }; 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; 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; 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; 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; } >; }; 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 }; }; 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 }; }; 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 } }; secrets: Record; }; 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 } }; }; 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 } }; }; expect(payload.agent_settings.agent_context?.secrets).toBeUndefined(); }); describe("ACP secret delivery", () => { // Settings factory for a containerized ACP conversation. const acpSettings = (overrides: Record = {}) => ({ ...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 }; }; secrets: Record; }; 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; }; 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 }; 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 }; 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 & { llm: Record; }; }; function getModelFrom( options: Parameters[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 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(""); 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(""); // 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 }; }; 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 }; }; 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(""); }); }); 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 & { acp_command?: string[]; acp_model?: string | null; agent_context?: unknown; }; tags?: Record; }; 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 & { llm: Record; }; tags?: Record; }; 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 }; 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 }; 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 & { 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 & { 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 & { 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 & { 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 & { 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 & { 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 & { llm: Record; }; }; 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 & { 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, 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(); }); });