Download __tests__/api/agent-server-conversation-service.test.ts from SaylorTwift/openhands: direct link, hf CLI and curl.
- Browser
- Download file 51.3 kB
-
https://huggingface.co/SaylorTwift/openhands/resolve/main/__tests__/api/agent-server-conversation-service.test.ts
- Command line
-
hf download hf://SaylorTwift/openhands/__tests__/api/agent-server-conversation-service.test.ts
-
curl -L -o agent-server-conversation-service.test.ts https://huggingface.co/SaylorTwift/openhands/resolve/main/__tests__/api/agent-server-conversation-service.test.ts
51.3 kB
| import { | |
| ConversationClient, | |
| FileClient, | |
| ProfilesClient, | |
| SettingsClient, | |
| } from "@openhands/typescript-client/clients"; | |
| import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; | |
| import { | |
| __resetActiveStoreForTests, | |
| setActiveSelection, | |
| setRegisteredBackends, | |
| } from "#/api/backend-registry/active-store"; | |
| import type { Backend } from "#/api/backend-registry/types"; | |
| import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; | |
| import { setStoredConversationMetadata } from "#/api/conversation-metadata-store"; | |
| import { | |
| getFetchCall, | |
| getJsonBody, | |
| mockJsonResponse, | |
| } from "./cloud/fetch-test-utils"; | |
| const { | |
| mockHttpGet, | |
| mockHttpPost, | |
| mockHttpDelete, | |
| mockConversationClient, | |
| mockFileClient, | |
| mockSettingsClient, | |
| mockSwitchProfile, | |
| mockSwitchLLM, | |
| mockGetSettings, | |
| mockGetSettingsForConversation, | |
| mockGetProfile, | |
| mockActivateProfile, | |
| mockListProfiles, | |
| mockGetTelemetryDistinctId, | |
| mockLoadHooks, | |
| } = vi.hoisted(() => ({ | |
| mockHttpGet: vi.fn(), | |
| mockHttpPost: vi.fn(), | |
| mockHttpDelete: vi.fn(), | |
| mockConversationClient: vi.fn(), | |
| mockFileClient: vi.fn(), | |
| mockSettingsClient: vi.fn(), | |
| mockSwitchProfile: vi.fn(), | |
| mockSwitchLLM: vi.fn(), | |
| mockGetSettings: vi.fn(), | |
| mockGetSettingsForConversation: vi.fn(), | |
| mockGetProfile: vi.fn(), | |
| mockActivateProfile: vi.fn(), | |
| mockListProfiles: vi.fn(), | |
| mockGetTelemetryDistinctId: vi.fn(), | |
| mockLoadHooks: vi.fn(), | |
| })); | |
| const originalFetch = global.fetch; | |
| const fetchMock = vi.fn(); | |
| vi.mock("@openhands/typescript-client/clients", async () => { | |
| const actual = await vi.importActual< | |
| typeof import("@openhands/typescript-client/clients") | |
| >("@openhands/typescript-client/clients"); | |
| return { | |
| ...actual, | |
| ConversationClient: vi.fn(function ConversationClientMock() { | |
| return mockConversationClient(); | |
| }), | |
| FileClient: vi.fn(function FileClientMock() { | |
| return mockFileClient(); | |
| }), | |
| ProfilesClient: vi.fn(function ProfilesClientMock() { | |
| return { | |
| getProfile: mockGetProfile, | |
| activateProfile: mockActivateProfile, | |
| listProfiles: mockListProfiles, | |
| }; | |
| }), | |
| SettingsClient: vi.fn(function SettingsClientMock() { | |
| return mockSettingsClient(); | |
| }), | |
| VSCodeClient: vi.fn(function VSCodeClientMock() { | |
| return { getUrl: vi.fn() }; | |
| }), | |
| HooksClient: vi.fn(function HooksClientMock() { | |
| return { loadHooks: mockLoadHooks }; | |
| }), | |
| }; | |
| }); | |
| vi.mock("#/api/agent-server-config", () => ({ | |
| DEFAULT_WORKING_DIR: "workspace/project", | |
| getAgentServerBaseUrl: vi.fn(() => "http://localhost:54928"), | |
| getAgentServerSessionApiKey: vi.fn(() => "test-api-key"), | |
| getAgentServerWorkingDir: vi.fn(() => "/workspace/project/agent-canvas"), | |
| getWorkspaceRootForBackend: vi.fn(() => "/workspace/project/agent-canvas"), | |
| buildConversationWorkingDirForBackend: vi.fn( | |
| (id: string) => `/state/workspaces/${id.replace(/-/g, "")}`, | |
| ), | |
| getAgentServerHeaders: vi.fn(() => ({ "X-Session-API-Key": "test-api-key" })), | |
| shouldLoadPublicSkills: vi.fn(() => true), | |
| syncBakedSessionApiKey: vi.fn(), | |
| getLockedCloudHost: vi.fn(() => null), | |
| })); | |
| vi.mock("#/api/settings-service/settings-service.api", () => ({ | |
| default: { | |
| getSettings: mockGetSettings, | |
| getSettingsForConversation: mockGetSettingsForConversation, | |
| }, | |
| })); | |
| vi.mock("#/services/telemetry", () => ({ | |
| getTelemetryDistinctId: mockGetTelemetryDistinctId, | |
| })); | |
| describe("AgentServerConversationService", () => { | |
| beforeEach(() => { | |
| vi.clearAllMocks(); | |
| mockHttpGet.mockReset(); | |
| mockHttpPost.mockReset(); | |
| mockHttpDelete.mockReset(); | |
| mockGetProfile.mockReset(); | |
| mockActivateProfile.mockReset(); | |
| mockListProfiles.mockReset().mockResolvedValue({ | |
| profiles: [], | |
| active_profile: null, | |
| }); | |
| mockSwitchProfile.mockReset(); | |
| mockSwitchLLM.mockReset(); | |
| fetchMock.mockReset(); | |
| global.fetch = originalFetch; | |
| vi.mocked(ConversationClient).mockClear(); | |
| vi.mocked(FileClient).mockClear(); | |
| vi.mocked(ProfilesClient).mockClear(); | |
| vi.mocked(SettingsClient).mockClear(); | |
| mockConversationClient.mockReturnValue({ | |
| createConversation: async (payload: unknown) => { | |
| const response = await mockHttpPost("/api/conversations", payload); | |
| return response.data; | |
| }, | |
| getConversations: async (conversationIds: string[]) => { | |
| const response = await mockHttpGet("/api/conversations", { | |
| params: { ids: conversationIds }, | |
| }); | |
| return response.data; | |
| }, | |
| deleteConversation: async (conversationId: string) => { | |
| const response = await mockHttpDelete( | |
| `/api/conversations/${conversationId}`, | |
| ); | |
| return response.data; | |
| }, | |
| searchConversations: vi.fn(), | |
| getConversation: vi.fn(), | |
| sendEvent: vi.fn(), | |
| updateConversation: vi.fn(), | |
| switchProfile: mockSwitchProfile, | |
| switchLLM: mockSwitchLLM, | |
| }); | |
| mockFileClient.mockReturnValue({ | |
| downloadTextFile: async (path: string) => { | |
| const response = await mockHttpGet("/api/file/download", { | |
| params: { path }, | |
| responseType: "arrayBuffer", | |
| }); | |
| return new TextDecoder().decode(response.data); | |
| }, | |
| downloadTrajectory: async (conversationId: string) => { | |
| const response = await mockHttpGet( | |
| `/api/file/download-trajectory/${conversationId}`, | |
| { responseType: "blob" }, | |
| ); | |
| return response.data; | |
| }, | |
| // @spec WUP-001 — createConversation resolves relative working dirs | |
| // via FileClient.getHome before sending the conversation-start payload. | |
| getHome: async () => ({ home: "/Users/agent" }), | |
| }); | |
| mockSettingsClient.mockReturnValue({ | |
| listSecrets: vi.fn().mockResolvedValue({ secrets: [] }), | |
| }); | |
| }); | |
| describe("readConversationFile", () => { | |
| it("downloads the plan from the conversation's own working_dir when no filePath is provided", async () => { | |
| const encodedPlan = new TextEncoder().encode("# PLAN content").buffer; | |
| mockHttpGet.mockImplementation((url: string) => { | |
| if (url === "/api/conversations") { | |
| return Promise.resolve({ | |
| data: [ | |
| { | |
| id: "conv-123", | |
| created_at: "2024-01-01", | |
| updated_at: "2024-01-01", | |
| workspace: { | |
| working_dir: "/workspace/project/agent-canvas/conv-123", | |
| }, | |
| }, | |
| ], | |
| }); | |
| } | |
| return Promise.resolve({ data: encodedPlan }); | |
| }); | |
| const content = | |
| await AgentServerConversationService.readConversationFile("conv-123"); | |
| expect(content).toBe("# PLAN content"); | |
| expect(ConversationClient).toHaveBeenCalledWith({ | |
| host: "http://localhost:54928", | |
| apiKey: "test-api-key", | |
| workingDir: "/workspace/project/agent-canvas", | |
| }); | |
| expect(FileClient).toHaveBeenCalledWith({ | |
| host: "http://localhost:54928", | |
| apiKey: "test-api-key", | |
| workingDir: "/workspace/project/agent-canvas", | |
| }); | |
| expect(mockHttpGet).toHaveBeenCalledWith( | |
| "/api/file/download", | |
| expect.objectContaining({ | |
| params: { | |
| path: "/workspace/project/agent-canvas/conv-123/.agents_tmp/PLAN.md", | |
| }, | |
| responseType: "arrayBuffer", | |
| }), | |
| ); | |
| }); | |
| it("rejects explicit file paths outside the conversation workspace", async () => { | |
| mockHttpGet.mockImplementation((url: string) => { | |
| if (url === "/api/conversations") { | |
| return Promise.resolve({ | |
| data: [ | |
| { | |
| id: "conv-123", | |
| created_at: "2024-01-01", | |
| updated_at: "2024-01-01", | |
| workspace: { | |
| working_dir: "/workspace/project/agent-canvas/conv-123", | |
| }, | |
| }, | |
| ], | |
| }); | |
| } | |
| return Promise.resolve({ data: new ArrayBuffer(0) }); | |
| }); | |
| await expect( | |
| AgentServerConversationService.readConversationFile( | |
| "conv-123", | |
| "/workspace/project/agent-canvas/other/PLAN.md", | |
| ), | |
| ).rejects.toThrow( | |
| "Conversation file path must stay inside the workspace", | |
| ); | |
| expect(mockHttpGet).not.toHaveBeenCalledWith( | |
| "/api/file/download", | |
| expect.anything(), | |
| ); | |
| }); | |
| }); | |
| describe("createConversation", () => { | |
| it("forwards the Canvas telemetry identity to the local agent server", async () => { | |
| mockGetTelemetryDistinctId.mockResolvedValue("ph-canvas-user"); | |
| mockGetSettings.mockResolvedValue({ | |
| agent_settings: { llm: { model: "gpt-4o" } }, | |
| conversation_settings: {}, | |
| }); | |
| mockGetSettingsForConversation.mockResolvedValue({ | |
| agentSettings: { llm: { model: "gpt-4o" } }, | |
| conversationSettings: {}, | |
| secretsEncrypted: true, | |
| }); | |
| mockHttpPost.mockResolvedValue({ | |
| data: { | |
| id: "conversation-1", | |
| created_at: "2024-01-01", | |
| updated_at: "2024-01-01", | |
| }, | |
| }); | |
| await AgentServerConversationService.createConversation(); | |
| expect(mockHttpPost).toHaveBeenCalledWith( | |
| "/api/conversations", | |
| expect.objectContaining({ user_id: "ph-canvas-user" }), | |
| ); | |
| }); | |
| it("omits user_id when Canvas telemetry has no consented identity", async () => { | |
| mockGetTelemetryDistinctId.mockResolvedValue(null); | |
| mockGetSettings.mockResolvedValue({ | |
| agent_settings: { llm: { model: "gpt-4o" } }, | |
| conversation_settings: {}, | |
| }); | |
| mockGetSettingsForConversation.mockResolvedValue({ | |
| agentSettings: { llm: { model: "gpt-4o" } }, | |
| conversationSettings: {}, | |
| secretsEncrypted: true, | |
| }); | |
| mockHttpPost.mockResolvedValue({ | |
| data: { | |
| id: "conversation-1", | |
| created_at: "2024-01-01", | |
| updated_at: "2024-01-01", | |
| }, | |
| }); | |
| await AgentServerConversationService.createConversation(); | |
| const payload = mockHttpPost.mock.calls[0][1] as Record<string, unknown>; | |
| expect(payload).not.toHaveProperty("user_id"); | |
| }); | |
| it("passes the selected title profile to local conversation starts", async () => { | |
| mockGetSettings.mockResolvedValue({ | |
| title_llm_profile: "Titles", | |
| agent_settings: { llm: { model: "gpt-4o" } }, | |
| conversation_settings: {}, | |
| }); | |
| mockGetSettingsForConversation.mockResolvedValue({ | |
| agentSettings: { llm: { model: "gpt-4o" } }, | |
| conversationSettings: {}, | |
| secretsEncrypted: true, | |
| }); | |
| mockListProfiles.mockResolvedValue({ | |
| profiles: [ | |
| { | |
| name: "Titles", | |
| model: "anthropic/claude-haiku-3-5", | |
| base_url: null, | |
| api_key_set: true, | |
| }, | |
| ], | |
| active_profile: null, | |
| }); | |
| mockHttpPost.mockResolvedValue({ | |
| data: { | |
| id: "ignored-server-id", | |
| created_at: "2024-01-01", | |
| updated_at: "2024-01-01", | |
| }, | |
| }); | |
| await AgentServerConversationService.createConversation(); | |
| expect(mockHttpPost).toHaveBeenCalledWith( | |
| "/api/conversations", | |
| expect.objectContaining({ title_llm_profile: "Titles" }), | |
| ); | |
| }); | |
| it("generates a unique conversation_id and isolated working_dir per call", async () => { | |
| mockGetSettings.mockResolvedValue({ | |
| agent_settings: { llm: { model: "gpt-4o" } }, | |
| conversation_settings: {}, | |
| }); | |
| mockGetSettingsForConversation.mockResolvedValue({ | |
| agentSettings: { llm: { model: "gpt-4o" } }, | |
| conversationSettings: {}, | |
| secretsEncrypted: true, | |
| }); | |
| mockHttpPost.mockResolvedValue({ | |
| data: { | |
| id: "ignored-server-id", | |
| created_at: "2024-01-01", | |
| updated_at: "2024-01-01", | |
| }, | |
| }); | |
| await AgentServerConversationService.createConversation(); | |
| await AgentServerConversationService.createConversation(); | |
| expect(ConversationClient).toHaveBeenCalledWith({ | |
| host: "http://localhost:54928", | |
| apiKey: "test-api-key", | |
| workingDir: "/workspace/project/agent-canvas", | |
| // See CREATE_CONVERSATION_TIMEOUT_MS in the service module: first | |
| // conversation on a cold agent-server boot exceeds the 60s default. | |
| timeout: 5 * 60 * 1000, | |
| }); | |
| expect(mockHttpPost).toHaveBeenCalledTimes(2); | |
| const [firstCall, secondCall] = mockHttpPost.mock.calls; | |
| const firstPayload = firstCall[1] as { | |
| conversation_id: string; | |
| workspace: { working_dir: string }; | |
| worktree: boolean; | |
| }; | |
| const secondPayload = secondCall[1] as { | |
| conversation_id: string; | |
| workspace: { working_dir: string }; | |
| worktree: boolean; | |
| }; | |
| expect(firstPayload.conversation_id).toBeTruthy(); | |
| expect(secondPayload.conversation_id).toBeTruthy(); | |
| expect(firstPayload.conversation_id).not.toBe( | |
| secondPayload.conversation_id, | |
| ); | |
| const firstHex = firstPayload.conversation_id.replace(/-/g, ""); | |
| const secondHex = secondPayload.conversation_id.replace(/-/g, ""); | |
| expect(firstPayload.workspace.working_dir).toBe( | |
| `/state/workspaces/${firstHex}`, | |
| ); | |
| expect(secondPayload.workspace.working_dir).toBe( | |
| `/state/workspaces/${secondHex}`, | |
| ); | |
| expect(firstPayload.worktree).toBe(true); | |
| expect(secondPayload.worktree).toBe(true); | |
| }); | |
| // @spec WUP-001 — When the default working_dir is relative, the | |
| // conversation-start payload must be anchored against the agent-server | |
| // home dir so the worktree and later file uploads agree on a writable | |
| // absolute path. | |
| it("resolves relative default working dirs against /api/file/home", async () => { | |
| const { buildConversationWorkingDirForBackend: mockedBuilder } = | |
| await import("#/api/agent-server-config"); | |
| vi.mocked(mockedBuilder).mockImplementationOnce( | |
| (id: string) => `workspace/project/${id.replace(/-/g, "")}`, | |
| ); | |
| const { clearAgentServerHomeDirCache } = | |
| await import("#/api/agent-server-home"); | |
| clearAgentServerHomeDirCache(); | |
| mockGetSettings.mockResolvedValue({ | |
| agent_settings: { llm: { model: "gpt-4o" } }, | |
| conversation_settings: {}, | |
| }); | |
| mockGetSettingsForConversation.mockResolvedValue({ | |
| agentSettings: { llm: { model: "gpt-4o" } }, | |
| conversationSettings: {}, | |
| secretsEncrypted: true, | |
| }); | |
| mockHttpPost.mockResolvedValue({ | |
| data: { | |
| id: "ignored-server-id", | |
| created_at: "2024-01-01", | |
| updated_at: "2024-01-01", | |
| }, | |
| }); | |
| await AgentServerConversationService.createConversation(); | |
| const [payloadCall] = mockHttpPost.mock.calls; | |
| const payload = payloadCall[1] as { | |
| conversation_id: string; | |
| workspace: { working_dir: string }; | |
| }; | |
| const hex = payload.conversation_id.replace(/-/g, ""); | |
| expect(payload.workspace.working_dir).toBe( | |
| `/Users/agent/workspace/project/${hex}`, | |
| ); | |
| }); | |
| // @spec WUP-001 — User-supplied workspace overrides are already absolute | |
| // (they come from `search_subdirs`), so they must pass through verbatim. | |
| it("leaves an absolute workingDirOverride untouched", async () => { | |
| mockGetSettings.mockResolvedValue({ | |
| agent_settings: { llm: { model: "gpt-4o" } }, | |
| conversation_settings: {}, | |
| }); | |
| mockGetSettingsForConversation.mockResolvedValue({ | |
| agentSettings: { llm: { model: "gpt-4o" } }, | |
| conversationSettings: {}, | |
| secretsEncrypted: true, | |
| }); | |
| mockHttpPost.mockResolvedValue({ | |
| data: { | |
| id: "ignored-server-id", | |
| created_at: "2024-01-01", | |
| updated_at: "2024-01-01", | |
| }, | |
| }); | |
| await AgentServerConversationService.createConversation({ | |
| workingDirOverride: "/Users/jane/projects/foo", | |
| }); | |
| const [payloadCall] = mockHttpPost.mock.calls; | |
| const payload = payloadCall[1] as { | |
| workspace: { working_dir: string }; | |
| worktree: boolean; | |
| }; | |
| expect(payload.workspace.working_dir).toBe("/Users/jane/projects/foo"); | |
| expect(payload.worktree).toBe(false); | |
| }); | |
| // Regression for #16907 — the conversation's own `<workspace>/<hex>` dir | |
| // does not exist yet, so hooks looked up there are never found. | |
| it("looks project hooks up in the workspace root, not the conversation dir", async () => { | |
| mockGetSettings.mockResolvedValue({ | |
| agent_settings: { llm: { model: "gpt-4o" } }, | |
| conversation_settings: {}, | |
| }); | |
| mockGetSettingsForConversation.mockResolvedValue({ | |
| agentSettings: { llm: { model: "gpt-4o" } }, | |
| conversationSettings: {}, | |
| secretsEncrypted: true, | |
| }); | |
| mockHttpPost.mockResolvedValue({ | |
| data: { | |
| id: "ignored-server-id", | |
| created_at: "2024-01-01", | |
| updated_at: "2024-01-01", | |
| }, | |
| }); | |
| await AgentServerConversationService.createConversation(); | |
| const [payloadCall] = mockHttpPost.mock.calls; | |
| const payload = payloadCall[1] as { | |
| workspace: { working_dir: string }; | |
| }; | |
| expect(mockLoadHooks).toHaveBeenCalledWith({ | |
| project_dir: "/workspace/project/agent-canvas", | |
| }); | |
| expect(mockLoadHooks).not.toHaveBeenCalledWith({ | |
| project_dir: payload.workspace.working_dir, | |
| }); | |
| }); | |
| // An explicit pick is the project, so hooks belong there. | |
| it("looks project hooks up in an explicitly picked workspace", async () => { | |
| mockGetSettings.mockResolvedValue({ | |
| agent_settings: { llm: { model: "gpt-4o" } }, | |
| conversation_settings: {}, | |
| }); | |
| mockGetSettingsForConversation.mockResolvedValue({ | |
| agentSettings: { llm: { model: "gpt-4o" } }, | |
| conversationSettings: {}, | |
| secretsEncrypted: true, | |
| }); | |
| mockHttpPost.mockResolvedValue({ | |
| data: { | |
| id: "ignored-server-id", | |
| created_at: "2024-01-01", | |
| updated_at: "2024-01-01", | |
| }, | |
| }); | |
| await AgentServerConversationService.createConversation({ | |
| workingDirOverride: "/Users/jane/projects/foo", | |
| }); | |
| expect(mockLoadHooks).toHaveBeenCalledWith({ | |
| project_dir: "/Users/jane/projects/foo", | |
| }); | |
| }); | |
| it("honors an explicit new-worktree mode for a selected workspace", async () => { | |
| mockGetSettings.mockResolvedValue({ | |
| agent_settings: { llm: { model: "gpt-4o" } }, | |
| conversation_settings: {}, | |
| }); | |
| mockGetSettingsForConversation.mockResolvedValue({ | |
| agentSettings: { llm: { model: "gpt-4o" } }, | |
| conversationSettings: {}, | |
| secretsEncrypted: true, | |
| }); | |
| mockHttpPost.mockResolvedValue({ | |
| data: { | |
| id: "ignored-server-id", | |
| created_at: "2024-01-01", | |
| updated_at: "2024-01-01", | |
| }, | |
| }); | |
| await AgentServerConversationService.createConversation({ | |
| workingDirOverride: "/Users/jane/projects/foo", | |
| workspaceMode: "new_worktree", | |
| }); | |
| const [payloadCall] = mockHttpPost.mock.calls; | |
| const payload = payloadCall[1] as { | |
| workspace: { working_dir: string }; | |
| worktree: boolean; | |
| }; | |
| expect(payload.workspace.working_dir).toBe("/Users/jane/projects/foo"); | |
| expect(payload.worktree).toBe(true); | |
| }); | |
| it("links a local conversation to its parent", async () => { | |
| mockGetSettings.mockResolvedValue({ | |
| agent_settings: { llm: { model: "gpt-4o" } }, | |
| conversation_settings: {}, | |
| }); | |
| mockGetSettingsForConversation.mockResolvedValue({ | |
| agentSettings: { llm: { model: "gpt-4o" } }, | |
| conversationSettings: {}, | |
| secretsEncrypted: true, | |
| }); | |
| mockHttpPost.mockResolvedValue({ | |
| data: { | |
| id: "ignored-server-id", | |
| created_at: "2024-01-01", | |
| updated_at: "2024-01-01", | |
| }, | |
| }); | |
| await AgentServerConversationService.createConversation({ | |
| workingDirOverride: "/Users/jane/projects/foo", | |
| workspaceMode: "new_worktree", | |
| parentConversationId: "parent-conversation-id", | |
| }); | |
| const [payloadCall] = mockHttpPost.mock.calls; | |
| expect(payloadCall[1]).toMatchObject({ | |
| parent_conversation_id: "parent-conversation-id", | |
| }); | |
| }); | |
| }); | |
| describe("downloadConversation local branch", () => { | |
| beforeEach(() => { | |
| window.localStorage.clear(); | |
| __resetActiveStoreForTests(); | |
| }); | |
| afterEach(() => { | |
| window.localStorage.clear(); | |
| __resetActiveStoreForTests(); | |
| fetchMock.mockReset(); | |
| global.fetch = originalFetch; | |
| }); | |
| it("hits the local /api/file/download-trajectory endpoint with responseType blob when active backend is local", async () => { | |
| const zipBlob = new Blob(["zip-bytes"], { type: "application/zip" }); | |
| mockHttpGet.mockResolvedValue({ data: zipBlob }); | |
| const result = | |
| await AgentServerConversationService.downloadConversation("conv-abc"); | |
| expect(mockHttpGet).toHaveBeenCalledWith( | |
| "/api/file/download-trajectory/conv-abc", | |
| expect.objectContaining({ responseType: "blob" }), | |
| ); | |
| expect(result).toBe(zipBlob); | |
| }); | |
| }); | |
| describe("deleteConversation local branch", () => { | |
| beforeEach(() => { | |
| window.localStorage.clear(); | |
| __resetActiveStoreForTests(); | |
| }); | |
| afterEach(() => { | |
| window.localStorage.clear(); | |
| __resetActiveStoreForTests(); | |
| }); | |
| it("hits the local /api/conversations/{id} endpoint when active backend is local", async () => { | |
| mockHttpDelete.mockResolvedValue({ data: undefined }); | |
| await AgentServerConversationService.deleteConversation("conv-abc"); | |
| expect(mockHttpDelete).toHaveBeenCalledWith( | |
| "/api/conversations/conv-abc", | |
| ); | |
| }); | |
| it("deletes the hidden planner helper reported by the server alongside its parent", async () => { | |
| mockHttpDelete.mockResolvedValue({ data: undefined }); | |
| mockHttpGet.mockImplementation((_url: string, options: unknown) => { | |
| const ids = (options as { params: { ids: string[] } }).params.ids; | |
| if (ids.includes("conv-abc")) { | |
| return Promise.resolve({ | |
| data: [ | |
| { | |
| id: "conv-abc", | |
| created_at: "2024-01-01T00:00:00.000Z", | |
| updated_at: "2024-01-01T00:00:00.000Z", | |
| sub_conversation_ids: ["plan-abc"], | |
| }, | |
| ], | |
| }); | |
| } | |
| return Promise.resolve({ | |
| data: [ | |
| { | |
| id: "plan-abc", | |
| created_at: "2024-01-01T00:00:00.000Z", | |
| updated_at: "2024-01-01T00:00:00.000Z", | |
| tags: { plannerparent: "conv-abc" }, | |
| }, | |
| ], | |
| }); | |
| }); | |
| await AgentServerConversationService.deleteConversation("conv-abc"); | |
| expect(mockHttpDelete).toHaveBeenCalledWith( | |
| "/api/conversations/plan-abc", | |
| ); | |
| expect(mockHttpDelete).toHaveBeenCalledWith( | |
| "/api/conversations/conv-abc", | |
| ); | |
| }); | |
| it("still deletes the parent when the planner helper is already gone", async () => { | |
| mockHttpGet.mockImplementation((_url: string, options: unknown) => { | |
| const ids = (options as { params: { ids: string[] } }).params.ids; | |
| if (ids.includes("conv-abc")) { | |
| return Promise.resolve({ | |
| data: [ | |
| { | |
| id: "conv-abc", | |
| created_at: "2024-01-01T00:00:00.000Z", | |
| updated_at: "2024-01-01T00:00:00.000Z", | |
| sub_conversation_ids: ["plan-abc"], | |
| }, | |
| ], | |
| }); | |
| } | |
| return Promise.resolve({ | |
| data: [ | |
| { | |
| id: "plan-abc", | |
| created_at: "2024-01-01T00:00:00.000Z", | |
| updated_at: "2024-01-01T00:00:00.000Z", | |
| tags: { plannerparent: "conv-abc" }, | |
| }, | |
| ], | |
| }); | |
| }); | |
| mockHttpDelete.mockImplementation(async (path: string) => { | |
| if (path === "/api/conversations/plan-abc") { | |
| throw new Error("404 Not Found"); | |
| } | |
| return { data: undefined }; | |
| }); | |
| await expect( | |
| AgentServerConversationService.deleteConversation("conv-abc"), | |
| ).resolves.toBeUndefined(); | |
| expect(mockHttpDelete).toHaveBeenCalledWith( | |
| "/api/conversations/conv-abc", | |
| ); | |
| }); | |
| it("does not delete an unrelated non-planner child conversation", async () => { | |
| // Regression: sub_conversation_ids is the generic server-derived child | |
| // list, not a planner-only list — an untagged child (e.g. a delegated | |
| // sub-agent from another feature) must survive deleting the parent. | |
| mockHttpDelete.mockResolvedValue({ data: undefined }); | |
| mockHttpGet.mockImplementation((_url: string, options: unknown) => { | |
| const ids = (options as { params: { ids: string[] } }).params.ids; | |
| if (ids.includes("conv-abc")) { | |
| return Promise.resolve({ | |
| data: [ | |
| { | |
| id: "conv-abc", | |
| created_at: "2024-01-01T00:00:00.000Z", | |
| updated_at: "2024-01-01T00:00:00.000Z", | |
| sub_conversation_ids: ["other-conv"], | |
| }, | |
| ], | |
| }); | |
| } | |
| return Promise.resolve({ | |
| data: [ | |
| { | |
| id: "other-conv", | |
| created_at: "2024-01-01T00:00:00.000Z", | |
| updated_at: "2024-01-01T00:00:00.000Z", | |
| tags: {}, | |
| }, | |
| ], | |
| }); | |
| }); | |
| await AgentServerConversationService.deleteConversation("conv-abc"); | |
| expect(mockHttpDelete).not.toHaveBeenCalledWith( | |
| "/api/conversations/other-conv", | |
| ); | |
| expect(mockHttpDelete).toHaveBeenCalledWith( | |
| "/api/conversations/conv-abc", | |
| ); | |
| }); | |
| }); | |
| describe("createLocalPlanningConversation", () => { | |
| beforeEach(() => { | |
| window.localStorage.clear(); | |
| __resetActiveStoreForTests(); | |
| mockGetSettingsForConversation.mockResolvedValue({ | |
| agentSettings: { llm: { model: "openhands/global-model" } }, | |
| conversationSettings: {}, | |
| secretsEncrypted: true, | |
| }); | |
| mockHttpPost.mockResolvedValue({ | |
| data: { | |
| id: "plan-abc", | |
| created_at: "2024-01-01T00:00:00.000Z", | |
| updated_at: "2024-01-01T00:00:00.000Z", | |
| }, | |
| }); | |
| }); | |
| afterEach(() => { | |
| window.localStorage.clear(); | |
| __resetActiveStoreForTests(); | |
| }); | |
| it("ignores active_profile for an ACP parent and falls back to global settings", async () => { | |
| // ACP parents get active_profile stamped with whatever LLM profile was | |
| // globally active at *their* creation time — not meaningfully tied to | |
| // the ACP agent — so it must not be treated as the planner's model. | |
| // agent_kind and active_profile are both derived client-side by | |
| // toAppConversation (from info.agent.kind and stored metadata | |
| // respectively), not read off the raw GET response. | |
| setStoredConversationMetadata("conv-abc", { | |
| selected_repository: null, | |
| selected_branch: null, | |
| git_provider: null, | |
| active_profile: "stale-acp-snapshot", | |
| }); | |
| mockHttpGet.mockResolvedValue({ | |
| data: [ | |
| { | |
| id: "conv-abc", | |
| created_at: "2024-01-01T00:00:00.000Z", | |
| updated_at: "2024-01-01T00:00:00.000Z", | |
| agent: { kind: "ACPAgent", llm: { model: "acp-managed" } }, | |
| sub_conversation_ids: [], | |
| }, | |
| ], | |
| }); | |
| await AgentServerConversationService.createLocalPlanningConversation( | |
| "conv-abc", | |
| ); | |
| expect(mockGetProfile).not.toHaveBeenCalledWith( | |
| "stale-acp-snapshot", | |
| { exposeSecrets: "encrypted" }, | |
| ); | |
| const [, payload] = mockHttpPost.mock.calls[0] as [ | |
| string, | |
| { agent: { llm: { model: string } } }, | |
| ]; | |
| expect(payload.agent.llm.model).toBe("openhands/global-model"); | |
| }); | |
| it("uses active_profile for an openhands-kind parent", async () => { | |
| // agent_kind and active_profile are both derived client-side by | |
| // toAppConversation (from info.agent.kind and stored metadata | |
| // respectively), not read off the raw GET response. | |
| setStoredConversationMetadata("conv-abc", { | |
| selected_repository: null, | |
| selected_branch: null, | |
| git_provider: null, | |
| active_profile: "switched-llm", | |
| }); | |
| mockHttpGet.mockResolvedValue({ | |
| data: [ | |
| { | |
| id: "conv-abc", | |
| created_at: "2024-01-01T00:00:00.000Z", | |
| updated_at: "2024-01-01T00:00:00.000Z", | |
| sub_conversation_ids: [], | |
| }, | |
| ], | |
| }); | |
| mockGetProfile.mockResolvedValue({ | |
| name: "switched-llm", | |
| api_key_set: true, | |
| config: { model: "openhands/switched-model" }, | |
| }); | |
| await AgentServerConversationService.createLocalPlanningConversation( | |
| "conv-abc", | |
| ); | |
| expect(mockGetProfile).toHaveBeenCalledWith("switched-llm", { | |
| exposeSecrets: "encrypted", | |
| }); | |
| const [, payload] = mockHttpPost.mock.calls[0] as [ | |
| string, | |
| { agent: { llm: { model: string } } }, | |
| ]; | |
| expect(payload.agent.llm.model).toBe("openhands/switched-model"); | |
| }); | |
| it("streams the planner's LLM tokens, matching the code agent", async () => { | |
| mockHttpGet.mockResolvedValue({ | |
| data: [ | |
| { | |
| id: "conv-abc", | |
| created_at: "2024-01-01T00:00:00.000Z", | |
| updated_at: "2024-01-01T00:00:00.000Z", | |
| sub_conversation_ids: [], | |
| }, | |
| ], | |
| }); | |
| await AgentServerConversationService.createLocalPlanningConversation( | |
| "conv-abc", | |
| ); | |
| const [, payload] = mockHttpPost.mock.calls[0] as [ | |
| string, | |
| { agent: { llm: { stream?: boolean } } }, | |
| ]; | |
| expect(payload.agent.llm.stream).toBe(true); | |
| }); | |
| }); | |
| describe("conversation update fallbacks", () => { | |
| it("throws a useful error when repository update cannot reload the conversation", async () => { | |
| mockHttpGet.mockResolvedValue({ data: [] }); | |
| await expect( | |
| AgentServerConversationService.updateConversationRepository( | |
| "missing-conv", | |
| "OpenHands/agent-canvas", | |
| ), | |
| ).rejects.toThrow("Conversation missing-conv was not found"); | |
| }); | |
| it("throws a useful error when title update cannot reload the conversation", async () => { | |
| mockHttpGet.mockResolvedValue({ data: [] }); | |
| await expect( | |
| AgentServerConversationService.updateConversationTitle( | |
| "missing-conv", | |
| "New title", | |
| ), | |
| ).rejects.toThrow("Conversation missing-conv was not found"); | |
| }); | |
| it("normalizes conversation list items with missing timestamps", async () => { | |
| mockHttpGet.mockResolvedValue({ | |
| data: [ | |
| { | |
| id: "conv-no-timestamps", | |
| title: "Conversation without timestamps", | |
| }, | |
| ], | |
| }); | |
| const [conversation] = | |
| await AgentServerConversationService.batchGetAppConversations([ | |
| "conv-no-timestamps", | |
| ]); | |
| expect(conversation).toMatchObject({ | |
| id: "conv-no-timestamps", | |
| created_at: "1970-01-01T00:00:00.000Z", | |
| updated_at: "1970-01-01T00:00:00.000Z", | |
| }); | |
| }); | |
| it("throws a user-friendly error for unusable conversation list responses", async () => { | |
| mockHttpGet.mockResolvedValue({ data: [{ title: "missing id" }] }); | |
| await expect( | |
| AgentServerConversationService.batchGetAppConversations(["missing-id"]), | |
| ).rejects.toThrow( | |
| "Unable to load conversations because the selected agent server returned", | |
| ); | |
| }); | |
| it("preserves sandbox_status from batchGetAppConversations response", async () => { | |
| mockHttpGet.mockResolvedValue({ | |
| data: [ | |
| { | |
| id: "conv-paused", | |
| created_at: "2024-01-01", | |
| updated_at: "2024-01-01", | |
| sandbox_status: "PAUSED", | |
| }, | |
| ], | |
| }); | |
| const [conversation] = | |
| await AgentServerConversationService.batchGetAppConversations([ | |
| "conv-paused", | |
| ]); | |
| expect(conversation?.sandbox_status).toBe("PAUSED"); | |
| }); | |
| it("preserves sandbox_status from searchConversations response", async () => { | |
| const searchSpy = vi.fn().mockResolvedValue({ | |
| items: [ | |
| { | |
| id: "conv-paused-search", | |
| created_at: "2024-01-01", | |
| updated_at: "2024-01-01", | |
| sandbox_status: "PAUSED", | |
| }, | |
| ], | |
| next_page_id: null, | |
| }); | |
| // Only searchConversations is called by the service method under test, | |
| // so we don't need to reproduce the full client mock object. | |
| mockConversationClient.mockReturnValue({ | |
| searchConversations: searchSpy, | |
| }); | |
| const result = | |
| await AgentServerConversationService.searchConversations(10); | |
| expect(result.items[0]?.sandbox_status).toBe("PAUSED"); | |
| }); | |
| it("falls back to stats.usage_to_metrics when searchConversations omits metrics (#16480)", async () => { | |
| const searchSpy = vi.fn().mockResolvedValue({ | |
| items: [ | |
| { | |
| id: "conv-stats-only", | |
| created_at: "2024-01-01", | |
| updated_at: "2024-01-01", | |
| stats: { | |
| usage_to_metrics: { | |
| default: { | |
| model_name: "test-model", | |
| accumulated_cost: 1.25, | |
| max_budget_per_task: null, | |
| accumulated_token_usage: { | |
| prompt_tokens: 100, | |
| completion_tokens: 50, | |
| cache_read_tokens: 0, | |
| cache_write_tokens: 0, | |
| context_window: 8000, | |
| per_turn_token: 150, | |
| }, | |
| costs: [], | |
| response_latencies: [], | |
| token_usages: [], | |
| }, | |
| }, | |
| }, | |
| }, | |
| ], | |
| next_page_id: null, | |
| }); | |
| mockConversationClient.mockReturnValue({ | |
| searchConversations: searchSpy, | |
| }); | |
| const result = | |
| await AgentServerConversationService.searchConversations(10); | |
| expect(result.items[0]?.metrics?.accumulated_cost).toBe(1.25); | |
| expect( | |
| result.items[0]?.metrics?.accumulated_token_usage?.prompt_tokens, | |
| ).toBe(100); | |
| }); | |
| it("preserves the launched Agent Profile through the wire normalizer", async () => { | |
| mockHttpGet.mockResolvedValue({ | |
| data: [ | |
| { | |
| id: "conv-profile", | |
| created_at: "2024-01-01", | |
| updated_at: "2024-01-01", | |
| launched_agent_profile: { | |
| agent_profile_id: "profile-1", | |
| revision: 3, | |
| }, | |
| }, | |
| ], | |
| }); | |
| const [conversation] = | |
| await AgentServerConversationService.batchGetAppConversations([ | |
| "conv-profile", | |
| ]); | |
| expect(conversation?.launched_agent_profile).toEqual({ | |
| agent_profile_id: "profile-1", | |
| revision: 3, | |
| }); | |
| }); | |
| it("passes sandbox_status null through when field is absent", async () => { | |
| mockHttpGet.mockResolvedValue({ | |
| data: [ | |
| { | |
| id: "conv-no-status", | |
| created_at: "2024-01-01", | |
| updated_at: "2024-01-01", | |
| }, | |
| ], | |
| }); | |
| const [conversation] = | |
| await AgentServerConversationService.batchGetAppConversations([ | |
| "conv-no-status", | |
| ]); | |
| expect(conversation?.sandbox_status).toBeNull(); | |
| }); | |
| it("sanitizes malformed optional conversation fields", async () => { | |
| mockHttpGet.mockResolvedValue({ | |
| data: [ | |
| { | |
| id: "conv-malformed-fields", | |
| title: "Conversation with malformed fields", | |
| metrics: { | |
| accumulated_cost: "1.23", | |
| max_budget_per_task: 10, | |
| accumulated_token_usage: { | |
| prompt_tokens: "123", | |
| completion_tokens: 4, | |
| }, | |
| }, | |
| agent: "not an agent object", | |
| workspace: "not a workspace object", | |
| }, | |
| ], | |
| }); | |
| const [conversation] = | |
| await AgentServerConversationService.batchGetAppConversations([ | |
| "conv-malformed-fields", | |
| ]); | |
| expect(conversation?.metrics).toEqual({ | |
| accumulated_cost: null, | |
| max_budget_per_task: 10, | |
| accumulated_token_usage: { | |
| prompt_tokens: 0, | |
| completion_tokens: 4, | |
| cache_read_tokens: 0, | |
| cache_write_tokens: 0, | |
| context_window: 0, | |
| per_turn_token: 0, | |
| }, | |
| }); | |
| expect(conversation?.llm_model).toBeTruthy(); | |
| expect(conversation?.workspace?.working_dir).toBe( | |
| "/workspace/project/agent-canvas", | |
| ); | |
| }); | |
| it("preserves the new ACP model fields through the wire normalizer", async () => { | |
| // Direct adapter tests pass DirectConversationInfo objects in-process | |
| // and so can't catch the case where the wire-format normalizer | |
| // (``normalizeAgent`` + ``requireDirectConversationInfo``) drops the | |
| // newly-added ACP fields. Exercises the full HTTP -> AppConversation | |
| // path so the chip's model resolution actually has the inputs it | |
| // needs on a real local-backend fetch. | |
| mockHttpGet.mockResolvedValue({ | |
| data: [ | |
| { | |
| id: "conv-acp-model-wire", | |
| created_at: "2024-01-01", | |
| updated_at: "2024-01-01", | |
| agent: { | |
| kind: "ACPAgent", | |
| acp_model: "claude-opus-4-7", | |
| llm: { model: "acp-managed" }, | |
| }, | |
| current_model_id: "claude-opus-4-7", | |
| current_model_name: "Claude Opus 4.7", | |
| tags: { acpserver: "claude-code" }, | |
| }, | |
| ], | |
| }); | |
| const [conversation] = | |
| await AgentServerConversationService.batchGetAppConversations([ | |
| "conv-acp-model-wire", | |
| ]); | |
| // ``current_model_name`` wins the precedence chain in the adapter. | |
| expect(conversation?.agent_kind).toBe("acp"); | |
| expect(conversation?.llm_model).toBe("Claude Opus 4.7"); | |
| }); | |
| it("sources acp_server from the agent when the acpserver tag is absent", async () => { | |
| // Profile launches don't stamp the ``acpserver`` tag client-side, so the | |
| // provider identity must survive from ``agent.acp_server`` (SDK #3692) | |
| // through ``normalizeAgent``. Without it the chip degrades to a generic | |
| // "ACP" and the in-conversation model picker shows no options (#1571). | |
| mockHttpGet.mockResolvedValue({ | |
| data: [ | |
| { | |
| id: "conv-acp-server-from-agent", | |
| created_at: "2024-01-01", | |
| updated_at: "2024-01-01", | |
| agent: { | |
| kind: "ACPAgent", | |
| acp_server: "claude-code", | |
| acp_model: "claude-sonnet-4-5", | |
| llm: { model: "acp-managed" }, | |
| }, | |
| // No ``acpserver`` tag — mirrors an agent_profile_id launch. | |
| tags: {}, | |
| }, | |
| ], | |
| }); | |
| const [conversation] = | |
| await AgentServerConversationService.batchGetAppConversations([ | |
| "conv-acp-server-from-agent", | |
| ]); | |
| expect(conversation?.agent_kind).toBe("acp"); | |
| expect(conversation?.acp_server).toBe("claude-code"); | |
| }); | |
| it("falls back to acp_model when SDK runtime fields are absent on the wire", async () => { | |
| // Older agent-servers don't populate ``current_model_*``. The | |
| // adapter must still surface a model on the chip — falling through | |
| // to ``agent.acp_model`` (the Canvas-configured value). | |
| mockHttpGet.mockResolvedValue({ | |
| data: [ | |
| { | |
| id: "conv-acp-fallback", | |
| created_at: "2024-01-01", | |
| updated_at: "2024-01-01", | |
| agent: { | |
| kind: "ACPAgent", | |
| acp_model: "claude-sonnet-4-6", | |
| llm: { model: "acp-managed" }, | |
| }, | |
| tags: { acpserver: "claude-code" }, | |
| }, | |
| ], | |
| }); | |
| const [conversation] = | |
| await AgentServerConversationService.batchGetAppConversations([ | |
| "conv-acp-fallback", | |
| ]); | |
| expect(conversation?.llm_model).toBe("claude-sonnet-4-6"); | |
| }); | |
| it("extracts the acpserver tag from the wire payload for the sidebar chip", async () => { | |
| // The agent-server stamps ``tags.acpserver`` at conversation create | |
| // time (see ``buildStartConversationRequest``); the read path | |
| // must surface it so the conversation card can render the human | |
| // ACP-agent badge ("Claude Code" / "Codex" / "Gemini CLI"). | |
| mockHttpGet.mockResolvedValue({ | |
| data: [ | |
| { | |
| id: "conv-acp", | |
| created_at: "2024-01-01", | |
| updated_at: "2024-01-01", | |
| agent: { kind: "ACPAgent", llm: { model: "acp-managed" } }, | |
| tags: { acpserver: "claude-code" }, | |
| }, | |
| ], | |
| }); | |
| const [conversation] = | |
| await AgentServerConversationService.batchGetAppConversations([ | |
| "conv-acp", | |
| ]); | |
| expect(conversation?.agent_kind).toBe("acp"); | |
| expect(conversation?.acp_server).toBe("claude-code"); | |
| }); | |
| it("drops non-string tag values while preserving the well-typed ones", async () => { | |
| // The wire field is server-validated to ``Record[str, str]`` but a | |
| // misbehaving server (or a future schema drift) shouldn't crash the | |
| // parser — we drop non-string values and keep the rest so the | |
| // sidebar still gets whatever good keys made it through. | |
| mockHttpGet.mockResolvedValue({ | |
| data: [ | |
| { | |
| id: "conv-malformed-tags", | |
| created_at: "2024-01-01", | |
| updated_at: "2024-01-01", | |
| agent: { kind: "ACPAgent", llm: { model: "acp-managed" } }, | |
| tags: { | |
| acpserver: "codex", | |
| numeric: 42, | |
| nested: { inner: "x" }, | |
| listy: ["a", "b"], | |
| nully: null, | |
| }, | |
| }, | |
| ], | |
| }); | |
| const [conversation] = | |
| await AgentServerConversationService.batchGetAppConversations([ | |
| "conv-malformed-tags", | |
| ]); | |
| expect(conversation?.acp_server).toBe("codex"); | |
| // The normalized map is also surfaced on ``AppConversation.tags`` | |
| // (including reserved keys — display filtering happens later in | |
| // ``getDisplayConversationTags``). Asserting the exact object here | |
| // pins the wire → AppConversation boundary: only string-valued | |
| // entries survive, and the field must not silently drop off the | |
| // adapter again. | |
| expect(conversation?.tags).toEqual({ acpserver: "codex" }); | |
| }); | |
| it("carries well-formed wire tags through to AppConversation.tags", async () => { | |
| mockHttpGet.mockResolvedValue({ | |
| data: [ | |
| { | |
| id: "conv-wire-tags", | |
| created_at: "2024-01-01", | |
| updated_at: "2024-01-01", | |
| agent: { kind: "ACPAgent", llm: { model: "acp-managed" } }, | |
| tags: { acpserver: "claude-code", origin: "slack", owner: "alice" }, | |
| }, | |
| ], | |
| }); | |
| const [conversation] = | |
| await AgentServerConversationService.batchGetAppConversations([ | |
| "conv-wire-tags", | |
| ]); | |
| expect(conversation?.tags).toEqual({ | |
| acpserver: "claude-code", | |
| origin: "slack", | |
| owner: "alice", | |
| }); | |
| }); | |
| it("surfaces AppConversation.tags as null when the wire field is absent", async () => { | |
| mockHttpGet.mockResolvedValue({ | |
| data: [ | |
| { | |
| id: "conv-no-tags", | |
| created_at: "2024-01-01", | |
| updated_at: "2024-01-01", | |
| agent: { kind: "ACPAgent", llm: { model: "acp-managed" } }, | |
| }, | |
| ], | |
| }); | |
| const [conversation] = | |
| await AgentServerConversationService.batchGetAppConversations([ | |
| "conv-no-tags", | |
| ]); | |
| expect(conversation?.tags).toBeNull(); | |
| }); | |
| }); | |
| describe("switchProfile", () => { | |
| beforeEach(() => { | |
| window.localStorage.clear(); | |
| __resetActiveStoreForTests(); | |
| }); | |
| afterEach(() => { | |
| window.localStorage.clear(); | |
| __resetActiveStoreForTests(); | |
| }); | |
| it("switches an active conversation with the full encrypted profile config", async () => { | |
| mockGetProfile.mockResolvedValue({ | |
| name: "haiku", | |
| config: { | |
| model: "openhands/claude-haiku-4-5", | |
| api_key: "encrypted-key", | |
| }, | |
| api_key_set: true, | |
| }); | |
| mockSwitchLLM.mockResolvedValue(undefined); | |
| await AgentServerConversationService.switchProfile("conv-1", "haiku"); | |
| expect(mockGetProfile).toHaveBeenCalledWith("haiku", { | |
| exposeSecrets: "encrypted", | |
| }); | |
| expect(mockSwitchLLM).toHaveBeenCalledWith( | |
| "conv-1", | |
| expect.objectContaining({ | |
| model: "openhands/claude-haiku-4-5", | |
| api_key: "encrypted-key", | |
| // Streaming must stay enabled after a mid-conversation switch. | |
| stream: true, | |
| usage_id: expect.stringMatching(/^profile:haiku:/), | |
| }), | |
| ); | |
| // Per-convo path: global default is left untouched and profile secrets are | |
| // only fetched as encrypted values for direct round-trip to switch_llm. | |
| expect(mockActivateProfile).not.toHaveBeenCalled(); | |
| expect(mockSwitchProfile).not.toHaveBeenCalled(); | |
| }); | |
| it("surfaces encrypted profile export failures instead of using the stale profile switch path", async () => { | |
| const error = new Error("No cipher"); | |
| mockGetProfile.mockRejectedValueOnce(error); | |
| await expect( | |
| AgentServerConversationService.switchProfile("conv-1", "haiku"), | |
| ).rejects.toThrow(error); | |
| expect(mockGetProfile).toHaveBeenCalledWith("haiku", { | |
| exposeSecrets: "encrypted", | |
| }); | |
| expect(mockSwitchProfile).not.toHaveBeenCalled(); | |
| expect(mockSwitchLLM).not.toHaveBeenCalled(); | |
| expect(mockActivateProfile).not.toHaveBeenCalled(); | |
| }); | |
| it("activates the profile globally when called without a conversationId", async () => { | |
| mockActivateProfile.mockResolvedValue({ | |
| name: "haiku", | |
| message: "ok", | |
| llm_applied: true, | |
| }); | |
| await AgentServerConversationService.switchProfile(null, "haiku"); | |
| expect(mockActivateProfile).toHaveBeenCalledWith("haiku"); | |
| // Home-page path: don't touch any conversation's LLM. | |
| expect(mockGetProfile).not.toHaveBeenCalled(); | |
| expect(mockSwitchProfile).not.toHaveBeenCalled(); | |
| expect(mockSwitchLLM).not.toHaveBeenCalled(); | |
| }); | |
| it("routes a cloud conversation switch through the app-server switch_profile endpoint", async () => { | |
| const cloudBackend: Backend = { | |
| id: "prod", | |
| name: "Production", | |
| host: "https://app.all-hands.dev", | |
| apiKey: "bearer-token", | |
| kind: "cloud", | |
| }; | |
| setRegisteredBackends([cloudBackend]); | |
| setActiveSelection({ backendId: cloudBackend.id }); | |
| fetchMock.mockResolvedValueOnce(mockJsonResponse({ success: true })); | |
| global.fetch = fetchMock as typeof fetch; | |
| await AgentServerConversationService.switchProfile("conv-1", "haiku"); | |
| const [url, init] = getFetchCall(fetchMock); | |
| expect(url).toBe( | |
| "https://app.all-hands.dev/api/v1/app-conversations/conv-1/switch_profile", | |
| ); | |
| expect(init).toMatchObject({ | |
| method: "POST", | |
| headers: { Authorization: "Bearer bearer-token" }, | |
| }); | |
| expect(getJsonBody(init)).toEqual({ profile_name: "haiku" }); | |
| // Cloud resolves the swap server-side: no client-side encrypted profile | |
| // fetch and no direct switch_llm call. | |
| expect(mockGetProfile).not.toHaveBeenCalled(); | |
| expect(mockSwitchLLM).not.toHaveBeenCalled(); | |
| }); | |
| }); | |
| describe("cloud branches", () => { | |
| const cloudBackend: Backend = { | |
| id: "prod", | |
| name: "Production", | |
| host: "https://app.all-hands.dev", | |
| apiKey: "bearer-token", | |
| kind: "cloud", | |
| }; | |
| beforeEach(() => { | |
| window.localStorage.clear(); | |
| __resetActiveStoreForTests(); | |
| setRegisteredBackends([cloudBackend]); | |
| setActiveSelection({ backendId: cloudBackend.id }); | |
| fetchMock.mockReset(); | |
| global.fetch = fetchMock as typeof fetch; | |
| }); | |
| afterEach(() => { | |
| window.localStorage.clear(); | |
| __resetActiveStoreForTests(); | |
| fetchMock.mockReset(); | |
| global.fetch = originalFetch; | |
| }); | |
| it("marks Canvas-created cloud conversations with the GUI trigger", async () => { | |
| // Arrange | |
| fetchMock.mockResolvedValueOnce( | |
| mockJsonResponse({ | |
| id: "task-1", | |
| status: "WORKING", | |
| app_conversation_id: null, | |
| agent_server_url: null, | |
| request: {}, | |
| created_at: "2024-01-01", | |
| updated_at: "2024-01-01", | |
| }), | |
| ); | |
| // Act | |
| await AgentServerConversationService.createConversation({ | |
| parentConversationId: "parent-conv-1", | |
| agentType: "plan", | |
| sandboxId: "sandbox-9", | |
| }); | |
| // Assert | |
| const [url, init] = getFetchCall(fetchMock); | |
| expect(url).toBe(`${cloudBackend.host}/api/v1/app-conversations`); | |
| expect(init).toMatchObject({ | |
| method: "POST", | |
| headers: { Authorization: "Bearer bearer-token" }, | |
| }); | |
| expect(getJsonBody(init)).toMatchObject({ | |
| parent_conversation_id: "parent-conv-1", | |
| agent_type: "plan", | |
| sandbox_id: "sandbox-9", | |
| trigger: "gui", | |
| }); | |
| }); | |
| it("routes readConversationFile to the cloud file endpoint with the file_path query param", async () => { | |
| // Arrange | |
| fetchMock.mockResolvedValueOnce( | |
| new Response("# PLAN content", { | |
| status: 200, | |
| headers: { "content-type": "text/plain" }, | |
| }), | |
| ); | |
| // Act | |
| const content = | |
| await AgentServerConversationService.readConversationFile( | |
| "conv-cloud-1", | |
| ); | |
| // Assert | |
| expect(content).toBe("# PLAN content"); | |
| const [url, init] = getFetchCall(fetchMock); | |
| expect(init).toMatchObject({ | |
| method: "GET", | |
| headers: { Authorization: "Bearer bearer-token" }, | |
| }); | |
| expect(url).toBe( | |
| `${cloudBackend.host}/api/v1/app-conversations/conv-cloud-1/file?file_path=%2Fworkspace%2Fproject%2F.agents_tmp%2FPLAN.md`, | |
| ); | |
| }); | |
| }); | |
| }); | |