mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 16:19:05 +08:00
feat: cloud backend support with multi-backend selector and SaaS proxy routing (#145)
* feat: multi-backend support with cloud SaaS proxy routing * feat: route conversation export through cloud proxy on cloud backends * fix: route conversation delete through cloud proxy on cloud backends * fix: forward settings diffs verbatim through cloud proxy save * fix: surface cloud-aware settings sub-pages and gate local-only routes * fix: route secrets settings through cloud proxy on cloud backends * fix: route conversation stop runtime through cloud proxy on cloud backends * fix: re-expose planning agent UI for cloud backends and route plan file reads through cloud proxy * fix: route Display Cost runtime fetch through cloud proxy and ungate local metrics without session API key * fix: handle WAITING_FOR_SANDBOX task status from cloud backends to prevent UI crash * fix: re-expose Public Share in conversation menu for cloud backends * fix: redirect to home when switching backends from a conversation page * fix: hide cloud orgs the API key can't access in backend selector * feat: support running multiple local agent-servers with shared persistence * fix: lint * fix: failing tests
This commit is contained in:
+3
-2
@@ -24,6 +24,7 @@ VITE_MOCK_API="false" # Enable/disable API mocking with MSW
|
||||
# OH_CANVAS_SAFE_VSCODE_PORT="18001" # Port forwarded to the embedded VS Code (defaults to backend port + 1)
|
||||
# OH_CANVAS_SAFE_STATE_DIR="$HOME/.openhands/agent-canvas" # Where conversations, tmux sockets, and bash events are stored
|
||||
|
||||
# Agent server version selection (uvx)
|
||||
# OH_AGENT_SERVER_VERSION="" # Specific PyPI version (e.g., "1.18.0"). Uses latest release if unset.
|
||||
# Agent server version selection (uvx) — listed in order of precedence (highest first)
|
||||
# OH_AGENT_SERVER_LOCAL_PATH="" # Absolute path to a local software-agent-sdk checkout. Runs the local checkout via uvx with editable openhands-sdk/tools/workspace installs so source edits are picked up on restart. Highest precedence.
|
||||
# OH_AGENT_SERVER_GIT_REF="" # Git commit SHA or branch name (e.g., "main", "abc1234"). Takes precedence over version.
|
||||
# OH_AGENT_SERVER_VERSION="" # Specific PyPI version (e.g., "1.18.0"). Uses latest release if unset.
|
||||
|
||||
@@ -82,9 +82,10 @@
|
||||
|
||||
- README expectation: keep the first section as a concrete, chronological from-scratch quickstart for running this frontend against a real `openhands-agent-server` (clone, install uv, optional `.env`, run `npm run dev`).
|
||||
- Keep README user-focused and move contributor/developer-specific workflows (`dev:safe`, mock mode, detailed env vars/build-test notes) into `DEVELOPMENT.md`.
|
||||
- `scripts/dev-safe.mjs` uses `uvx` for temporary agent-server installation — no permanent `uv tool install` needed. Environment variables:
|
||||
- `OH_AGENT_SERVER_VERSION` — specific PyPI version (e.g., "1.18.0")
|
||||
- `scripts/dev-safe.mjs` uses `uvx` for temporary agent-server installation — no permanent `uv tool install` needed. Environment variables (highest precedence first):
|
||||
- `OH_AGENT_SERVER_LOCAL_PATH` — absolute path to a local `software-agent-sdk` checkout. Runs the local checkout via `uvx` with `--with-editable` for `openhands-sdk`/`openhands-tools`/`openhands-workspace` and `--reinstall` for `openhands-agent-server`, so SDK edits are picked up on restart. Highest precedence.
|
||||
- `OH_AGENT_SERVER_GIT_REF` — git commit SHA or branch name (takes precedence over version)
|
||||
- `OH_AGENT_SERVER_VERSION` — specific PyPI version (e.g., "1.18.0")
|
||||
- `OH_SECRET_KEY` — secret key for settings encryption; uses a default value for local dev, override for production
|
||||
- Default: latest released version from PyPI
|
||||
- `scripts/dev-safe.mjs` should fail fast if `uvx` cannot be spawned (for example missing PATH entries).
|
||||
|
||||
+18
-3
@@ -16,17 +16,24 @@ It uses `uvx` to run a temporary `agent-server` installation for this checkout o
|
||||
|
||||
### Agent server version selection
|
||||
|
||||
By default, the latest released version from PyPI is used. You can override this:
|
||||
By default, the latest released version from PyPI is used. You can override this (highest precedence first):
|
||||
|
||||
```sh
|
||||
# Use a specific PyPI version
|
||||
OH_AGENT_SERVER_VERSION=1.18.0 npm run dev
|
||||
# Run against a local software-agent-sdk checkout. Workspace packages
|
||||
# (openhands-sdk, openhands-tools, openhands-workspace) are installed editable
|
||||
# so source edits there are picked up on restart. Highest precedence.
|
||||
OH_AGENT_SERVER_LOCAL_PATH=/abs/path/to/software-agent-sdk npm run dev
|
||||
|
||||
# Use a git branch or commit (takes precedence over version)
|
||||
OH_AGENT_SERVER_GIT_REF=main npm run dev
|
||||
OH_AGENT_SERVER_GIT_REF=abc1234 npm run dev
|
||||
|
||||
# Use a specific PyPI version
|
||||
OH_AGENT_SERVER_VERSION=1.18.0 npm run dev
|
||||
```
|
||||
|
||||
`OH_AGENT_SERVER_LOCAL_PATH` must be an absolute path to a `software-agent-sdk` checkout containing the `openhands-agent-server`, `openhands-sdk`, `openhands-tools`, and `openhands-workspace` workspace packages. The agent-server itself is rebuilt from local source on each `npm run dev` start (`uvx --reinstall`); the other workspace packages are installed editable, so their source changes take effect without a rebuild.
|
||||
|
||||
### Other useful overrides
|
||||
|
||||
- `OH_CANVAS_SAFE_BACKEND_PORT` — backend port for the isolated server (default `18000`)
|
||||
@@ -36,6 +43,14 @@ OH_AGENT_SERVER_GIT_REF=abc1234 npm run dev
|
||||
|
||||
## Alternative development workflows
|
||||
|
||||
### Multiple local backends (shared persistence)
|
||||
|
||||
To run a second standalone agent-server alongside `npm run dev` while sharing
|
||||
its conversation history and encrypted secrets, see
|
||||
[docs/multi-backend-setup.md](./docs/multi-backend-setup.md). The
|
||||
`npm run dev:extra-backend` helper launches an extra server on `:18002` that
|
||||
reuses the bundled instance's state dir.
|
||||
|
||||
### Frontend against an existing backend
|
||||
|
||||
Use this only if you intentionally started `agent-server` yourself or want the frontend to talk to another backend:
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
setActiveSelection,
|
||||
setRegisteredBackends,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import { ensureCompatibleAgentServer } from "#/api/agent-server-compatibility";
|
||||
|
||||
const getServerInfoMock = vi.fn();
|
||||
const createServerClientMock = vi.fn(() => ({
|
||||
getServerInfo: getServerInfoMock,
|
||||
}));
|
||||
|
||||
vi.mock("#/api/typescript-client", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("#/api/typescript-client")
|
||||
>("#/api/typescript-client");
|
||||
return {
|
||||
...actual,
|
||||
createServerClient: (...args: unknown[]) =>
|
||||
createServerClientMock(...(args as [])),
|
||||
};
|
||||
});
|
||||
|
||||
const cloudBackend: Backend = {
|
||||
id: "prod",
|
||||
name: "Production",
|
||||
host: "https://app.all-hands.dev",
|
||||
apiKey: "bearer-token",
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
getServerInfoMock.mockReset();
|
||||
createServerClientMock.mockClear();
|
||||
// Return a future-compatible fake version so the check passes.
|
||||
getServerInfoMock.mockResolvedValue({ version: "99.0.0" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
describe("ensureCompatibleAgentServer", () => {
|
||||
it("targets the bundled local backend even when the active backend is cloud", async () => {
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
|
||||
await ensureCompatibleAgentServer();
|
||||
|
||||
expect(createServerClientMock).toHaveBeenCalledOnce();
|
||||
const callArgs = createServerClientMock.mock.calls[0] as unknown as [
|
||||
{ host?: string; sessionApiKey?: string | null },
|
||||
];
|
||||
const overrides = callArgs[0];
|
||||
|
||||
// Must NOT use the cloud host — that endpoint doesn't exist on SaaS
|
||||
// and would fail with a CORS preflight error.
|
||||
expect(overrides.host).toBeDefined();
|
||||
expect(overrides.host).not.toBe(cloudBackend.host);
|
||||
expect(overrides.host).not.toContain("all-hands.dev");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
getActiveBackend,
|
||||
setActiveSelection,
|
||||
setRegisteredBackends,
|
||||
subscribeActiveBackend,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import { BUNDLED_BACKEND_ID } from "#/api/backend-registry/types";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.unstubAllEnvs();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
const cloudBackend: Backend = {
|
||||
id: "prod",
|
||||
name: "Production",
|
||||
host: "https://app.all-hands.dev",
|
||||
apiKey: "bearer-key",
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
describe("active-store", () => {
|
||||
it("falls back to the bundled backend when nothing is selected", () => {
|
||||
const { backend, orgId } = getActiveBackend();
|
||||
expect(backend.id).toBe(BUNDLED_BACKEND_ID);
|
||||
expect(backend.kind).toBe("local");
|
||||
expect(orgId).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the registered backend matching the active selection", () => {
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id, orgId: "org-2" });
|
||||
|
||||
const { backend, orgId } = getActiveBackend();
|
||||
expect(backend).toEqual(cloudBackend);
|
||||
expect(orgId).toBe("org-2");
|
||||
});
|
||||
|
||||
it("falls back to bundled when the active backend was removed", () => {
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id, orgId: null });
|
||||
setRegisteredBackends([]);
|
||||
|
||||
expect(getActiveBackend().backend.id).toBe(BUNDLED_BACKEND_ID);
|
||||
});
|
||||
|
||||
it("notifies subscribers when selection changes", () => {
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = subscribeActiveBackend(listener);
|
||||
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
|
||||
expect(listener).toHaveBeenCalled();
|
||||
|
||||
unsubscribe();
|
||||
listener.mockClear();
|
||||
setActiveSelection(null);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
ACTIVE_BACKEND_STORAGE_KEY,
|
||||
BACKENDS_STORAGE_KEY,
|
||||
readStoredActiveBackend,
|
||||
readStoredBackends,
|
||||
writeStoredActiveBackend,
|
||||
writeStoredBackends,
|
||||
} from "#/api/backend-registry/storage";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
describe("backend-registry storage", () => {
|
||||
it("round-trips a list of backends", () => {
|
||||
const backends: Backend[] = [
|
||||
{
|
||||
id: "abc",
|
||||
name: "Local 1",
|
||||
host: "http://127.0.0.1:9000",
|
||||
apiKey: "key-1",
|
||||
kind: "local",
|
||||
},
|
||||
{
|
||||
id: "xyz",
|
||||
name: "Production",
|
||||
host: "https://app.all-hands.dev",
|
||||
apiKey: "bearer-2",
|
||||
kind: "cloud",
|
||||
},
|
||||
];
|
||||
|
||||
writeStoredBackends(backends);
|
||||
|
||||
expect(readStoredBackends()).toEqual(backends);
|
||||
});
|
||||
|
||||
it("returns empty list when storage is malformed", () => {
|
||||
window.localStorage.setItem(BACKENDS_STORAGE_KEY, "{not-json");
|
||||
expect(readStoredBackends()).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters out backends with invalid shape", () => {
|
||||
window.localStorage.setItem(
|
||||
BACKENDS_STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{ id: "ok", name: "x", host: "y", apiKey: "z", kind: "local" },
|
||||
{ id: "missing-kind", name: "x", host: "y", apiKey: "z" },
|
||||
{ kind: "cloud" },
|
||||
"not-an-object",
|
||||
]),
|
||||
);
|
||||
|
||||
expect(readStoredBackends()).toEqual([
|
||||
{ id: "ok", name: "x", host: "y", apiKey: "z", kind: "local" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("round-trips active selection with orgId", () => {
|
||||
writeStoredActiveBackend({ backendId: "xyz", orgId: "org-1" });
|
||||
expect(readStoredActiveBackend()).toEqual({
|
||||
backendId: "xyz",
|
||||
orgId: "org-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes missing orgId to null", () => {
|
||||
writeStoredActiveBackend({ backendId: "xyz" });
|
||||
expect(readStoredActiveBackend()).toEqual({
|
||||
backendId: "xyz",
|
||||
orgId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears storage when active selection is set to null", () => {
|
||||
writeStoredActiveBackend({ backendId: "xyz", orgId: "o" });
|
||||
writeStoredActiveBackend(null);
|
||||
|
||||
expect(window.localStorage.getItem(ACTIVE_BACKEND_STORAGE_KEY)).toBeNull();
|
||||
expect(readStoredActiveBackend()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null active selection when storage is malformed", () => {
|
||||
window.localStorage.setItem(ACTIVE_BACKEND_STORAGE_KEY, "{broken");
|
||||
expect(readStoredActiveBackend()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import axios from "axios";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
setActiveSelection,
|
||||
setRegisteredBackends,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
|
||||
vi.mock("axios");
|
||||
|
||||
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 });
|
||||
vi.mocked(axios.post).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
describe("V1ConversationService cloud branch", () => {
|
||||
it("createConversation POSTs the SaaS payload through the proxy and returns a WORKING task", async () => {
|
||||
vi.mocked(axios.post).mockResolvedValue({
|
||||
data: {
|
||||
id: "task-123",
|
||||
created_by_user_id: null,
|
||||
status: "WORKING",
|
||||
detail: null,
|
||||
app_conversation_id: null,
|
||||
agent_server_url: null,
|
||||
request: {},
|
||||
created_at: "2026-05-06T00:00:00Z",
|
||||
updated_at: "2026-05-06T00:00:00Z",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await V1ConversationService.createConversation(
|
||||
"fix the bug",
|
||||
"Optional title",
|
||||
undefined,
|
||||
{
|
||||
selected_repository: "user/repo",
|
||||
selected_branch: "main",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
git_provider: "github" as any,
|
||||
},
|
||||
);
|
||||
|
||||
expect(axios.post).toHaveBeenCalledOnce();
|
||||
const [url, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
|
||||
// Must go through the local cloud-proxy (not directly to cloud).
|
||||
expect(url).toMatch(/\/api\/cloud-proxy$/);
|
||||
expect(body).toMatchObject({
|
||||
host: cloudBackend.host,
|
||||
method: "POST",
|
||||
path: "/api/v1/app-conversations",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
const proxiedBody = (body as { body: Record<string, unknown> }).body;
|
||||
|
||||
// SaaS payload shape — flat fields, NO encrypted-settings round-trip.
|
||||
expect(proxiedBody.selected_repository).toBe("user/repo");
|
||||
expect(proxiedBody.selected_branch).toBe("main");
|
||||
expect(proxiedBody.git_provider).toBe("github");
|
||||
expect(proxiedBody.title).toBe("Optional title");
|
||||
expect(proxiedBody.initial_message).toEqual({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "fix the bug" }],
|
||||
});
|
||||
// The local-only encrypted-settings keys must NOT be present.
|
||||
expect(proxiedBody).not.toHaveProperty("agent_settings_encrypted");
|
||||
expect(proxiedBody).not.toHaveProperty("conversation_settings_encrypted");
|
||||
|
||||
// The returned task is the upstream task — WORKING, no app_conversation_id yet.
|
||||
expect(result.id).toBe("task-123");
|
||||
expect(result.status).toBe("WORKING");
|
||||
expect(result.app_conversation_id).toBeNull();
|
||||
});
|
||||
|
||||
it("getStartTask polls /api/v1/app-conversations/start-tasks?ids= through the proxy", async () => {
|
||||
vi.mocked(axios.post).mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "task-123",
|
||||
created_by_user_id: null,
|
||||
status: "READY",
|
||||
detail: null,
|
||||
app_conversation_id: "conv-456",
|
||||
agent_server_url: "https://runtime-456.app.all-hands.dev",
|
||||
request: {},
|
||||
created_at: "2026-05-06T00:00:00Z",
|
||||
updated_at: "2026-05-06T00:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await V1ConversationService.getStartTask("task-123");
|
||||
|
||||
const [url, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
expect(url).toMatch(/\/api\/cloud-proxy$/);
|
||||
expect(body).toMatchObject({
|
||||
host: cloudBackend.host,
|
||||
method: "GET",
|
||||
path: "/api/v1/app-conversations/start-tasks?ids=task-123",
|
||||
});
|
||||
expect(result?.status).toBe("READY");
|
||||
expect(result?.app_conversation_id).toBe("conv-456");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import axios from "axios";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
setActiveSelection,
|
||||
setRegisteredBackends,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
|
||||
vi.mock("axios");
|
||||
|
||||
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 });
|
||||
vi.mocked(axios.post).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
describe("V1ConversationService.deleteConversation cloud branch", () => {
|
||||
it("routes through /api/cloud-proxy to the SaaS DELETE app-conversations endpoint", async () => {
|
||||
vi.mocked(axios.post).mockResolvedValue({ data: { success: true } });
|
||||
|
||||
await V1ConversationService.deleteConversation("conv-abc");
|
||||
|
||||
expect(axios.post).toHaveBeenCalledOnce();
|
||||
const [url, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
|
||||
expect(url).toMatch(/\/api\/cloud-proxy$/);
|
||||
expect(body).toMatchObject({
|
||||
host: cloudBackend.host,
|
||||
method: "DELETE",
|
||||
path: "/api/v1/app-conversations/conv-abc",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import axios from "axios";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
setActiveSelection,
|
||||
setRegisteredBackends,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
|
||||
vi.mock("axios");
|
||||
|
||||
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 });
|
||||
vi.mocked(axios.post).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
describe("V1ConversationService.downloadConversation cloud branch", () => {
|
||||
it("routes through /api/cloud-proxy to the SaaS download endpoint with responseType blob and returns the Blob", async () => {
|
||||
const zipBlob = new Blob(["zip-bytes"], { type: "application/zip" });
|
||||
vi.mocked(axios.post).mockResolvedValue({ data: zipBlob });
|
||||
|
||||
const result = await V1ConversationService.downloadConversation("conv-abc");
|
||||
|
||||
expect(axios.post).toHaveBeenCalledOnce();
|
||||
const [url, body, config] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
|
||||
expect(url).toMatch(/\/api\/cloud-proxy$/);
|
||||
expect(body).toMatchObject({
|
||||
host: cloudBackend.host,
|
||||
method: "GET",
|
||||
path: "/api/v1/app-conversations/conv-abc/download",
|
||||
});
|
||||
expect(config).toMatchObject({ responseType: "blob" });
|
||||
expect(result).toBe(zipBlob);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import axios from "axios";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
setActiveSelection,
|
||||
setRegisteredBackends,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
import type { V1AppConversation } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
import { pauseV1Conversation } from "#/hooks/mutation/conversation-mutation-utils";
|
||||
import { V1ExecutionStatus } from "#/types/v1/core/base/common";
|
||||
|
||||
vi.mock("axios");
|
||||
|
||||
const cloudBackend: Backend = {
|
||||
id: "prod",
|
||||
name: "Production",
|
||||
host: "https://app.all-hands.dev",
|
||||
apiKey: "bearer-token",
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
const buildConversation = (
|
||||
overrides: Partial<V1AppConversation> = {},
|
||||
): V1AppConversation => ({
|
||||
id: "conv-abc",
|
||||
created_by_user_id: null,
|
||||
selected_repository: null,
|
||||
selected_branch: null,
|
||||
git_provider: null,
|
||||
title: "Test",
|
||||
trigger: null,
|
||||
pr_number: [],
|
||||
llm_model: null,
|
||||
metrics: null,
|
||||
created_at: "2026-04-16T00:00:00Z",
|
||||
updated_at: "2026-04-16T00:00:00Z",
|
||||
execution_status: V1ExecutionStatus.RUNNING,
|
||||
conversation_url: null,
|
||||
session_api_key: null,
|
||||
sandbox_id: "sandbox-xyz",
|
||||
sub_conversation_ids: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
vi.mocked(axios.post).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("pauseV1Conversation cloud branch", () => {
|
||||
it("routes through /api/cloud-proxy to POST the SaaS sandbox pause endpoint", async () => {
|
||||
vi.spyOn(
|
||||
V1ConversationService,
|
||||
"batchGetAppConversations",
|
||||
).mockResolvedValue([buildConversation({ sandbox_id: "sandbox-xyz" })]);
|
||||
vi.mocked(axios.post).mockResolvedValue({ data: { success: true } });
|
||||
|
||||
await pauseV1Conversation("conv-abc");
|
||||
|
||||
expect(axios.post).toHaveBeenCalledOnce();
|
||||
const [url, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
expect(url).toMatch(/\/api\/cloud-proxy$/);
|
||||
expect(body).toMatchObject({
|
||||
host: cloudBackend.host,
|
||||
method: "POST",
|
||||
path: "/api/v1/sandboxes/sandbox-xyz/pause",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws and does not call cloud-proxy when the cloud conversation has no sandbox_id", async () => {
|
||||
vi.spyOn(
|
||||
V1ConversationService,
|
||||
"batchGetAppConversations",
|
||||
).mockResolvedValue([buildConversation({ sandbox_id: null })]);
|
||||
|
||||
await expect(pauseV1Conversation("conv-abc")).rejects.toThrow(/sandbox_id/);
|
||||
expect(axios.post).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import axios from "axios";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
setActiveSelection,
|
||||
setRegisteredBackends,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
|
||||
vi.mock("axios");
|
||||
|
||||
const cloudBackend: Backend = {
|
||||
id: "prod",
|
||||
name: "Production",
|
||||
host: "https://app.all-hands.dev",
|
||||
apiKey: "bearer-token",
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
vi.mocked(axios.post).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
describe("V1ConversationService.updateConversationPublicFlag", () => {
|
||||
it("PATCHes /api/v1/app-conversations/{id} via cloud-proxy on a cloud backend", async () => {
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
vi.mocked(axios.post).mockResolvedValue({
|
||||
data: { id: "conv-abc", public: true },
|
||||
});
|
||||
|
||||
await V1ConversationService.updateConversationPublicFlag("conv-abc", true);
|
||||
|
||||
expect(axios.post).toHaveBeenCalledOnce();
|
||||
const [url, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
expect(url).toMatch(/\/api\/cloud-proxy$/);
|
||||
expect(body).toMatchObject({
|
||||
host: cloudBackend.host,
|
||||
method: "PATCH",
|
||||
path: "/api/v1/app-conversations/conv-abc",
|
||||
body: { public: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects without calling the proxy when the active backend is local", async () => {
|
||||
// Default state after reset is the bundled local backend.
|
||||
await expect(
|
||||
V1ConversationService.updateConversationPublicFlag("conv-abc", true),
|
||||
).rejects.toThrow(/cloud backend/);
|
||||
expect(axios.post).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import axios from "axios";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
setActiveSelection,
|
||||
setRegisteredBackends,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
|
||||
vi.mock("axios");
|
||||
|
||||
const cloudBackend: Backend = {
|
||||
id: "prod",
|
||||
name: "Production",
|
||||
host: "https://app.all-hands.dev",
|
||||
apiKey: "bearer-token",
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
const localBackend: Backend = {
|
||||
id: "self-hosted",
|
||||
name: "Self-hosted",
|
||||
host: "http://192.168.1.99:9999",
|
||||
apiKey: "local-key",
|
||||
kind: "local",
|
||||
};
|
||||
|
||||
const runtimeResponse = {
|
||||
id: "conv-abc",
|
||||
title: "Test conversation",
|
||||
created_at: "2026-04-16T00:00:00Z",
|
||||
updated_at: "2026-04-16T00:00:00Z",
|
||||
execution_status: "idle",
|
||||
metrics: null,
|
||||
stats: {
|
||||
usage_to_metrics: {
|
||||
agent: {
|
||||
model_name: "test-model",
|
||||
accumulated_cost: 1.23,
|
||||
max_budget_per_task: null,
|
||||
accumulated_token_usage: null,
|
||||
costs: [],
|
||||
response_latencies: [],
|
||||
token_usages: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("V1ConversationService.getRuntimeConversation", () => {
|
||||
describe("cloud mode", () => {
|
||||
beforeEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
vi.mocked(axios.post).mockReset();
|
||||
});
|
||||
|
||||
it("routes through /api/cloud-proxy targeting the conversation runtime host", async () => {
|
||||
// Arrange
|
||||
vi.mocked(axios.post).mockResolvedValue({ data: runtimeResponse });
|
||||
const conversationUrl =
|
||||
"http://abc123.runtime.all-hands.dev/api/conversations/conv-abc";
|
||||
|
||||
// Act
|
||||
const result = await V1ConversationService.getRuntimeConversation(
|
||||
"conv-abc",
|
||||
conversationUrl,
|
||||
"session-xyz",
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(axios.post).toHaveBeenCalledOnce();
|
||||
const [url, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
expect(url).toMatch(/\/api\/cloud-proxy$/);
|
||||
expect(body).toMatchObject({
|
||||
host: "http://abc123.runtime.all-hands.dev",
|
||||
method: "GET",
|
||||
path: "/api/conversations/conv-abc",
|
||||
headers: { "X-Session-API-Key": "session-xyz" },
|
||||
});
|
||||
expect(result.stats.usage_to_metrics.agent?.accumulated_cost).toBe(1.23);
|
||||
});
|
||||
});
|
||||
|
||||
describe("local mode", () => {
|
||||
beforeEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([localBackend]);
|
||||
setActiveSelection({ backendId: localBackend.id });
|
||||
vi.mocked(axios.post).mockReset();
|
||||
});
|
||||
|
||||
it("targets the conversation_url host (not the active backend host) and forwards X-Session-API-Key", async () => {
|
||||
// Arrange
|
||||
const fetchMock = vi.fn(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify(runtimeResponse), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const conversationUrl =
|
||||
"http://192.168.1.42:8888/api/conversations/conv-abc";
|
||||
|
||||
// Act
|
||||
const result = await V1ConversationService.getRuntimeConversation(
|
||||
"conv-abc",
|
||||
conversationUrl,
|
||||
"session-xyz",
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(axios.post).not.toHaveBeenCalled();
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const call = fetchMock.mock.calls[0] as unknown as [
|
||||
RequestInfo,
|
||||
RequestInit,
|
||||
];
|
||||
expect(String(call[0])).toContain("192.168.1.42:8888");
|
||||
expect(String(call[0])).not.toContain(localBackend.host);
|
||||
expect(call[1]?.headers).toMatchObject({
|
||||
"X-Session-API-Key": "session-xyz",
|
||||
});
|
||||
expect(result.id).toBe("conv-abc");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import axios from "axios";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
setActiveSelection,
|
||||
setRegisteredBackends,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import { getCloudOrganizationMe } from "#/api/cloud/organization-service.api";
|
||||
|
||||
vi.mock("axios");
|
||||
|
||||
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 });
|
||||
vi.mocked(axios.post).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
describe("cloud organization /me via local proxy", () => {
|
||||
it("calls /api/organizations/{orgId}/me through the proxy and returns user_id", async () => {
|
||||
const orgId = "0b93b5f2-5396-49f2-8d98-61f906184270";
|
||||
vi.mocked(axios.post).mockResolvedValue({
|
||||
data: {
|
||||
org_id: orgId,
|
||||
user_id: orgId,
|
||||
email: "hieptl.developer@gmail.com",
|
||||
role: "owner",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getCloudOrganizationMe(orgId);
|
||||
|
||||
const [url, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
expect(url).toMatch(/\/api\/cloud-proxy$/);
|
||||
expect(body).toMatchObject({
|
||||
host: cloudBackend.host,
|
||||
method: "GET",
|
||||
path: `/api/organizations/${orgId}/me`,
|
||||
});
|
||||
expect(result).toEqual({ orgId, userId: orgId });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import axios from "axios";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getCloudOrganizations,
|
||||
getCurrentCloudApiKey,
|
||||
switchCloudOrganization,
|
||||
} from "#/api/cloud/organization-service.api";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
|
||||
vi.mock("axios");
|
||||
|
||||
const cloudBackend: Backend = {
|
||||
id: "prod",
|
||||
name: "Production",
|
||||
host: "https://app.all-hands.dev",
|
||||
apiKey: "bearer-token",
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.mocked(axios.post).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.mocked(axios.post).mockReset();
|
||||
});
|
||||
|
||||
describe("cloud organization-service via local proxy", () => {
|
||||
it("getCloudOrganizations posts the right envelope to the local proxy and returns normalized data", async () => {
|
||||
vi.mocked(axios.post).mockResolvedValue({
|
||||
data: {
|
||||
items: [{ id: "org-1", name: "Personal" }],
|
||||
current_org_id: "org-1",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getCloudOrganizations(cloudBackend);
|
||||
|
||||
expect(axios.post).toHaveBeenCalledOnce();
|
||||
const [url, body, options] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
|
||||
// Should target the bundled local agent-server, not the cloud host.
|
||||
expect(url).toMatch(/\/api\/cloud-proxy$/);
|
||||
expect(url).not.toContain("app.all-hands.dev");
|
||||
|
||||
// The envelope carries the cloud host + path + bearer header.
|
||||
expect(body).toMatchObject({
|
||||
host: cloudBackend.host,
|
||||
method: "GET",
|
||||
path: "/api/organizations",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
|
||||
// The outer request to the local agent-server uses the local
|
||||
// X-Session-API-Key auth, NOT the cloud bearer.
|
||||
expect(
|
||||
(options as { headers?: Record<string, string> } | undefined)?.headers ??
|
||||
{},
|
||||
).not.toHaveProperty("Authorization");
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [{ id: "org-1", name: "Personal" }],
|
||||
currentOrgId: "org-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("getCurrentCloudApiKey hits /api/keys/current and returns the bound orgId", async () => {
|
||||
vi.mocked(axios.post).mockResolvedValue({
|
||||
data: {
|
||||
id: "key-1",
|
||||
name: "k",
|
||||
org_id: "org-bound",
|
||||
user_id: "user-1",
|
||||
auth_type: "bearer",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getCurrentCloudApiKey(cloudBackend);
|
||||
|
||||
const [, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
expect((body as { path: string }).path).toBe("/api/keys/current");
|
||||
expect(result).toEqual({ orgId: "org-bound", isLegacyKey: false });
|
||||
});
|
||||
|
||||
it("getCurrentCloudApiKey treats an upstream 400 as a legacy key (no binding)", async () => {
|
||||
const error = Object.assign(new Error("Bad Request"), {
|
||||
response: { status: 400 },
|
||||
});
|
||||
vi.mocked(axios.isAxiosError).mockReturnValueOnce(true);
|
||||
vi.mocked(axios.post).mockRejectedValueOnce(error);
|
||||
|
||||
const result = await getCurrentCloudApiKey(cloudBackend);
|
||||
|
||||
expect(result).toEqual({ orgId: null, isLegacyKey: true });
|
||||
});
|
||||
|
||||
it("getCurrentCloudApiKey rethrows non-400 upstream errors (e.g. revoked key)", async () => {
|
||||
const error = Object.assign(new Error("Unauthorized"), {
|
||||
response: { status: 401 },
|
||||
});
|
||||
vi.mocked(axios.isAxiosError).mockReturnValueOnce(true);
|
||||
vi.mocked(axios.post).mockRejectedValueOnce(error);
|
||||
|
||||
await expect(getCurrentCloudApiKey(cloudBackend)).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it("switchCloudOrganization posts to the org-switch path through the proxy", async () => {
|
||||
vi.mocked(axios.post).mockResolvedValue({ data: {} });
|
||||
|
||||
await switchCloudOrganization("org-2", cloudBackend);
|
||||
|
||||
const [, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
expect(body).toMatchObject({
|
||||
host: cloudBackend.host,
|
||||
method: "POST",
|
||||
path: "/api/organizations/org-2/switch",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import axios from "axios";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
setActiveSelection,
|
||||
setRegisteredBackends,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import { SecretsService } from "#/api/secrets-service";
|
||||
|
||||
vi.mock("axios");
|
||||
|
||||
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 });
|
||||
vi.mocked(axios.post).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
describe("SecretsService against cloud backend", () => {
|
||||
it("paginates getSecrets through /api/cloud-proxy and returns the merged list", async () => {
|
||||
vi.mocked(axios.post)
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
items: [
|
||||
{ name: "ALPHA", description: "first" },
|
||||
{ name: "BETA", description: "second" },
|
||||
],
|
||||
next_page_id: "BETA",
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
items: [{ name: "GAMMA", description: "third" }],
|
||||
next_page_id: null,
|
||||
},
|
||||
});
|
||||
|
||||
const secrets = await SecretsService.getSecrets();
|
||||
|
||||
expect(vi.mocked(axios.post)).toHaveBeenCalledTimes(2);
|
||||
|
||||
const [firstUrl, firstBody] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
expect(firstUrl).toMatch(/\/api\/cloud-proxy$/);
|
||||
expect(firstBody).toMatchObject({
|
||||
host: cloudBackend.host,
|
||||
method: "GET",
|
||||
});
|
||||
expect((firstBody as { path: string }).path).toMatch(
|
||||
/^\/api\/v1\/secrets\/search\?/,
|
||||
);
|
||||
expect((firstBody as { path: string }).path).not.toContain("page_id=");
|
||||
|
||||
const [, secondBody] = vi.mocked(axios.post).mock.calls[1]!;
|
||||
expect((secondBody as { path: string }).path).toContain("page_id=BETA");
|
||||
|
||||
expect(secrets.map((s) => s.name)).toEqual(["ALPHA", "BETA", "GAMMA"]);
|
||||
});
|
||||
|
||||
it("creates a secret via POST /api/v1/secrets through the cloud proxy", async () => {
|
||||
vi.mocked(axios.post).mockResolvedValueOnce({ data: {} });
|
||||
|
||||
await SecretsService.createSecret(
|
||||
"OPENAI_API_KEY",
|
||||
"sk-test",
|
||||
"OpenAI key",
|
||||
);
|
||||
|
||||
const [url, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
expect(url).toMatch(/\/api\/cloud-proxy$/);
|
||||
expect(body).toMatchObject({
|
||||
host: cloudBackend.host,
|
||||
method: "POST",
|
||||
path: "/api/v1/secrets",
|
||||
body: {
|
||||
name: "OPENAI_API_KEY",
|
||||
value: "sk-test",
|
||||
description: "OpenAI key",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("updates a secret via PUT /api/v1/secrets/{id} with name + description only", async () => {
|
||||
vi.mocked(axios.post).mockResolvedValueOnce({ data: {} });
|
||||
|
||||
// The form/hook calls updateSecret(secretToEdit, newName, description).
|
||||
await SecretsService.updateSecret("OLD_NAME", "NEW_NAME", "renamed");
|
||||
|
||||
const [url, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
expect(url).toMatch(/\/api\/cloud-proxy$/);
|
||||
expect(body).toMatchObject({
|
||||
host: cloudBackend.host,
|
||||
method: "PUT",
|
||||
path: "/api/v1/secrets/OLD_NAME",
|
||||
body: { name: "NEW_NAME", description: "renamed" },
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes a secret via DELETE /api/v1/secrets/{id} through the cloud proxy", async () => {
|
||||
vi.mocked(axios.post).mockResolvedValueOnce({ data: {} });
|
||||
|
||||
await SecretsService.deleteSecret("token with space");
|
||||
|
||||
const [url, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
expect(url).toMatch(/\/api\/cloud-proxy$/);
|
||||
expect(body).toMatchObject({
|
||||
host: cloudBackend.host,
|
||||
method: "DELETE",
|
||||
path: "/api/v1/secrets/token%20with%20space",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import axios from "axios";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
setActiveSelection,
|
||||
setRegisteredBackends,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import {
|
||||
fetchCloudSettings,
|
||||
saveCloudSettings,
|
||||
} from "#/api/cloud/settings-service.api";
|
||||
|
||||
vi.mock("axios");
|
||||
|
||||
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 });
|
||||
vi.mocked(axios.post).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
describe("cloud settings via local proxy", () => {
|
||||
it("fetchCloudSettings preserves provider_tokens_set so the repo chain can fire", async () => {
|
||||
vi.mocked(axios.post).mockResolvedValue({
|
||||
data: {
|
||||
llm_model: "anthropic/claude-3-5-sonnet",
|
||||
llm_base_url: "https://api.anthropic.com",
|
||||
llm_api_key_set: true,
|
||||
agent: "CodeActAgent",
|
||||
confirmation_mode: true,
|
||||
security_analyzer: "llm",
|
||||
max_iterations: 30,
|
||||
provider_tokens_set: { github: "***" },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await fetchCloudSettings();
|
||||
|
||||
// Outer hop must be local proxy.
|
||||
const [url, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
expect(url).toMatch(/\/api\/cloud-proxy$/);
|
||||
expect(body).toMatchObject({
|
||||
host: cloudBackend.host,
|
||||
method: "GET",
|
||||
path: "/api/v1/settings",
|
||||
});
|
||||
|
||||
// provider_tokens_set must round-trip — it's what drives
|
||||
// useUserProviders → useAppInstallations → useGitRepositories.
|
||||
expect(result.provider_tokens_set).toEqual({ github: "***" });
|
||||
|
||||
// Top-level cloud fields are preserved as-is.
|
||||
expect(result.llm_model).toBe("anthropic/claude-3-5-sonnet");
|
||||
expect(result.llm_api_key_set).toBe(true);
|
||||
expect(result.agent).toBe("CodeActAgent");
|
||||
|
||||
// Nested shape derived for the local-mode settings page.
|
||||
expect(result.agent_settings?.agent).toBe("CodeActAgent");
|
||||
expect(result.agent_settings?.llm).toEqual({
|
||||
model: "anthropic/claude-3-5-sonnet",
|
||||
base_url: "https://api.anthropic.com",
|
||||
});
|
||||
expect(result.conversation_settings?.confirmation_mode).toBe(true);
|
||||
expect(result.conversation_settings?.security_analyzer).toBe("llm");
|
||||
expect(result.conversation_settings?.max_iterations).toBe(30);
|
||||
});
|
||||
|
||||
it("saveCloudSettings forwards diffs verbatim and omits the legacy keys the cloud rejects", async () => {
|
||||
vi.mocked(axios.post).mockResolvedValue({ data: {} });
|
||||
|
||||
const agentDiff = {
|
||||
llm: { model: "openai/gpt-4o", base_url: "https://api.openai.com" },
|
||||
agent: "CodeActAgent",
|
||||
};
|
||||
const conversationDiff = { max_iterations: 50 };
|
||||
|
||||
await saveCloudSettings({
|
||||
agent_settings_diff: agentDiff,
|
||||
conversation_settings_diff: conversationDiff,
|
||||
});
|
||||
|
||||
const [url, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
expect(url).toMatch(/\/api\/cloud-proxy$/);
|
||||
expect(body).toMatchObject({
|
||||
host: cloudBackend.host,
|
||||
method: "POST",
|
||||
path: "/api/v1/settings",
|
||||
});
|
||||
const proxiedBody = (body as { body: Record<string, unknown> }).body;
|
||||
expect(proxiedBody).toEqual({
|
||||
agent_settings_diff: agentDiff,
|
||||
conversation_settings_diff: conversationDiff,
|
||||
});
|
||||
expect(proxiedBody).not.toHaveProperty("agent_settings");
|
||||
expect(proxiedBody).not.toHaveProperty("conversation_settings");
|
||||
});
|
||||
|
||||
it("saveCloudSettings omits an empty conversation_settings_diff (LLM-only save)", async () => {
|
||||
vi.mocked(axios.post).mockResolvedValue({ data: {} });
|
||||
|
||||
await saveCloudSettings({
|
||||
agent_settings_diff: {
|
||||
llm: { model: "anthropic/claude-sonnet-4-20250514" },
|
||||
},
|
||||
conversation_settings_diff: {},
|
||||
});
|
||||
|
||||
const [, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
const proxiedBody = (body as { body: Record<string, unknown> }).body;
|
||||
expect(proxiedBody).toEqual({
|
||||
agent_settings_diff: {
|
||||
llm: { model: "anthropic/claude-sonnet-4-20250514" },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import axios from "axios";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
setActiveSelection,
|
||||
setRegisteredBackends,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import SkillsService from "#/api/skills-service";
|
||||
|
||||
vi.mock("axios");
|
||||
|
||||
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 });
|
||||
vi.mocked(axios.post).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
describe("SkillsService.getSkills against cloud backend", () => {
|
||||
it("paginates /api/v1/skills/search via the local cloud-proxy and returns the merged list", async () => {
|
||||
vi.mocked(axios.post)
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
items: [
|
||||
{ name: "alpha", type: "knowledge", source: "global" },
|
||||
{
|
||||
name: "beta",
|
||||
type: "task",
|
||||
source: "user",
|
||||
triggers: ["foo"],
|
||||
},
|
||||
],
|
||||
next_page_id: "beta",
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
items: [{ name: "gamma", type: "knowledge", source: "user" }],
|
||||
next_page_id: null,
|
||||
},
|
||||
});
|
||||
|
||||
const skills = await SkillsService.getSkills();
|
||||
|
||||
expect(vi.mocked(axios.post)).toHaveBeenCalledTimes(2);
|
||||
|
||||
const [firstUrl, firstBody] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
expect(firstUrl).toMatch(/\/api\/cloud-proxy$/);
|
||||
expect(firstBody).toMatchObject({
|
||||
host: cloudBackend.host,
|
||||
method: "GET",
|
||||
});
|
||||
expect((firstBody as { path: string }).path).toMatch(
|
||||
/^\/api\/v1\/skills\/search\?/,
|
||||
);
|
||||
expect((firstBody as { path: string }).path).not.toContain("page_id=");
|
||||
|
||||
const [, secondBody] = vi.mocked(axios.post).mock.calls[1]!;
|
||||
expect((secondBody as { path: string }).path).toContain("page_id=beta");
|
||||
|
||||
expect(skills.map((s) => s.name)).toEqual(["alpha", "beta", "gamma"]);
|
||||
expect(skills[1]).toMatchObject({ triggers: ["foo"] });
|
||||
});
|
||||
});
|
||||
@@ -98,9 +98,18 @@ describe("ProviderHandler", () => {
|
||||
expect(page.next_page_id).toBeNull();
|
||||
});
|
||||
|
||||
it("throws GitProviderAuthError when no providers are configured", async () => {
|
||||
await expect(ProviderHandler.getUserGitInfo()).rejects.toBeInstanceOf(
|
||||
GitProviderAuthError,
|
||||
);
|
||||
it("returns null when no providers are configured (no throw)", async () => {
|
||||
// Switching from cloud → local while settings is briefly stale
|
||||
// would otherwise hit this code path with no local tokens. Throwing
|
||||
// surfaces as a global error toast — returning null lets the caller
|
||||
// (`useGitUser`) render an empty user-info state instead.
|
||||
const result = await ProviderHandler.getUserGitInfo();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("still throws GitProviderAuthError when an explicit provider is requested but missing", async () => {
|
||||
await expect(
|
||||
ProviderHandler.getUserGitInfo("github"),
|
||||
).rejects.toBeInstanceOf(GitProviderAuthError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { openHands } from "#/api/open-hands-axios";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
setActiveSelection,
|
||||
setRegisteredBackends,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
|
||||
const localBackend: Backend = {
|
||||
id: "local-1",
|
||||
name: "Local 1",
|
||||
host: "http://localhost:9099",
|
||||
apiKey: "session-key-local",
|
||||
kind: "local",
|
||||
};
|
||||
|
||||
const cloudBackend: Backend = {
|
||||
id: "prod",
|
||||
name: "Production",
|
||||
host: "https://app.all-hands.dev",
|
||||
apiKey: "bearer-token-cloud",
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
async function runInterceptors() {
|
||||
const { AxiosHeaders } = await import("axios");
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const config: any = { headers: new AxiosHeaders({}) };
|
||||
|
||||
// Walk the registered request handlers; axios stores them as
|
||||
// { fulfilled, rejected } objects on the manager.
|
||||
const { handlers } = openHands.interceptors.request as unknown as {
|
||||
handlers: Array<{
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
fulfilled: (c: any) => any;
|
||||
} | null>;
|
||||
};
|
||||
let next = config;
|
||||
for (const handler of handlers) {
|
||||
if (handler && typeof handler.fulfilled === "function") {
|
||||
// Interceptors are an explicit chain — each input depends on the prior
|
||||
// output, so awaiting in order is correct here.
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
next = await handler.fulfilled(next);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
describe("openHands axios — active backend interceptor", () => {
|
||||
it("rewrites baseURL + sends X-Session-API-Key for a local backend", async () => {
|
||||
setRegisteredBackends([localBackend]);
|
||||
setActiveSelection({ backendId: localBackend.id });
|
||||
|
||||
const config = await runInterceptors();
|
||||
|
||||
expect(config.baseURL).toBe(localBackend.host);
|
||||
expect(config.headers.get("X-Session-API-Key")).toBe(localBackend.apiKey);
|
||||
expect(config.headers.get("Authorization")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("falls back to the bundled local backend when active is cloud (cloud calls go via callCloudProxy)", async () => {
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
|
||||
const config = await runInterceptors();
|
||||
|
||||
// Must NOT route default openHands traffic to the cloud host — those
|
||||
// endpoints don't exist on the SaaS and would CORS-fail. Cloud-only
|
||||
// calls use callCloudProxy directly and bypass this interceptor.
|
||||
expect(config.baseURL).not.toBe(cloudBackend.host);
|
||||
expect(config.baseURL).not.toContain("all-hands.dev");
|
||||
expect(config.headers.get("Authorization")).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
setActiveSelection,
|
||||
setRegisteredBackends,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import UserService from "#/api/user-service/user-service.api";
|
||||
|
||||
const cloudGitUser = vi.fn();
|
||||
const providerHandlerGetUser = vi.fn();
|
||||
|
||||
vi.mock("#/api/cloud/user-service.api", () => ({
|
||||
getCloudGitUser: () => cloudGitUser(),
|
||||
}));
|
||||
vi.mock("#/api/git-providers/provider-handler", () => ({
|
||||
ProviderHandler: {
|
||||
getUserGitInfo: () => providerHandlerGetUser(),
|
||||
},
|
||||
}));
|
||||
|
||||
const cloudBackend: Backend = {
|
||||
id: "prod",
|
||||
name: "Production",
|
||||
host: "https://app.all-hands.dev",
|
||||
apiKey: "bearer-token",
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
cloudGitUser.mockReset();
|
||||
providerHandlerGetUser.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
describe("UserService.getUser branch by backend kind", () => {
|
||||
it("uses the cloud /api/v1/users/git-info path when active is cloud (no false 'No git provider' toast)", async () => {
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
cloudGitUser.mockResolvedValue({
|
||||
id: "u1",
|
||||
login: "alice",
|
||||
avatar_url: "",
|
||||
company: null,
|
||||
name: null,
|
||||
email: null,
|
||||
});
|
||||
|
||||
const user = await UserService.getUser();
|
||||
|
||||
expect(cloudGitUser).toHaveBeenCalledOnce();
|
||||
// The local "no git provider" check must NOT run for cloud — it would
|
||||
// throw on a clean install where the user has not pasted a local PAT.
|
||||
expect(providerHandlerGetUser).not.toHaveBeenCalled();
|
||||
expect(user?.login).toBe("alice");
|
||||
});
|
||||
|
||||
it("uses the local ProviderHandler when active is local (preserves the local 'No git provider' check)", async () => {
|
||||
providerHandlerGetUser.mockResolvedValue({
|
||||
id: "u2",
|
||||
login: "bob",
|
||||
avatar_url: "",
|
||||
company: null,
|
||||
name: null,
|
||||
email: null,
|
||||
});
|
||||
|
||||
await UserService.getUser();
|
||||
|
||||
expect(providerHandlerGetUser).toHaveBeenCalledOnce();
|
||||
expect(cloudGitUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,19 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import axios from "axios";
|
||||
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 V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
|
||||
vi.mock("axios");
|
||||
|
||||
const {
|
||||
mockHttpGet,
|
||||
mockHttpPost,
|
||||
mockHttpDelete,
|
||||
mockFileUpload,
|
||||
mockCreateHttpClient,
|
||||
mockCreateRemoteWorkspace,
|
||||
@@ -12,6 +22,7 @@ const {
|
||||
} = vi.hoisted(() => ({
|
||||
mockHttpGet: vi.fn(),
|
||||
mockHttpPost: vi.fn(),
|
||||
mockHttpDelete: vi.fn(),
|
||||
mockFileUpload: vi.fn(),
|
||||
mockCreateHttpClient: vi.fn(),
|
||||
mockCreateRemoteWorkspace: vi.fn(),
|
||||
@@ -48,13 +59,14 @@ describe("V1ConversationService", () => {
|
||||
vi.clearAllMocks();
|
||||
mockHttpGet.mockReset();
|
||||
mockHttpPost.mockReset();
|
||||
mockHttpDelete.mockReset();
|
||||
mockFileUpload.mockReset();
|
||||
|
||||
mockCreateHttpClient.mockReturnValue({
|
||||
get: mockHttpGet,
|
||||
post: mockHttpPost,
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
delete: mockHttpDelete,
|
||||
});
|
||||
mockCreateRemoteWorkspace.mockReturnValue({
|
||||
fileUpload: mockFileUpload,
|
||||
@@ -147,6 +159,135 @@ describe("V1ConversationService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadConversation local branch", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
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 V1ConversationService.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 V1ConversationService.deleteConversation("conv-abc");
|
||||
|
||||
expect(mockHttpDelete).toHaveBeenCalledWith(
|
||||
"/api/conversations/conv-abc",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
vi.mocked(axios.post).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
it("forwards parent_conversation_id, agent_type, and sandbox_id to the cloud createConversation payload", async () => {
|
||||
// Arrange
|
||||
vi.mocked(axios.post).mockResolvedValue({
|
||||
data: {
|
||||
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 V1ConversationService.createConversation(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
null,
|
||||
undefined,
|
||||
"parent-conv-1",
|
||||
"plan",
|
||||
"sandbox-9",
|
||||
);
|
||||
|
||||
// Assert
|
||||
const [, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
const upstream = body as {
|
||||
path: string;
|
||||
body: Record<string, unknown>;
|
||||
};
|
||||
expect(upstream.path).toBe("/api/v1/app-conversations");
|
||||
expect(upstream.body).toMatchObject({
|
||||
parent_conversation_id: "parent-conv-1",
|
||||
agent_type: "plan",
|
||||
sandbox_id: "sandbox-9",
|
||||
});
|
||||
});
|
||||
|
||||
it("routes readConversationFile to the SaaS file endpoint with the file_path query param", async () => {
|
||||
// Arrange
|
||||
vi.mocked(axios.post).mockResolvedValue({ data: "# PLAN content" });
|
||||
|
||||
// Act
|
||||
const content =
|
||||
await V1ConversationService.readConversationFile("conv-cloud-1");
|
||||
|
||||
// Assert
|
||||
expect(content).toBe("# PLAN content");
|
||||
const [, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
const upstream = body as { method: string; path: string };
|
||||
expect(upstream.method).toBe("GET");
|
||||
expect(upstream.path).toBe(
|
||||
"/api/v1/app-conversations/conv-cloud-1/file?file_path=%2Fworkspace%2Fproject%2F.agents_tmp%2FPLAN.md",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("uploadFile", () => {
|
||||
it("uses query params for file upload path", async () => {
|
||||
const file = new File(["test content"], "test.txt", {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AddBackendMenuItem } from "#/components/features/backends/add-backend-menu-item";
|
||||
|
||||
describe("AddBackendMenuItem", () => {
|
||||
it("calls onOpen when clicked and does not render the modal itself", async () => {
|
||||
const onOpen = vi.fn();
|
||||
render(<AddBackendMenuItem onOpen={onOpen} />);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId("add-backend-menu-item"));
|
||||
|
||||
expect(onOpen).toHaveBeenCalledOnce();
|
||||
expect(screen.queryByTestId("add-backend-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import React from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store";
|
||||
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
|
||||
import { AddBackendModal } from "#/components/features/backends/add-backend-modal";
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ActiveBackendProvider>{ui}</ActiveBackendProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
describe("AddBackendModal", () => {
|
||||
it("renders Save (left) before Cancel (right) in a 2-column grid", () => {
|
||||
renderWithProviders(<AddBackendModal onClose={vi.fn()} />);
|
||||
|
||||
const submit = screen.getByTestId("add-backend-submit");
|
||||
const cancel = screen.getByTestId("add-backend-cancel");
|
||||
const row = submit.parentElement!;
|
||||
|
||||
// Save comes before Cancel in DOM order — Save is on the left.
|
||||
const orderTest = submit.compareDocumentPosition(cancel);
|
||||
// Bit 4 = DOCUMENT_POSITION_FOLLOWING: cancel comes after submit.
|
||||
// eslint-disable-next-line no-bitwise
|
||||
expect(orderTest & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
|
||||
// Equal-width via grid-cols-2: each column is exactly 50% of the row.
|
||||
expect(row.className).toContain("grid-cols-2");
|
||||
});
|
||||
|
||||
it("disables submit until all fields are filled", async () => {
|
||||
const onClose = vi.fn();
|
||||
renderWithProviders(<AddBackendModal onClose={onClose} />);
|
||||
|
||||
const submit = screen.getByTestId(
|
||||
"add-backend-submit",
|
||||
) as HTMLButtonElement;
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.type(screen.getByTestId("add-backend-name"), "Production");
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
await user.type(
|
||||
screen.getByTestId("add-backend-host"),
|
||||
"https://app.all-hands.dev",
|
||||
);
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
await user.type(screen.getByTestId("add-backend-api-key"), "secret-key");
|
||||
expect(submit).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("infers cloud kind from an all-hands.dev host", async () => {
|
||||
renderWithProviders(<AddBackendModal onClose={vi.fn()} />);
|
||||
|
||||
const cloudRadio = screen.getByTestId(
|
||||
"add-backend-kind-cloud",
|
||||
) as HTMLInputElement;
|
||||
const localRadio = screen.getByTestId(
|
||||
"add-backend-kind-local",
|
||||
) as HTMLInputElement;
|
||||
|
||||
fireEvent.change(screen.getByTestId("add-backend-host"), {
|
||||
target: { value: "https://app.all-hands.dev" },
|
||||
});
|
||||
|
||||
expect(cloudRadio.checked).toBe(true);
|
||||
expect(localRadio.checked).toBe(false);
|
||||
});
|
||||
|
||||
it("allows submitting a local backend with a blank API key", async () => {
|
||||
const onClose = vi.fn();
|
||||
renderWithProviders(<AddBackendModal onClose={onClose} />);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.type(screen.getByTestId("add-backend-name"), "Local Extra");
|
||||
await user.type(
|
||||
screen.getByTestId("add-backend-host"),
|
||||
"http://127.0.0.1:18002",
|
||||
);
|
||||
// No API key entered; kind auto-infers to "local" from the host.
|
||||
|
||||
await user.click(screen.getByTestId("add-backend-submit"));
|
||||
|
||||
const stored = JSON.parse(
|
||||
window.localStorage.getItem("openhands-backends") ?? "[]",
|
||||
);
|
||||
expect(stored).toMatchObject([
|
||||
{
|
||||
name: "Local Extra",
|
||||
host: "http://127.0.0.1:18002",
|
||||
apiKey: "",
|
||||
kind: "local",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps submit disabled for cloud backends until an API key is entered", async () => {
|
||||
renderWithProviders(<AddBackendModal onClose={vi.fn()} />);
|
||||
|
||||
const submit = screen.getByTestId(
|
||||
"add-backend-submit",
|
||||
) as HTMLButtonElement;
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.type(screen.getByTestId("add-backend-name"), "Cloud");
|
||||
await user.type(
|
||||
screen.getByTestId("add-backend-host"),
|
||||
"https://app.all-hands.dev",
|
||||
);
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
await user.type(screen.getByTestId("add-backend-api-key"), "token");
|
||||
expect(submit).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("saves the backend and closes WITHOUT switching the active selection", async () => {
|
||||
const onClose = vi.fn();
|
||||
renderWithProviders(<AddBackendModal onClose={onClose} />);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.type(screen.getByTestId("add-backend-name"), "Local 1");
|
||||
await user.type(
|
||||
screen.getByTestId("add-backend-host"),
|
||||
"http://localhost:9000",
|
||||
);
|
||||
await user.type(screen.getByTestId("add-backend-api-key"), "k");
|
||||
await user.click(screen.getByTestId("add-backend-kind-local"));
|
||||
|
||||
await user.click(screen.getByTestId("add-backend-submit"));
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
|
||||
const stored = JSON.parse(
|
||||
window.localStorage.getItem("openhands-backends") ?? "[]",
|
||||
);
|
||||
expect(stored).toHaveLength(1);
|
||||
expect(stored[0]).toMatchObject({
|
||||
name: "Local 1",
|
||||
host: "http://localhost:9000",
|
||||
apiKey: "k",
|
||||
kind: "local",
|
||||
});
|
||||
|
||||
// Adding a backend must NOT change the active selection. Auto-switch
|
||||
// would write `(backendId, null)` for a cloud backend, which the
|
||||
// dropdown can't render once orgs load — UI/API would drift.
|
||||
expect(window.localStorage.getItem("openhands-active-backend")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,435 @@
|
||||
import React from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createRoutesStub, MemoryRouter } from "react-router";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store";
|
||||
import {
|
||||
ActiveBackendProvider,
|
||||
useActiveBackendContext,
|
||||
} from "#/contexts/active-backend-context";
|
||||
import { BackendSelector } from "#/components/features/backends/backend-selector";
|
||||
|
||||
import {
|
||||
getCloudOrganizations,
|
||||
switchCloudOrganization,
|
||||
getCloudOrganizationMe,
|
||||
getCurrentCloudApiKey,
|
||||
} from "#/api/cloud/organization-service.api";
|
||||
|
||||
vi.mock("#/api/cloud/organization-service.api", () => ({
|
||||
getCloudOrganizations: vi.fn(),
|
||||
switchCloudOrganization: vi.fn().mockResolvedValue(undefined),
|
||||
getCloudOrganizationMe: vi.fn(),
|
||||
getCurrentCloudApiKey: vi.fn(),
|
||||
}));
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ActiveBackendProvider>{ui}</ActiveBackendProvider>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
function TestSeed({
|
||||
onMount,
|
||||
children,
|
||||
}: {
|
||||
onMount: (ctx: ReturnType<typeof useActiveBackendContext>) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const ctx = useActiveBackendContext();
|
||||
React.useEffect(() => {
|
||||
onMount(ctx);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return children as React.ReactElement;
|
||||
}
|
||||
|
||||
async function openDropdown() {
|
||||
const user = userEvent.setup();
|
||||
const wrapper = screen.getByTestId("backend-selector");
|
||||
await user.click(within(wrapper).getByTestId("dropdown-trigger"));
|
||||
return user;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
vi.mocked(getCloudOrganizations).mockReset();
|
||||
vi.mocked(switchCloudOrganization).mockReset();
|
||||
vi.mocked(switchCloudOrganization).mockResolvedValue(undefined);
|
||||
vi.mocked(getCloudOrganizationMe).mockReset();
|
||||
vi.mocked(getCloudOrganizationMe).mockResolvedValue({
|
||||
orgId: "",
|
||||
userId: "",
|
||||
});
|
||||
// Default to the legacy-key fallback so existing assertions about
|
||||
// multiple orgs being visible still hold. Tests that exercise
|
||||
// org-filter behavior override this with an explicit orgId.
|
||||
vi.mocked(getCurrentCloudApiKey).mockReset();
|
||||
vi.mocked(getCurrentCloudApiKey).mockResolvedValue({
|
||||
orgId: null,
|
||||
isLegacyKey: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
describe("BackendSelector", () => {
|
||||
it("uses the bundled Local label by default", () => {
|
||||
renderWithProviders(<BackendSelector />);
|
||||
const wrapper = screen.getByTestId("backend-selector");
|
||||
const input = wrapper.querySelector("input") as HTMLInputElement;
|
||||
expect(input.value).toBe("BACKEND$LOCAL_ROW");
|
||||
});
|
||||
|
||||
it("lists all registered backends in the dropdown", async () => {
|
||||
renderWithProviders(
|
||||
<TestSeed
|
||||
onMount={(ctx) => {
|
||||
ctx.addBackend({
|
||||
name: "Local 1",
|
||||
host: "http://localhost:9000",
|
||||
apiKey: "k",
|
||||
kind: "local",
|
||||
});
|
||||
ctx.addBackend({
|
||||
name: "Production",
|
||||
host: "https://app.all-hands.dev",
|
||||
apiKey: "b",
|
||||
kind: "cloud",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<BackendSelector />
|
||||
</TestSeed>,
|
||||
);
|
||||
|
||||
await openDropdown();
|
||||
|
||||
expect(screen.getByText("BACKEND$LOCAL_ROW")).toBeInTheDocument();
|
||||
expect(screen.getByText("Local 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Production")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("expands a cloud backend into one row per org and fires switch-org on select", async () => {
|
||||
vi.mocked(getCloudOrganizations).mockResolvedValue({
|
||||
items: [
|
||||
{ id: "org-personal", name: "Personal" },
|
||||
{ id: "org-2", name: "Acme Inc" },
|
||||
],
|
||||
currentOrgId: "org-personal",
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<TestSeed
|
||||
onMount={(ctx) => {
|
||||
ctx.addBackend({
|
||||
name: "Production",
|
||||
host: "https://app.all-hands.dev",
|
||||
apiKey: "bearer-key",
|
||||
kind: "cloud",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<BackendSelector />
|
||||
</TestSeed>,
|
||||
);
|
||||
|
||||
const user = await openDropdown();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Production – Personal")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Production – Acme Inc")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText("Production – Acme Inc"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(switchCloudOrganization).toHaveBeenCalled();
|
||||
});
|
||||
// The selector now passes an explicit backend so /switch lands on
|
||||
// the right cloud BEFORE the active selection flips.
|
||||
expect(switchCloudOrganization).toHaveBeenCalledWith(
|
||||
"org-2",
|
||||
expect.objectContaining({ host: "https://app.all-hands.dev" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("filters each cloud backend's org rows to the org its API key is bound to", async () => {
|
||||
// Both backends point at the same host; the user belongs to all three
|
||||
// orgs, but each API key is scoped to a different one. The selector
|
||||
// must show one row per backend, each labeled with its key's own org.
|
||||
vi.mocked(getCloudOrganizations).mockResolvedValue({
|
||||
items: [
|
||||
{ id: "org-personal", name: "Personal" },
|
||||
{ id: "org-acme", name: "Acme Inc" },
|
||||
{ id: "org-beta", name: "Beta Co" },
|
||||
],
|
||||
currentOrgId: "org-personal",
|
||||
});
|
||||
vi.mocked(getCurrentCloudApiKey).mockImplementation(async (backend) => ({
|
||||
orgId: backend?.apiKey === "key-personal" ? "org-personal" : "org-acme",
|
||||
isLegacyKey: false,
|
||||
}));
|
||||
|
||||
renderWithProviders(
|
||||
<TestSeed
|
||||
onMount={(ctx) => {
|
||||
ctx.addBackend({
|
||||
name: "ProdPersonal",
|
||||
host: "https://app.all-hands.dev",
|
||||
apiKey: "key-personal",
|
||||
kind: "cloud",
|
||||
});
|
||||
ctx.addBackend({
|
||||
name: "ProdAcme",
|
||||
host: "https://app.all-hands.dev",
|
||||
apiKey: "key-acme",
|
||||
kind: "cloud",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<BackendSelector />
|
||||
</TestSeed>,
|
||||
);
|
||||
|
||||
await openDropdown();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("ProdPersonal – Personal"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("ProdAcme – Acme Inc")).toBeInTheDocument();
|
||||
// Inaccessible orgs must not appear under either backend.
|
||||
expect(
|
||||
screen.queryByText("ProdPersonal – Acme Inc"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("ProdAcme – Personal"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Beta Co/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("labels an org as 'Personal Workspace' when /me reports user_id === org.id", async () => {
|
||||
const personalOrgId = "0b93b5f2-5396-49f2-8d98-61f906184270";
|
||||
vi.mocked(getCloudOrganizations).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: personalOrgId,
|
||||
// The auto-generated personal-workspace org has an unfriendly
|
||||
// backend-side name; the GUI must override it.
|
||||
name: `user_${personalOrgId}_org`,
|
||||
},
|
||||
{ id: "org-2", name: "Acme Inc" },
|
||||
],
|
||||
currentOrgId: personalOrgId,
|
||||
});
|
||||
// /me for the personal org returns user_id === org_id; for the team
|
||||
// org user_id !== org_id.
|
||||
vi.mocked(getCloudOrganizationMe).mockImplementation(async (orgId) => ({
|
||||
orgId,
|
||||
userId: orgId === personalOrgId ? personalOrgId : "some-user",
|
||||
}));
|
||||
|
||||
renderWithProviders(
|
||||
<TestSeed
|
||||
onMount={(ctx) => {
|
||||
ctx.addBackend({
|
||||
name: "Production",
|
||||
host: "https://app.all-hands.dev",
|
||||
apiKey: "bearer-key",
|
||||
kind: "cloud",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<BackendSelector />
|
||||
</TestSeed>,
|
||||
);
|
||||
|
||||
await openDropdown();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("Production – BACKEND$PERSONAL_WORKSPACE"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Production – Acme Inc")).toBeInTheDocument();
|
||||
// The auto-generated org name must NOT be rendered.
|
||||
expect(
|
||||
screen.queryByText(`Production – user_${personalOrgId}_org`),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("self-heals (cloud, null) → (cloud, personal-workspace org) once orgs + /me resolve", async () => {
|
||||
const personalOrgId = "0b93b5f2-5396-49f2-8d98-61f906184270";
|
||||
vi.mocked(getCloudOrganizations).mockResolvedValue({
|
||||
items: [
|
||||
{ id: personalOrgId, name: "Auto-generated personal" },
|
||||
{ id: "org-2", name: "Acme Inc" },
|
||||
],
|
||||
currentOrgId: personalOrgId,
|
||||
});
|
||||
vi.mocked(getCloudOrganizationMe).mockResolvedValue({
|
||||
orgId: personalOrgId,
|
||||
userId: personalOrgId,
|
||||
});
|
||||
|
||||
let cloudId = "";
|
||||
renderWithProviders(
|
||||
<TestSeed
|
||||
onMount={(ctx) => {
|
||||
cloudId = ctx.addBackend({
|
||||
name: "Production",
|
||||
host: "https://app.all-hands.dev",
|
||||
apiKey: "bearer-key",
|
||||
kind: "cloud",
|
||||
}).id;
|
||||
// Simulate the post-refresh malformed state: active backend is
|
||||
// the cloud one but no orgId is set yet.
|
||||
ctx.setActive(cloudId, null);
|
||||
}}
|
||||
>
|
||||
<BackendSelector />
|
||||
</TestSeed>,
|
||||
);
|
||||
|
||||
// After orgs + /me resolve, the selector should have snapped the
|
||||
// selection onto the personal-workspace org and fired switchOrg.
|
||||
await waitFor(() => {
|
||||
expect(switchCloudOrganization).toHaveBeenCalled();
|
||||
});
|
||||
expect(switchCloudOrganization).toHaveBeenCalledWith(
|
||||
personalOrgId,
|
||||
expect.objectContaining({ host: "https://app.all-hands.dev" }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const stored = JSON.parse(
|
||||
window.localStorage.getItem("openhands-active-backend") ?? "null",
|
||||
);
|
||||
expect(stored).toEqual({ backendId: cloudId, orgId: personalOrgId });
|
||||
});
|
||||
});
|
||||
|
||||
it("switches the active backend when an option is selected", async () => {
|
||||
renderWithProviders(
|
||||
<TestSeed
|
||||
onMount={(ctx) => {
|
||||
ctx.addBackend({
|
||||
name: "Local 1",
|
||||
host: "http://localhost:9000",
|
||||
apiKey: "k",
|
||||
kind: "local",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<BackendSelector />
|
||||
</TestSeed>,
|
||||
);
|
||||
|
||||
const user = await openDropdown();
|
||||
await user.click(screen.getByText("Local 1"));
|
||||
|
||||
const wrapper = screen.getByTestId("backend-selector");
|
||||
const input = wrapper.querySelector("input") as HTMLInputElement;
|
||||
expect(input.value).toBe("Local 1");
|
||||
});
|
||||
|
||||
it("redirects to home when switching backends from a conversation route", async () => {
|
||||
function ConversationRoute() {
|
||||
return (
|
||||
<TestSeed
|
||||
onMount={(ctx) => {
|
||||
ctx.addBackend({
|
||||
name: "Local 1",
|
||||
host: "http://localhost:9000",
|
||||
apiKey: "k",
|
||||
kind: "local",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<BackendSelector />
|
||||
</TestSeed>
|
||||
);
|
||||
}
|
||||
function HomeRoute() {
|
||||
return <div data-testid="home" />;
|
||||
}
|
||||
const RouterStub = createRoutesStub([
|
||||
{ path: "/conversations/:conversationId", Component: ConversationRoute },
|
||||
{ path: "/", Component: HomeRoute },
|
||||
]);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ActiveBackendProvider>
|
||||
<RouterStub initialEntries={["/conversations/abc"]} />
|
||||
</ActiveBackendProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const user = await openDropdown();
|
||||
await user.click(screen.getByText("Local 1"));
|
||||
|
||||
expect(await screen.findByTestId("home")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not redirect when switching backends from a non-conversation route", async () => {
|
||||
function SettingsRoute() {
|
||||
return (
|
||||
<TestSeed
|
||||
onMount={(ctx) => {
|
||||
ctx.addBackend({
|
||||
name: "Local 1",
|
||||
host: "http://localhost:9000",
|
||||
apiKey: "k",
|
||||
kind: "local",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<BackendSelector />
|
||||
</TestSeed>
|
||||
);
|
||||
}
|
||||
function HomeRoute() {
|
||||
return <div data-testid="home" />;
|
||||
}
|
||||
const RouterStub = createRoutesStub([
|
||||
{ path: "/settings", Component: SettingsRoute },
|
||||
{ path: "/", Component: HomeRoute },
|
||||
]);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ActiveBackendProvider>
|
||||
<RouterStub initialEntries={["/settings"]} />
|
||||
</ActiveBackendProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const user = await openDropdown();
|
||||
await user.click(screen.getByText("Local 1"));
|
||||
|
||||
const wrapper = screen.getByTestId("backend-selector");
|
||||
const input = wrapper.querySelector("input") as HTMLInputElement;
|
||||
expect(input.value).toBe("Local 1");
|
||||
expect(screen.queryByTestId("home")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,14 @@
|
||||
import React from "react";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
setActiveSelection,
|
||||
setRegisteredBackends,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
|
||||
vi.mock("#/components/features/controls/agent-status", () => ({
|
||||
AgentStatus: () => <div data-testid="agent-status-stub" />,
|
||||
@@ -11,8 +18,6 @@ vi.mock("#/components/features/controls/tools", () => ({
|
||||
Tools: () => <div data-testid="tools-stub" />,
|
||||
}));
|
||||
|
||||
// Sentinel: this stub only renders if a future change re-imports
|
||||
// ChangeAgentButton in ChatInputActions, which would fail the assertion below.
|
||||
vi.mock("#/components/features/chat/change-agent-button", () => ({
|
||||
ChangeAgentButton: () => <div data-testid="change-agent-button-stub" />,
|
||||
}));
|
||||
@@ -29,9 +34,22 @@ vi.mock("#/hooks/mutation/conversation-mutation-utils", () => ({
|
||||
// eslint-disable-next-line import/first
|
||||
import { ChatInputActions } from "#/components/features/chat/components/chat-input-actions";
|
||||
|
||||
describe("ChatInputActions", () => {
|
||||
it("does not render the Change Agent button while the planning agent feature is disabled", () => {
|
||||
// Arrange + Act
|
||||
const cloudBackend: Backend = {
|
||||
id: "prod",
|
||||
name: "Production",
|
||||
host: "https://app.all-hands.dev",
|
||||
apiKey: "bearer-token",
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
describe("ChatInputActions Change Agent button visibility", () => {
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
it("hides the Change Agent button on a local backend", () => {
|
||||
// Arrange + Act — default active backend is the bundled local one.
|
||||
renderWithProviders(<ChatInputActions disabled={false} />);
|
||||
|
||||
// Assert
|
||||
@@ -39,4 +57,20 @@ describe("ChatInputActions", () => {
|
||||
screen.queryByTestId("change-agent-button-stub"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the Change Agent button on a cloud backend", () => {
|
||||
// Arrange
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ActiveBackendProvider>
|
||||
<ChatInputActions disabled={false} />
|
||||
</ActiveBackendProvider>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId("change-agent-button-stub")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,13 @@ import { ConversationCard } from "#/components/features/conversation-panel/conve
|
||||
import { clickOnEditButton } from "./utils";
|
||||
import { ConversationCardActions } from "#/components/features/conversation-panel/conversation-card/conversation-card-actions";
|
||||
import { V1ExecutionStatus } from "#/types/v1/core/base/common";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
setActiveSelection,
|
||||
setRegisteredBackends,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
|
||||
|
||||
// We'll use the actual i18next implementation but override the translation function
|
||||
|
||||
@@ -50,6 +57,7 @@ describe("ConversationCard", () => {
|
||||
open: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
location: { origin: "http://localhost:3000" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -505,4 +513,55 @@ describe("ConversationCard", () => {
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
describe("stop button label by active backend", () => {
|
||||
const cloudBackend: Backend = {
|
||||
id: "prod",
|
||||
name: "Production",
|
||||
host: "https://app.all-hands.dev",
|
||||
apiKey: "bearer-token",
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
it("uses COMMON$STOP_CONVERSATION on a local backend", () => {
|
||||
// Default active backend (no provider, no registered backends) is the
|
||||
// bundled local backend.
|
||||
renderWithProviders(
|
||||
<ConversationCardActions
|
||||
contextMenuOpen={true}
|
||||
onContextMenuToggle={vi.fn()}
|
||||
onStop={vi.fn()}
|
||||
executionStatus={V1ExecutionStatus.RUNNING}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("stop-button")).toHaveTextContent(
|
||||
"COMMON$STOP_CONVERSATION",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses COMMON$CLOSE_CONVERSATION_STOP_RUNTIME on a cloud backend", () => {
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
|
||||
renderWithProviders(
|
||||
<ActiveBackendProvider>
|
||||
<ConversationCardActions
|
||||
contextMenuOpen={true}
|
||||
onContextMenuToggle={vi.fn()}
|
||||
onStop={vi.fn()}
|
||||
executionStatus={V1ExecutionStatus.RUNNING}
|
||||
/>
|
||||
</ActiveBackendProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("stop-button")).toHaveTextContent(
|
||||
"COMMON$CLOSE_CONVERSATION_STOP_RUNTIME",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ const createMockConversation = (overrides: Partial<V1AppConversation> = {}): V1A
|
||||
trigger: null,
|
||||
pr_number: [],
|
||||
session_api_key: null,
|
||||
sandbox_id: null,
|
||||
sub_conversation_ids: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -12,14 +12,33 @@ import {
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { ConversationName } from "#/components/features/conversation/conversation-name";
|
||||
import { ConversationNameContextMenu } from "#/components/features/conversation/conversation-name-context-menu";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import type { Conversation } from "#/api/open-hands.types";
|
||||
|
||||
const localBackend: Backend = {
|
||||
id: "bundled",
|
||||
name: "Bundled",
|
||||
host: "http://localhost:3000",
|
||||
apiKey: "",
|
||||
kind: "local",
|
||||
};
|
||||
|
||||
const cloudBackend: Backend = {
|
||||
id: "prod",
|
||||
name: "Production",
|
||||
host: "https://app.all-hands.dev",
|
||||
apiKey: "bearer-token",
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
// Hoisted mocks for controllable return values
|
||||
const {
|
||||
mockMutate,
|
||||
mockDisplaySuccessToast,
|
||||
useActiveConversationMock,
|
||||
useConfigMock,
|
||||
useActiveBackendMock,
|
||||
} = vi.hoisted(() => ({
|
||||
mockMutate: vi.fn(),
|
||||
mockDisplaySuccessToast: vi.fn(),
|
||||
@@ -33,6 +52,7 @@ const {
|
||||
useConfigMock: vi.fn(() => ({
|
||||
data: {},
|
||||
})),
|
||||
useActiveBackendMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-active-conversation", () => ({
|
||||
@@ -49,8 +69,13 @@ vi.mock("#/hooks/mutation/use-update-conversation", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/contexts/active-backend-context", () => ({
|
||||
useActiveBackend: () => useActiveBackendMock(),
|
||||
}));
|
||||
|
||||
vi.mock("#/utils/custom-toast-handlers", () => ({
|
||||
displaySuccessToast: mockDisplaySuccessToast,
|
||||
displayErrorToast: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock react-i18next
|
||||
@@ -69,7 +94,8 @@ vi.mock("react-i18next", async () => {
|
||||
CONVERSATION$SHOW_SKILLS: "Show Skills",
|
||||
BUTTON$DISPLAY_COST: "Display Cost",
|
||||
COMMON$CLOSE_CONVERSATION_STOP_RUNTIME:
|
||||
"Close Conversation",
|
||||
"Stop Conversation (Runtime)",
|
||||
COMMON$STOP_CONVERSATION: "Stop Conversation",
|
||||
COMMON$DELETE_CONVERSATION: "Delete Conversation",
|
||||
CONVERSATION$SHARE_PUBLICLY: "Share Publicly",
|
||||
CONVERSATION$LINK_COPIED: "Link copied to clipboard",
|
||||
@@ -107,6 +133,13 @@ describe("ConversationName", () => {
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
useActiveBackendMock.mockReturnValue({
|
||||
backend: localBackend,
|
||||
orgId: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -355,6 +388,13 @@ describe("ConversationNameContextMenu", () => {
|
||||
onClose: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
useActiveBackendMock.mockReturnValue({
|
||||
backend: localBackend,
|
||||
orgId: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -545,7 +585,7 @@ describe("ConversationNameContextMenu", () => {
|
||||
"Delete Conversation",
|
||||
);
|
||||
expect(screen.getByTestId("stop-button")).toHaveTextContent(
|
||||
"Close Conversation",
|
||||
"Stop Conversation",
|
||||
);
|
||||
expect(screen.getByTestId("display-cost-button")).toHaveTextContent(
|
||||
"Display Cost",
|
||||
@@ -581,3 +621,101 @@ describe("ConversationNameContextMenu", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConversationName public sharing", () => {
|
||||
let updatePublicFlagSpy: ReturnType<typeof vi.spyOn>;
|
||||
let writeTextSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
if (!("clipboard" in navigator)) {
|
||||
Object.defineProperty(globalThis.navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText: () => Promise.resolve() },
|
||||
});
|
||||
}
|
||||
writeTextSpy = vi
|
||||
.spyOn(navigator.clipboard, "writeText")
|
||||
.mockResolvedValue(undefined);
|
||||
useActiveConversationMock.mockReturnValue({
|
||||
data: {
|
||||
conversation_id: "test-conversation-id",
|
||||
title: "Test Conversation",
|
||||
status: "RUNNING",
|
||||
public: false,
|
||||
} as Conversation,
|
||||
});
|
||||
useActiveBackendMock.mockReturnValue({
|
||||
backend: cloudBackend,
|
||||
orgId: null,
|
||||
});
|
||||
updatePublicFlagSpy = vi
|
||||
.spyOn(V1ConversationService, "updateConversationPublicFlag")
|
||||
.mockResolvedValue({ id: "test-conversation-id", public: true } as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
updatePublicFlagSpy.mockRestore();
|
||||
writeTextSpy.mockRestore();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the Public Share menu item on cloud backends", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderConversationNameWithRouter();
|
||||
|
||||
await user.click(screen.getByTestId("ellipsis-button"));
|
||||
|
||||
expect(screen.getByTestId("share-publicly-button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the Public Share menu item on local backends", async () => {
|
||||
useActiveBackendMock.mockReturnValue({
|
||||
backend: localBackend,
|
||||
orgId: null,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderConversationNameWithRouter();
|
||||
|
||||
await user.click(screen.getByTestId("ellipsis-button"));
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("share-publicly-button"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls the update service with the toggled flag when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderConversationNameWithRouter();
|
||||
|
||||
await user.click(screen.getByTestId("ellipsis-button"));
|
||||
await user.click(screen.getByTestId("share-publicly-button"));
|
||||
|
||||
expect(updatePublicFlagSpy).toHaveBeenCalledWith(
|
||||
"test-conversation-id",
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the cloud environment domain for the share link and clipboard copy", async () => {
|
||||
useActiveConversationMock.mockReturnValue({
|
||||
data: {
|
||||
conversation_id: "test-conversation-id",
|
||||
title: "Test Conversation",
|
||||
status: "RUNNING",
|
||||
public: true,
|
||||
} as Conversation,
|
||||
});
|
||||
const expectedUrl =
|
||||
"https://app.all-hands.dev/shared/conversations/test-conversation-id";
|
||||
const user = userEvent.setup();
|
||||
renderConversationNameWithRouter();
|
||||
|
||||
await user.click(screen.getByTestId("ellipsis-button"));
|
||||
expect(screen.getByTestId("open-share-link-button")).toHaveAttribute(
|
||||
"href",
|
||||
expectedUrl,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("copy-share-link-button"));
|
||||
expect(writeTextSpy).toHaveBeenCalledWith(expectedUrl);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ const baseConversation: V1AppConversation = {
|
||||
trigger: null,
|
||||
pr_number: [],
|
||||
session_api_key: null,
|
||||
sandbox_id: null,
|
||||
sub_conversation_ids: [],
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { useHomeStore } from "#/stores/home-store";
|
||||
import { useWorkspacesStore } from "#/stores/workspaces-store";
|
||||
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
|
||||
|
||||
// Create mock functions
|
||||
const mockUseUserRepositories = vi.fn();
|
||||
@@ -346,7 +347,7 @@ describe("RepositorySelectionForm", () => {
|
||||
})
|
||||
}
|
||||
>
|
||||
{children}
|
||||
<ActiveBackendProvider>{children}</ActiveBackendProvider>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -140,6 +140,8 @@ describe("TaskCard", () => {
|
||||
git_provider: MOCK_TASK_1.git_provider,
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -168,6 +168,8 @@ describe("WorkspaceSelectionForm", () => {
|
||||
undefined,
|
||||
null,
|
||||
"/Users/me/dev/repo2",
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/conversations/conv-xyz"),
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
setActiveSelection,
|
||||
setRegisteredBackends,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
|
||||
import { LaunchTabs } from "#/components/features/home/launch-tabs";
|
||||
|
||||
// LaunchTabs's children pull a lot of git/data — stub them so the test
|
||||
// stays focused on the tab visibility decision.
|
||||
vi.mock("#/components/features/home/repo-selection-form", () => ({
|
||||
RepositorySelectionForm: () => <div data-testid="stub-repo-form" />,
|
||||
}));
|
||||
vi.mock("#/components/features/home/workspace-selection-form", () => ({
|
||||
WorkspaceSelectionForm: () => <div data-testid="stub-workspace-form" />,
|
||||
}));
|
||||
|
||||
const cloudBackend: Backend = {
|
||||
id: "prod",
|
||||
name: "Production",
|
||||
host: "https://app.all-hands.dev",
|
||||
apiKey: "k",
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
function renderTabs() {
|
||||
return render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ActiveBackendProvider>
|
||||
<LaunchTabs onRepoSelection={() => {}} />
|
||||
</ActiveBackendProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
describe("LaunchTabs", () => {
|
||||
it("shows the Workspaces tab when active backend is local", () => {
|
||||
renderTabs();
|
||||
expect(screen.getByTestId("repositories-tab")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("workspaces-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the Workspaces tab when active backend is cloud", () => {
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
|
||||
renderTabs();
|
||||
expect(screen.getByTestId("repositories-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("workspaces-tab")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { UserActions } from "#/components/features/sidebar/user-actions";
|
||||
import { OSS_NAV_ITEMS } from "#/constants/settings-nav";
|
||||
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
|
||||
|
||||
vi.mock("#/hooks/use-settings-nav-items", () => ({
|
||||
useSettingsNavItems: () => [
|
||||
@@ -13,25 +14,71 @@ vi.mock("#/hooks/use-settings-nav-items", () => ({
|
||||
],
|
||||
}));
|
||||
|
||||
function renderUserActions() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ActiveBackendProvider>
|
||||
<UserActions
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
/>
|
||||
</ActiveBackendProvider>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("UserActions", () => {
|
||||
it("shows the OSS user menu on hover without hosted-only actions", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<UserActions user={{ avatar_url: "https://example.com/avatar.png" }} />
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
renderUserActions();
|
||||
|
||||
await user.hover(screen.getByTestId("user-actions"));
|
||||
|
||||
expect(screen.getByTestId("user-context-menu")).toBeVisible();
|
||||
expect(screen.getByTestId("backend-selector")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("add-backend-menu-item")).toBeInTheDocument();
|
||||
expect(screen.getByText("SETTINGS$NAV_LLM")).toBeInTheDocument();
|
||||
expect(screen.getByText("SETTINGS$NAV_APPLICATION")).toBeInTheDocument();
|
||||
expect(screen.getByText("SIDEBAR$DOCS")).toBeInTheDocument();
|
||||
expect(screen.queryByText("ACCOUNT_SETTINGS$LOGOUT")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("ACCOUNT_SETTINGS$LOGOUT"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("context-menu-cta")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the Add Backend modal and hides the context menu when 'Add Backend' is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderUserActions();
|
||||
|
||||
await user.hover(screen.getByTestId("user-actions"));
|
||||
await user.click(screen.getByTestId("add-backend-menu-item"));
|
||||
|
||||
expect(screen.getByTestId("add-backend-modal")).toBeInTheDocument();
|
||||
|
||||
// While the modal is open, the menu wrapper must lose BOTH the
|
||||
// explicit visibility classes AND the `group-hover:*` classes — the
|
||||
// latter is what was keeping the menu visible even after the menu
|
||||
// state was closed.
|
||||
const wrapper = screen.getByTestId("user-context-menu-wrapper");
|
||||
expect(wrapper.className).not.toMatch(/(?:^|\s)opacity-100(?:\s|$)/);
|
||||
expect(wrapper.className).not.toMatch(
|
||||
/(?:^|\s)pointer-events-auto(?:\s|$)/,
|
||||
);
|
||||
expect(wrapper.className).not.toContain("group-hover:opacity-100");
|
||||
expect(wrapper.className).not.toContain("group-hover:pointer-events-auto");
|
||||
});
|
||||
|
||||
it("survives extraneous keypresses while the modal is open (Escape does not close it)", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderUserActions();
|
||||
|
||||
await user.hover(screen.getByTestId("user-actions"));
|
||||
await user.click(screen.getByTestId("add-backend-menu-item"));
|
||||
|
||||
expect(screen.getByTestId("add-backend-modal")).toBeInTheDocument();
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.getByTestId("add-backend-modal")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import React from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, render, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store";
|
||||
import { BUNDLED_BACKEND_ID } from "#/api/backend-registry/types";
|
||||
import {
|
||||
ActiveBackendProvider,
|
||||
useActiveBackendContext,
|
||||
} from "#/contexts/active-backend-context";
|
||||
|
||||
function makeWrapper(queryClient = new QueryClient()) {
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ActiveBackendProvider>{children}</ActiveBackendProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
return Wrapper;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
describe("ActiveBackendProvider", () => {
|
||||
it("exposes the bundled backend by default", () => {
|
||||
const { result } = renderHook(() => useActiveBackendContext(), {
|
||||
wrapper: makeWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.active.backend.id).toBe(BUNDLED_BACKEND_ID);
|
||||
expect(result.current.backends).toEqual([]);
|
||||
});
|
||||
|
||||
it("addBackend persists and exposes the new backend", () => {
|
||||
const { result } = renderHook(() => useActiveBackendContext(), {
|
||||
wrapper: makeWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.addBackend({
|
||||
name: "Production",
|
||||
host: "https://app.all-hands.dev",
|
||||
apiKey: "bearer-1",
|
||||
kind: "cloud",
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.backends).toHaveLength(1);
|
||||
expect(result.current.backends[0]).toMatchObject({
|
||||
name: "Production",
|
||||
kind: "cloud",
|
||||
});
|
||||
});
|
||||
|
||||
it("setActive switches the active backend without touching unrelated React Query cache entries", () => {
|
||||
const queryClient = new QueryClient();
|
||||
queryClient.setQueryData(["dummy"], { value: 1 });
|
||||
|
||||
const { result } = renderHook(() => useActiveBackendContext(), {
|
||||
wrapper: makeWrapper(queryClient),
|
||||
});
|
||||
|
||||
let added: { id: string } | null = null;
|
||||
act(() => {
|
||||
added = result.current.addBackend({
|
||||
name: "Local 1",
|
||||
host: "http://localhost:9000",
|
||||
apiKey: "key-1",
|
||||
kind: "local",
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setActive(added!.id);
|
||||
});
|
||||
|
||||
expect(result.current.active.backend.id).toBe(added!.id);
|
||||
// No blanket cache mutation: long-lived hooks include the active
|
||||
// backend identity in their query keys, so refetches happen via
|
||||
// key change rather than via an explicit invalidate from setActive.
|
||||
const dummyState = queryClient.getQueryState(["dummy"]);
|
||||
expect(dummyState?.isInvalidated).toBe(false);
|
||||
expect(queryClient.getQueryData(["dummy"])).toEqual({ value: 1 });
|
||||
});
|
||||
|
||||
it("removeBackend falls back to bundled if the active was removed", () => {
|
||||
const { result } = renderHook(() => useActiveBackendContext(), {
|
||||
wrapper: makeWrapper(),
|
||||
});
|
||||
|
||||
let id = "";
|
||||
act(() => {
|
||||
id = result.current.addBackend({
|
||||
name: "Local 1",
|
||||
host: "http://localhost:9000",
|
||||
apiKey: "k",
|
||||
kind: "local",
|
||||
}).id;
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setActive(id);
|
||||
});
|
||||
expect(result.current.active.backend.id).toBe(id);
|
||||
|
||||
act(() => {
|
||||
result.current.removeBackend(id);
|
||||
});
|
||||
expect(result.current.active.backend.id).toBe(BUNDLED_BACKEND_ID);
|
||||
expect(result.current.backends).toEqual([]);
|
||||
});
|
||||
|
||||
it("throws if used outside the provider", () => {
|
||||
function HookConsumer() {
|
||||
useActiveBackendContext();
|
||||
return null;
|
||||
}
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
expect(() => render(<HookConsumer />)).toThrow(/ActiveBackendProvider/);
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,14 @@
|
||||
import React from "react";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { useSlashCommand } from "#/hooks/chat/use-slash-command";
|
||||
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
setActiveSelection,
|
||||
setRegisteredBackends,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
|
||||
const mockSkills = vi.hoisted(() => ({
|
||||
data: undefined as unknown[] | undefined,
|
||||
@@ -31,6 +39,14 @@ function makeChatInputRef() {
|
||||
return { current: document.createElement("div") };
|
||||
}
|
||||
|
||||
const cloudBackend: Backend = {
|
||||
id: "prod",
|
||||
name: "Production",
|
||||
host: "https://app.all-hands.dev",
|
||||
apiKey: "bearer-token",
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
describe("useSlashCommand", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -39,10 +55,14 @@ describe("useSlashCommand", () => {
|
||||
mockConversation.data = undefined;
|
||||
});
|
||||
|
||||
it("excludes /new from the built-in commands while the planning agent is disabled", () => {
|
||||
// Arrange
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
it("excludes /new from the built-in commands on a local backend", () => {
|
||||
// Arrange — default active backend is the bundled local one.
|
||||
mockConversation.data = { conversation_version: "V1" };
|
||||
mockSkills.isLoading = false;
|
||||
mockSkills.data = [makeSkill("code-search", ["/code-search"])];
|
||||
|
||||
// Act
|
||||
@@ -54,4 +74,23 @@ describe("useSlashCommand", () => {
|
||||
expect(commands).not.toContain("/new");
|
||||
expect(commands).toEqual(expect.arrayContaining(["/btw", "/code-search"]));
|
||||
});
|
||||
|
||||
it("includes /new in the built-in commands on a cloud backend", () => {
|
||||
// Arrange
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
mockConversation.data = { conversation_version: "V1" };
|
||||
mockSkills.data = [];
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(ActiveBackendProvider, null, children);
|
||||
|
||||
// Act
|
||||
const ref = makeChatInputRef();
|
||||
const { result } = renderHook(() => useSlashCommand(ref), { wrapper });
|
||||
|
||||
// Assert
|
||||
const commands = result.current.filteredItems.map((i) => i.command);
|
||||
expect(commands).toContain("/new");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,8 +63,8 @@ describe("useAddGitProviders", () => {
|
||||
});
|
||||
|
||||
expect(invalidateSpy).toHaveBeenCalled();
|
||||
expect(queryClient.getQueryState(personalSettingsQueryKey)?.isInvalidated).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
queryClient.getQueryState(personalSettingsQueryKey)?.isInvalidated,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,6 +81,8 @@ describe("useCreateConversation", () => {
|
||||
git_provider: "github",
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,6 +45,7 @@ const mockConversation = {
|
||||
selected_repository: null,
|
||||
selected_branch: null,
|
||||
git_provider: null,
|
||||
sandbox_id: "sandbox-abc",
|
||||
conversation_version: "V1" as const,
|
||||
};
|
||||
|
||||
@@ -114,8 +115,9 @@ describe("useNewConversationCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when the new conversation id is missing", async () => {
|
||||
it("throws when the start task ends in ERROR", async () => {
|
||||
const errorTask = makeStartTask({
|
||||
status: "ERROR",
|
||||
detail: "Setup failed",
|
||||
app_conversation_id: null,
|
||||
});
|
||||
@@ -131,6 +133,30 @@ describe("useNewConversationCommand", () => {
|
||||
await expect(result.current.mutateAsync()).rejects.toThrow("Setup failed");
|
||||
});
|
||||
|
||||
it("navigates to /conversations/task-{id} for a cloud WORKING task without app_conversation_id", async () => {
|
||||
const workingTask = makeStartTask({
|
||||
status: "WORKING",
|
||||
detail: null,
|
||||
app_conversation_id: null,
|
||||
});
|
||||
|
||||
vi.spyOn(V1ConversationService, "createConversation").mockResolvedValue(
|
||||
workingTask as never,
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useNewConversationCommand(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await result.current.mutateAsync();
|
||||
|
||||
await waitFor(() => {
|
||||
// Format matches OpenHands' SaaS pattern: useTaskPolling unwraps
|
||||
// `task-{uuid}` and polls until READY, then redirects.
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/conversations/task-task-789");
|
||||
});
|
||||
});
|
||||
|
||||
it("invalidates conversation list queries on success", async () => {
|
||||
const readyTask = makeStartTask();
|
||||
|
||||
@@ -156,6 +182,35 @@ describe("useNewConversationCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards the active conversation's sandbox_id so /new reuses the same runtime", async () => {
|
||||
// Arrange
|
||||
const readyTask = makeStartTask();
|
||||
const createSpy = vi
|
||||
.spyOn(V1ConversationService, "createConversation")
|
||||
.mockResolvedValue(readyTask as never);
|
||||
|
||||
// Act
|
||||
const { result } = renderHook(() => useNewConversationCommand(), {
|
||||
wrapper,
|
||||
});
|
||||
await result.current.mutateAsync();
|
||||
|
||||
// Assert — sandbox_id is the 8th positional argument; parent_conversation_id
|
||||
// and agent_type stay undefined because /new is NOT a sub-conversation.
|
||||
await waitFor(() => {
|
||||
expect(createSpy).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
"sandbox-abc",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a loading toast and dismisses it on success", async () => {
|
||||
const readyTask = makeStartTask();
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useSwitchCloudOrganization } from "#/hooks/mutation/use-switch-cloud-organization";
|
||||
|
||||
vi.mock("#/api/cloud/organization-service.api", () => ({
|
||||
switchCloudOrganization: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function makeWrapper(queryClient: QueryClient) {
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
describe("useSwitchCloudOrganization", () => {
|
||||
it("on success removes per-conversation queries and leaves unrelated cache entries untouched", async () => {
|
||||
const orgId = "fd950a0a-25bc-48ff-b4dd-ad53a990fb82";
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
queryClient.setQueryData(["user", "conversation", "abc"], { dummy: 1 });
|
||||
queryClient.setQueryData(["unrelated"], { dummy: 1 });
|
||||
|
||||
const { result } = renderHook(() => useSwitchCloudOrganization(), {
|
||||
wrapper: makeWrapper(queryClient),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.mutate({ orgId });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
// Per-conversation queries must be physically removed (their org
|
||||
// context is stale).
|
||||
expect(
|
||||
queryClient.getQueryData(["user", "conversation", "abc"]),
|
||||
).toBeUndefined();
|
||||
// Other queries are NOT invalidated by this hook — they refetch via
|
||||
// the active-backend key change driven by `setActive` in the caller.
|
||||
expect(queryClient.getQueryData(["unrelated"])).toEqual({ dummy: 1 });
|
||||
});
|
||||
});
|
||||
@@ -36,9 +36,11 @@ const createWrapper = () => {
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
return function ({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
describe("useUpdateConversationRepository", () => {
|
||||
@@ -54,9 +56,9 @@ describe("useUpdateConversationRepository", () => {
|
||||
git_provider: "github",
|
||||
};
|
||||
|
||||
vi.mocked(V1ConversationService.updateConversationRepository).mockResolvedValue(
|
||||
mockResponse as any,
|
||||
);
|
||||
vi.mocked(
|
||||
V1ConversationService.updateConversationRepository,
|
||||
).mockResolvedValue(mockResponse as any);
|
||||
|
||||
const { result } = renderHook(() => useUpdateConversationRepository(), {
|
||||
wrapper: createWrapper(),
|
||||
@@ -73,7 +75,9 @@ describe("useUpdateConversationRepository", () => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(V1ConversationService.updateConversationRepository).toHaveBeenCalledWith(
|
||||
expect(
|
||||
V1ConversationService.updateConversationRepository,
|
||||
).toHaveBeenCalledWith(
|
||||
"test-conversation-id",
|
||||
"owner/repo",
|
||||
"main",
|
||||
@@ -89,9 +93,9 @@ describe("useUpdateConversationRepository", () => {
|
||||
git_provider: null,
|
||||
};
|
||||
|
||||
vi.mocked(V1ConversationService.updateConversationRepository).mockResolvedValue(
|
||||
mockResponse as any,
|
||||
);
|
||||
vi.mocked(
|
||||
V1ConversationService.updateConversationRepository,
|
||||
).mockResolvedValue(mockResponse as any);
|
||||
|
||||
const { result } = renderHook(() => useUpdateConversationRepository(), {
|
||||
wrapper: createWrapper(),
|
||||
@@ -108,18 +112,15 @@ describe("useUpdateConversationRepository", () => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(V1ConversationService.updateConversationRepository).toHaveBeenCalledWith(
|
||||
"test-conversation-id",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
expect(
|
||||
V1ConversationService.updateConversationRepository,
|
||||
).toHaveBeenCalledWith("test-conversation-id", null, null, null);
|
||||
});
|
||||
|
||||
it("should handle errors gracefully", async () => {
|
||||
vi.mocked(V1ConversationService.updateConversationRepository).mockRejectedValue(
|
||||
new Error("Failed to update repository"),
|
||||
);
|
||||
vi.mocked(
|
||||
V1ConversationService.updateConversationRepository,
|
||||
).mockRejectedValue(new Error("Failed to update repository"));
|
||||
|
||||
const { result } = renderHook(() => useUpdateConversationRepository(), {
|
||||
wrapper: createWrapper(),
|
||||
|
||||
@@ -32,6 +32,7 @@ describe("useV1ResumeConversation", () => {
|
||||
created_by_user_id: null,
|
||||
conversation_url: "http://localhost:3000",
|
||||
session_api_key: "test-key",
|
||||
sandbox_id: null,
|
||||
selected_repository: null,
|
||||
selected_branch: null,
|
||||
git_provider: null,
|
||||
@@ -64,7 +65,8 @@ describe("useV1ResumeConversation", () => {
|
||||
const invalidateCalls = invalidateSpy.mock.calls.map((call) => call[0]);
|
||||
const conversationInvalidation = invalidateCalls.find(
|
||||
(call) =>
|
||||
call?.queryKey?.[0] === "user" && call?.queryKey?.[1] === "conversation",
|
||||
call?.queryKey?.[0] === "user" &&
|
||||
call?.queryKey?.[1] === "conversation",
|
||||
);
|
||||
|
||||
expect(conversationInvalidation).toBeDefined();
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import React from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
setActiveSelection,
|
||||
setRegisteredBackends,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
|
||||
import { useCloudCurrentUserId } from "#/hooks/query/use-cloud-current-user-id";
|
||||
|
||||
const getCloudOrganizationMeMock = vi.fn();
|
||||
const useAllCloudOrganizationsMock = vi.fn();
|
||||
|
||||
vi.mock("#/api/cloud/organization-service.api", () => ({
|
||||
getCloudOrganizationMe: (...args: unknown[]) =>
|
||||
getCloudOrganizationMeMock(...args),
|
||||
}));
|
||||
vi.mock("#/hooks/query/use-cloud-organizations", () => ({
|
||||
useAllCloudOrganizations: () => useAllCloudOrganizationsMock(),
|
||||
}));
|
||||
|
||||
const cloudBackend: Backend = {
|
||||
id: "prod",
|
||||
name: "Production",
|
||||
host: "https://app.all-hands.dev",
|
||||
apiKey: "bearer-token",
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
function makeWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ActiveBackendProvider>{children}</ActiveBackendProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
return Wrapper;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
getCloudOrganizationMeMock.mockReset();
|
||||
useAllCloudOrganizationsMock.mockReset();
|
||||
useAllCloudOrganizationsMock.mockReturnValue({
|
||||
[cloudBackend.id]: {
|
||||
backend: cloudBackend,
|
||||
isLoading: false,
|
||||
orgs: [
|
||||
{ id: "org-personal", name: "Personal" },
|
||||
{ id: "org-2", name: "Acme Inc" },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
describe("useCloudCurrentUserId", () => {
|
||||
it("uses active.orgId for /me when the active backend is this cloud backend", async () => {
|
||||
setActiveSelection({ backendId: cloudBackend.id, orgId: "org-2" });
|
||||
getCloudOrganizationMeMock.mockResolvedValue({
|
||||
orgId: "org-2",
|
||||
userId: "user-X",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useCloudCurrentUserId(), {
|
||||
wrapper: makeWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current[cloudBackend.id]?.userId).toBe("user-X");
|
||||
});
|
||||
|
||||
// /me must be invoked with the ACTIVE org (org-2), not the first org.
|
||||
expect(getCloudOrganizationMeMock).toHaveBeenCalledOnce();
|
||||
expect(getCloudOrganizationMeMock).toHaveBeenCalledWith(
|
||||
"org-2",
|
||||
cloudBackend,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the first org for /me when no org is selected yet", async () => {
|
||||
// Active is bundled local — `prod` is registered but not active.
|
||||
getCloudOrganizationMeMock.mockResolvedValue({
|
||||
orgId: "org-personal",
|
||||
userId: "user-X",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useCloudCurrentUserId(), {
|
||||
wrapper: makeWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current[cloudBackend.id]?.userId).toBe("user-X");
|
||||
});
|
||||
|
||||
// Sentinel = first org.
|
||||
expect(getCloudOrganizationMeMock).toHaveBeenCalledWith(
|
||||
"org-personal",
|
||||
cloudBackend,
|
||||
);
|
||||
});
|
||||
|
||||
it("issues exactly one /me call per cloud backend (not one per org)", async () => {
|
||||
setActiveSelection({ backendId: cloudBackend.id, orgId: "org-2" });
|
||||
getCloudOrganizationMeMock.mockResolvedValue({
|
||||
orgId: "org-2",
|
||||
userId: "user-X",
|
||||
});
|
||||
|
||||
renderHook(() => useCloudCurrentUserId(), { wrapper: makeWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getCloudOrganizationMeMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -82,7 +82,16 @@ describe("useConversationHistory", () => {
|
||||
expect(result.current.data).toBeDefined();
|
||||
});
|
||||
|
||||
expect(EventService.searchEventsV1).toHaveBeenCalledWith("conv-123");
|
||||
// searchEventsV1 now accepts (conversationId, limit, conversationUrl,
|
||||
// sessionApiKey). The latter two are forwarded so cloud-mode calls
|
||||
// can target the correct host. In this test fixture they're null
|
||||
// (no conversation_url / session_api_key on the mocked conversation).
|
||||
expect(EventService.searchEventsV1).toHaveBeenCalledWith(
|
||||
"conv-123",
|
||||
100,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import React from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { useConversationMetrics } from "#/hooks/query/use-conversation-metrics";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
import { V1ExecutionStatus } from "#/types/v1/core/base/common";
|
||||
|
||||
const runtimeInfo = {
|
||||
id: "conv-abc",
|
||||
title: "Test",
|
||||
metrics: null,
|
||||
created_at: "2026-04-16T00:00:00Z",
|
||||
updated_at: "2026-04-16T00:00:00Z",
|
||||
status: V1ExecutionStatus.IDLE,
|
||||
stats: {
|
||||
usage_to_metrics: {
|
||||
agent: {
|
||||
model_name: "test-model",
|
||||
accumulated_cost: 2.5,
|
||||
max_budget_per_task: null,
|
||||
accumulated_token_usage: null,
|
||||
costs: [],
|
||||
response_latencies: [],
|
||||
token_usages: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function makeWrapper() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("useConversationMetrics", () => {
|
||||
it("fires the query when sessionApiKey is null (local backends without auth)", async () => {
|
||||
// Arrange
|
||||
const spy = vi
|
||||
.spyOn(V1ConversationService, "getRuntimeConversation")
|
||||
.mockResolvedValue(runtimeInfo);
|
||||
|
||||
// Act
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useConversationMetrics(
|
||||
"conv-abc",
|
||||
"http://localhost:8888/api/conversations/conv-abc",
|
||||
null,
|
||||
true,
|
||||
),
|
||||
{ wrapper: makeWrapper() },
|
||||
);
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(result.current.data?.accumulated_cost).toBe(2.5);
|
||||
});
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
"conv-abc",
|
||||
"http://localhost:8888/api/conversations/conv-abc",
|
||||
null,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -25,9 +25,13 @@ describe("useSubConversationTaskPolling", () => {
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
return function ({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
const createMockTask = (
|
||||
|
||||
@@ -26,6 +26,7 @@ function makeConversation(): V1AppConversation {
|
||||
execution_status: V1ExecutionStatus.RUNNING,
|
||||
conversation_url: null,
|
||||
session_api_key: null,
|
||||
sandbox_id: null,
|
||||
sub_conversation_ids: [],
|
||||
created_by_user_id: null,
|
||||
trigger: null,
|
||||
|
||||
@@ -5,11 +5,38 @@ import { useSettingsNavItems } from "#/hooks/use-settings-nav-items";
|
||||
import { WebClientConfig } from "#/api/option-service/option.types";
|
||||
|
||||
const useConfigMock = vi.fn();
|
||||
const useActiveBackendMock = vi.fn();
|
||||
|
||||
vi.mock("#/hooks/query/use-config", () => ({
|
||||
useConfig: () => useConfigMock(),
|
||||
}));
|
||||
|
||||
vi.mock("#/contexts/active-backend-context", () => ({
|
||||
useActiveBackend: () => useActiveBackendMock(),
|
||||
}));
|
||||
|
||||
const localActive = {
|
||||
backend: {
|
||||
id: "__bundled__",
|
||||
name: "Local",
|
||||
host: "http://localhost",
|
||||
apiKey: "",
|
||||
kind: "local" as const,
|
||||
},
|
||||
orgId: null,
|
||||
};
|
||||
|
||||
const cloudActive = {
|
||||
backend: {
|
||||
id: "prod",
|
||||
name: "Production",
|
||||
host: "https://app.all-hands.dev",
|
||||
apiKey: "bearer",
|
||||
kind: "cloud" as const,
|
||||
},
|
||||
orgId: null,
|
||||
};
|
||||
|
||||
const createConfig = (
|
||||
feature_flags: Partial<WebClientConfig["feature_flags"]> = {},
|
||||
): WebClientConfig => ({
|
||||
@@ -34,6 +61,7 @@ const createConfig = (
|
||||
describe("useSettingsNavItems", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useActiveBackendMock.mockReturnValue(localActive);
|
||||
});
|
||||
|
||||
it("returns the OSS settings items in order", () => {
|
||||
@@ -64,4 +92,23 @@ describe("useSettingsNavItems", () => {
|
||||
expect(paths).toContain("/settings/mcp");
|
||||
expect(paths).toContain("/settings/secrets");
|
||||
});
|
||||
|
||||
it("hides local-only sub-pages when the active backend is cloud", () => {
|
||||
useConfigMock.mockReturnValue({ data: createConfig() });
|
||||
useActiveBackendMock.mockReturnValue(cloudActive);
|
||||
|
||||
const { result } = renderHook(() => useSettingsNavItems());
|
||||
const paths = result.current
|
||||
.filter((item) => item.type === "item")
|
||||
.map((item) => (item.type === "item" ? item.item.to : null));
|
||||
|
||||
expect(paths).not.toContain("/settings/agent-server");
|
||||
expect(paths).not.toContain("/settings/integrations");
|
||||
expect(paths).toContain("/settings");
|
||||
expect(paths).toContain("/settings/condenser");
|
||||
expect(paths).toContain("/settings/verification");
|
||||
expect(paths).toContain("/settings/mcp");
|
||||
expect(paths).toContain("/settings/skills");
|
||||
expect(paths).toContain("/settings/secrets");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createRoutesStub } from "react-router";
|
||||
import MainApp from "#/routes/root-layout";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
|
||||
|
||||
// Hoisted mocks for useIsAuthed and useConfig to allow dynamic control in tests
|
||||
const { useIsAuthedMock, useConfigMock } = vi.hoisted(() => ({
|
||||
@@ -75,7 +76,7 @@ describe("MainApp - Auth refetch behavior", () => {
|
||||
render(<RouterStub initialEntries={["/settings"]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<ActiveBackendProvider>{children}</ActiveBackendProvider>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -26,6 +26,10 @@ vi.mock("#/hooks/use-sync-posthog-consent", () => ({
|
||||
useSyncPostHogConsent: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-cloud-git-user", () => ({
|
||||
useCloudGitUser: () => ({ data: undefined, isLoading: false }),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-app-title", () => ({
|
||||
useAppTitle: () => "OpenHands",
|
||||
}));
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import SettingsScreen, { clientLoader } from "#/routes/settings";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
setActiveSelection,
|
||||
setRegisteredBackends,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import { getFirstAvailablePath } from "#/utils/settings-utils";
|
||||
import { OSS_NAV_ITEMS } from "#/constants/settings-nav";
|
||||
|
||||
@@ -14,9 +20,24 @@ vi.mock("#/hooks/use-settings-nav-items", () => ({
|
||||
],
|
||||
}));
|
||||
|
||||
const cloudBackend: Backend = {
|
||||
id: "prod",
|
||||
name: "Production",
|
||||
host: "https://app.all-hands.dev",
|
||||
apiKey: "bearer-token",
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
describe("settings route", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
it("prefers OSS fallback routes only", () => {
|
||||
@@ -61,6 +82,30 @@ describe("settings route", () => {
|
||||
expect(response.headers.get("Location")).toBe("/settings/mcp");
|
||||
});
|
||||
|
||||
it("redirects local-only settings paths to /settings when the active backend is cloud", async () => {
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
|
||||
const integrationsResponse = (await clientLoader({
|
||||
request: new Request("http://localhost/settings/integrations"),
|
||||
params: {},
|
||||
context: {},
|
||||
} as never)) as Response;
|
||||
|
||||
const agentServerResponse = (await clientLoader({
|
||||
request: new Request("http://localhost/settings/agent-server"),
|
||||
params: {},
|
||||
context: {},
|
||||
} as never)) as Response;
|
||||
|
||||
expect(integrationsResponse.status).toBe(302);
|
||||
expect(integrationsResponse.headers.get("Location")).toBe("/settings");
|
||||
expect(agentServerResponse.status).toBe(302);
|
||||
expect(agentServerResponse.headers.get("Location")).toBe("/settings");
|
||||
expect(getConfigSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips backend config loading for the agent server settings route", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildExtraBackendConfig } from "../../scripts/dev-extra-backend.mjs";
|
||||
import { buildSafeDevConfig } from "../../scripts/dev-safe.mjs";
|
||||
|
||||
const repoRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../..",
|
||||
);
|
||||
|
||||
describe("buildExtraBackendConfig", () => {
|
||||
it("defaults to ports 18002/18003 distinct from the bundled instance", () => {
|
||||
const bundled = buildSafeDevConfig(repoRoot, {});
|
||||
const extra = buildExtraBackendConfig(repoRoot, {});
|
||||
|
||||
expect(extra.backendPort).toBe(18002);
|
||||
expect(extra.vscodePort).toBe(18003);
|
||||
expect(extra.backendBaseUrl).toBe("http://127.0.0.1:18002");
|
||||
expect(extra.backendHost).toBe("127.0.0.1:18002");
|
||||
expect(extra.backendPort).not.toBe(bundled.backendPort);
|
||||
expect(extra.vscodePort).not.toBe(bundled.vscodePort);
|
||||
});
|
||||
|
||||
it("honors OH_CANVAS_EXTRA_BACKEND_PORT and OH_CANVAS_EXTRA_VSCODE_PORT", () => {
|
||||
const config = buildExtraBackendConfig(repoRoot, {
|
||||
OH_CANVAS_EXTRA_BACKEND_PORT: "29000",
|
||||
OH_CANVAS_EXTRA_VSCODE_PORT: "29001",
|
||||
});
|
||||
|
||||
expect(config.backendPort).toBe(29000);
|
||||
expect(config.vscodePort).toBe(29001);
|
||||
expect(config.backendBaseUrl).toBe("http://127.0.0.1:29000");
|
||||
});
|
||||
|
||||
it("shares state dir, conversations, bash events, and secret key with the bundled config", () => {
|
||||
const env = { OH_CANVAS_SAFE_STATE_DIR: "/tmp/canvas-state" };
|
||||
const bundled = buildSafeDevConfig(repoRoot, env);
|
||||
const extra = buildExtraBackendConfig(repoRoot, env);
|
||||
|
||||
expect(extra.stateDir).toBe(bundled.stateDir);
|
||||
expect(extra.conversationsPath).toBe(bundled.conversationsPath);
|
||||
expect(extra.bashEventsDir).toBe(bundled.bashEventsDir);
|
||||
expect(extra.tmuxTmpDir).toBe(bundled.tmuxTmpDir);
|
||||
expect(extra.secretKey).toBe(bundled.secretKey);
|
||||
});
|
||||
|
||||
it("rejects an invalid OH_CANVAS_EXTRA_BACKEND_PORT", () => {
|
||||
expect(() =>
|
||||
buildExtraBackendConfig(repoRoot, {
|
||||
OH_CANVAS_EXTRA_BACKEND_PORT: "not-a-port",
|
||||
}),
|
||||
).toThrow(/Invalid port/);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
buildNpmScriptCommand,
|
||||
buildAgentServerCommand,
|
||||
formatMissingUvxGuidance,
|
||||
validateLocalAgentServerPath,
|
||||
} from "../../scripts/dev-safe.mjs";
|
||||
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
const repoRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
@@ -118,6 +121,96 @@ describe("buildAgentServerCommand", () => {
|
||||
);
|
||||
expect(cmd.args).not.toContain("openhands-agent-server==1.18.0");
|
||||
});
|
||||
|
||||
it("uses local path with editable workspace packages when OH_AGENT_SERVER_LOCAL_PATH is set", () => {
|
||||
const sdk = "/abs/path/to/software-agent-sdk";
|
||||
const cmd = buildAgentServerCommand({ OH_AGENT_SERVER_LOCAL_PATH: sdk });
|
||||
|
||||
expect(cmd.command).toBe("uvx");
|
||||
expect(cmd.args).toEqual([
|
||||
"--reinstall",
|
||||
"--from",
|
||||
path.join(sdk, "openhands-agent-server"),
|
||||
"--with-editable",
|
||||
path.join(sdk, "openhands-sdk"),
|
||||
"--with-editable",
|
||||
path.join(sdk, "openhands-tools"),
|
||||
"--with-editable",
|
||||
path.join(sdk, "openhands-workspace"),
|
||||
"agent-server",
|
||||
]);
|
||||
expect(cmd.source).toBe(`local (${sdk})`);
|
||||
});
|
||||
|
||||
it("local path takes precedence over git ref and version", () => {
|
||||
const sdk = "/abs/path/to/software-agent-sdk";
|
||||
const cmd = buildAgentServerCommand({
|
||||
OH_AGENT_SERVER_LOCAL_PATH: sdk,
|
||||
OH_AGENT_SERVER_GIT_REF: "feature-branch",
|
||||
OH_AGENT_SERVER_VERSION: "1.18.0",
|
||||
});
|
||||
|
||||
expect(cmd.source).toBe(`local (${sdk})`);
|
||||
expect(cmd.args).toContain(path.join(sdk, "openhands-agent-server"));
|
||||
expect(cmd.args).not.toContain(
|
||||
"git+https://github.com/OpenHands/software-agent-sdk@feature-branch#subdirectory=openhands-agent-server",
|
||||
);
|
||||
expect(cmd.args).not.toContain("openhands-agent-server==1.18.0");
|
||||
});
|
||||
|
||||
it("rejects relative OH_AGENT_SERVER_LOCAL_PATH", () => {
|
||||
expect(() =>
|
||||
buildAgentServerCommand({
|
||||
OH_AGENT_SERVER_LOCAL_PATH: "./software-agent-sdk",
|
||||
}),
|
||||
).toThrow(/must be an absolute path/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateLocalAgentServerPath", () => {
|
||||
it("passes when all four workspace packages exist", () => {
|
||||
const tmp = mkdtempSync(path.join(tmpdir(), "sdk-"));
|
||||
try {
|
||||
for (const subdir of [
|
||||
"openhands-agent-server",
|
||||
"openhands-sdk",
|
||||
"openhands-tools",
|
||||
"openhands-workspace",
|
||||
]) {
|
||||
mkdirSync(path.join(tmp, subdir));
|
||||
}
|
||||
expect(() => validateLocalAgentServerPath(tmp)).not.toThrow();
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("throws when the path does not exist", () => {
|
||||
expect(() =>
|
||||
validateLocalAgentServerPath("/definitely/does/not/exist/sdk"),
|
||||
).toThrow(/does not exist/);
|
||||
});
|
||||
|
||||
it("throws when a workspace package subdirectory is missing", () => {
|
||||
const tmp = mkdtempSync(path.join(tmpdir(), "sdk-"));
|
||||
try {
|
||||
mkdirSync(path.join(tmp, "openhands-agent-server"));
|
||||
mkdirSync(path.join(tmp, "openhands-sdk"));
|
||||
mkdirSync(path.join(tmp, "openhands-tools"));
|
||||
// openhands-workspace is intentionally absent
|
||||
expect(() => validateLocalAgentServerPath(tmp)).toThrow(
|
||||
/openhands-workspace/,
|
||||
);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("throws when given a relative path", () => {
|
||||
expect(() => validateLocalAgentServerPath("./sdk")).toThrow(
|
||||
/must be an absolute path/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSafeDevConfig", () => {
|
||||
|
||||
@@ -19,7 +19,11 @@ describe("convertRawProvidersToList", () => {
|
||||
azure_devops: "test-token",
|
||||
};
|
||||
|
||||
expect(convertRawProvidersToList(example1)).toEqual(["github", "gitlab", "azure_devops"]);
|
||||
expect(convertRawProvidersToList(example1)).toEqual([
|
||||
"github",
|
||||
"gitlab",
|
||||
"azure_devops",
|
||||
]);
|
||||
expect(convertRawProvidersToList(example2)).toEqual(["github"]);
|
||||
expect(convertRawProvidersToList(example3)).toEqual(["gitlab"]);
|
||||
expect(convertRawProvidersToList(example4)).toEqual(["azure_devops"]);
|
||||
|
||||
@@ -73,7 +73,9 @@ describe("extractModelAndProvider", () => {
|
||||
separator: "/",
|
||||
});
|
||||
|
||||
expect(extractModelAndProvider("openhands/claude-opus-4-5-20251101")).toEqual({
|
||||
expect(
|
||||
extractModelAndProvider("openhands/claude-opus-4-5-20251101"),
|
||||
).toEqual({
|
||||
provider: "openhands",
|
||||
model: "claude-opus-4-5-20251101",
|
||||
separator: "/",
|
||||
|
||||
@@ -89,7 +89,12 @@ describe("getInvalidEmails", () => {
|
||||
});
|
||||
|
||||
test("returns all invalid emails", () => {
|
||||
const emails = ["valid@example.com", "invalid", "test@", "another@valid.org"];
|
||||
const emails = [
|
||||
"valid@example.com",
|
||||
"invalid",
|
||||
"test@",
|
||||
"another@valid.org",
|
||||
];
|
||||
expect(getInvalidEmails(emails)).toEqual(["invalid", "test@"]);
|
||||
});
|
||||
|
||||
@@ -148,7 +153,9 @@ describe("hasDuplicates", () => {
|
||||
});
|
||||
|
||||
test("returns true when duplicates exist", () => {
|
||||
expect(hasDuplicates(["a@test.com", "b@test.com", "a@test.com"])).toBe(true);
|
||||
expect(hasDuplicates(["a@test.com", "b@test.com", "a@test.com"])).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("returns true for case-insensitive duplicates", () => {
|
||||
|
||||
@@ -16,27 +16,39 @@ describe("local-storage utilities", () => {
|
||||
describe("setLoginMethod", () => {
|
||||
it("stores the login method in local storage", () => {
|
||||
setLoginMethod(LoginMethod.GITHUB);
|
||||
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("github");
|
||||
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe(
|
||||
"github",
|
||||
);
|
||||
});
|
||||
|
||||
it("stores different login methods correctly", () => {
|
||||
setLoginMethod(LoginMethod.GITLAB);
|
||||
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("gitlab");
|
||||
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe(
|
||||
"gitlab",
|
||||
);
|
||||
|
||||
setLoginMethod(LoginMethod.BITBUCKET);
|
||||
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("bitbucket");
|
||||
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe(
|
||||
"bitbucket",
|
||||
);
|
||||
|
||||
setLoginMethod(LoginMethod.AZURE_DEVOPS);
|
||||
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("azure_devops");
|
||||
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe(
|
||||
"azure_devops",
|
||||
);
|
||||
|
||||
setLoginMethod(LoginMethod.BITBUCKET_DATA_CENTER);
|
||||
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("bitbucket_data_center");
|
||||
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe(
|
||||
"bitbucket_data_center",
|
||||
);
|
||||
});
|
||||
|
||||
it("overwrites previous login method", () => {
|
||||
setLoginMethod(LoginMethod.GITHUB);
|
||||
setLoginMethod(LoginMethod.GITLAB);
|
||||
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("gitlab");
|
||||
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe(
|
||||
"gitlab",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ import { describe, it, expect } from "vitest";
|
||||
import { extractSettings } from "#/utils/settings-utils";
|
||||
|
||||
function getLlmModel(settings: ReturnType<typeof extractSettings>): unknown {
|
||||
const as = settings.agent_settings_diff as Record<string, unknown> | undefined;
|
||||
const as = settings.agent_settings_diff as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const llm = as?.llm as Record<string, unknown> | undefined;
|
||||
return llm?.model;
|
||||
}
|
||||
@@ -17,9 +19,7 @@ describe("Model name case preservation", () => {
|
||||
|
||||
const settings = extractSettings(formData);
|
||||
|
||||
expect(getLlmModel(settings)).toBe(
|
||||
"SambaNova/Meta-Llama-3.1-8B-Instruct",
|
||||
);
|
||||
expect(getLlmModel(settings)).toBe("SambaNova/Meta-Llama-3.1-8B-Instruct");
|
||||
});
|
||||
|
||||
it("should preserve openai model case", () => {
|
||||
@@ -56,8 +56,6 @@ describe("Model name case preservation", () => {
|
||||
expect(getLlmModel(settings)).not.toBe(
|
||||
"sambanova/meta-llama-3.1-8b-instruct",
|
||||
);
|
||||
expect(getLlmModel(settings)).toBe(
|
||||
"SambaNova/Meta-Llama-3.1-8B-Instruct",
|
||||
);
|
||||
expect(getLlmModel(settings)).toBe("SambaNova/Meta-Llama-3.1-8B-Instruct");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -173,8 +173,8 @@ describe("sdk settings schema helpers", () => {
|
||||
agent_settings: {
|
||||
...BASE_SETTINGS.agent_settings,
|
||||
critic: {
|
||||
...(BASE_SETTINGS.agent_settings as Record<string, unknown>)
|
||||
.critic as Record<string, unknown>,
|
||||
...((BASE_SETTINGS.agent_settings as Record<string, unknown>)
|
||||
.critic as Record<string, unknown>),
|
||||
mode: "all_actions",
|
||||
},
|
||||
},
|
||||
@@ -303,7 +303,9 @@ describe("sdk settings schema helpers", () => {
|
||||
"llm.litellm_extra_body": true,
|
||||
};
|
||||
|
||||
expect(buildSdkSettingsPayloadForView(schema, values, dirty, "basic")).toEqual({
|
||||
expect(
|
||||
buildSdkSettingsPayloadForView(schema, values, dirty, "basic"),
|
||||
).toEqual({
|
||||
llm: {
|
||||
model: "anthropic/claude-sonnet-4-20250514",
|
||||
timeout: 30,
|
||||
@@ -325,7 +327,9 @@ describe("sdk settings schema helpers", () => {
|
||||
mcp_config: null,
|
||||
});
|
||||
|
||||
expect(buildSdkSettingsPayloadForView(schema, values, dirty, "all")).toEqual({
|
||||
expect(
|
||||
buildSdkSettingsPayloadForView(schema, values, dirty, "all"),
|
||||
).toEqual({
|
||||
llm: {
|
||||
model: "anthropic/claude-sonnet-4-20250514",
|
||||
timeout: 90,
|
||||
|
||||
@@ -3,15 +3,23 @@ import { shouldStartMockWorker } from "#/mocks/should-start-mock-worker";
|
||||
|
||||
describe("shouldStartMockWorker", () => {
|
||||
it("starts the worker whenever mock API mode is enabled in the browser", () => {
|
||||
expect(shouldStartMockWorker({ mockApi: "true", hasWindow: true })).toBe(true);
|
||||
expect(shouldStartMockWorker({ mockApi: "true", hasWindow: true })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not start the worker when mock API mode is disabled", () => {
|
||||
expect(shouldStartMockWorker({ mockApi: "false", hasWindow: true })).toBe(false);
|
||||
expect(shouldStartMockWorker({ mockApi: undefined, hasWindow: true })).toBe(false);
|
||||
expect(shouldStartMockWorker({ mockApi: "false", hasWindow: true })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(shouldStartMockWorker({ mockApi: undefined, hasWindow: true })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not start the worker during server-side rendering", () => {
|
||||
expect(shouldStartMockWorker({ mockApi: "true", hasWindow: false })).toBe(false);
|
||||
expect(shouldStartMockWorker({ mockApi: "true", hasWindow: false })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldUseInstallationRepos } from "#/utils/utils";
|
||||
|
||||
describe("shouldUseInstallationRepos", () => {
|
||||
it("returns false for null/undefined provider regardless of mode", () => {
|
||||
expect(shouldUseInstallationRepos(undefined, "cloud")).toBe(false);
|
||||
expect(shouldUseInstallationRepos(null, "cloud")).toBe(false);
|
||||
expect(shouldUseInstallationRepos(undefined, "local")).toBe(false);
|
||||
});
|
||||
|
||||
it("uses installations for github only when active backend is cloud", () => {
|
||||
expect(shouldUseInstallationRepos("github", "cloud")).toBe(true);
|
||||
expect(shouldUseInstallationRepos("github", "local")).toBe(false);
|
||||
// Default (no app mode passed) preserves the old local behavior.
|
||||
expect(shouldUseInstallationRepos("github")).toBe(false);
|
||||
});
|
||||
|
||||
it("always uses installations for bitbucket variants", () => {
|
||||
expect(shouldUseInstallationRepos("bitbucket", "local")).toBe(true);
|
||||
expect(shouldUseInstallationRepos("bitbucket", "cloud")).toBe(true);
|
||||
expect(shouldUseInstallationRepos("bitbucket_data_center", "local")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(shouldUseInstallationRepos("bitbucket_data_center", "cloud")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("never uses installations for gitlab / azure / forgejo", () => {
|
||||
expect(shouldUseInstallationRepos("gitlab", "cloud")).toBe(false);
|
||||
expect(shouldUseInstallationRepos("gitlab", "local")).toBe(false);
|
||||
expect(shouldUseInstallationRepos("azure_devops", "cloud")).toBe(false);
|
||||
expect(shouldUseInstallationRepos("forgejo", "cloud")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { V1ExecutionStatus } from "#/types/v1/core";
|
||||
import type { V1AppConversationStartTaskStatus } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
|
||||
describe("getStatusCode", () => {
|
||||
it("returns RUNNING_TASK when execution status is running", () => {
|
||||
@@ -25,11 +26,7 @@ describe("getStatusCode", () => {
|
||||
});
|
||||
|
||||
it("returns starting i18n key when task is running setup", () => {
|
||||
const result = getStatusCode(
|
||||
"OPEN",
|
||||
null,
|
||||
"STARTING_CONVERSATION",
|
||||
);
|
||||
const result = getStatusCode("OPEN", null, "STARTING_CONVERSATION");
|
||||
expect(result).toBe(I18nKey.CONVERSATION$STARTING_CONVERSATION);
|
||||
});
|
||||
|
||||
@@ -42,6 +39,18 @@ describe("getStatusCode", () => {
|
||||
const result = getStatusCode("CLOSED", V1ExecutionStatus.IDLE);
|
||||
expect(result).toBe(I18nKey.CHAT_INTERFACE$DISCONNECTED);
|
||||
});
|
||||
|
||||
it("returns COMMON$WAITING_FOR_SANDBOX when task is waiting for sandbox", () => {
|
||||
const result = getStatusCode("OPEN", null, "WAITING_FOR_SANDBOX");
|
||||
expect(result).toBe(I18nKey.COMMON$WAITING_FOR_SANDBOX);
|
||||
});
|
||||
|
||||
it("falls back to starting i18n key for an unknown task status instead of throwing", () => {
|
||||
const unknownStatus =
|
||||
"FUTURE_STATUS_FROM_CLOUD" as V1AppConversationStartTaskStatus;
|
||||
const result = getStatusCode("OPEN", null, unknownStatus);
|
||||
expect(result).toBe(I18nKey.CONVERSATION$STARTING_CONVERSATION);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIndicatorColor", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, test } from "vitest";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getStatusText } from "#/utils/utils";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
@@ -73,9 +73,7 @@ describe("getStatusText", () => {
|
||||
t,
|
||||
});
|
||||
|
||||
expect(result).toBe(
|
||||
t(I18nKey.CONVERSATION$ERROR_STARTING_CONVERSATION),
|
||||
);
|
||||
expect(result).toBe(t(I18nKey.CONVERSATION$ERROR_STARTING_CONVERSATION));
|
||||
});
|
||||
|
||||
it("returns READY translation when task is ready", () => {
|
||||
|
||||
@@ -52,6 +52,8 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "npm run dev:automation",
|
||||
"dev:safe": "node --env-file-if-exists=.env scripts/dev-safe.mjs",
|
||||
"dev:extra-backend": "node --env-file-if-exists=.env scripts/dev-extra-backend.mjs",
|
||||
"dev:automation": "node --env-file-if-exists=.env scripts/dev-with-automation.mjs",
|
||||
"dev:minimal": "node --env-file-if-exists=.env scripts/dev-safe.mjs",
|
||||
"dev:frontend": "npm run make-i18n && cross-env VITE_MOCK_API=false react-router dev",
|
||||
|
||||
+88
-88
@@ -12,104 +12,104 @@ const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
addEventListener("install", function () {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
addEventListener("activate", function (event) {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
addEventListener('message', async function (event) {
|
||||
const clientId = Reflect.get(event.source || {}, 'id')
|
||||
addEventListener("message", async function (event) {
|
||||
const clientId = Reflect.get(event.source || {}, "id");
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
const client = await self.clients.get(clientId);
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
type: "window",
|
||||
});
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
case "KEEPALIVE_REQUEST": {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
type: "KEEPALIVE_RESPONSE",
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
case "INTEGRITY_CHECK_REQUEST": {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
type: "INTEGRITY_CHECK_RESPONSE",
|
||||
payload: {
|
||||
packageVersion: PACKAGE_VERSION,
|
||||
checksum: INTEGRITY_CHECKSUM,
|
||||
},
|
||||
})
|
||||
break
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
case "MOCK_ACTIVATE": {
|
||||
activeClientIds.add(clientId);
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
type: "MOCKING_ENABLED",
|
||||
payload: {
|
||||
client: {
|
||||
id: client.id,
|
||||
frameType: client.frameType,
|
||||
},
|
||||
},
|
||||
})
|
||||
break
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
case "CLIENT_CLOSED": {
|
||||
activeClientIds.delete(clientId);
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
return client.id !== clientId;
|
||||
});
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
self.registration.unregister();
|
||||
}
|
||||
|
||||
break
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
addEventListener('fetch', function (event) {
|
||||
const requestInterceptedAt = Date.now()
|
||||
addEventListener("fetch", function (event) {
|
||||
const requestInterceptedAt = Date.now();
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (event.request.mode === 'navigate') {
|
||||
return
|
||||
if (event.request.mode === "navigate") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (
|
||||
event.request.cache === 'only-if-cached' &&
|
||||
event.request.mode !== 'same-origin'
|
||||
event.request.cache === "only-if-cached" &&
|
||||
event.request.mode !== "same-origin"
|
||||
) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been terminated (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID()
|
||||
@@ -122,28 +122,28 @@ addEventListener('fetch', function (event) {
|
||||
* @param {number} requestInterceptedAt
|
||||
*/
|
||||
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
const client = await resolveMainClient(event)
|
||||
const requestCloneForEvents = event.request.clone()
|
||||
const client = await resolveMainClient(event);
|
||||
const requestCloneForEvents = event.request.clone();
|
||||
const response = await getResponse(
|
||||
event,
|
||||
client,
|
||||
requestId,
|
||||
requestInterceptedAt,
|
||||
)
|
||||
);
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||
const serializedRequest = await serializeRequest(requestCloneForEvents);
|
||||
|
||||
// Clone the response so both the client and the library could consume it.
|
||||
const responseClone = response.clone()
|
||||
const responseClone = response.clone();
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
type: "RESPONSE",
|
||||
payload: {
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
request: {
|
||||
@@ -160,10 +160,10 @@ async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
},
|
||||
},
|
||||
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,30 +175,30 @@ async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
* @returns {Promise<Client | undefined>}
|
||||
*/
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
const client = await self.clients.get(event.clientId);
|
||||
|
||||
if (activeClientIds.has(event.clientId)) {
|
||||
return client
|
||||
return client;
|
||||
}
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
if (client?.frameType === "top-level") {
|
||||
return client;
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
type: "window",
|
||||
});
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
return client.visibilityState === "visible";
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
return activeClientIds.has(client.id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,36 +211,36 @@ async function resolveMainClient(event) {
|
||||
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const requestClone = event.request.clone()
|
||||
const requestClone = event.request.clone();
|
||||
|
||||
function passthrough() {
|
||||
// Cast the request headers to a new Headers instance
|
||||
// so the headers can be manipulated with.
|
||||
const headers = new Headers(requestClone.headers)
|
||||
const headers = new Headers(requestClone.headers);
|
||||
|
||||
// Remove the "accept" header value that marked this request as passthrough.
|
||||
// This prevents request alteration and also keeps it compliant with the
|
||||
// user-defined CORS policies.
|
||||
const acceptHeader = headers.get('accept')
|
||||
const acceptHeader = headers.get("accept");
|
||||
if (acceptHeader) {
|
||||
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||
const values = acceptHeader.split(",").map((value) => value.trim());
|
||||
const filteredValues = values.filter(
|
||||
(value) => value !== 'msw/passthrough',
|
||||
)
|
||||
(value) => value !== "msw/passthrough",
|
||||
);
|
||||
|
||||
if (filteredValues.length > 0) {
|
||||
headers.set('accept', filteredValues.join(', '))
|
||||
headers.set("accept", filteredValues.join(", "));
|
||||
} else {
|
||||
headers.delete('accept')
|
||||
headers.delete("accept");
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(requestClone, { headers })
|
||||
return fetch(requestClone, { headers });
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
return passthrough();
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
@@ -248,15 +248,15 @@ async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
return passthrough();
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const serializedRequest = await serializeRequest(event.request)
|
||||
const serializedRequest = await serializeRequest(event.request);
|
||||
const clientMessage = await sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'REQUEST',
|
||||
type: "REQUEST",
|
||||
payload: {
|
||||
id: requestId,
|
||||
interceptedAt: requestInterceptedAt,
|
||||
@@ -264,19 +264,19 @@ async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||
},
|
||||
},
|
||||
[serializedRequest.body],
|
||||
)
|
||||
);
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
case "MOCK_RESPONSE": {
|
||||
return respondWithMock(clientMessage.data);
|
||||
}
|
||||
|
||||
case 'PASSTHROUGH': {
|
||||
return passthrough()
|
||||
case "PASSTHROUGH": {
|
||||
return passthrough();
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
return passthrough();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -287,21 +287,21 @@ async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||
*/
|
||||
function sendToClient(client, message, transferrables = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
const channel = new MessageChannel();
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
return reject(event.data.error);
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
resolve(event.data);
|
||||
};
|
||||
|
||||
client.postMessage(message, [
|
||||
channel.port2,
|
||||
...transferrables.filter(Boolean),
|
||||
])
|
||||
})
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -314,17 +314,17 @@ function respondWithMock(response) {
|
||||
// instance will have status code set to 0. Since it's not possible to create
|
||||
// a Response instance with status code 0, handle that use-case separately.
|
||||
if (response.status === 0) {
|
||||
return Response.error()
|
||||
return Response.error();
|
||||
}
|
||||
|
||||
const mockedResponse = new Response(response.body, response)
|
||||
const mockedResponse = new Response(response.body, response);
|
||||
|
||||
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
})
|
||||
});
|
||||
|
||||
return mockedResponse
|
||||
return mockedResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -345,5 +345,5 @@ async function serializeRequest(request) {
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: await request.arrayBuffer(),
|
||||
keepalive: request.keepalive,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import {
|
||||
buildAgentServerCommand,
|
||||
buildAgentServerEnv,
|
||||
buildSafeDevConfig,
|
||||
formatMissingUvxGuidance,
|
||||
validateLocalAgentServerPath,
|
||||
} from "./dev-safe.mjs";
|
||||
|
||||
const DEFAULT_EXTRA_BACKEND_PORT = 18002;
|
||||
const DEFAULT_EXTRA_VSCODE_PORT = 18003;
|
||||
const DEFAULT_WAIT_TIMEOUT_MS = 30_000;
|
||||
|
||||
function parsePort(value, fallback) {
|
||||
if (value == null || value === "") {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw new Error(`Invalid port: ${value}`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a config for an *extra* standalone agent-server that shares the
|
||||
* bundled instance's persistence (state dir, conversations, secret key)
|
||||
* but listens on a different backend + vscode port.
|
||||
*
|
||||
* @param {string} cwd
|
||||
* @param {Record<string, string | undefined>} env
|
||||
*/
|
||||
export function buildExtraBackendConfig(cwd = process.cwd(), env = process.env) {
|
||||
const base = buildSafeDevConfig(cwd, env);
|
||||
|
||||
const backendPort = parsePort(
|
||||
env.OH_CANVAS_EXTRA_BACKEND_PORT,
|
||||
DEFAULT_EXTRA_BACKEND_PORT,
|
||||
);
|
||||
const vscodePort = parsePort(
|
||||
env.OH_CANVAS_EXTRA_VSCODE_PORT,
|
||||
DEFAULT_EXTRA_VSCODE_PORT,
|
||||
);
|
||||
|
||||
return {
|
||||
...base,
|
||||
backendPort,
|
||||
vscodePort,
|
||||
backendBaseUrl: `http://127.0.0.1:${backendPort}`,
|
||||
backendHost: `127.0.0.1:${backendPort}`,
|
||||
};
|
||||
}
|
||||
|
||||
function isEnoentError(error) {
|
||||
return Boolean(
|
||||
(error &&
|
||||
typeof error === "object" &&
|
||||
"code" in error &&
|
||||
error.code === "ENOENT") ||
|
||||
/ENOENT/.test(String(error)),
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForServer(url, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS) {
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Keep polling until timeout.
|
||||
}
|
||||
|
||||
await delay(500);
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for agent-server at ${url}`);
|
||||
}
|
||||
|
||||
function spawnProcess(command, args, options) {
|
||||
const child = spawn(command, args, { stdio: "inherit", ...options });
|
||||
|
||||
child.once("error", (error) => {
|
||||
if (isEnoentError(error) && command === "uvx") {
|
||||
console.error(formatMissingUvxGuidance(options?.cwd));
|
||||
} else if (isEnoentError(error)) {
|
||||
console.error(
|
||||
`Failed to start ${command}. Make sure it is installed and on your PATH.`,
|
||||
);
|
||||
} else {
|
||||
console.error(`Failed to start ${command}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const config = buildExtraBackendConfig();
|
||||
|
||||
if (process.env.OH_AGENT_SERVER_LOCAL_PATH) {
|
||||
validateLocalAgentServerPath(process.env.OH_AGENT_SERVER_LOCAL_PATH);
|
||||
}
|
||||
|
||||
for (const dir of [
|
||||
config.stateDir,
|
||||
config.tmuxTmpDir,
|
||||
config.conversationsPath,
|
||||
config.workspacesPath,
|
||||
config.bashEventsDir,
|
||||
]) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const agentServerCmd = buildAgentServerCommand();
|
||||
|
||||
const secretKeySource = process.env.OH_SECRET_KEY
|
||||
? "custom (from OH_SECRET_KEY)"
|
||||
: "default (for local development)";
|
||||
|
||||
console.log("Starting EXTRA standalone agent-server (shared state)...");
|
||||
console.log(`- agent-server: ${agentServerCmd.source}`);
|
||||
console.log(`- backend: ${config.backendBaseUrl}`);
|
||||
console.log(`- vscode port: ${config.vscodePort}`);
|
||||
console.log(`- shared state dir: ${config.stateDir}`);
|
||||
console.log(`- shared conversations: ${config.conversationsPath}`);
|
||||
console.log(`- secret key: ${secretKeySource}`);
|
||||
console.log("");
|
||||
console.log(
|
||||
"Connect via the GUI: open Add Backend, enter " +
|
||||
`${config.backendBaseUrl} as the host. Leave the API key blank ` +
|
||||
"unless this server is started with OH_SESSION_API_KEYS_0 set.",
|
||||
);
|
||||
console.log("");
|
||||
|
||||
const backend = spawnProcess(
|
||||
agentServerCmd.command,
|
||||
[
|
||||
...agentServerCmd.args,
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
String(config.backendPort),
|
||||
],
|
||||
{
|
||||
cwd: config.cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...buildAgentServerEnv(config),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let shuttingDown = false;
|
||||
|
||||
const shutdown = (signal = "SIGTERM") => {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
shuttingDown = true;
|
||||
backend.kill(signal);
|
||||
};
|
||||
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
|
||||
const backendErrored = new Promise((_, reject) => {
|
||||
backend.once("error", (error) => reject(error));
|
||||
});
|
||||
const backendExited = new Promise((_, reject) => {
|
||||
backend.once("exit", (code, signal) => {
|
||||
if (!shuttingDown) {
|
||||
reject(
|
||||
new Error(
|
||||
`agent-server exited before startup completed (code=${code ?? "null"}, signal=${signal ?? "null"})`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
waitForServer(`${config.backendBaseUrl}/server_info`),
|
||||
backendErrored,
|
||||
backendExited,
|
||||
]);
|
||||
} catch (error) {
|
||||
shutdown();
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log(`Extra agent-server is ready at ${config.backendBaseUrl}.`);
|
||||
|
||||
backend.once("exit", (code) => {
|
||||
if (!shuttingDown) {
|
||||
console.error(
|
||||
`agent-server exited unexpectedly with code ${code ?? 0}`,
|
||||
);
|
||||
shutdown();
|
||||
process.exitCode = code ?? 1;
|
||||
} else {
|
||||
process.exitCode = code ?? 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
+61
-5
@@ -1,5 +1,5 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
@@ -10,6 +10,12 @@ const DEFAULT_BACKEND_PORT = 18000;
|
||||
const DEFAULT_WAIT_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_AGENT_SERVER_PACKAGE = "openhands-agent-server";
|
||||
const AGENT_SERVER_GIT_REPO = "https://github.com/OpenHands/software-agent-sdk";
|
||||
const LOCAL_AGENT_SERVER_SUBDIRS = [
|
||||
"openhands-agent-server",
|
||||
"openhands-sdk",
|
||||
"openhands-tools",
|
||||
"openhands-workspace",
|
||||
];
|
||||
// Default secret key for local development (DO NOT use in production)
|
||||
const DEFAULT_SECRET_KEY = "openhands-dev-secret-key-change-in-prod";
|
||||
// Default to main branch until settings persistence APIs are in a released version.
|
||||
@@ -51,24 +57,49 @@ export function formatMissingUvxGuidance(cwd = process.cwd()) {
|
||||
/**
|
||||
* Build the uvx command and arguments for running agent-server.
|
||||
*
|
||||
* Environment variables:
|
||||
* Environment variables (highest precedence first):
|
||||
* - OH_AGENT_SERVER_LOCAL_PATH: Absolute path to a software-agent-sdk checkout.
|
||||
* Runs the local checkout via uvx with editable installs of the workspace
|
||||
* packages (openhands-sdk, openhands-tools, openhands-workspace) so source
|
||||
* edits are picked up without a manual reinstall. The agent-server itself
|
||||
* is rebuilt from local source on each invocation (--reinstall).
|
||||
* - OH_AGENT_SERVER_GIT_REF: Git commit SHA or branch name
|
||||
* - OH_AGENT_SERVER_VERSION: Specific PyPI version (e.g., "1.18.0")
|
||||
* - OH_AGENT_SERVER_GIT_REF: Git commit SHA or branch name (takes precedence over version)
|
||||
*
|
||||
* If neither is set, defaults to main branch until settings persistence APIs
|
||||
* If none are set, defaults to main branch until settings persistence APIs
|
||||
* are released. Set OH_AGENT_SERVER_VERSION to use a released version.
|
||||
*
|
||||
* @param {Record<string, string | undefined>} env
|
||||
* @returns {{ command: string, args: string[], source: string }}
|
||||
*/
|
||||
export function buildAgentServerCommand(env = process.env) {
|
||||
const localPath = env.OH_AGENT_SERVER_LOCAL_PATH;
|
||||
const gitRef = env.OH_AGENT_SERVER_GIT_REF;
|
||||
const version = env.OH_AGENT_SERVER_VERSION;
|
||||
|
||||
const uvxArgs = [];
|
||||
let source = "";
|
||||
|
||||
if (gitRef) {
|
||||
if (localPath) {
|
||||
if (!path.isAbsolute(localPath)) {
|
||||
throw new Error(
|
||||
`OH_AGENT_SERVER_LOCAL_PATH must be an absolute path, got: ${localPath}`,
|
||||
);
|
||||
}
|
||||
uvxArgs.push(
|
||||
"--reinstall",
|
||||
"--from",
|
||||
path.join(localPath, "openhands-agent-server"),
|
||||
"--with-editable",
|
||||
path.join(localPath, "openhands-sdk"),
|
||||
"--with-editable",
|
||||
path.join(localPath, "openhands-tools"),
|
||||
"--with-editable",
|
||||
path.join(localPath, "openhands-workspace"),
|
||||
"agent-server",
|
||||
);
|
||||
source = `local (${localPath})`;
|
||||
} else if (gitRef) {
|
||||
// Use git ref with subdirectory syntax for uv workspace monorepo
|
||||
// The software-agent-sdk repo has packages in subdirectories:
|
||||
// openhands-agent-server/, openhands-tools/, openhands-workspace/
|
||||
@@ -224,6 +255,27 @@ export function buildNpmScriptCommand(
|
||||
};
|
||||
}
|
||||
|
||||
export function validateLocalAgentServerPath(localPath) {
|
||||
if (!path.isAbsolute(localPath)) {
|
||||
throw new Error(
|
||||
`OH_AGENT_SERVER_LOCAL_PATH must be an absolute path, got: ${localPath}`,
|
||||
);
|
||||
}
|
||||
if (!existsSync(localPath)) {
|
||||
throw new Error(
|
||||
`OH_AGENT_SERVER_LOCAL_PATH does not exist: ${localPath}`,
|
||||
);
|
||||
}
|
||||
for (const subdir of LOCAL_AGENT_SERVER_SUBDIRS) {
|
||||
const subdirPath = path.join(localPath, subdir);
|
||||
if (!existsSync(subdirPath)) {
|
||||
throw new Error(
|
||||
`OH_AGENT_SERVER_LOCAL_PATH is missing expected workspace package '${subdir}': ${subdirPath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForServer(url, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS) {
|
||||
const startedAt = Date.now();
|
||||
|
||||
@@ -264,6 +316,10 @@ function spawnProcess(command, args, options) {
|
||||
async function main() {
|
||||
const config = buildSafeDevConfig();
|
||||
|
||||
if (process.env.OH_AGENT_SERVER_LOCAL_PATH) {
|
||||
validateLocalAgentServerPath(process.env.OH_AGENT_SERVER_LOCAL_PATH);
|
||||
}
|
||||
|
||||
for (const dir of [
|
||||
config.stateDir,
|
||||
config.tmuxTmpDir,
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { Settings, SettingsValue } from "#/types/settings";
|
||||
import { V1ExecutionStatus } from "#/types/v1/core";
|
||||
import {
|
||||
getAgentServerBaseUrl,
|
||||
getAgentServerSessionApiKey,
|
||||
getAgentServerWorkingDir,
|
||||
getConfiguredWorkerUrls,
|
||||
} from "./agent-server-config";
|
||||
import { getAgentServerWorkingDir } from "./agent-server-config";
|
||||
import { getEffectiveLocalBackend } from "./backend-registry/active-store";
|
||||
import {
|
||||
GetHooksResponse,
|
||||
GetSkillsResponse,
|
||||
@@ -54,7 +50,10 @@ function browserToolsEnabled() {
|
||||
}
|
||||
|
||||
export function toConversationUrl(conversationId: string): string {
|
||||
return `${getAgentServerBaseUrl()}/api/conversations/${conversationId}`;
|
||||
// Local-format conversation URL — points at whichever local agent-server
|
||||
// is actually serving the conversation (the bundled one when the active
|
||||
// selection is cloud).
|
||||
return `${getEffectiveLocalBackend().host}/api/conversations/${conversationId}`;
|
||||
}
|
||||
|
||||
// TODO(i18n): extract "Conversation" once we add CONVERSATION$DEFAULT_TITLE
|
||||
@@ -74,7 +73,9 @@ export function toV1AppConversation(
|
||||
selected_repository: metadata?.selected_repository ?? null,
|
||||
selected_branch: metadata?.selected_branch ?? null,
|
||||
git_provider: metadata?.git_provider ?? null,
|
||||
title: info.title?.trim() ? info.title : getDefaultConversationTitle(info.id),
|
||||
title: info.title?.trim()
|
||||
? info.title
|
||||
: getDefaultConversationTitle(info.id),
|
||||
trigger: null,
|
||||
pr_number: [],
|
||||
llm_model: info.agent?.llm?.model ?? DEFAULT_SETTINGS.llm_model,
|
||||
@@ -106,7 +107,8 @@ export function toV1AppConversation(
|
||||
(info.execution_status as V1AppConversation["execution_status"]) ??
|
||||
V1ExecutionStatus.IDLE,
|
||||
conversation_url: toConversationUrl(info.id),
|
||||
session_api_key: getAgentServerSessionApiKey(),
|
||||
session_api_key: getEffectiveLocalBackend().apiKey || null,
|
||||
sandbox_id: null,
|
||||
workspace: {
|
||||
working_dir: info.workspace?.working_dir ?? getAgentServerWorkingDir(),
|
||||
},
|
||||
@@ -365,7 +367,9 @@ export interface StartConversationOptions {
|
||||
customSecrets?: Array<{ name: string; description?: string }>;
|
||||
}
|
||||
|
||||
export function buildStartConversationRequest(options: StartConversationOptions) {
|
||||
export function buildStartConversationRequest(
|
||||
options: StartConversationOptions,
|
||||
) {
|
||||
// Use encrypted settings if provided, otherwise fall back to regular settings
|
||||
const sourceAgentSettings = options.encryptedAgentSettings
|
||||
? { ...options.settings, agent_settings: options.encryptedAgentSettings }
|
||||
@@ -385,8 +389,9 @@ export function buildStartConversationRequest(options: StartConversationOptions)
|
||||
}
|
||||
: options;
|
||||
|
||||
const conversationSettings =
|
||||
buildConfiguredConversationSettings(sourceConversationOptions);
|
||||
const conversationSettings = buildConfiguredConversationSettings(
|
||||
sourceConversationOptions,
|
||||
);
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
agent,
|
||||
@@ -439,8 +444,9 @@ export function buildStartConversationRequest(options: StartConversationOptions)
|
||||
// Add custom secrets as LookupSecret entries
|
||||
// The agent-server will fetch values at runtime from /api/settings/secrets/{name}
|
||||
if (options.customSecrets && options.customSecrets.length > 0) {
|
||||
const baseUrl = getAgentServerBaseUrl();
|
||||
const sessionApiKey = getAgentServerSessionApiKey();
|
||||
const backend = getEffectiveLocalBackend();
|
||||
const baseUrl = backend.host;
|
||||
const sessionApiKey = backend.apiKey || null;
|
||||
|
||||
const secrets: Record<string, LookupSecret> = {};
|
||||
for (const secret of options.customSecrets) {
|
||||
@@ -450,7 +456,8 @@ export function buildStartConversationRequest(options: StartConversationOptions)
|
||||
description: secret.description,
|
||||
};
|
||||
|
||||
// Include session API key header if configured
|
||||
// Include session API key header if configured (local agent-server only —
|
||||
// cloud LookupSecrets aren't fetched by the local runtime).
|
||||
if (sessionApiKey) {
|
||||
lookupSecret.headers = {
|
||||
"X-Session-API-Key": sessionApiKey,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
import { HttpError } from "@openhands/typescript-client/client/http-client";
|
||||
import { getBundledBackend } from "#/api/backend-registry/bundled";
|
||||
import { createServerClient, type ServerInfo } from "#/api/typescript-client";
|
||||
|
||||
export const MINIMUM_SUPPORTED_AGENT_SERVER_VERSION = "1.17.0";
|
||||
@@ -93,10 +95,17 @@ export const isAgentServerUnavailableError = (
|
||||
error.name === "AgentServerUnavailableError");
|
||||
|
||||
export async function ensureCompatibleAgentServer() {
|
||||
// The compatibility check is a *local* agent-server concern — it verifies
|
||||
// that the runtime hosting the GUI is at the right version. It must NEVER
|
||||
// run against the active backend, because cloud SaaS hosts don't expose
|
||||
// /api/server_info and would fail with a CORS error besides.
|
||||
const bundled = getBundledBackend();
|
||||
let serverInfo: ServerInfo;
|
||||
|
||||
try {
|
||||
serverInfo = await createServerClient({
|
||||
host: bundled.host,
|
||||
sessionApiKey: bundled.apiKey || null,
|
||||
timeout: AGENT_SERVER_INFO_TIMEOUT_MS,
|
||||
}).getServerInfo();
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { getBundledBackend } from "./bundled";
|
||||
import {
|
||||
readStoredActiveBackend,
|
||||
readStoredBackends,
|
||||
writeStoredActiveBackend,
|
||||
writeStoredBackends,
|
||||
} from "./storage";
|
||||
import {
|
||||
BUNDLED_BACKEND_ID,
|
||||
type Backend,
|
||||
type BackendSelection,
|
||||
type ResolvedActiveBackend,
|
||||
} from "./types";
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
interface Snapshot {
|
||||
backends: Backend[];
|
||||
selection: BackendSelection | null;
|
||||
active: ResolvedActiveBackend;
|
||||
}
|
||||
|
||||
function computeSnapshot(
|
||||
backends: Backend[],
|
||||
selection: BackendSelection | null,
|
||||
): Snapshot {
|
||||
const bundled = getBundledBackend();
|
||||
|
||||
let activeBackend: Backend = bundled;
|
||||
let activeOrgId: string | null = null;
|
||||
|
||||
if (selection) {
|
||||
activeOrgId = selection.orgId ?? null;
|
||||
if (selection.backendId !== BUNDLED_BACKEND_ID) {
|
||||
const found = backends.find((b) => b.id === selection.backendId);
|
||||
if (found) {
|
||||
activeBackend = found;
|
||||
} else {
|
||||
activeOrgId = null; // selection points at a removed backend
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
backends,
|
||||
selection,
|
||||
active: { backend: activeBackend, orgId: activeOrgId },
|
||||
};
|
||||
}
|
||||
|
||||
let snapshot: Snapshot = computeSnapshot(
|
||||
readStoredBackends(),
|
||||
readStoredActiveBackend(),
|
||||
);
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
function notify(): void {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export function getActiveBackend(): ResolvedActiveBackend {
|
||||
return snapshot.active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the backend to use for *local agent-server protocol* calls.
|
||||
*
|
||||
* Most of the GUI's services (settings reads/writes, conversation CRUD,
|
||||
* skills/MCP/secrets, etc.) speak the local agent-server's protocol —
|
||||
* they would fail against a cloud SaaS host. When the user has chosen a
|
||||
* cloud backend as active, those calls fall back to the bundled local
|
||||
* agent-server. Cloud-only call sites import `getActiveBackend` directly.
|
||||
*/
|
||||
export function getEffectiveLocalBackend(): Backend {
|
||||
const active = snapshot.active.backend;
|
||||
if (active.kind === "cloud") return getBundledBackend();
|
||||
return active;
|
||||
}
|
||||
|
||||
export function getRegisteredBackends(): Backend[] {
|
||||
return snapshot.backends;
|
||||
}
|
||||
|
||||
export function getActiveSelection(): BackendSelection | null {
|
||||
return snapshot.selection;
|
||||
}
|
||||
|
||||
export function getSnapshot(): Snapshot {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function setActiveSelection(selection: BackendSelection | null): void {
|
||||
writeStoredActiveBackend(selection);
|
||||
snapshot = computeSnapshot(snapshot.backends, selection);
|
||||
notify();
|
||||
}
|
||||
|
||||
export function setRegisteredBackends(backends: Backend[]): void {
|
||||
writeStoredBackends(backends);
|
||||
|
||||
let nextSelection = snapshot.selection;
|
||||
if (
|
||||
nextSelection &&
|
||||
nextSelection.backendId !== BUNDLED_BACKEND_ID &&
|
||||
!backends.some((b) => b.id === nextSelection!.backendId)
|
||||
) {
|
||||
nextSelection = null;
|
||||
writeStoredActiveBackend(null);
|
||||
}
|
||||
|
||||
snapshot = computeSnapshot(backends, nextSelection);
|
||||
notify();
|
||||
}
|
||||
|
||||
export function subscribeActiveBackend(listener: Listener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/** Test-only: re-read storage and clear listeners. */
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export function __resetActiveStoreForTests(): void {
|
||||
snapshot = computeSnapshot(readStoredBackends(), readStoredActiveBackend());
|
||||
listeners.clear();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Backend } from "./types";
|
||||
|
||||
/**
|
||||
* Build the auth headers to send to a backend.
|
||||
*
|
||||
* Local agent-server uses `X-Session-API-Key`. Cloud SaaS expects a bearer
|
||||
* token in the `Authorization` header.
|
||||
*/
|
||||
export function buildAuthHeaders(backend: Backend): Record<string, string> {
|
||||
if (!backend.apiKey) return {};
|
||||
|
||||
if (backend.kind === "cloud") {
|
||||
return { Authorization: `Bearer ${backend.apiKey}` };
|
||||
}
|
||||
|
||||
return { "X-Session-API-Key": backend.apiKey };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {
|
||||
getAgentServerBaseUrl,
|
||||
getAgentServerSessionApiKey,
|
||||
} from "../agent-server-config";
|
||||
import { BUNDLED_BACKEND_ID, type Backend } from "./types";
|
||||
|
||||
export const BUNDLED_BACKEND_NAME = "Local";
|
||||
|
||||
export function getBundledBackend(): Backend {
|
||||
return {
|
||||
id: BUNDLED_BACKEND_ID,
|
||||
name: BUNDLED_BACKEND_NAME,
|
||||
host: getAgentServerBaseUrl(),
|
||||
apiKey: getAgentServerSessionApiKey() ?? "",
|
||||
kind: "local",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./types";
|
||||
export * from "./storage";
|
||||
export * from "./bundled";
|
||||
export * from "./active-store";
|
||||
export * from "./auth";
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { Backend, BackendKind, BackendSelection } from "./types";
|
||||
|
||||
export const BACKENDS_STORAGE_KEY = "openhands-backends";
|
||||
export const ACTIVE_BACKEND_STORAGE_KEY = "openhands-active-backend";
|
||||
|
||||
function isValidKind(value: unknown): value is BackendKind {
|
||||
return value === "local" || value === "cloud";
|
||||
}
|
||||
|
||||
function isValidBackend(value: unknown): value is Backend {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const v = value as Partial<Backend>;
|
||||
return (
|
||||
typeof v.id === "string" &&
|
||||
v.id.length > 0 &&
|
||||
typeof v.name === "string" &&
|
||||
typeof v.host === "string" &&
|
||||
typeof v.apiKey === "string" &&
|
||||
isValidKind(v.kind)
|
||||
);
|
||||
}
|
||||
|
||||
export function readStoredBackends(): Backend[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(BACKENDS_STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter(isValidBackend);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredBackends(backends: Backend[]): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(BACKENDS_STORAGE_KEY, JSON.stringify(backends));
|
||||
} catch {
|
||||
/* ignore quota / serialization errors */
|
||||
}
|
||||
}
|
||||
|
||||
export function readStoredActiveBackend(): BackendSelection | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(ACTIVE_BACKEND_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (
|
||||
typeof parsed !== "object" ||
|
||||
parsed === null ||
|
||||
typeof (parsed as BackendSelection).backendId !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const orgIdRaw = (parsed as BackendSelection).orgId;
|
||||
return {
|
||||
backendId: (parsed as BackendSelection).backendId,
|
||||
orgId:
|
||||
typeof orgIdRaw === "string" && orgIdRaw.length > 0 ? orgIdRaw : null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredActiveBackend(
|
||||
selection: BackendSelection | null,
|
||||
): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
if (!selection) {
|
||||
window.localStorage.removeItem(ACTIVE_BACKEND_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(
|
||||
ACTIVE_BACKEND_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
backendId: selection.backendId,
|
||||
orgId: selection.orgId ?? null,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export type BackendKind = "local" | "cloud";
|
||||
|
||||
export interface Backend {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
apiKey: string;
|
||||
kind: BackendKind;
|
||||
}
|
||||
|
||||
export interface BackendSelection {
|
||||
backendId: string;
|
||||
orgId?: string | null;
|
||||
}
|
||||
|
||||
export interface ResolvedActiveBackend {
|
||||
backend: Backend;
|
||||
orgId: string | null;
|
||||
}
|
||||
|
||||
export const BUNDLED_BACKEND_ID = "__bundled__";
|
||||
@@ -0,0 +1,29 @@
|
||||
import { getActiveBackend } from "../backend-registry/active-store";
|
||||
import type { Backend } from "../backend-registry/types";
|
||||
import { callCloudProxy } from "./proxy";
|
||||
|
||||
/**
|
||||
* Cloud (SaaS) authentication probe. Routed through the bundled local
|
||||
* agent-server's `/api/cloud-proxy` to avoid cross-origin browser calls.
|
||||
*
|
||||
* Returns true if the API key is accepted by the cloud backend.
|
||||
*/
|
||||
export async function authenticateCloud(backend?: Backend): Promise<boolean> {
|
||||
const target =
|
||||
backend ??
|
||||
(() => {
|
||||
const active = getActiveBackend().backend;
|
||||
if (active.kind !== "cloud") {
|
||||
throw new Error(
|
||||
"authenticateCloud requires a cloud backend. Active backend is local.",
|
||||
);
|
||||
}
|
||||
return active;
|
||||
})();
|
||||
await callCloudProxy<unknown>({
|
||||
backend: target,
|
||||
method: "POST",
|
||||
path: "/api/authenticate",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { getActiveBackend } from "../backend-registry/active-store";
|
||||
import type { Backend } from "../backend-registry/types";
|
||||
import type {
|
||||
V1AppConversation,
|
||||
V1AppConversationPage,
|
||||
V1AppConversationStartRequest,
|
||||
V1AppConversationStartTask,
|
||||
} from "../conversation-service/v1-conversation-service.types";
|
||||
import { callCloudProxy } from "./proxy";
|
||||
|
||||
function getActiveCloudBackend(): Backend {
|
||||
const active = getActiveBackend().backend;
|
||||
if (active.kind !== "cloud") {
|
||||
throw new Error("Cloud conversations call requires a cloud backend.");
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the cloud SaaS app-conversations list. Mirrors the local
|
||||
* `V1ConversationService.searchConversations` interface but routes
|
||||
* through the bundled agent-server's cloud proxy and hits the SaaS
|
||||
* endpoint `/api/v1/app-conversations/search`.
|
||||
*/
|
||||
export async function searchCloudConversations(
|
||||
limit: number = 20,
|
||||
pageId?: string,
|
||||
): Promise<V1AppConversationPage> {
|
||||
const backend = getActiveCloudBackend();
|
||||
const params = new URLSearchParams();
|
||||
params.set("limit", String(limit));
|
||||
if (pageId) params.set("page_id", pageId);
|
||||
params.set("sort_order", "UPDATED_AT_DESC");
|
||||
|
||||
const data = await callCloudProxy<{
|
||||
items: V1AppConversation[];
|
||||
next_page_id: string | null;
|
||||
}>({
|
||||
backend,
|
||||
method: "GET",
|
||||
path: `/api/v1/app-conversations/search?${params.toString()}`,
|
||||
});
|
||||
|
||||
return {
|
||||
items: data?.items ?? [],
|
||||
next_page_id: data?.next_page_id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-fetch cloud app-conversations by id. Mirrors the local
|
||||
* `V1ConversationService.batchGetAppConversations` interface.
|
||||
*/
|
||||
export async function batchGetCloudConversations(
|
||||
ids: string[],
|
||||
): Promise<(V1AppConversation | null)[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const backend = getActiveCloudBackend();
|
||||
const params = new URLSearchParams();
|
||||
for (const id of ids) params.append("ids", id);
|
||||
const data = await callCloudProxy<(V1AppConversation | null)[]>({
|
||||
backend,
|
||||
method: "GET",
|
||||
path: `/api/v1/app-conversations?${params.toString()}`,
|
||||
});
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a v1 app-conversation on the cloud SaaS.
|
||||
*
|
||||
* Mirrors OpenHands' SaaS flow: POST /api/v1/app-conversations with the
|
||||
* `V1AppConversationStartRequest` payload, returning a
|
||||
* `V1AppConversationStartTask`. The task is initially WORKING; the caller
|
||||
* polls `getCloudAppConversationStartTask` (3s cadence per OpenHands)
|
||||
* until status is READY (then `app_conversation_id`, `agent_server_url`,
|
||||
* and `session_api_key` are populated) or ERROR.
|
||||
*
|
||||
* This path does NOT use encrypted-settings round-tripping. Secrets stay
|
||||
* server-side on the SaaS — the only auth carried is the cloud bearer
|
||||
* token (via the proxy's headers), and the conversation runtime is
|
||||
* provisioned with its own ephemeral session_api_key returned in the
|
||||
* task.
|
||||
*/
|
||||
export async function createCloudAppConversation(
|
||||
request: V1AppConversationStartRequest,
|
||||
): Promise<V1AppConversationStartTask> {
|
||||
const backend = getActiveCloudBackend();
|
||||
const data = await callCloudProxy<V1AppConversationStartTask>({
|
||||
backend,
|
||||
method: "POST",
|
||||
path: "/api/v1/app-conversations",
|
||||
body: request as unknown as Record<string, unknown>,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a v1 app-conversation as a ZIP from the cloud SaaS. Mirrors
|
||||
* the local `V1ConversationService.downloadConversation` interface but
|
||||
* routes through the bundled agent-server's cloud proxy and hits
|
||||
* `GET /api/v1/app-conversations/{id}/download`, which returns
|
||||
* `application/zip` with `Content-Disposition` set by the SaaS.
|
||||
*/
|
||||
export async function downloadCloudConversation(
|
||||
conversationId: string,
|
||||
): Promise<Blob> {
|
||||
const backend = getActiveCloudBackend();
|
||||
return callCloudProxy<Blob>({
|
||||
backend,
|
||||
method: "GET",
|
||||
path: `/api/v1/app-conversations/${conversationId}/download`,
|
||||
responseType: "blob",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a v1 app-conversation on the cloud SaaS. Mirrors the local
|
||||
* `V1ConversationService.deleteConversation` interface but routes
|
||||
* through the bundled agent-server's cloud proxy and hits
|
||||
* `DELETE /api/v1/app-conversations/{id}`, which returns a JSON
|
||||
* `Success` envelope (discarded here — the caller only needs to know
|
||||
* the request didn't error).
|
||||
*/
|
||||
export async function deleteCloudConversation(
|
||||
conversationId: string,
|
||||
): Promise<void> {
|
||||
const backend = getActiveCloudBackend();
|
||||
await callCloudProxy<unknown>({
|
||||
backend,
|
||||
method: "DELETE",
|
||||
path: `/api/v1/app-conversations/${conversationId}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the public-sharing flag on a cloud v1 app-conversation. Mirrors
|
||||
* OpenHands' `V1ConversationService.updateConversationPublicFlag` —
|
||||
* routes through the bundled agent-server's cloud proxy and hits
|
||||
* `PATCH /api/v1/app-conversations/{id}` with `{ public }`, returning
|
||||
* the updated conversation.
|
||||
*/
|
||||
export async function updateCloudConversationPublicFlag(
|
||||
conversationId: string,
|
||||
isPublic: boolean,
|
||||
): Promise<V1AppConversation> {
|
||||
const backend = getActiveCloudBackend();
|
||||
const data = await callCloudProxy<V1AppConversation>({
|
||||
backend,
|
||||
method: "PATCH",
|
||||
path: `/api/v1/app-conversations/${conversationId}`,
|
||||
body: { public: isPublic },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause the cloud sandbox backing a v1 app-conversation. Mirrors
|
||||
* OpenHands' `SandboxService.pauseSandbox` — routes through the
|
||||
* bundled agent-server's cloud proxy and hits
|
||||
* `POST /api/v1/sandboxes/{sandboxId}/pause` on the SaaS, which stops
|
||||
* the runtime owning the conversation.
|
||||
*/
|
||||
export async function pauseCloudSandbox(sandboxId: string): Promise<void> {
|
||||
const backend = getActiveCloudBackend();
|
||||
await callCloudProxy<unknown>({
|
||||
backend,
|
||||
method: "POST",
|
||||
path: `/api/v1/sandboxes/${sandboxId}/pause`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file from a cloud conversation's sandbox workspace. Mirrors
|
||||
* OpenHands' `V1ConversationService.readConversationFile` — hits
|
||||
* `GET /api/v1/app-conversations/{id}/file?file_path=...` on the SaaS
|
||||
* and returns the file content as a string.
|
||||
*/
|
||||
export async function readCloudConversationFile(
|
||||
conversationId: string,
|
||||
filePath: string,
|
||||
): Promise<string> {
|
||||
const backend = getActiveCloudBackend();
|
||||
const params = new URLSearchParams();
|
||||
params.append("file_path", filePath);
|
||||
const data = await callCloudProxy<string>({
|
||||
backend,
|
||||
method: "GET",
|
||||
path: `/api/v1/app-conversations/${conversationId}/file?${params.toString()}`,
|
||||
});
|
||||
return data ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single v1 app-conversation start task. Mirrors OpenHands'
|
||||
* `V1ConversationService.getStartTask` — uses the batch search endpoint
|
||||
* with a single id and unwraps the first result.
|
||||
*/
|
||||
export async function getCloudAppConversationStartTask(
|
||||
taskId: string,
|
||||
): Promise<V1AppConversationStartTask | null> {
|
||||
const backend = getActiveCloudBackend();
|
||||
const params = new URLSearchParams();
|
||||
params.set("ids", taskId);
|
||||
const data = await callCloudProxy<(V1AppConversationStartTask | null)[]>({
|
||||
backend,
|
||||
method: "GET",
|
||||
path: `/api/v1/app-conversations/start-tasks?${params.toString()}`,
|
||||
});
|
||||
return data?.[0] ?? null;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type {
|
||||
BranchPage,
|
||||
GitRepository,
|
||||
InstallationPage,
|
||||
RepositoryPage,
|
||||
} from "#/types/git";
|
||||
import type { Provider } from "#/types/settings";
|
||||
import { getActiveBackend } from "../backend-registry/active-store";
|
||||
import type { Backend } from "../backend-registry/types";
|
||||
import { callCloudProxy } from "./proxy";
|
||||
|
||||
function getActiveCloudBackend(): Backend {
|
||||
const active = getActiveBackend().backend;
|
||||
if (active.kind !== "cloud") {
|
||||
throw new Error("Cloud git call requires a cloud backend.");
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
export async function searchCloudRepositories(args: {
|
||||
provider: Provider;
|
||||
query?: string;
|
||||
limit?: number;
|
||||
pageId?: string;
|
||||
installationId?: string;
|
||||
}): Promise<RepositoryPage> {
|
||||
const backend = getActiveCloudBackend();
|
||||
const params = new URLSearchParams();
|
||||
params.set("provider", args.provider);
|
||||
params.set("limit", String(args.limit ?? 100));
|
||||
if (args.query) params.set("query", args.query);
|
||||
if (args.pageId) params.set("page_id", args.pageId);
|
||||
if (args.installationId) params.set("installation_id", args.installationId);
|
||||
|
||||
const data = await callCloudProxy<{
|
||||
items: GitRepository[];
|
||||
next_page_id: string | null;
|
||||
}>({
|
||||
backend,
|
||||
method: "GET",
|
||||
path: `/api/v1/git/repositories/search?${params.toString()}`,
|
||||
});
|
||||
|
||||
return {
|
||||
items: data?.items ?? [],
|
||||
next_page_id: data?.next_page_id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCloudInstallations(args: {
|
||||
provider: Provider;
|
||||
pageId?: string;
|
||||
limit?: number;
|
||||
}): Promise<InstallationPage> {
|
||||
const backend = getActiveCloudBackend();
|
||||
const params = new URLSearchParams();
|
||||
params.set("provider", args.provider);
|
||||
params.set("limit", String(args.limit ?? 100));
|
||||
if (args.pageId) params.set("page_id", args.pageId);
|
||||
|
||||
const data = await callCloudProxy<{
|
||||
items: string[];
|
||||
next_page_id: string | null;
|
||||
}>({
|
||||
backend,
|
||||
method: "GET",
|
||||
path: `/api/v1/git/installations/search?${params.toString()}`,
|
||||
});
|
||||
|
||||
return {
|
||||
items: data?.items ?? [],
|
||||
next_page_id: data?.next_page_id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCloudRepositoryBranches(args: {
|
||||
provider: Provider;
|
||||
repository: string;
|
||||
query?: string;
|
||||
pageId?: string;
|
||||
limit?: number;
|
||||
}): Promise<BranchPage> {
|
||||
const backend = getActiveCloudBackend();
|
||||
const params = new URLSearchParams();
|
||||
params.set("provider", args.provider);
|
||||
params.set("repository", args.repository);
|
||||
params.set("limit", String(args.limit ?? 30));
|
||||
if (args.query) params.set("query", args.query);
|
||||
if (args.pageId) params.set("page_id", args.pageId);
|
||||
|
||||
const data = await callCloudProxy<{
|
||||
items: BranchPage["items"];
|
||||
next_page_id: string | null;
|
||||
}>({
|
||||
backend,
|
||||
method: "GET",
|
||||
path: `/api/v1/git/branches/search?${params.toString()}`,
|
||||
});
|
||||
|
||||
return {
|
||||
items: data?.items ?? [],
|
||||
next_page_id: data?.next_page_id ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import axios from "axios";
|
||||
import { getActiveBackend } from "../backend-registry/active-store";
|
||||
import type { Backend } from "../backend-registry/types";
|
||||
import { callCloudProxy } from "./proxy";
|
||||
import type {
|
||||
CloudApiKeyMetadata,
|
||||
CloudOrganization,
|
||||
CloudOrganizationsResponse,
|
||||
} from "./types";
|
||||
|
||||
interface OrganizationsResult {
|
||||
items: CloudOrganization[];
|
||||
currentOrgId: string | null;
|
||||
}
|
||||
|
||||
function normalizeResult(
|
||||
data: CloudOrganizationsResponse | undefined | null,
|
||||
): OrganizationsResult {
|
||||
return {
|
||||
items: data?.items ?? [],
|
||||
currentOrgId: data?.current_org_id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBackend(backend?: Backend): Backend {
|
||||
if (backend) return backend;
|
||||
const active = getActiveBackend().backend;
|
||||
if (active.kind !== "cloud") {
|
||||
throw new Error(
|
||||
"Cloud organization calls require a cloud backend. Active backend is local.",
|
||||
);
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the org list for a cloud backend. With no argument, uses the active
|
||||
* cloud backend; pass `backend` explicitly to fetch for an inactive cloud
|
||||
* (used by the selector to flatten all cloud rows).
|
||||
*
|
||||
* Routed through the bundled agent-server's `/api/cloud-proxy` to avoid
|
||||
* cross-origin browser calls.
|
||||
*/
|
||||
export async function getCloudOrganizations(
|
||||
backend?: Backend,
|
||||
): Promise<OrganizationsResult> {
|
||||
const target = resolveBackend(backend);
|
||||
const data = await callCloudProxy<CloudOrganizationsResponse>({
|
||||
backend: target,
|
||||
method: "GET",
|
||||
path: "/api/organizations",
|
||||
});
|
||||
return normalizeResult(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch metadata for the API key used to authenticate this cloud backend.
|
||||
* The returned `orgId` is the single org the key is authorized to act on
|
||||
* (the SaaS contract: one key → one org).
|
||||
*
|
||||
* Legacy keys minted before per-key org binding existed cause the upstream
|
||||
* to return HTTP 400 — we surface that as `isLegacyKey: true` with a null
|
||||
* `orgId` so the caller can fall back to the unfiltered behavior. Other
|
||||
* statuses (401 revoked, 5xx outage) propagate so React Query can mark
|
||||
* the query as failed and the selector can render the no-org-known row.
|
||||
*/
|
||||
export async function getCurrentCloudApiKey(
|
||||
backend?: Backend,
|
||||
): Promise<{ orgId: string | null; isLegacyKey: boolean }> {
|
||||
const target = resolveBackend(backend);
|
||||
try {
|
||||
const data = await callCloudProxy<CloudApiKeyMetadata>({
|
||||
backend: target,
|
||||
method: "GET",
|
||||
path: "/api/keys/current",
|
||||
});
|
||||
return { orgId: data?.org_id ?? null, isLegacyKey: false };
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e) && e.response?.status === 400) {
|
||||
return { orgId: null, isLegacyKey: true };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function switchCloudOrganization(
|
||||
orgId: string,
|
||||
backend?: Backend,
|
||||
): Promise<void> {
|
||||
const target = resolveBackend(backend);
|
||||
await callCloudProxy<unknown>({
|
||||
backend: target,
|
||||
method: "POST",
|
||||
path: `/api/organizations/${encodeURIComponent(orgId)}/switch`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch `GET /api/organizations/{orgId}/me`. Identifies the calling user as
|
||||
* a member of `orgId`. The GUI uses `me.org_id === me.user_id` to decide
|
||||
* whether `orgId` is the user's personal workspace — that's the SaaS
|
||||
* contract (the auto-generated personal-workspace org has the same id as
|
||||
* the user).
|
||||
*/
|
||||
export async function getCloudOrganizationMe(
|
||||
orgId: string,
|
||||
backend?: Backend,
|
||||
): Promise<{ orgId: string; userId: string }> {
|
||||
const target = resolveBackend(backend);
|
||||
const data = await callCloudProxy<{ org_id: string; user_id: string }>({
|
||||
backend: target,
|
||||
method: "GET",
|
||||
path: `/api/organizations/${encodeURIComponent(orgId)}/me`,
|
||||
});
|
||||
return {
|
||||
orgId: data?.org_id ?? orgId,
|
||||
userId: data?.user_id ?? "",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import axios from "axios";
|
||||
import { buildAuthHeaders } from "../backend-registry/auth";
|
||||
import { getBundledBackend } from "../backend-registry/bundled";
|
||||
import type { Backend } from "../backend-registry/types";
|
||||
|
||||
interface CloudProxyRequest {
|
||||
/**
|
||||
* Cloud backend whose bearer token authenticates the upstream call.
|
||||
* `backend.host` is also the default upstream host unless `hostOverride`
|
||||
* is set.
|
||||
*/
|
||||
backend: Backend;
|
||||
/** HTTP method against the upstream host. */
|
||||
method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
|
||||
/** Path on the upstream host, e.g. "/api/v1/conversation/123/events/search". */
|
||||
path: string;
|
||||
/** Optional JSON body for non-GET methods. */
|
||||
body?: unknown;
|
||||
/** Extra headers merged with the auth header for the upstream call. */
|
||||
headers?: Record<string, string>;
|
||||
/** Override the upstream timeout, in seconds. */
|
||||
timeoutSeconds?: number;
|
||||
/**
|
||||
* Override the upstream host. When set, the proxy targets this host
|
||||
* instead of `backend.host`. Used for runtime-sandbox calls where the
|
||||
* upstream lives at the conversation's runtime URL (e.g.
|
||||
* `http://<id>.prod-runtime.all-hands.dev`) rather than the SaaS API.
|
||||
* The host must still pass the proxy's allowlist server-side.
|
||||
*/
|
||||
hostOverride?: string;
|
||||
/**
|
||||
* Auth strategy for the upstream call. Defaults to "bearer" (uses the
|
||||
* cloud backend's bearer token via `buildAuthHeaders`). For
|
||||
* runtime-sandbox calls, set to "session-api-key" and pass
|
||||
* `sessionApiKey` — those endpoints don't accept bearer tokens, only
|
||||
* `X-Session-API-Key`. "none" sends no auth header.
|
||||
*/
|
||||
authMode?: "bearer" | "session-api-key" | "none";
|
||||
/** Required when `authMode === "session-api-key"`. */
|
||||
sessionApiKey?: string | null;
|
||||
/**
|
||||
* Axios responseType for the inner POST to the bundled agent-server.
|
||||
* Set to "blob" when the upstream cloud endpoint returns a binary
|
||||
* payload (e.g. ZIP downloads); leave undefined for default JSON.
|
||||
*/
|
||||
responseType?: "blob";
|
||||
}
|
||||
|
||||
function buildUpstreamAuthHeaders(
|
||||
req: CloudProxyRequest,
|
||||
): Record<string, string> {
|
||||
const mode = req.authMode ?? "bearer";
|
||||
if (mode === "bearer") return buildAuthHeaders(req.backend);
|
||||
if (mode === "session-api-key") {
|
||||
return req.sessionApiKey ? { "X-Session-API-Key": req.sessionApiKey } : {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a cloud-proxy envelope to the bundled local agent-server. The local
|
||||
* server forwards the request to the upstream host server-side, which
|
||||
* sidesteps the cross-origin restrictions that would block a direct
|
||||
* browser → SaaS or browser → runtime-sandbox call.
|
||||
*
|
||||
* Auth headers (bearer or session-api-key) are attached server-side; they
|
||||
* never cross an origin boundary in the browser.
|
||||
*/
|
||||
export async function callCloudProxy<TResponse = unknown>(
|
||||
req: CloudProxyRequest,
|
||||
): Promise<TResponse> {
|
||||
const bundled = getBundledBackend();
|
||||
const upstreamHeaders = {
|
||||
...buildUpstreamAuthHeaders(req),
|
||||
...(req.headers ?? {}),
|
||||
};
|
||||
const upstreamHost = req.hostOverride ?? req.backend.host;
|
||||
|
||||
// Talk directly to the bundled local agent-server, bypassing the global
|
||||
// openHands axios interceptor (which would otherwise read host + auth
|
||||
// from the active backend — wrong for this call).
|
||||
const response = await axios.post<TResponse>(
|
||||
`${bundled.host.replace(/\/+$/, "")}/api/cloud-proxy`,
|
||||
{
|
||||
host: upstreamHost,
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
headers: upstreamHeaders,
|
||||
body: req.body ?? null,
|
||||
...(req.timeoutSeconds ? { timeout_seconds: req.timeoutSeconds } : {}),
|
||||
},
|
||||
{
|
||||
headers: buildAuthHeaders(bundled),
|
||||
timeout: 30_000,
|
||||
...(req.responseType ? { responseType: req.responseType } : {}),
|
||||
},
|
||||
);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { getActiveBackend } from "../backend-registry/active-store";
|
||||
import type { Backend } from "../backend-registry/types";
|
||||
import type { CustomSecretWithoutValue } from "../secrets-service.types";
|
||||
import { callCloudProxy } from "./proxy";
|
||||
|
||||
interface CloudSecretsPage {
|
||||
items: CustomSecretWithoutValue[];
|
||||
next_page_id: string | null;
|
||||
}
|
||||
|
||||
const PAGE_LIMIT = 100;
|
||||
|
||||
function getActiveCloudBackend(): Backend {
|
||||
const active = getActiveBackend().backend;
|
||||
if (active.kind !== "cloud") {
|
||||
throw new Error("Cloud secrets call requires a cloud backend.");
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk every page of the cloud SaaS `/api/v1/secrets/search` endpoint via the
|
||||
* bundled `/api/cloud-proxy` and return the merged list. The cloud shape
|
||||
* (name + description) matches `CustomSecretWithoutValue`, so items pass
|
||||
* through unchanged.
|
||||
*/
|
||||
export async function fetchCloudSecrets(): Promise<CustomSecretWithoutValue[]> {
|
||||
const backend = getActiveCloudBackend();
|
||||
const all: CustomSecretWithoutValue[] = [];
|
||||
let pageId: string | null = null;
|
||||
|
||||
do {
|
||||
const query = new URLSearchParams({ limit: String(PAGE_LIMIT) });
|
||||
if (pageId) query.set("page_id", pageId);
|
||||
|
||||
const page: CloudSecretsPage = await callCloudProxy<CloudSecretsPage>({
|
||||
backend,
|
||||
method: "GET",
|
||||
path: `/api/v1/secrets/search?${query.toString()}`,
|
||||
});
|
||||
|
||||
all.push(...(page.items ?? []));
|
||||
pageId = page.next_page_id ?? null;
|
||||
} while (pageId);
|
||||
|
||||
return all;
|
||||
}
|
||||
|
||||
export async function createCloudSecret(
|
||||
name: string,
|
||||
value: string,
|
||||
description?: string,
|
||||
): Promise<void> {
|
||||
const backend = getActiveCloudBackend();
|
||||
await callCloudProxy<unknown>({
|
||||
backend,
|
||||
method: "POST",
|
||||
path: "/api/v1/secrets",
|
||||
body: { name, value, description },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename and/or redescribe an existing cloud secret. The cloud `PUT` endpoint
|
||||
* does not accept a value field — it only updates name + description — which
|
||||
* matches what `useUpdateSecret` actually sends from the secret-edit form.
|
||||
*/
|
||||
export async function updateCloudSecret(
|
||||
secretToEdit: string,
|
||||
name: string,
|
||||
description?: string,
|
||||
): Promise<void> {
|
||||
const backend = getActiveCloudBackend();
|
||||
await callCloudProxy<unknown>({
|
||||
backend,
|
||||
method: "PUT",
|
||||
path: `/api/v1/secrets/${encodeURIComponent(secretToEdit)}`,
|
||||
body: { name, description },
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteCloudSecret(name: string): Promise<void> {
|
||||
const backend = getActiveCloudBackend();
|
||||
await callCloudProxy<unknown>({
|
||||
backend,
|
||||
method: "DELETE",
|
||||
path: `/api/v1/secrets/${encodeURIComponent(name)}`,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
type MCPConfig,
|
||||
type Provider,
|
||||
type Settings,
|
||||
type SettingsValue,
|
||||
} from "#/types/settings";
|
||||
import { getActiveBackend } from "../backend-registry/active-store";
|
||||
import type { Backend } from "../backend-registry/types";
|
||||
import { callCloudProxy } from "./proxy";
|
||||
|
||||
/**
|
||||
* The cloud SaaS Settings response is mostly flat — top-level fields like
|
||||
* `llm_model`, `provider_tokens_set`, etc., rather than the nested
|
||||
* `{ agent_settings, conversation_settings }` shape the local agent-server
|
||||
* uses. We deliberately do NOT remap cloud fields into the local shape:
|
||||
* the GUI's `Settings` type already supports both layouts (it has flat
|
||||
* fields AND nested `agent_settings`/`conversation_settings`), and
|
||||
* cloud-aware hooks like `useUserProviders` read directly from the flat
|
||||
* `provider_tokens_set` field. So the cloud response is passed through as
|
||||
* a `Partial<Settings>`, with a small derivation step to also populate
|
||||
* the nested fields the local-mode settings UI consumes.
|
||||
*/
|
||||
type CloudSettingsResponse = {
|
||||
llm_model?: string;
|
||||
llm_base_url?: string;
|
||||
llm_api_key?: string | null;
|
||||
llm_api_key_set?: boolean;
|
||||
search_api_key_set?: boolean;
|
||||
agent?: string;
|
||||
confirmation_mode?: boolean;
|
||||
security_analyzer?: string | null;
|
||||
max_iterations?: number | null;
|
||||
enable_default_condenser?: boolean;
|
||||
condenser_max_size?: number | null;
|
||||
enable_proactive_conversation_starters?: boolean;
|
||||
enable_solvability_analysis?: boolean;
|
||||
enable_sound_notifications?: boolean;
|
||||
language?: string;
|
||||
email?: string;
|
||||
email_verified?: boolean;
|
||||
git_user_name?: string;
|
||||
git_user_email?: string;
|
||||
user_consents_to_analytics?: boolean | null;
|
||||
is_new_user?: boolean;
|
||||
v1_enabled?: boolean;
|
||||
remote_runtime_resource_factor?: number | null;
|
||||
max_budget_per_task?: number | null;
|
||||
provider_tokens_set?: Partial<Record<Provider, string | null>>;
|
||||
mcp_config?: MCPConfig;
|
||||
disabled_skills?: string[];
|
||||
agent_settings?: Record<string, SettingsValue> | null;
|
||||
conversation_settings?: Record<string, SettingsValue> | null;
|
||||
agent_settings_schema?: unknown;
|
||||
conversation_settings_schema?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
function getActiveCloudBackend(): Backend {
|
||||
const active = getActiveBackend().backend;
|
||||
if (active.kind !== "cloud") {
|
||||
throw new Error("Cloud settings call requires a cloud backend.");
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the nested `agent_settings` block from the cloud's flat fields.
|
||||
* Used so the local-mode settings page (which renders against the nested
|
||||
* shape) shows the right values when the active backend is cloud.
|
||||
*/
|
||||
function deriveAgentSettings(
|
||||
flat: CloudSettingsResponse,
|
||||
): Record<string, SettingsValue> {
|
||||
if (flat.agent_settings && Object.keys(flat.agent_settings).length > 0) {
|
||||
return flat.agent_settings;
|
||||
}
|
||||
const agent: Record<string, SettingsValue> = {};
|
||||
const llm: Record<string, SettingsValue> = {};
|
||||
if (typeof flat.llm_model === "string") llm.model = flat.llm_model;
|
||||
if (typeof flat.llm_base_url === "string") llm.base_url = flat.llm_base_url;
|
||||
if (typeof flat.llm_api_key === "string") llm.api_key = flat.llm_api_key;
|
||||
if (Object.keys(llm).length > 0) agent.llm = llm;
|
||||
|
||||
const condenser: Record<string, SettingsValue> = {};
|
||||
if (typeof flat.enable_default_condenser === "boolean") {
|
||||
condenser.enabled = flat.enable_default_condenser;
|
||||
}
|
||||
if (typeof flat.condenser_max_size === "number") {
|
||||
condenser.max_size = flat.condenser_max_size;
|
||||
}
|
||||
if (Object.keys(condenser).length > 0) agent.condenser = condenser;
|
||||
|
||||
if (typeof flat.agent === "string") agent.agent = flat.agent;
|
||||
return agent;
|
||||
}
|
||||
|
||||
function deriveConversationSettings(
|
||||
flat: CloudSettingsResponse,
|
||||
): Record<string, SettingsValue> {
|
||||
if (
|
||||
flat.conversation_settings &&
|
||||
Object.keys(flat.conversation_settings).length > 0
|
||||
) {
|
||||
return flat.conversation_settings;
|
||||
}
|
||||
const out: Record<string, SettingsValue> = {};
|
||||
if (typeof flat.confirmation_mode === "boolean") {
|
||||
out.confirmation_mode = flat.confirmation_mode;
|
||||
}
|
||||
if (
|
||||
typeof flat.security_analyzer === "string" ||
|
||||
flat.security_analyzer === null
|
||||
) {
|
||||
out.security_analyzer = flat.security_analyzer;
|
||||
}
|
||||
if (typeof flat.max_iterations === "number") {
|
||||
out.max_iterations = flat.max_iterations;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the cloud SaaS settings and return them as a `Partial<Settings>`.
|
||||
*
|
||||
* Top-level fields like `provider_tokens_set` are preserved unchanged so
|
||||
* the existing `useUserProviders` → `useAppInstallations` →
|
||||
* `useGitRepositories` chain (which reads `settings.provider_tokens_set`)
|
||||
* fires correctly in cloud mode. Nested `agent_settings` /
|
||||
* `conversation_settings` are derived for the settings page.
|
||||
*/
|
||||
export async function fetchCloudSettings(): Promise<Partial<Settings>> {
|
||||
const backend = getActiveCloudBackend();
|
||||
const flat = await callCloudProxy<CloudSettingsResponse>({
|
||||
backend,
|
||||
method: "GET",
|
||||
path: "/api/v1/settings",
|
||||
});
|
||||
|
||||
return {
|
||||
...flat,
|
||||
agent_settings: deriveAgentSettings(flat),
|
||||
conversation_settings: deriveConversationSettings(flat),
|
||||
llm_api_key_set: !!flat.llm_api_key_set,
|
||||
search_api_key_set: !!flat.search_api_key_set,
|
||||
provider_tokens_set: flat.provider_tokens_set,
|
||||
} as Partial<Settings>;
|
||||
}
|
||||
|
||||
export async function saveCloudSettings(diff: {
|
||||
agent_settings_diff?: Record<string, SettingsValue>;
|
||||
conversation_settings_diff?: Record<string, SettingsValue>;
|
||||
}): Promise<void> {
|
||||
const backend = getActiveCloudBackend();
|
||||
const body: Record<string, unknown> = {};
|
||||
if (
|
||||
diff.agent_settings_diff &&
|
||||
Object.keys(diff.agent_settings_diff).length > 0
|
||||
) {
|
||||
body.agent_settings_diff = diff.agent_settings_diff;
|
||||
}
|
||||
if (
|
||||
diff.conversation_settings_diff &&
|
||||
Object.keys(diff.conversation_settings_diff).length > 0
|
||||
) {
|
||||
body.conversation_settings_diff = diff.conversation_settings_diff;
|
||||
}
|
||||
await callCloudProxy<unknown>({
|
||||
backend,
|
||||
method: "POST",
|
||||
path: "/api/v1/settings",
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchCloudSettingsSchema(): Promise<unknown> {
|
||||
const backend = getActiveCloudBackend();
|
||||
return callCloudProxy<unknown>({
|
||||
backend,
|
||||
method: "GET",
|
||||
path: "/api/v1/settings/agent-schema",
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchCloudConversationSettingsSchema(): Promise<unknown> {
|
||||
const backend = getActiveCloudBackend();
|
||||
return callCloudProxy<unknown>({
|
||||
backend,
|
||||
method: "GET",
|
||||
path: "/api/v1/settings/conversation-schema",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { SkillInfo } from "#/types/settings";
|
||||
import { getActiveBackend } from "../backend-registry/active-store";
|
||||
import type { Backend } from "../backend-registry/types";
|
||||
import { callCloudProxy } from "./proxy";
|
||||
|
||||
interface CloudSkillsPage {
|
||||
items: SkillInfo[];
|
||||
next_page_id: string | null;
|
||||
}
|
||||
|
||||
const PAGE_LIMIT = 100;
|
||||
|
||||
function getActiveCloudBackend(): Backend {
|
||||
const active = getActiveBackend().backend;
|
||||
if (active.kind !== "cloud") {
|
||||
throw new Error("Cloud skills call requires a cloud backend.");
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the full list of skills from the cloud SaaS via the bundled
|
||||
* /api/cloud-proxy. The cloud endpoint is paginated (page_id cursor); we
|
||||
* walk all pages so the settings UI gets a complete list in one call. The
|
||||
* cloud SkillInfo shape (name/type/source/triggers) matches the GUI's
|
||||
* SkillInfo type, so items are passed through unchanged.
|
||||
*/
|
||||
export async function fetchCloudSkills(): Promise<SkillInfo[]> {
|
||||
const backend = getActiveCloudBackend();
|
||||
const all: SkillInfo[] = [];
|
||||
let pageId: string | null = null;
|
||||
|
||||
do {
|
||||
const query = new URLSearchParams({ limit: String(PAGE_LIMIT) });
|
||||
if (pageId) query.set("page_id", pageId);
|
||||
|
||||
const page: CloudSkillsPage = await callCloudProxy<CloudSkillsPage>({
|
||||
backend,
|
||||
method: "GET",
|
||||
path: `/api/v1/skills/search?${query.toString()}`,
|
||||
});
|
||||
|
||||
all.push(...(page.items ?? []));
|
||||
pageId = page.next_page_id ?? null;
|
||||
} while (pageId);
|
||||
|
||||
return all;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Minimal slice of the OpenHands SaaS organization shape that the GUI needs
|
||||
* to render the backend selector. Full shape lives in the OpenHands repo;
|
||||
* we keep only the fields actually read by this codebase.
|
||||
*/
|
||||
export interface CloudOrganization {
|
||||
id: string;
|
||||
name: string;
|
||||
is_personal?: boolean;
|
||||
}
|
||||
|
||||
export interface CloudOrganizationsResponse {
|
||||
items: CloudOrganization[];
|
||||
current_org_id: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subset of OpenHands' OrganizationMember that the GUI reads. Returned by
|
||||
* `GET /api/organizations/{orgId}/me`. The relationship between the org and
|
||||
* the current user is the source of truth for "is this a personal
|
||||
* workspace?" — namely `org_id === user_id`.
|
||||
*/
|
||||
export interface CloudOrganizationMember {
|
||||
org_id: string;
|
||||
user_id: string;
|
||||
email?: string | null;
|
||||
role?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from `GET /api/keys/current`. Identifies the org the calling
|
||||
* API key is scoped to. Legacy keys without an org binding cause the
|
||||
* upstream to return HTTP 400 instead of producing this shape — the
|
||||
* caller catches that case rather than expecting a partial response.
|
||||
*/
|
||||
export interface CloudApiKeyMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
org_id: string | null;
|
||||
user_id: string;
|
||||
auth_type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from `GET /api/v1/users/git-info`. Identifies the currently
|
||||
* authenticated user across the connected git providers.
|
||||
*/
|
||||
export interface CloudGitUser {
|
||||
id: string;
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
company: string | null;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { getActiveBackend } from "../backend-registry/active-store";
|
||||
import type { Backend } from "../backend-registry/types";
|
||||
import { callCloudProxy } from "./proxy";
|
||||
import type { CloudGitUser } from "./types";
|
||||
|
||||
function getActiveCloudBackend(): Backend {
|
||||
const active = getActiveBackend().backend;
|
||||
if (active.kind !== "cloud") {
|
||||
throw new Error("Cloud user calls require a cloud backend.");
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the SaaS git-info record (`GET /api/v1/users/git-info`). Returns
|
||||
* the currently authenticated user's identity across the connected git
|
||||
* providers — login, avatar, etc.
|
||||
*
|
||||
* Routed through the bundled agent-server's cloud proxy.
|
||||
*/
|
||||
export async function getCloudGitUser(
|
||||
backend?: Backend,
|
||||
): Promise<CloudGitUser> {
|
||||
const target = backend ?? getActiveCloudBackend();
|
||||
const data = await callCloudProxy<CloudGitUser>({
|
||||
backend: target,
|
||||
method: "GET",
|
||||
path: "/api/v1/users/git-info",
|
||||
});
|
||||
return data;
|
||||
}
|
||||
@@ -1,11 +1,25 @@
|
||||
import { Provider } from "#/types/settings";
|
||||
import { SuggestedTask } from "#/utils/types";
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { buildHttpBaseUrl } from "#/utils/websocket-url";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
buildConversationWorkingDir,
|
||||
getAgentServerBaseUrl,
|
||||
getAgentServerWorkingDir,
|
||||
} from "../agent-server-config";
|
||||
import {
|
||||
getActiveBackend,
|
||||
getEffectiveLocalBackend,
|
||||
} from "../backend-registry/active-store";
|
||||
import { callCloudProxy } from "../cloud/proxy";
|
||||
import {
|
||||
batchGetCloudConversations,
|
||||
createCloudAppConversation,
|
||||
deleteCloudConversation,
|
||||
downloadCloudConversation,
|
||||
getCloudAppConversationStartTask,
|
||||
readCloudConversationFile,
|
||||
searchCloudConversations,
|
||||
updateCloudConversationPublicFlag,
|
||||
} from "../cloud/conversation-service.api";
|
||||
import {
|
||||
DirectConversationInfo,
|
||||
buildStartConversationRequestWithEncryptedSettings,
|
||||
@@ -16,7 +30,7 @@ import {
|
||||
toV1AppConversation,
|
||||
toV1ConversationPage,
|
||||
} from "../agent-server-adapter";
|
||||
import { ConversationTrigger, GetVSCodeUrlResponse } from "../open-hands.types";
|
||||
import { GetVSCodeUrlResponse } from "../open-hands.types";
|
||||
import {
|
||||
createHttpClient,
|
||||
createRemoteWorkspace,
|
||||
@@ -63,7 +77,35 @@ class V1ConversationService {
|
||||
plugins?: PluginSpec[],
|
||||
metadata?: ConversationMetadata | null,
|
||||
workingDirOverride?: string,
|
||||
parentConversationId?: string,
|
||||
agentType?: "default" | "plan",
|
||||
sandboxId?: string,
|
||||
): Promise<V1AppConversationStartTask> {
|
||||
if (getActiveBackend().backend.kind === "cloud") {
|
||||
// Cloud SaaS path mirrors OpenHands' frontend: build a flat
|
||||
// V1AppConversationStartRequest, POST /api/v1/app-conversations
|
||||
// (returns a WORKING task), and let the conversation route's
|
||||
// useTaskPolling drive it to READY. NO encrypted-settings
|
||||
// round-trip — the SaaS holds secrets server-side.
|
||||
const request: V1AppConversationStartRequest = {
|
||||
initial_message: initialUserMsg
|
||||
? {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: initialUserMsg }],
|
||||
}
|
||||
: null,
|
||||
title: conversationInstructions ?? null,
|
||||
selected_repository: metadata?.selected_repository ?? null,
|
||||
selected_branch: metadata?.selected_branch ?? null,
|
||||
git_provider: metadata?.git_provider ?? null,
|
||||
plugins: plugins ?? null,
|
||||
parent_conversation_id: parentConversationId ?? null,
|
||||
agent_type: agentType,
|
||||
sandbox_id: sandboxId ?? null,
|
||||
};
|
||||
return createCloudAppConversation(request);
|
||||
}
|
||||
|
||||
const settings = await SettingsService.getSettings();
|
||||
const conversationId = uuidv4();
|
||||
const workingDir =
|
||||
@@ -98,7 +140,7 @@ class V1ConversationService {
|
||||
status: "READY",
|
||||
detail: null,
|
||||
app_conversation_id: data.id,
|
||||
agent_server_url: getAgentServerBaseUrl(),
|
||||
agent_server_url: getEffectiveLocalBackend().host,
|
||||
request: {
|
||||
initial_message: payload.initial_message as
|
||||
| V1AppConversationStartRequest["initial_message"]
|
||||
@@ -111,8 +153,14 @@ class V1ConversationService {
|
||||
}
|
||||
|
||||
static async getStartTask(
|
||||
_taskId: string,
|
||||
taskId: string,
|
||||
): Promise<V1AppConversationStartTask | null> {
|
||||
if (getActiveBackend().backend.kind === "cloud") {
|
||||
return getCloudAppConversationStartTask(taskId);
|
||||
}
|
||||
// Local agent-server creates conversations synchronously — every
|
||||
// local "task" is already READY when createConversation returns, so
|
||||
// there's nothing to poll for.
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -124,12 +172,35 @@ class V1ConversationService {
|
||||
|
||||
static async getVSCodeUrl(
|
||||
conversationId: string,
|
||||
_conversationUrl: string | null | undefined,
|
||||
conversationUrl: string | null | undefined,
|
||||
sessionApiKey?: string | null,
|
||||
): Promise<GetVSCodeUrlResponse> {
|
||||
const active = getActiveBackend().backend;
|
||||
|
||||
// Cloud mode: route through the cloud-proxy to the runtime sandbox.
|
||||
// The runtime exposes a SaaS-style endpoint at `/api/vscode/url`
|
||||
// that returns `{ url }`; we map it back to `{ vscode_url }` to
|
||||
// match the local response shape.
|
||||
if (active.kind === "cloud" && conversationUrl) {
|
||||
const data = await callCloudProxy<{ url: string | null }>({
|
||||
backend: active,
|
||||
method: "GET",
|
||||
hostOverride: buildHttpBaseUrl(conversationUrl),
|
||||
path: "/api/vscode/url",
|
||||
authMode: "session-api-key",
|
||||
sessionApiKey,
|
||||
});
|
||||
return { vscode_url: data?.url ?? null };
|
||||
}
|
||||
|
||||
const workspaceDir =
|
||||
await this.resolveConversationWorkingDir(conversationId);
|
||||
const vscode_url = await createVSCodeClient({ sessionApiKey }).getUrl({
|
||||
// Local mode: the typescript-client targets the local agent-server
|
||||
// directly via the conversationUrl override.
|
||||
const vscode_url = await createVSCodeClient({
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
}).getUrl({
|
||||
baseUrl:
|
||||
typeof window !== "undefined" ? window.location.origin : undefined,
|
||||
workspaceDir,
|
||||
@@ -189,6 +260,10 @@ class V1ConversationService {
|
||||
): Promise<(V1AppConversation | null)[]> {
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
if (getActiveBackend().backend.kind === "cloud") {
|
||||
return batchGetCloudConversations(ids);
|
||||
}
|
||||
|
||||
const response = await createHttpClient().get<
|
||||
(DirectConversationInfo | null)[]
|
||||
>("/api/conversations", { params: { ids } });
|
||||
@@ -216,10 +291,12 @@ class V1ConversationService {
|
||||
|
||||
static async updateConversationPublicFlag(
|
||||
conversationId: string,
|
||||
_isPublic: boolean,
|
||||
isPublic: boolean,
|
||||
): Promise<V1AppConversation> {
|
||||
const results = await this.batchGetAppConversations([conversationId]);
|
||||
return results[0] as V1AppConversation;
|
||||
if (getActiveBackend().backend.kind !== "cloud") {
|
||||
throw new Error("Public sharing requires a cloud backend.");
|
||||
}
|
||||
return updateCloudConversationPublicFlag(conversationId, isPublic);
|
||||
}
|
||||
|
||||
static async updateConversationRepository(
|
||||
@@ -245,6 +322,16 @@ class V1ConversationService {
|
||||
conversationId: string,
|
||||
filePath?: string,
|
||||
): Promise<string> {
|
||||
if (getActiveBackend().backend.kind === "cloud") {
|
||||
// Cloud SaaS exposes a per-conversation file endpoint; the sandbox
|
||||
// working dir is fixed (`/workspace/project`), so PLAN.md lives at
|
||||
// a known absolute path. Mirrors OpenHands' readConversationFile.
|
||||
return readCloudConversationFile(
|
||||
conversationId,
|
||||
filePath ?? "/workspace/project/.agents_tmp/PLAN.md",
|
||||
);
|
||||
}
|
||||
|
||||
if (filePath) {
|
||||
return downloadTextFile(filePath);
|
||||
}
|
||||
@@ -254,6 +341,10 @@ class V1ConversationService {
|
||||
}
|
||||
|
||||
static async downloadConversation(conversationId: string): Promise<Blob> {
|
||||
if (getActiveBackend().backend.kind === "cloud") {
|
||||
return downloadCloudConversation(conversationId);
|
||||
}
|
||||
|
||||
const response = await createHttpClient().get<Blob>(
|
||||
`/api/file/download-trajectory/${conversationId}`,
|
||||
{
|
||||
@@ -277,17 +368,41 @@ class V1ConversationService {
|
||||
|
||||
static async getRuntimeConversation(
|
||||
conversationId: string,
|
||||
_conversationUrl: string | null | undefined,
|
||||
conversationUrl: string | null | undefined,
|
||||
sessionApiKey?: string | null,
|
||||
): Promise<V1RuntimeConversationInfo> {
|
||||
const response = await createHttpClient({ sessionApiKey }).get<
|
||||
DirectConversationInfo & { stats?: V1RuntimeConversationInfo["stats"] }
|
||||
>(`/api/conversations/${conversationId}`);
|
||||
const { data } = response;
|
||||
const active = getActiveBackend().backend;
|
||||
|
||||
type RawRuntime = DirectConversationInfo & {
|
||||
stats?: V1RuntimeConversationInfo["stats"];
|
||||
};
|
||||
|
||||
// Cloud mode: route through the cloud-proxy to the runtime sandbox at
|
||||
// the conversation's runtime URL — same pattern as getVSCodeUrl. Local
|
||||
// mode forwards conversationUrl so the host explicitly resolves to the
|
||||
// conversation's runtime instead of falling back to the active backend.
|
||||
const data: RawRuntime =
|
||||
active.kind === "cloud" && conversationUrl
|
||||
? await callCloudProxy<RawRuntime>({
|
||||
backend: active,
|
||||
method: "GET",
|
||||
hostOverride: buildHttpBaseUrl(conversationUrl),
|
||||
path: `/api/conversations/${conversationId}`,
|
||||
authMode: "session-api-key",
|
||||
sessionApiKey,
|
||||
})
|
||||
: (
|
||||
await createHttpClient({
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
}).get<RawRuntime>(`/api/conversations/${conversationId}`)
|
||||
).data;
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
title: data.title?.trim() ? data.title : getDefaultConversationTitle(data.id),
|
||||
title: data.title?.trim()
|
||||
? data.title
|
||||
: getDefaultConversationTitle(data.id),
|
||||
metrics: data.metrics
|
||||
? {
|
||||
accumulated_cost: data.metrics.accumulated_cost ?? null,
|
||||
@@ -324,6 +439,10 @@ class V1ConversationService {
|
||||
limit: number = 20,
|
||||
pageId?: string,
|
||||
): Promise<V1AppConversationPage> {
|
||||
if (getActiveBackend().backend.kind === "cloud") {
|
||||
return searchCloudConversations(limit, pageId);
|
||||
}
|
||||
|
||||
const response = await createHttpClient().get<{
|
||||
items: DirectConversationInfo[];
|
||||
next_page_id: string | null;
|
||||
@@ -339,7 +458,11 @@ class V1ConversationService {
|
||||
}
|
||||
|
||||
static async deleteConversation(conversationId: string): Promise<void> {
|
||||
await createHttpClient().delete(`/api/conversations/${conversationId}`);
|
||||
if (getActiveBackend().backend.kind === "cloud") {
|
||||
await deleteCloudConversation(conversationId);
|
||||
} else {
|
||||
await createHttpClient().delete(`/api/conversations/${conversationId}`);
|
||||
}
|
||||
removeStoredConversationMetadata(conversationId);
|
||||
}
|
||||
|
||||
|
||||
@@ -61,11 +61,13 @@ export interface V1AppConversationStartRequest {
|
||||
pr_number?: number[];
|
||||
parent_conversation_id?: string | null;
|
||||
agent_type?: "default" | "plan";
|
||||
sandbox_id?: string | null;
|
||||
plugins?: PluginSpec[] | null; // Plugins to load when starting the conversation
|
||||
}
|
||||
|
||||
export type V1AppConversationStartTaskStatus =
|
||||
| "WORKING"
|
||||
| "WAITING_FOR_SANDBOX"
|
||||
| "PREPARING_REPOSITORY"
|
||||
| "RUNNING_SETUP_SCRIPT"
|
||||
| "SETTING_UP_GIT_HOOKS"
|
||||
@@ -116,6 +118,7 @@ export interface V1AppConversation {
|
||||
execution_status: V1ExecutionStatus | null;
|
||||
conversation_url: string | null;
|
||||
session_api_key: string | null;
|
||||
sandbox_id: string | null;
|
||||
workspace?: V1ConversationWorkspace | null;
|
||||
public?: boolean;
|
||||
sub_conversation_ids: string[];
|
||||
|
||||
@@ -1,18 +1,57 @@
|
||||
import { OpenHandsEvent } from "#/types/v1/core";
|
||||
import { buildHttpBaseUrl } from "#/utils/websocket-url";
|
||||
import { getActiveBackend } from "../backend-registry/active-store";
|
||||
import { callCloudProxy } from "../cloud/proxy";
|
||||
import { createHttpClient, createRemoteEventsList } from "../typescript-client";
|
||||
import type {
|
||||
ConfirmationResponseRequest,
|
||||
ConfirmationResponseResponse,
|
||||
} from "./event-service.types";
|
||||
|
||||
/**
|
||||
* Cloud-mode REST calls are split between two upstream hosts (matching
|
||||
* OpenHands' SaaS frontend):
|
||||
*
|
||||
* - **App API** (`backend.host`, default in `callCloudProxy`):
|
||||
* event *history* (`/api/v1/conversation/{id}/events/search`).
|
||||
* Persisted by the SaaS — survives the runtime sandbox.
|
||||
*
|
||||
* - **Runtime sandbox** (extracted from `conversation.conversation_url`
|
||||
* and passed as `hostOverride`): live runtime endpoints like
|
||||
* `/api/conversations/{id}/events/count` and
|
||||
* `/api/conversations/{id}/events/respond_to_confirmation`. Auth on
|
||||
* these endpoints is `X-Session-API-Key`, not `Authorization: Bearer`.
|
||||
*
|
||||
* Both go through the bundled local agent-server's `/api/cloud-proxy`,
|
||||
* which sidesteps the cross-origin restrictions that block the GUI at
|
||||
* `localhost` from talking directly to either the SaaS or the runtime.
|
||||
*
|
||||
* Local mode keeps the existing typescript-client path: it targets the
|
||||
* conversation's host directly via `createRemoteEventsList`/`createHttpClient`.
|
||||
*/
|
||||
class EventService {
|
||||
static async respondToConfirmation(
|
||||
conversationId: string,
|
||||
_conversationUrl: string,
|
||||
conversationUrl: string,
|
||||
request: ConfirmationResponseRequest,
|
||||
sessionApiKey?: string | null,
|
||||
): Promise<ConfirmationResponseResponse> {
|
||||
const active = getActiveBackend().backend;
|
||||
|
||||
if (active.kind === "cloud") {
|
||||
return callCloudProxy<ConfirmationResponseResponse>({
|
||||
backend: active,
|
||||
method: "POST",
|
||||
hostOverride: buildHttpBaseUrl(conversationUrl),
|
||||
path: `/api/conversations/${conversationId}/events/respond_to_confirmation`,
|
||||
body: request,
|
||||
authMode: "session-api-key",
|
||||
sessionApiKey,
|
||||
});
|
||||
}
|
||||
|
||||
const response = await createHttpClient({
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
}).post<ConfirmationResponseResponse>(
|
||||
`/api/conversations/${conversationId}/events/respond_to_confirmation`,
|
||||
@@ -24,18 +63,51 @@ class EventService {
|
||||
|
||||
static async getEventCount(
|
||||
conversationId: string,
|
||||
_conversationUrl: string,
|
||||
conversationUrl: string,
|
||||
sessionApiKey?: string | null,
|
||||
): Promise<number> {
|
||||
return createRemoteEventsList(conversationId, { sessionApiKey }).count();
|
||||
const active = getActiveBackend().backend;
|
||||
|
||||
if (active.kind === "cloud") {
|
||||
return callCloudProxy<number>({
|
||||
backend: active,
|
||||
method: "GET",
|
||||
hostOverride: buildHttpBaseUrl(conversationUrl),
|
||||
path: `/api/conversations/${conversationId}/events/count`,
|
||||
authMode: "session-api-key",
|
||||
sessionApiKey,
|
||||
});
|
||||
}
|
||||
|
||||
return createRemoteEventsList(conversationId, {
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
}).count();
|
||||
}
|
||||
|
||||
static async searchEventsV1(
|
||||
conversationId: string,
|
||||
limit = 100,
|
||||
conversationUrl?: string | null,
|
||||
sessionApiKey?: string | null,
|
||||
) {
|
||||
const active = getActiveBackend().backend;
|
||||
|
||||
if (active.kind === "cloud") {
|
||||
// Event *history* lives on the SaaS App API, not the runtime
|
||||
// sandbox. Path is singular `conversation` and v1-prefixed.
|
||||
const params = new URLSearchParams();
|
||||
params.set("limit", String(limit));
|
||||
const data = await callCloudProxy<{ items?: OpenHandsEvent[] }>({
|
||||
backend: active,
|
||||
method: "GET",
|
||||
path: `/api/v1/conversation/${conversationId}/events/search?${params.toString()}`,
|
||||
});
|
||||
return data?.items ?? [];
|
||||
}
|
||||
|
||||
const page = await createRemoteEventsList(conversationId, {
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
}).search({
|
||||
limit,
|
||||
|
||||
@@ -83,22 +83,32 @@ const requireService = (provider: Provider): GitProviderService => {
|
||||
return service;
|
||||
};
|
||||
|
||||
const firstConfiguredService = (): GitProviderService => {
|
||||
const firstConfiguredService = (): GitProviderService | null => {
|
||||
const all = collectAllServices();
|
||||
if (all.length === 0) {
|
||||
throw new GitProviderAuthError("No git provider configured");
|
||||
}
|
||||
return all[0];
|
||||
return all[0] ?? null;
|
||||
};
|
||||
|
||||
export const ProviderHandler = {
|
||||
getServiceForProvider: getServiceFor,
|
||||
|
||||
async getUserGitInfo(provider?: Provider): Promise<GitUser> {
|
||||
const service = provider
|
||||
? requireService(provider)
|
||||
: firstConfiguredService();
|
||||
return service.getUser();
|
||||
/**
|
||||
* Resolve the calling user's git profile from any locally-configured
|
||||
* provider. Returns `null` when no provider is configured — the
|
||||
* caller (e.g. `useGitUser`) treats that as "no info to show", which
|
||||
* is the right outcome for a clean local install or for the brief
|
||||
* window after a Cloud → Local switch where settings haven't refetched
|
||||
* yet. Throwing here would surface as a global error toast.
|
||||
*
|
||||
* When a `provider` is passed explicitly, missing local credentials
|
||||
* are still an error — the caller asked for that specific provider,
|
||||
* so we propagate `requireService`'s `GitProviderAuthError`.
|
||||
*/
|
||||
async getUserGitInfo(provider?: Provider): Promise<GitUser | null> {
|
||||
if (provider) {
|
||||
return requireService(provider).getUser();
|
||||
}
|
||||
const service = firstConfiguredService();
|
||||
return service ? service.getUser() : null;
|
||||
},
|
||||
|
||||
async getSuggestedTasks(
|
||||
|
||||
@@ -5,9 +5,17 @@ import V1ConversationService from "../conversation-service/v1-conversation-servi
|
||||
import { createRemoteWorkspace } from "../typescript-client";
|
||||
import { mapAnyGitStatusToV0Status } from "#/utils/git-status-mapper";
|
||||
import { ProviderHandler } from "../git-providers/provider-handler";
|
||||
import { getActiveBackend } from "../backend-registry/active-store";
|
||||
import {
|
||||
getCloudInstallations,
|
||||
getCloudRepositoryBranches,
|
||||
searchCloudRepositories,
|
||||
} from "../cloud/git-service.api";
|
||||
|
||||
const safeProvider = (value: string): Provider => value as Provider;
|
||||
|
||||
const isCloudActive = () => getActiveBackend().backend.kind === "cloud";
|
||||
|
||||
class GitService {
|
||||
static async searchGitRepositories(
|
||||
query: string,
|
||||
@@ -16,6 +24,15 @@ class GitService {
|
||||
pageId?: string,
|
||||
installationId?: string,
|
||||
): Promise<RepositoryPage> {
|
||||
if (isCloudActive()) {
|
||||
return searchCloudRepositories({
|
||||
provider: safeProvider(provider),
|
||||
query: query || undefined,
|
||||
limit,
|
||||
pageId,
|
||||
installationId,
|
||||
});
|
||||
}
|
||||
return ProviderHandler.searchRepositories(safeProvider(provider), {
|
||||
query: query || undefined,
|
||||
installationId,
|
||||
@@ -30,6 +47,14 @@ class GitService {
|
||||
limit = 30,
|
||||
installationId?: string,
|
||||
): Promise<RepositoryPage> {
|
||||
if (isCloudActive()) {
|
||||
return searchCloudRepositories({
|
||||
provider: safeProvider(provider),
|
||||
limit,
|
||||
pageId,
|
||||
installationId,
|
||||
});
|
||||
}
|
||||
return ProviderHandler.searchRepositories(safeProvider(provider), {
|
||||
installationId,
|
||||
pageId,
|
||||
@@ -46,6 +71,14 @@ class GitService {
|
||||
): Promise<RepositoryPage> {
|
||||
const installationId = installations[installationIndex];
|
||||
if (!installationId) return { items: [], next_page_id: null };
|
||||
if (isCloudActive()) {
|
||||
return searchCloudRepositories({
|
||||
provider: safeProvider(provider),
|
||||
installationId,
|
||||
limit,
|
||||
pageId,
|
||||
});
|
||||
}
|
||||
return ProviderHandler.searchRepositories(safeProvider(provider), {
|
||||
installationId,
|
||||
pageId,
|
||||
@@ -60,6 +93,15 @@ class GitService {
|
||||
pageId?: string,
|
||||
limit = 30,
|
||||
): Promise<BranchPage> {
|
||||
if (isCloudActive()) {
|
||||
return getCloudRepositoryBranches({
|
||||
provider: safeProvider(provider),
|
||||
repository,
|
||||
query: query || undefined,
|
||||
pageId,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
return ProviderHandler.getBranches(safeProvider(provider), {
|
||||
repository,
|
||||
query: query || undefined,
|
||||
@@ -75,6 +117,15 @@ class GitService {
|
||||
pageId?: string,
|
||||
limit = 30,
|
||||
): Promise<BranchPage> {
|
||||
if (isCloudActive()) {
|
||||
return getCloudRepositoryBranches({
|
||||
provider: safeProvider(provider),
|
||||
repository,
|
||||
query,
|
||||
pageId,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
return ProviderHandler.getBranches(safeProvider(provider), {
|
||||
repository,
|
||||
query,
|
||||
@@ -88,6 +139,13 @@ class GitService {
|
||||
pageId?: string,
|
||||
limit = 100,
|
||||
): Promise<InstallationPage> {
|
||||
if (isCloudActive()) {
|
||||
return getCloudInstallations({
|
||||
provider: safeProvider(provider),
|
||||
pageId,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
return ProviderHandler.getInstallations(safeProvider(provider), {
|
||||
pageId,
|
||||
limit,
|
||||
|
||||
@@ -1,13 +1,73 @@
|
||||
import { mapAnyGitStatusToV0Status } from "#/utils/git-status-mapper";
|
||||
import { buildHttpBaseUrl } from "#/utils/websocket-url";
|
||||
import type { GitChange, GitChangeDiff } from "../open-hands.types";
|
||||
import { getActiveBackend } from "../backend-registry/active-store";
|
||||
import { callCloudProxy } from "../cloud/proxy";
|
||||
import { createRemoteWorkspace } from "../typescript-client";
|
||||
|
||||
interface V1GitChange {
|
||||
status: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Git operations for V1 conversations.
|
||||
*
|
||||
* In **local** mode the runtime is reachable directly from the browser
|
||||
* (it's `127.0.0.1:18000`); the SDK's `RemoteWorkspace` calls land
|
||||
* fine. In **cloud** mode the runtime is at
|
||||
* `*.prod-runtime.all-hands.dev`, which doesn't allow CORS from
|
||||
* `localhost`. So cloud-mode calls go through `callCloudProxy` with the
|
||||
* runtime URL as `hostOverride` and the conversation's
|
||||
* `session_api_key` as the auth header — server-side hop, no CORS.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The cloud runtime's `/api/git/{changes,diff}` endpoints prepend
|
||||
* `/workspace/` to relative paths (so a relative arg like
|
||||
* `workspace/project` becomes `/workspace/workspace/project` and 404s).
|
||||
* `getGitPath` returns the local agent-server's relative convention by
|
||||
* default; normalize to an absolute path before sending to the cloud
|
||||
* runtime.
|
||||
*/
|
||||
function toAbsoluteRuntimePath(path: string): string {
|
||||
return path.startsWith("/") ? path : `/${path}`;
|
||||
}
|
||||
|
||||
class V1GitService {
|
||||
static async getGitChanges(
|
||||
conversationUrl: string | null | undefined,
|
||||
sessionApiKey: string | null | undefined,
|
||||
path: string,
|
||||
): Promise<GitChange[]> {
|
||||
const active = getActiveBackend().backend;
|
||||
|
||||
if (active.kind === "cloud" && conversationUrl) {
|
||||
const params = new URLSearchParams();
|
||||
params.set("path", toAbsoluteRuntimePath(path));
|
||||
const data = await callCloudProxy<V1GitChange[]>({
|
||||
backend: active,
|
||||
method: "GET",
|
||||
hostOverride: buildHttpBaseUrl(conversationUrl),
|
||||
path: `/api/git/changes?${params.toString()}`,
|
||||
authMode: "session-api-key",
|
||||
sessionApiKey,
|
||||
});
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error(
|
||||
"Invalid response from runtime - runtime may be unavailable",
|
||||
);
|
||||
}
|
||||
return data.map((change) => ({
|
||||
status: mapAnyGitStatusToV0Status(
|
||||
String(change.status) as Parameters<
|
||||
typeof mapAnyGitStatusToV0Status
|
||||
>[0],
|
||||
),
|
||||
path: change.path,
|
||||
}));
|
||||
}
|
||||
|
||||
const changes = await createRemoteWorkspace({
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
@@ -34,16 +94,36 @@ class V1GitService {
|
||||
sessionApiKey: string | null | undefined,
|
||||
path: string,
|
||||
): Promise<GitChangeDiff> {
|
||||
const diff = await createRemoteWorkspace({
|
||||
const active = getActiveBackend().backend;
|
||||
|
||||
if (active.kind === "cloud" && conversationUrl) {
|
||||
const params = new URLSearchParams();
|
||||
params.set("path", toAbsoluteRuntimePath(path));
|
||||
const diff = await callCloudProxy<GitChangeDiff & { diff?: string }>({
|
||||
backend: active,
|
||||
method: "GET",
|
||||
hostOverride: buildHttpBaseUrl(conversationUrl),
|
||||
path: `/api/git/diff?${params.toString()}`,
|
||||
authMode: "session-api-key",
|
||||
sessionApiKey,
|
||||
});
|
||||
return {
|
||||
modified: diff?.modified ?? "",
|
||||
original: diff?.original ?? "",
|
||||
...(diff?.diff ? { diff: diff.diff } : {}),
|
||||
} as GitChangeDiff;
|
||||
}
|
||||
|
||||
const diff = (await createRemoteWorkspace({
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
}).gitDiff(path);
|
||||
}).gitDiff(path)) as GitChangeDiff & { diff?: string };
|
||||
|
||||
return {
|
||||
modified: diff.modified ?? "",
|
||||
original: diff.original ?? "",
|
||||
...(diff.diff ? { diff: diff.diff } : {}),
|
||||
};
|
||||
} as GitChangeDiff;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,9 @@ import axios, {
|
||||
AxiosResponse,
|
||||
InternalAxiosRequestConfig,
|
||||
} from "axios";
|
||||
import {
|
||||
getAgentServerBaseUrl,
|
||||
getAgentServerHeaders,
|
||||
} from "./agent-server-config";
|
||||
import { getActiveBackend } from "./backend-registry/active-store";
|
||||
import { buildAuthHeaders } from "./backend-registry/auth";
|
||||
import { getBundledBackend } from "./backend-registry/bundled";
|
||||
|
||||
function serializeParams(
|
||||
params: Record<string, unknown> | URLSearchParams,
|
||||
@@ -38,12 +37,23 @@ function serializeParams(
|
||||
}
|
||||
|
||||
export const openHands = axios.create({
|
||||
baseURL: getAgentServerBaseUrl(),
|
||||
paramsSerializer: { serialize: serializeParams },
|
||||
});
|
||||
|
||||
openHands.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const headers = getAgentServerHeaders();
|
||||
// The default openHands axios speaks the *local agent-server's* protocol
|
||||
// (X-Session-API-Key auth, /api/* paths). When the active backend is
|
||||
// cloud, fall back to the bundled local agent-server — cloud-specific
|
||||
// calls go through `callCloudProxy` (which uses axios directly) and
|
||||
// never hit this interceptor.
|
||||
const active = getActiveBackend().backend;
|
||||
const backend = active.kind === "cloud" ? getBundledBackend() : active;
|
||||
|
||||
// Mutating the per-request config is the canonical axios interceptor pattern.
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
if (!config.baseURL) config.baseURL = backend.host;
|
||||
|
||||
const headers = buildAuthHeaders(backend);
|
||||
Object.entries(headers).forEach(([key, value]) => {
|
||||
config.headers.set(key, value);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import { getActiveBackend } from "./backend-registry/active-store";
|
||||
import {
|
||||
createCloudSecret,
|
||||
deleteCloudSecret,
|
||||
fetchCloudSecrets,
|
||||
updateCloudSecret,
|
||||
} from "./cloud/secrets-service.api";
|
||||
import { createHttpClient } from "./typescript-client";
|
||||
import { CustomSecretWithoutValue } from "./secrets-service.types";
|
||||
import { Provider, ProviderOptions, ProviderToken } from "#/types/settings";
|
||||
@@ -170,6 +177,9 @@ export class SecretsService {
|
||||
*/
|
||||
static async getSecrets(): Promise<CustomSecretWithoutValue[]> {
|
||||
try {
|
||||
if (getActiveBackend().backend.kind === "cloud") {
|
||||
return await withRetry(() => fetchCloudSecrets());
|
||||
}
|
||||
const response = await withRetry(() =>
|
||||
createHttpClient().get<SecretsListResponse>("/api/settings/secrets"),
|
||||
);
|
||||
@@ -197,6 +207,10 @@ export class SecretsService {
|
||||
value: string,
|
||||
description?: string,
|
||||
): Promise<void> {
|
||||
if (getActiveBackend().backend.kind === "cloud") {
|
||||
await withRetry(() => createCloudSecret(name, value, description));
|
||||
return;
|
||||
}
|
||||
await withRetry(() =>
|
||||
createHttpClient().put<CreateSecretResponse>("/api/settings/secrets", {
|
||||
name,
|
||||
@@ -221,6 +235,13 @@ export class SecretsService {
|
||||
value: string,
|
||||
description?: string,
|
||||
): Promise<void> {
|
||||
if (getActiveBackend().backend.kind === "cloud") {
|
||||
// The cloud PUT endpoint renames + redescribes only (no value field),
|
||||
// matching what `useUpdateSecret` actually sends:
|
||||
// (secretToEdit=name, newName=value, description).
|
||||
await withRetry(() => updateCloudSecret(name, value, description));
|
||||
return;
|
||||
}
|
||||
// Agent-server uses upsert, so update is the same as create
|
||||
await this.createSecret(name, value, description);
|
||||
}
|
||||
@@ -234,6 +255,10 @@ export class SecretsService {
|
||||
*/
|
||||
static async deleteSecret(name: string): Promise<void> {
|
||||
try {
|
||||
if (getActiveBackend().backend.kind === "cloud") {
|
||||
await withRetry(() => deleteCloudSecret(name));
|
||||
return;
|
||||
}
|
||||
await withRetry(() =>
|
||||
createHttpClient().delete<{ deleted: boolean }>(
|
||||
`/api/settings/secrets/${encodeURIComponent(name)}`,
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { Provider, Settings, SettingsSchema, SettingsValue } from "#/types/settings";
|
||||
import {
|
||||
Provider,
|
||||
Settings,
|
||||
SettingsSchema,
|
||||
SettingsValue,
|
||||
} from "#/types/settings";
|
||||
import { getStoredGitProviders } from "../secrets-service";
|
||||
import { getActiveBackend } from "../backend-registry/active-store";
|
||||
import {
|
||||
fetchCloudConversationSettingsSchema,
|
||||
fetchCloudSettings,
|
||||
fetchCloudSettingsSchema,
|
||||
saveCloudSettings,
|
||||
} from "../cloud/settings-service.api";
|
||||
import { createHttpClient, createSettingsClient } from "../typescript-client";
|
||||
|
||||
/**
|
||||
@@ -53,7 +65,7 @@ async function withRetry<T>(
|
||||
lastError = error;
|
||||
if (attempt < maxRetries - 1) {
|
||||
// Exponential backoff: 500ms, 1000ms, 2000ms
|
||||
const delay = baseDelayMs * Math.pow(2, attempt);
|
||||
const delay = baseDelayMs * 2 ** attempt;
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
@@ -214,6 +226,22 @@ class SettingsService {
|
||||
* Uses in-memory cache for performance.
|
||||
*/
|
||||
static async getSettings(): Promise<Settings> {
|
||||
// Cloud SaaS uses a different settings shape (flat top-level fields
|
||||
// including provider_tokens_set, llm_model, etc.). Branch out before
|
||||
// touching the local-only cache: cloud responses bypass the local
|
||||
// SettingsApiResponse shape and feed straight into syncDerivedSettings
|
||||
// so cloud-native fields like provider_tokens_set reach the GUI's
|
||||
// useUserProviders → useAppInstallations → useGitRepositories chain.
|
||||
if (getActiveBackend().backend.kind === "cloud") {
|
||||
try {
|
||||
const cloud = await withRetry(() => fetchCloudSettings());
|
||||
return syncDerivedSettings(cloud);
|
||||
} catch (error) {
|
||||
console.warn("Failed to fetch cloud settings, using defaults:", error);
|
||||
return syncDerivedSettings({});
|
||||
}
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
if (isCacheValid() && settingsCache.redacted) {
|
||||
return syncDerivedSettings(transformApiResponse(settingsCache.redacted));
|
||||
@@ -268,10 +296,16 @@ class SettingsService {
|
||||
}
|
||||
|
||||
static async getSettingsSchema(): Promise<SettingsSchema> {
|
||||
if (getActiveBackend().backend.kind === "cloud") {
|
||||
return (await fetchCloudSettingsSchema()) as SettingsSchema;
|
||||
}
|
||||
return (await createSettingsClient().getAgentSchema()) as SettingsSchema;
|
||||
}
|
||||
|
||||
static async getConversationSettingsSchema(): Promise<SettingsSchema> {
|
||||
if (getActiveBackend().backend.kind === "cloud") {
|
||||
return (await fetchCloudConversationSettingsSchema()) as SettingsSchema;
|
||||
}
|
||||
return (await createSettingsClient().getConversationSchema()) as SettingsSchema;
|
||||
}
|
||||
|
||||
@@ -304,16 +338,17 @@ class SettingsService {
|
||||
}
|
||||
|
||||
// Only call API if we have something to update
|
||||
if (
|
||||
!payload.agent_settings_diff &&
|
||||
!payload.conversation_settings_diff
|
||||
) {
|
||||
if (!payload.agent_settings_diff && !payload.conversation_settings_diff) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await withRetry(() =>
|
||||
createHttpClient().patch<SettingsApiResponse>("/api/settings", payload),
|
||||
);
|
||||
if (getActiveBackend().backend.kind === "cloud") {
|
||||
await withRetry(() => saveCloudSettings(payload));
|
||||
} else {
|
||||
await withRetry(() =>
|
||||
createHttpClient().patch<SettingsApiResponse>("/api/settings", payload),
|
||||
);
|
||||
}
|
||||
|
||||
// Invalidate cache after successful save
|
||||
clearCache();
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { SkillInfo } from "#/types/settings";
|
||||
import { getAgentServerWorkingDir } from "./agent-server-config";
|
||||
import { getActiveBackend } from "./backend-registry/active-store";
|
||||
import { fetchCloudSkills } from "./cloud/skills-service.api";
|
||||
import { createSkillsClient } from "./typescript-client";
|
||||
|
||||
class SkillsService {
|
||||
static async getSkills(): Promise<SkillInfo[]> {
|
||||
if (getActiveBackend().backend.kind === "cloud") {
|
||||
return fetchCloudSkills();
|
||||
}
|
||||
|
||||
const response = await createSkillsClient().getSkills({
|
||||
load_public: true,
|
||||
load_user: true,
|
||||
|
||||
@@ -9,11 +9,9 @@ import { HttpClient } from "@openhands/typescript-client/client/http-client";
|
||||
import { RemoteEventsList } from "@openhands/typescript-client/events/remote-events-list";
|
||||
import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace";
|
||||
import { buildHttpBaseUrl } from "#/utils/websocket-url";
|
||||
import {
|
||||
getAgentServerBaseUrl,
|
||||
getAgentServerSessionApiKey,
|
||||
getAgentServerWorkingDir,
|
||||
} from "./agent-server-config";
|
||||
import { getActiveBackend } from "./backend-registry/active-store";
|
||||
import { getBundledBackend } from "./backend-registry/bundled";
|
||||
import { getAgentServerWorkingDir } from "./agent-server-config";
|
||||
|
||||
export type { ServerInfo } from "@openhands/typescript-client";
|
||||
|
||||
@@ -32,20 +30,36 @@ interface ResolvedClientOptions {
|
||||
workingDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the backend whose host + API key the typescript-client should use
|
||||
* by default. The typescript-client clients (createHttpClient and friends)
|
||||
* speak the *local agent-server's* protocol — `X-Session-API-Key` auth and
|
||||
* paths like `/api/conversations`, `/api/skills`, etc. The cloud SaaS
|
||||
* exposes neither, so when the active backend is cloud, fall back to the
|
||||
* bundled local agent-server for these calls. Cloud-specific calls go
|
||||
* through `callCloudProxy` separately and never touch this resolver.
|
||||
*/
|
||||
function resolveDefaultBackend() {
|
||||
const active = getActiveBackend().backend;
|
||||
if (active.kind === "cloud") return getBundledBackend();
|
||||
return active;
|
||||
}
|
||||
|
||||
function resolveHost(overrides: TypeScriptClientOverrides): string {
|
||||
if (overrides.host) return overrides.host.replace(/\/$/, "");
|
||||
if (overrides.conversationUrl)
|
||||
return buildHttpBaseUrl(overrides.conversationUrl);
|
||||
return resolveDefaultBackend().host;
|
||||
}
|
||||
|
||||
function resolveClientOptions(
|
||||
overrides: TypeScriptClientOverrides = {},
|
||||
): ResolvedClientOptions {
|
||||
const host = overrides.host
|
||||
? overrides.host.replace(/\/$/, "")
|
||||
: overrides.conversationUrl
|
||||
? buildHttpBaseUrl(overrides.conversationUrl)
|
||||
: getAgentServerBaseUrl();
|
||||
const backend = resolveDefaultBackend();
|
||||
|
||||
const host = resolveHost(overrides);
|
||||
const apiKey =
|
||||
overrides.sessionApiKey ??
|
||||
overrides.apiKey ??
|
||||
getAgentServerSessionApiKey() ??
|
||||
undefined;
|
||||
overrides.sessionApiKey ?? overrides.apiKey ?? backend.apiKey ?? undefined;
|
||||
|
||||
return {
|
||||
host,
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
import { GitUser } from "#/types/git";
|
||||
import { getActiveBackend } from "../backend-registry/active-store";
|
||||
import { getCloudGitUser } from "../cloud/user-service.api";
|
||||
import { ProviderHandler } from "../git-providers/provider-handler";
|
||||
|
||||
/**
|
||||
* User Service API - Handles all user-related API endpoints.
|
||||
*
|
||||
* The OSS agent-server runtime does not expose /api/v1/users/git-info, so we
|
||||
* resolve the user directly from the configured git provider in the browser.
|
||||
* Local mode: the OSS agent-server runtime does not expose
|
||||
* `/api/v1/users/git-info`, so we resolve the user directly from the
|
||||
* configured git provider in the browser via `ProviderHandler`. If no
|
||||
* provider tokens are stored locally, this throws
|
||||
* "No git provider configured" — the right signal for local operation.
|
||||
*
|
||||
* Cloud mode: the SaaS exposes `/api/v1/users/git-info` and holds
|
||||
* provider tokens server-side. There is nothing local to check, so we
|
||||
* route through the cloud proxy. The local "no provider configured"
|
||||
* check is meaningless for cloud and would otherwise surface as a false
|
||||
* toast — branching here keeps it scoped to local.
|
||||
*/
|
||||
class UserService {
|
||||
static async getUser(): Promise<GitUser> {
|
||||
static async getUser(): Promise<GitUser | null> {
|
||||
if (getActiveBackend().backend.kind === "cloud") {
|
||||
return getCloudGitUser();
|
||||
}
|
||||
return ProviderHandler.getUserGitInfo();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus } from "lucide-react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface AddBackendMenuItemProps {
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
export function AddBackendMenuItem({ onOpen }: AddBackendMenuItemProps) {
|
||||
const { t } = useTranslation("openhands");
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="add-backend-menu-item"
|
||||
onClick={onOpen}
|
||||
className="flex w-full items-center gap-2 p-2 rounded text-xs cursor-pointer hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<Plus width={16} height={16} className="text-white shrink-0" />
|
||||
{t(I18nKey.BACKEND$ADD)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { SettingsInput } from "#/components/features/settings/settings-input";
|
||||
import { useActiveBackendContext } from "#/contexts/active-backend-context";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import type { BackendKind } from "#/api/backend-registry/types";
|
||||
|
||||
interface AddBackendModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function inferKindFromHost(host: string): BackendKind {
|
||||
const trimmed = host.trim().toLowerCase();
|
||||
if (trimmed.includes("all-hands.dev") || trimmed.includes("openhands.dev")) {
|
||||
return "cloud";
|
||||
}
|
||||
return "local";
|
||||
}
|
||||
|
||||
function normalizeHost(host: string): string {
|
||||
const trimmed = host.trim().replace(/\/+$/, "");
|
||||
if (!trimmed) return "";
|
||||
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||
return `https://${trimmed}`;
|
||||
}
|
||||
|
||||
export function AddBackendModal({ onClose }: AddBackendModalProps) {
|
||||
const { t } = useTranslation("openhands");
|
||||
const { addBackend } = useActiveBackendContext();
|
||||
|
||||
const [name, setName] = React.useState("");
|
||||
const [host, setHost] = React.useState("");
|
||||
const [apiKey, setApiKey] = React.useState("");
|
||||
const [kind, setKind] = React.useState<BackendKind>("cloud");
|
||||
const [touchedKind, setTouchedKind] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!touchedKind && host) {
|
||||
setKind(inferKindFromHost(host));
|
||||
}
|
||||
}, [host, touchedKind]);
|
||||
|
||||
const canSubmit =
|
||||
name.trim().length > 0 &&
|
||||
host.trim().length > 0 &&
|
||||
(kind === "local" || apiKey.trim().length > 0);
|
||||
|
||||
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
|
||||
addBackend({
|
||||
name: name.trim(),
|
||||
host: normalizeHost(host),
|
||||
apiKey: apiKey.trim(),
|
||||
kind,
|
||||
});
|
||||
|
||||
// Adding a backend is a pure save — we do NOT auto-switch the active
|
||||
// selection. The user picks the new backend from the dropdown when
|
||||
// they're ready. Auto-switching would write `(backendId, null)` for a
|
||||
// cloud backend, which the dropdown can't render once orgs load and
|
||||
// therefore drifts from the API layer.
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalBackdrop
|
||||
onClose={onClose}
|
||||
closeOnEscape={false}
|
||||
aria-label={t(I18nKey.BACKEND$ADD_TITLE)}
|
||||
>
|
||||
<form
|
||||
data-testid="add-backend-modal"
|
||||
onSubmit={onSubmit}
|
||||
className="bg-base-secondary p-6 rounded-xl flex flex-col gap-4 border border-tertiary"
|
||||
style={{ width: "480px" }}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-xl font-bold">{t(I18nKey.BACKEND$ADD_TITLE)}</h3>
|
||||
<p className="text-xs text-gray-400">
|
||||
{t(I18nKey.BACKEND$ADD_SUBTITLE)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsInput
|
||||
testId="add-backend-name"
|
||||
name="add-backend-name"
|
||||
type="text"
|
||||
label={t(I18nKey.BACKEND$NAME_LABEL)}
|
||||
value={name}
|
||||
onChange={setName}
|
||||
placeholder="Production"
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
<SettingsInput
|
||||
testId="add-backend-host"
|
||||
name="add-backend-host"
|
||||
type="text"
|
||||
label={t(I18nKey.BACKEND$HOST_LABEL)}
|
||||
value={host}
|
||||
onChange={setHost}
|
||||
placeholder="https://app.all-hands.dev"
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
<SettingsInput
|
||||
testId="add-backend-api-key"
|
||||
name="add-backend-api-key"
|
||||
type="password"
|
||||
label={t(I18nKey.BACKEND$KEY_LABEL)}
|
||||
value={apiKey}
|
||||
onChange={setApiKey}
|
||||
placeholder=""
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
<fieldset className="flex flex-col">
|
||||
<legend className="text-sm mb-3">
|
||||
{t(I18nKey.BACKEND$KIND_LABEL)}
|
||||
</legend>
|
||||
<div className="flex gap-6">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="add-backend-kind"
|
||||
checked={kind === "local"}
|
||||
onChange={() => {
|
||||
setKind("local");
|
||||
setTouchedKind(true);
|
||||
}}
|
||||
data-testid="add-backend-kind-local"
|
||||
/>
|
||||
{t(I18nKey.BACKEND$KIND_LOCAL)}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="add-backend-kind"
|
||||
checked={kind === "cloud"}
|
||||
onChange={() => {
|
||||
setKind("cloud");
|
||||
setTouchedKind(true);
|
||||
}}
|
||||
data-testid="add-backend-kind-cloud"
|
||||
/>
|
||||
{t(I18nKey.BACKEND$KIND_CLOUD)}
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-3">
|
||||
{kind === "cloud"
|
||||
? t(I18nKey.BACKEND$KEY_HELPER_CLOUD)
|
||||
: t(I18nKey.BACKEND$KEY_HELPER_LOCAL)}
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 mt-2 w-full">
|
||||
<BrandButton
|
||||
type="submit"
|
||||
variant="primary"
|
||||
isDisabled={!canSubmit}
|
||||
testId="add-backend-submit"
|
||||
className="w-full text-center"
|
||||
>
|
||||
{t(I18nKey.BACKEND$SAVE)}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
testId="add-backend-cancel"
|
||||
className="w-full text-center"
|
||||
>
|
||||
{t(I18nKey.BUTTON$CANCEL)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</form>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMatch, useNavigate } from "react-router";
|
||||
import { Dropdown } from "#/ui/dropdown/dropdown";
|
||||
import { DropdownOption } from "#/ui/dropdown/types";
|
||||
import { useActiveBackendContext } from "#/contexts/active-backend-context";
|
||||
import { useAllCloudOrganizations } from "#/hooks/query/use-cloud-organizations";
|
||||
import { useCloudCurrentUserId } from "#/hooks/query/use-cloud-current-user-id";
|
||||
import { useSwitchCloudOrganization } from "#/hooks/mutation/use-switch-cloud-organization";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
|
||||
const VALUE_SEPARATOR = "::";
|
||||
|
||||
function makeOptionValue(backendId: string, orgId: string | null): string {
|
||||
return orgId ? `${backendId}${VALUE_SEPARATOR}${orgId}` : backendId;
|
||||
}
|
||||
|
||||
function parseOptionValue(value: string): {
|
||||
backendId: string;
|
||||
orgId: string | null;
|
||||
} {
|
||||
const [backendId, orgId] = value.split(VALUE_SEPARATOR);
|
||||
return { backendId, orgId: orgId ?? null };
|
||||
}
|
||||
|
||||
function buildOptions(
|
||||
bundled: Backend,
|
||||
registered: Backend[],
|
||||
bundledLabel: string,
|
||||
personalWorkspaceLabel: string,
|
||||
cloudOrgs: ReturnType<typeof useAllCloudOrganizations>,
|
||||
currentUserIds: ReturnType<typeof useCloudCurrentUserId>,
|
||||
): DropdownOption[] {
|
||||
const options: DropdownOption[] = [
|
||||
{ value: makeOptionValue(bundled.id, null), label: bundledLabel },
|
||||
];
|
||||
|
||||
const locals = registered.filter((b) => b.kind === "local");
|
||||
const clouds = registered.filter((b) => b.kind === "cloud");
|
||||
|
||||
for (const b of locals) {
|
||||
options.push({ value: makeOptionValue(b.id, null), label: b.name });
|
||||
}
|
||||
|
||||
for (const b of clouds) {
|
||||
const entry = cloudOrgs[b.id];
|
||||
if (!entry || entry.orgs.length === 0) {
|
||||
options.push({ value: makeOptionValue(b.id, null), label: b.name });
|
||||
} else {
|
||||
// Personal-workspace rule (per the SaaS contract): the org whose
|
||||
// id matches the calling user's id is the user's personal
|
||||
// workspace. We resolve `user_id` once per backend (via /me on any
|
||||
// one org) and apply it across all orgs of that backend.
|
||||
const userIdForBackend = currentUserIds[b.id]?.userId ?? null;
|
||||
|
||||
for (const org of entry.orgs) {
|
||||
const isPersonal = !!userIdForBackend && userIdForBackend === org.id;
|
||||
const orgLabel = isPersonal ? personalWorkspaceLabel : org.name;
|
||||
options.push({
|
||||
value: makeOptionValue(b.id, org.id),
|
||||
label: `${b.name} – ${orgLabel}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export function BackendSelector() {
|
||||
const { t } = useTranslation("openhands");
|
||||
const { backends, bundledBackend, active, setActive } =
|
||||
useActiveBackendContext();
|
||||
const cloudOrgs = useAllCloudOrganizations();
|
||||
const currentUserIds = useCloudCurrentUserId();
|
||||
const { mutateAsync: switchOrg, isPending: isSwitching } =
|
||||
useSwitchCloudOrganization();
|
||||
const navigate = useNavigate();
|
||||
const conversationMatch = useMatch("/conversations/:conversationId");
|
||||
|
||||
const bundledLabel = t(I18nKey.BACKEND$LOCAL_ROW);
|
||||
const personalWorkspaceLabel = t(I18nKey.BACKEND$PERSONAL_WORKSPACE);
|
||||
|
||||
const options = React.useMemo(
|
||||
() =>
|
||||
buildOptions(
|
||||
bundledBackend,
|
||||
backends,
|
||||
bundledLabel,
|
||||
personalWorkspaceLabel,
|
||||
cloudOrgs,
|
||||
currentUserIds,
|
||||
),
|
||||
[
|
||||
bundledBackend,
|
||||
backends,
|
||||
bundledLabel,
|
||||
personalWorkspaceLabel,
|
||||
cloudOrgs,
|
||||
currentUserIds,
|
||||
],
|
||||
);
|
||||
|
||||
const activeValue = makeOptionValue(active.backend.id, active.orgId);
|
||||
const activeOption = options.find((o) => o.value === activeValue);
|
||||
|
||||
const someCloudLoading = Object.values(cloudOrgs).some((c) => c.isLoading);
|
||||
|
||||
// Self-heal a malformed `(cloudBackendId, null)` selection.
|
||||
//
|
||||
// Once a cloud backend's orgs resolve, the dropdown only renders
|
||||
// per-org rows for it — the `(backendId, null)` row disappears, so
|
||||
// selecting that shape would drift from what the dropdown can render
|
||||
// (UI says "Local", APIs hit cloud). When we detect the drift, snap
|
||||
// the selection onto the personal-workspace org (or, lacking a /me
|
||||
// result, the first org). Pre-switch the SaaS-side current_org BEFORE
|
||||
// touching active state so queries refetch (via key change) only
|
||||
// once and against the correct org context.
|
||||
React.useEffect(() => {
|
||||
if (active.backend.kind !== "cloud" || active.orgId) return;
|
||||
const { backend } = active;
|
||||
const entry = cloudOrgs[backend.id];
|
||||
if (!entry || entry.orgs.length === 0) return;
|
||||
const userId = currentUserIds[backend.id]?.userId ?? null;
|
||||
const personal = userId
|
||||
? entry.orgs.find((o) => o.id === userId)
|
||||
: undefined;
|
||||
const target = personal ?? entry.orgs[0];
|
||||
if (!target) return;
|
||||
switchOrg({ orgId: target.id, backend })
|
||||
.then(() => setActive(backend.id, target.id))
|
||||
.catch(() => {
|
||||
// Error is surfaced by the mutation cache's global handler.
|
||||
});
|
||||
}, [active, cloudOrgs, currentUserIds, setActive, switchOrg]);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
testId="backend-selector"
|
||||
key={`${activeValue}-${activeOption?.label ?? ""}`}
|
||||
defaultValue={activeOption ?? { value: activeValue, label: bundledLabel }}
|
||||
onChange={async (item) => {
|
||||
if (!item || item.value === activeValue) return;
|
||||
const { backendId, orgId } = parseOptionValue(item.value);
|
||||
const target = backends.find((b) => b.id === backendId);
|
||||
|
||||
// Cloud + org pick: fire `/switch` FIRST against the explicit
|
||||
// target backend, then update the active selection after it
|
||||
// resolves. This ensures the SaaS-side `current_org_id` is
|
||||
// already in place before any of our backend-keyed queries
|
||||
// refetch — they fire exactly once, with the correct context.
|
||||
//
|
||||
// We use `mutateAsync` + `await` (rather than `mutate(... ,
|
||||
// { onSuccess })`) because per-call onSuccess callbacks were
|
||||
// observed not to run reliably for this hook in practice; the
|
||||
// promise-based shape is unambiguous.
|
||||
if (orgId && target?.kind === "cloud") {
|
||||
try {
|
||||
await switchOrg({ orgId, backend: target });
|
||||
} catch {
|
||||
// Error is surfaced by the mutation cache's global handler.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Pure backend swap (local-↔-bundled or backend-only cloud
|
||||
// selection without an org) skips `/switch` and updates active
|
||||
// directly; cloud-with-org falls through here after `/switch`.
|
||||
setActive(backendId, orgId);
|
||||
|
||||
// The current conversation belongs to the previous backend
|
||||
// and is no longer reachable under the new one — redirect home
|
||||
// so the user lands on a coherent screen.
|
||||
if (conversationMatch) navigate("/");
|
||||
}}
|
||||
placeholder={bundledLabel}
|
||||
loading={someCloudLoading || isSwitching}
|
||||
options={options}
|
||||
className="bg-[#1F1F1F66] border-[#242424]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user