| import { beforeEach, describe, expect, it, vi } from "vitest"; |
| import { CANVAS_UI_CLIENT_TOOL_NAME } from "#/constants/canvas-ui"; |
| import { LAUNCH_CHILD_CONVERSATION_TOOL_NAME } from "#/constants/child-conversation"; |
| import { DEFAULT_SETTINGS } from "#/services/settings"; |
| import type { Settings } from "#/types/settings"; |
| import { |
| AGENT_CANVAS_SOURCE, |
| CLIENT_SOURCE_TAG_KEY, |
| buildStartConversationRequest, |
| buildStartPlanningConversationRequest, |
| buildStartPlanningConversationRequestWithEncryptedSettings, |
| toConversationPage, |
| } from "./agent-server-adapter"; |
| import SettingsService from "./settings-service/settings-service.api"; |
| import AgentProfilesService from "./agent-profiles-service/agent-profiles-service.api"; |
| import ProfilesService from "./profiles-service/profiles-service.api"; |
|
|
| vi.mock("./settings-service/settings-service.api", () => ({ |
| default: { getSettingsForConversation: vi.fn() }, |
| })); |
| vi.mock("./secrets-service", () => ({ |
| SecretsService: { getSecrets: vi.fn().mockResolvedValue([]) }, |
| })); |
| vi.mock("./agent-profiles-service/agent-profiles-service.api", () => ({ |
| default: { listProfiles: vi.fn() }, |
| })); |
| vi.mock("./profiles-service/profiles-service.api", () => ({ |
| default: { getProfile: vi.fn() }, |
| })); |
|
|
| const encryptedValue = "gAAAAAencrypted-mcp-header"; |
|
|
| function makeSettings(agentSettings: Settings["agent_settings"]): Settings { |
| return { |
| ...DEFAULT_SETTINGS, |
| agent_settings: agentSettings, |
| conversation_settings: { |
| confirmation_mode: false, |
| security_analyzer: null, |
| max_iterations: 20, |
| }, |
| }; |
| } |
|
|
| function getAgentContextSkillNames( |
| payload: ReturnType<typeof buildStartConversationRequest>, |
| ): Array<string | undefined> { |
| const agentSettings = payload.agent_settings as |
| | { |
| agent_context?: { |
| skills?: Array<{ name?: string }>; |
| }; |
| } |
| | undefined; |
| return agentSettings?.agent_context?.skills?.map((skill) => skill.name) ?? []; |
| } |
|
|
| function getPlannerAgentContextSkillNames(payload: { |
| agent: { agent_context?: { skills?: Array<{ name?: string }> } }; |
| }): Array<string | undefined> { |
| return payload.agent.agent_context?.skills?.map((skill) => skill.name) ?? []; |
| } |
|
|
| describe("buildStartConversationRequest", () => { |
| it("marks OpenHands start requests as encrypted when MCP headers are encrypted", () => { |
| const agentSettings = { |
| agent_kind: "openhands", |
| llm: { |
| model: "litellm_proxy/openai/gpt-5.5", |
| api_key: "gAAAAAencrypted-llm-api-key", |
| }, |
| mcp_config: { |
| linear: { |
| url: "https://mcp.linear.app/mcp", |
| transport: "http", |
| headers: { |
| Authorization: encryptedValue, |
| }, |
| }, |
| }, |
| }; |
| const settings = makeSettings(agentSettings); |
|
|
| const payload = buildStartConversationRequest({ |
| settings, |
| encryptedAgentSettings: agentSettings, |
| encryptedConversationSettings: settings.conversation_settings!, |
| secretsEncrypted: true, |
| }); |
|
|
| expect(payload.agent_settings!.agent_kind).toBe("openhands"); |
| expect(payload.agent_settings!.mcp_config).toEqual( |
| agentSettings.mcp_config, |
| ); |
| expect(payload.secrets_encrypted).toBe(true); |
| }); |
|
|
| it("marks ACP start requests as encrypted when MCP headers are encrypted", () => { |
| const agentSettings = { |
| agent_kind: "acp", |
| acp_server: "codex", |
| acp_command: ["codex-acp"], |
| acp_model: "gpt-5.5/medium", |
| mcp_config: { |
| linear: { |
| url: "https://mcp.linear.app/mcp", |
| transport: "http", |
| headers: { |
| Authorization: encryptedValue, |
| }, |
| }, |
| }, |
| }; |
| const settings = makeSettings(agentSettings); |
|
|
| const payload = buildStartConversationRequest({ |
| settings, |
| encryptedAgentSettings: agentSettings, |
| encryptedConversationSettings: settings.conversation_settings!, |
| secretsEncrypted: true, |
| }); |
|
|
| expect(payload.agent_settings!.agent_kind).toBe("acp"); |
| expect(payload.agent_settings!.mcp_config).toEqual( |
| agentSettings.mcp_config, |
| ); |
| expect(payload.secrets_encrypted).toBe(true); |
| }); |
|
|
| it("builds a raw planning agent request for local Planner", () => { |
| const agentSettings = { |
| agent_kind: "openhands", |
| llm: { |
| model: "openhands/minimax-m2.7", |
| api_key: "gAAAAAencrypted-llm-api-key", |
| }, |
| }; |
|
|
| const payload = buildStartPlanningConversationRequest({ |
| encryptedAgentSettings: agentSettings, |
| workingDir: "/workspace/project/agent-canvas", |
| parentConversationId: "parent-1", |
| secretsEncrypted: true, |
| customSecrets: [{ name: "CUSTOM_TOKEN" }], |
| }); |
|
|
| expect(payload.agent).toMatchObject({ |
| kind: "Agent", |
| system_prompt_filename: "system_prompt_planning.j2", |
| system_prompt_kwargs: { |
| plan_structure: expect.stringContaining("OBJECTIVE"), |
| }, |
| llm: { |
| model: "openhands/minimax-m2.7", |
| api_key: "gAAAAAencrypted-llm-api-key", |
| }, |
| tools: [ |
| { name: "glob", params: {} }, |
| { name: "grep", params: {} }, |
| { |
| name: "planning_file_editor", |
| params: { |
| plan_path: "/workspace/project/agent-canvas/.agents_tmp/PLAN.md", |
| }, |
| }, |
| ], |
| |
| condenser: { |
| kind: "LLMSummarizingCondenser", |
| max_size: 100, |
| keep_first: 6, |
| llm: { |
| model: "openhands/minimax-m2.7", |
| usage_id: "planning_condenser", |
| }, |
| }, |
| }); |
| expect(payload.agent_settings).toBeUndefined(); |
| expect(payload.worktree).toBe(false); |
| expect(payload.tags).toEqual({ plannerparent: "parent-1" }); |
| expect(payload.secrets_encrypted).toBe(true); |
| expect(payload.secrets).toHaveProperty("CUSTOM_TOKEN"); |
| }); |
|
|
| it("links the local planner to its parent server-side so the relationship survives storage loss", () => { |
| const payload = buildStartPlanningConversationRequest({ |
| encryptedAgentSettings: { |
| agent_kind: "openhands", |
| llm: { model: "openhands/minimax-m2.7" }, |
| }, |
| workingDir: "/workspace/project", |
| parentConversationId: "parent-1", |
| }); |
|
|
| |
| |
| expect(payload.parent_conversation_id).toBe("parent-1"); |
| }); |
|
|
| it("defaults the planner's max_iterations to 500 when not provided", () => { |
| const payload = buildStartPlanningConversationRequest({ |
| encryptedAgentSettings: { |
| agent_kind: "openhands", |
| llm: { model: "openhands/minimax-m2.7" }, |
| }, |
| workingDir: "/workspace/project", |
| parentConversationId: "parent-1", |
| }); |
|
|
| expect(payload.max_iterations).toBe(500); |
| }); |
|
|
| it("uses the caller-provided max_iterations instead of the hardcoded default", () => { |
| const payload = buildStartPlanningConversationRequest({ |
| encryptedAgentSettings: { |
| agent_kind: "openhands", |
| llm: { model: "openhands/minimax-m2.7" }, |
| }, |
| workingDir: "/workspace/project", |
| parentConversationId: "parent-1", |
| maxIterations: 250, |
| }); |
|
|
| expect(payload.max_iterations).toBe(250); |
| }); |
|
|
| it("omits local planner helper conversations from paginated conversation results", () => { |
| const page = toConversationPage({ |
| items: [ |
| { |
| id: "main-1", |
| created_at: "2024-01-01T00:00:00.000Z", |
| updated_at: "2024-01-01T00:00:00.000Z", |
| execution_status: "idle", |
| tags: {}, |
| }, |
| { |
| id: "plan-1", |
| created_at: "2024-01-01T00:00:00.000Z", |
| updated_at: "2024-01-01T00:00:00.000Z", |
| execution_status: "idle", |
| tags: { plannerparent: "main-1" }, |
| }, |
| ], |
| }); |
|
|
| expect(page.items.map((item) => item.id)).toEqual(["main-1"]); |
| }); |
|
|
| it("keeps ACP start requests unencrypted when no encrypted MCP values are present", () => { |
| const agentSettings = { |
| agent_kind: "acp", |
| acp_server: "codex", |
| acp_command: ["codex-acp"], |
| acp_model: "gpt-5.5/medium", |
| mcp_config: { |
| publicDocs: { |
| url: "https://docs.example.com/mcp", |
| transport: "http", |
| }, |
| }, |
| }; |
| const settings = makeSettings(agentSettings); |
|
|
| const payload = buildStartConversationRequest({ |
| settings, |
| encryptedAgentSettings: agentSettings, |
| encryptedConversationSettings: settings.conversation_settings!, |
| secretsEncrypted: true, |
| }); |
|
|
| expect(payload.agent_settings!.agent_kind).toBe("acp"); |
| expect(payload.secrets_encrypted).toBeUndefined(); |
| }); |
|
|
| it("ships only allow-listed catalog skills to a OpenHands conversation context", () => { |
| const settings = makeSettings({ |
| agent_kind: "openhands", |
| llm: { |
| model: "litellm_proxy/openai/gpt-5.5", |
| api_key: "sk-test", |
| }, |
| agent_context: { |
| skills: [ |
| { name: "disabled-custom", content: "disabled" }, |
| { name: "enabled-custom", content: "enabled" }, |
| ], |
| }, |
| }); |
| settings.disabled_skills = ["agent-memory", "disabled-custom"]; |
|
|
| const payload = buildStartConversationRequest({ settings }); |
| const skillNames = getAgentContextSkillNames(payload); |
|
|
| |
| |
| |
| expect(skillNames).not.toContain("agent-memory"); |
| expect(skillNames).not.toContain("disabled-custom"); |
| |
| expect(skillNames).toContain("enabled-custom"); |
| |
| |
| |
| expect(skillNames).toContain("add-skill"); |
| expect(skillNames).not.toContain("add-javadoc"); |
|
|
| expect(payload.agent_settings?.agent_context?.disabled_skills).toEqual([ |
| "agent-memory", |
| "disabled-custom", |
| ]); |
| }); |
|
|
| it("ships only allow-listed catalog skills to a ACP conversation context", () => { |
| const settings = makeSettings({ |
| agent_kind: "acp", |
| acp_server: "codex", |
| acp_command: ["codex-acp"], |
| acp_model: "gpt-5.5/medium", |
| agent_context: { |
| skills: [ |
| { name: "disabled-custom", content: "disabled" }, |
| { name: "enabled-custom", content: "enabled" }, |
| ], |
| }, |
| }); |
| settings.disabled_skills = ["agent-memory", "disabled-custom"]; |
|
|
| const payload = buildStartConversationRequest({ settings }); |
| const skillNames = getAgentContextSkillNames(payload); |
|
|
| |
| |
| |
| expect(skillNames).not.toContain("agent-memory"); |
| expect(skillNames).not.toContain("disabled-custom"); |
| |
| expect(skillNames).toContain("enabled-custom"); |
| |
| |
| |
| expect(skillNames).toContain("add-skill"); |
| expect(skillNames).not.toContain("add-javadoc"); |
|
|
| expect(payload.agent_settings?.agent_context?.disabled_skills).toEqual([ |
| "agent-memory", |
| "disabled-custom", |
| ]); |
| }); |
|
|
| it("loads a catalog skill the opening slash command invokes", () => { |
| |
| |
| |
| |
| const settings = makeSettings({ |
| agent_kind: "openhands", |
| llm: { model: "litellm_proxy/openai/gpt-5.5", api_key: "sk-test" }, |
| }); |
|
|
| const payload = buildStartConversationRequest({ |
| settings, |
| query: "/standup-digest:setup", |
| }); |
|
|
| expect(getAgentContextSkillNames(payload)).toContain( |
| "slack-standup-digest", |
| ); |
| }); |
|
|
| it("does not admit a skill named mid-sentence rather than invoked", () => { |
| const settings = makeSettings({ |
| agent_kind: "openhands", |
| llm: { model: "litellm_proxy/openai/gpt-5.5", api_key: "sk-test" }, |
| }); |
|
|
| const payload = buildStartConversationRequest({ |
| settings, |
| query: "tell me what /standup-digest:setup would do", |
| }); |
|
|
| expect(getAgentContextSkillNames(payload)).not.toContain( |
| "slack-standup-digest", |
| ); |
| }); |
|
|
| it("lets an invoked skill override a stored deny entry for that conversation", () => { |
| const settings = makeSettings({ |
| agent_kind: "openhands", |
| llm: { model: "litellm_proxy/openai/gpt-5.5", api_key: "sk-test" }, |
| }); |
| settings.disabled_skills = ["slack-standup-digest"]; |
|
|
| const payload = buildStartConversationRequest({ |
| settings, |
| query: "/standup-digest:setup", |
| }); |
|
|
| expect(getAgentContextSkillNames(payload)).toContain( |
| "slack-standup-digest", |
| ); |
| }); |
| }); |
|
|
| describe("buildStartPlanningConversationRequestWithEncryptedSettings", () => { |
| const globallyActiveSettings = { |
| agentSettings: { |
| agent_kind: "openhands", |
| llm: { |
| model: "openhands/globally-active-model", |
| api_key: "gAAAAAglobal-key", |
| base_url: "https://global.example.com", |
| }, |
| }, |
| conversationSettings: {}, |
| secretsEncrypted: true, |
| skillEnablement: { disabledSkills: [] }, |
| }; |
|
|
| beforeEach(() => { |
| vi.clearAllMocks(); |
| vi.mocked(SettingsService.getSettingsForConversation).mockResolvedValue( |
| globallyActiveSettings, |
| ); |
| vi.mocked(AgentProfilesService.listProfiles).mockResolvedValue({ |
| profiles: [ |
| { |
| id: "profile-parent", |
| name: "parent-profile", |
| agent_kind: "openhands", |
| revision: 1, |
| llm_profile_ref: "parent-llm", |
| mcp_server_refs: null, |
| }, |
| ], |
| active_agent_profile_id: "profile-other", |
| }); |
| vi.mocked(ProfilesService.getProfile).mockResolvedValue({ |
| name: "parent-llm", |
| api_key_set: true, |
| config: { |
| model: "openhands/parent-profile-model", |
| api_key: "gAAAAAparent-key", |
| base_url: "https://parent.example.com", |
| }, |
| }); |
| }); |
|
|
| it("pins the planner to the parent's launched profile, not the globally active one", async () => { |
| const payload = |
| await buildStartPlanningConversationRequestWithEncryptedSettings({ |
| workingDir: "/workspace/project", |
| parentConversationId: "parent-1", |
| parentAgentProfileId: "profile-parent", |
| }); |
|
|
| expect(ProfilesService.getProfile).toHaveBeenCalledWith( |
| "parent-llm", |
| "encrypted", |
| ); |
| expect(payload.agent.llm).toMatchObject({ |
| model: "openhands/parent-profile-model", |
| api_key: "gAAAAAparent-key", |
| base_url: "https://parent.example.com", |
| }); |
| }); |
|
|
| it("falls back to global settings when the parent was not launched from a profile", async () => { |
| const payload = |
| await buildStartPlanningConversationRequestWithEncryptedSettings({ |
| workingDir: "/workspace/project", |
| parentConversationId: "parent-1", |
| parentAgentProfileId: null, |
| }); |
|
|
| expect(AgentProfilesService.listProfiles).not.toHaveBeenCalled(); |
| expect(payload.agent.llm).toMatchObject({ |
| model: "openhands/globally-active-model", |
| }); |
| }); |
|
|
| it("prefers the parent conversation's current active_profile over its launched agent profile", async () => { |
| |
| |
| |
| vi.mocked(ProfilesService.getProfile).mockImplementation(async (name) => |
| name === "switched-llm" |
| ? { |
| name: "switched-llm", |
| api_key_set: true, |
| config: { |
| model: "openhands/switched-model", |
| api_key: "gAAAAAswitched-key", |
| base_url: "https://switched.example.com", |
| }, |
| } |
| : { |
| name: "parent-llm", |
| api_key_set: true, |
| config: { |
| model: "openhands/parent-profile-model", |
| api_key: "gAAAAAparent-key", |
| base_url: "https://parent.example.com", |
| }, |
| }, |
| ); |
|
|
| const payload = |
| await buildStartPlanningConversationRequestWithEncryptedSettings({ |
| workingDir: "/workspace/project", |
| parentConversationId: "parent-1", |
| parentActiveProfileName: "switched-llm", |
| parentAgentProfileId: "profile-parent", |
| }); |
|
|
| expect(ProfilesService.getProfile).toHaveBeenCalledWith( |
| "switched-llm", |
| "encrypted", |
| ); |
| |
| |
| expect(AgentProfilesService.listProfiles).not.toHaveBeenCalled(); |
| expect(payload.agent.llm).toMatchObject({ |
| model: "openhands/switched-model", |
| api_key: "gAAAAAswitched-key", |
| base_url: "https://switched.example.com", |
| }); |
| }); |
|
|
| it("falls back to the launched agent profile when active_profile can't be resolved", async () => { |
| vi.mocked(ProfilesService.getProfile).mockImplementation(async (name) => { |
| if (name === "dangling-llm") throw new Error("not found"); |
| return { |
| name: "parent-llm", |
| api_key_set: true, |
| config: { |
| model: "openhands/parent-profile-model", |
| api_key: "gAAAAAparent-key", |
| base_url: "https://parent.example.com", |
| }, |
| }; |
| }); |
|
|
| const payload = |
| await buildStartPlanningConversationRequestWithEncryptedSettings({ |
| workingDir: "/workspace/project", |
| parentConversationId: "parent-1", |
| parentActiveProfileName: "dangling-llm", |
| parentAgentProfileId: "profile-parent", |
| }); |
|
|
| expect(payload.agent.llm).toMatchObject({ |
| model: "openhands/parent-profile-model", |
| }); |
| }); |
|
|
| it("falls back to global settings when the parent's profile reference dangles", async () => { |
| vi.mocked(AgentProfilesService.listProfiles).mockResolvedValue({ |
| profiles: [], |
| active_agent_profile_id: null, |
| }); |
|
|
| const payload = |
| await buildStartPlanningConversationRequestWithEncryptedSettings({ |
| workingDir: "/workspace/project", |
| parentConversationId: "parent-1", |
| parentAgentProfileId: "profile-parent", |
| }); |
|
|
| expect(ProfilesService.getProfile).not.toHaveBeenCalled(); |
| expect(payload.agent.llm).toMatchObject({ |
| model: "openhands/globally-active-model", |
| }); |
| }); |
|
|
| it("mirrors the parent's configured max_iterations instead of hardcoding a lower cap", async () => { |
| vi.mocked(SettingsService.getSettingsForConversation).mockResolvedValue({ |
| ...globallyActiveSettings, |
| conversationSettings: { max_iterations: 1000 }, |
| }); |
|
|
| const payload = |
| await buildStartPlanningConversationRequestWithEncryptedSettings({ |
| workingDir: "/workspace/project", |
| parentConversationId: "parent-1", |
| }); |
|
|
| expect(payload.max_iterations).toBe(1000); |
| }); |
|
|
| it("falls back to 500 when the parent has no configured max_iterations", async () => { |
| const payload = |
| await buildStartPlanningConversationRequestWithEncryptedSettings({ |
| workingDir: "/workspace/project", |
| parentConversationId: "parent-1", |
| }); |
|
|
| expect(payload.max_iterations).toBe(500); |
| }); |
|
|
| it("applies the full skill enablement to the planner's agent context, mirroring the code agent", async () => { |
| |
| |
| |
| |
| vi.mocked(SettingsService.getSettingsForConversation).mockResolvedValue({ |
| ...globallyActiveSettings, |
| skillEnablement: { |
| enabledSkills: ["add-javadoc", "agent-memory"], |
| disabledSkills: ["agent-memory"], |
| }, |
| }); |
|
|
| const payload = |
| await buildStartPlanningConversationRequestWithEncryptedSettings({ |
| workingDir: "/workspace/project", |
| parentConversationId: "parent-1", |
| }); |
|
|
| const skillNames = getPlannerAgentContextSkillNames(payload); |
| expect(skillNames).not.toContain("agent-memory"); |
| expect(skillNames).toContain("add-javadoc"); |
| }); |
| }); |
|
|
| describe("buildStartConversationRequest — agentProfileId path", () => { |
| it("sends agent_profile_id and omits agent_settings (mutually exclusive)", () => { |
| const settings = makeSettings({ |
| agent_kind: "openhands", |
| llm: { model: "litellm_proxy/openai/gpt-5.5", api_key: "sk-test" }, |
| }); |
|
|
| const payload = buildStartConversationRequest({ |
| settings, |
| agentProfileId: "profile-xyz", |
| agentProfileKind: "openhands", |
| }); |
|
|
| expect(payload.agent_profile_id).toBe("profile-xyz"); |
| expect(payload.agent_settings).toBeUndefined(); |
| expect(payload.client_tools.map((tool) => tool.name)).toEqual([ |
| CANVAS_UI_CLIENT_TOOL_NAME, |
| LAUNCH_CHILD_CONVERSATION_TOOL_NAME, |
| ]); |
| }); |
|
|
| it("suppresses the ACP server tag when launching from a profile", () => { |
| const agentSettings = { |
| agent_kind: "acp", |
| acp_server: "codex", |
| acp_command: ["codex-acp"], |
| acp_model: "gpt-5.5/medium", |
| }; |
|
|
| |
| expect( |
| buildStartConversationRequest({ settings: makeSettings(agentSettings) }) |
| .tags, |
| ).toBeDefined(); |
|
|
| |
| |
| |
| const payload = buildStartConversationRequest({ |
| settings: makeSettings(agentSettings), |
| agentProfileId: "profile-xyz", |
| }); |
| expect(payload.tags).toEqual({ |
| [CLIENT_SOURCE_TAG_KEY]: AGENT_CANVAS_SOURCE, |
| }); |
| }); |
|
|
| it("suppresses secrets_encrypted when launching from a profile", () => { |
| const agentSettings = { |
| agent_kind: "openhands", |
| llm: { |
| model: "litellm_proxy/openai/gpt-5.5", |
| api_key: "gAAAAAencrypted-llm-api-key", |
| }, |
| mcp_config: { |
| mcpServers: { |
| linear: { |
| url: "https://mcp.linear.app/mcp", |
| transport: "http", |
| headers: { Authorization: encryptedValue }, |
| }, |
| }, |
| }, |
| }; |
| const settings = makeSettings(agentSettings); |
|
|
| |
| |
| const payload = buildStartConversationRequest({ |
| settings, |
| encryptedAgentSettings: agentSettings, |
| encryptedConversationSettings: settings.conversation_settings!, |
| secretsEncrypted: true, |
| agentProfileId: "profile-xyz", |
| }); |
|
|
| expect(payload.secrets_encrypted).toBeUndefined(); |
| }); |
| }); |
|
|