Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- src/api/README.md +98 -0
- src/api/agent-canvas-updates.test.ts +41 -0
- src/api/agent-canvas-updates.ts +37 -0
- src/api/agent-server-adapter.test.ts +704 -0
- src/api/agent-server-adapter.ts +1638 -0
- src/api/agent-server-client-options.ts +80 -0
- src/api/agent-server-compatibility.test.ts +58 -0
- src/api/agent-server-compatibility.ts +444 -0
- src/api/agent-server-config.test.ts +117 -0
- src/api/agent-server-config.ts +290 -0
- src/api/agent-server-home.ts +114 -0
- src/api/canvas-extensions-service.test.ts +208 -0
- src/api/canvas-extensions-service.ts +192 -0
- src/api/canvas-ui-client-tool.ts +91 -0
- src/api/client-source.ts +20 -0
- src/api/conversation-file-upload.api.ts +172 -0
- src/api/conversation-metadata-store.ts +128 -0
- src/api/device-flow-client.ts +38 -0
- src/api/git-provider-items-service.ts +290 -0
- src/api/hooks-service.ts +49 -0
- src/api/launch-child-conversation-client-tool.ts +112 -0
- src/api/llm-balance-service.test.ts +135 -0
- src/api/llm-balance-service.ts +113 -0
- src/api/llm-subscription-service.ts +202 -0
- src/api/main-app-auth.ts +76 -0
- src/api/no-direct-agent-server-calls.test.ts +80 -0
- src/api/open-hands.types.ts +140 -0
- src/api/plugins-management-service.test.ts +137 -0
- src/api/plugins-management-service.ts +136 -0
- src/api/plugins-service.test.ts +159 -0
- src/api/plugins-service.ts +150 -0
- src/api/secrets-service.ts +156 -0
- src/api/secrets-service.types.ts +15 -0
- src/api/skills-service.ts +80 -0
- src/api/with-retry.ts +26 -0
- src/api/workspace-path-resolution.test.ts +189 -0
- src/api/workspace-upload-path.ts +99 -0
- src/dev/seed-pending-chat-preview.ts +69 -0
- src/fixtures/canvas-demo-conversation.ts +131 -0
- src/fixtures/home-automations-demo.ts +478 -0
- src/fixtures/table-demo-conversation.ts +58 -0
- src/hooks/use-acp-model-context.ts +68 -0
- src/hooks/use-agent-notification.ts +49 -0
- src/hooks/use-app-title.test.tsx +125 -0
- src/hooks/use-app-title.ts +32 -0
- src/hooks/use-auto-resize.ts +379 -0
- src/hooks/use-automation-permissions.ts +102 -0
- src/hooks/use-await-context-compaction.ts +164 -0
- src/hooks/use-backend-scoped-path.ts +27 -0
- src/hooks/use-bash-command-runner.ts +206 -0
src/api/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# API Services Guide
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
|
| 5 |
+
Services are the abstraction layer between frontend components and backend APIs. Local agent-server API access should use `@openhands/typescript-client` classes directly, with shared connection options from `src/api/agent-server-client-options.ts` for the active local backend host, session API key, and workspace defaults.
|
| 6 |
+
|
| 7 |
+
Cloud-specific APIs should use the cloud service modules/proxy helpers instead of local agent-server clients.
|
| 8 |
+
|
| 9 |
+
Each service is a plain object with async methods.
|
| 10 |
+
|
| 11 |
+
## Structure
|
| 12 |
+
|
| 13 |
+
Each service lives in its own directory:
|
| 14 |
+
|
| 15 |
+
```
|
| 16 |
+
src/api/
|
| 17 |
+
└── feature-service/
|
| 18 |
+
├── feature-service.api.ts # Service methods
|
| 19 |
+
└── feature.types.ts # Types and interfaces
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
## Creating a Service
|
| 23 |
+
|
| 24 |
+
Use an object literal with named export. Use object destructuring for parameters to make calls self-documenting. Prefer typed `@openhands/typescript-client` classes over generic HTTP calls. If a needed endpoint is missing, add it to `@openhands/typescript-client` first.
|
| 25 |
+
|
| 26 |
+
```typescript
|
| 27 |
+
// feature-service/feature-service.api.ts
|
| 28 |
+
import { FeatureClient } from "@openhands/typescript-client/clients";
|
| 29 |
+
import { getAgentServerClientOptions } from "../agent-server-client-options";
|
| 30 |
+
import { Feature, CreateFeatureParams } from "./feature.types";
|
| 31 |
+
|
| 32 |
+
export const featureService = {
|
| 33 |
+
getFeature: async ({ id }: { id: string }): Promise<Feature> => {
|
| 34 |
+
return new FeatureClient(getAgentServerClientOptions()).getFeature(id);
|
| 35 |
+
},
|
| 36 |
+
|
| 37 |
+
createFeature: async (params: CreateFeatureParams): Promise<Feature> => {
|
| 38 |
+
return new FeatureClient(getAgentServerClientOptions()).createFeature(params);
|
| 39 |
+
},
|
| 40 |
+
};
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
### Types
|
| 44 |
+
|
| 45 |
+
Define app-specific types in a separate file within the same directory when the TypeScript client models are not sufficient:
|
| 46 |
+
|
| 47 |
+
```typescript
|
| 48 |
+
// feature-service/feature.types.ts
|
| 49 |
+
export interface Feature {
|
| 50 |
+
id: string;
|
| 51 |
+
name: string;
|
| 52 |
+
description: string;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
export interface CreateFeatureParams {
|
| 56 |
+
name: string;
|
| 57 |
+
description: string;
|
| 58 |
+
}
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
## Usage
|
| 62 |
+
|
| 63 |
+
> [!IMPORTANT]
|
| 64 |
+
> **Don't call services directly in components.** Wrap them in TanStack Query hooks.
|
| 65 |
+
>
|
| 66 |
+
> Why? TanStack Query provides:
|
| 67 |
+
>
|
| 68 |
+
> - **Caching** - Avoid redundant network requests
|
| 69 |
+
> - **Deduplication** - Multiple components requesting the same data share one request
|
| 70 |
+
> - **Loading/error states** - Built-in `isLoading`, `isError`, `data` states
|
| 71 |
+
> - **Background refetching** - Data stays fresh automatically
|
| 72 |
+
>
|
| 73 |
+
> Hooks location:
|
| 74 |
+
>
|
| 75 |
+
> - `src/hooks/query/` for data fetching (`useQuery`)
|
| 76 |
+
> - `src/hooks/mutation/` for writes/updates (`useMutation`)
|
| 77 |
+
|
| 78 |
+
```typescript
|
| 79 |
+
// src/hooks/query/use-feature.ts
|
| 80 |
+
import { useQuery } from "@tanstack/react-query";
|
| 81 |
+
import { featureService } from "#/api/feature-service/feature-service.api";
|
| 82 |
+
|
| 83 |
+
export const useFeature = (id: string) => {
|
| 84 |
+
return useQuery({
|
| 85 |
+
queryKey: ["feature", id],
|
| 86 |
+
queryFn: () => featureService.getFeature({ id }),
|
| 87 |
+
});
|
| 88 |
+
};
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
## Naming Conventions
|
| 92 |
+
|
| 93 |
+
| Item | Convention | Example |
|
| 94 |
+
| ------------ | ------------------------ | ------------------------ |
|
| 95 |
+
| Directory | `feature-service/` | `secrets-service/` |
|
| 96 |
+
| Service file | `feature-service.api.ts` | `secrets-service.api.ts` |
|
| 97 |
+
| Types file | `feature.types.ts` | `secrets.types.ts` |
|
| 98 |
+
| Export name | `featureService` | `secretsService` |
|
src/api/agent-canvas-updates.test.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import { fetchLatestAgentCanvasVersion } from "./agent-canvas-updates";
|
| 3 |
+
|
| 4 |
+
function stubFetchResponse(response: Partial<Response>) {
|
| 5 |
+
const fetchMock = vi.fn().mockResolvedValue(response as Response);
|
| 6 |
+
vi.stubGlobal("fetch", fetchMock);
|
| 7 |
+
return fetchMock;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
afterEach(() => {
|
| 11 |
+
vi.unstubAllGlobals();
|
| 12 |
+
});
|
| 13 |
+
|
| 14 |
+
describe("fetchLatestAgentCanvasVersion", () => {
|
| 15 |
+
it("fetches the npm registry latest dist-tag and returns the trimmed version", async () => {
|
| 16 |
+
const fetchMock = stubFetchResponse({
|
| 17 |
+
ok: true,
|
| 18 |
+
json: async () => ({ version: " 1.6.0 " }),
|
| 19 |
+
});
|
| 20 |
+
|
| 21 |
+
await expect(fetchLatestAgentCanvasVersion()).resolves.toBe("1.6.0");
|
| 22 |
+
expect(fetchMock).toHaveBeenCalledWith(
|
| 23 |
+
"https://registry.npmjs.org/@openhands/agent-canvas/latest",
|
| 24 |
+
expect.objectContaining({ headers: { Accept: "application/json" } }),
|
| 25 |
+
);
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
it("rejects when the registry responds with an error status", async () => {
|
| 29 |
+
stubFetchResponse({ ok: false, status: 503 });
|
| 30 |
+
|
| 31 |
+
await expect(fetchLatestAgentCanvasVersion()).rejects.toThrow("503");
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
it("rejects when the response has no usable version field", async () => {
|
| 35 |
+
stubFetchResponse({ ok: true, json: async () => ({ version: 42 }) });
|
| 36 |
+
|
| 37 |
+
await expect(fetchLatestAgentCanvasVersion()).rejects.toThrow(
|
| 38 |
+
"missing version",
|
| 39 |
+
);
|
| 40 |
+
});
|
| 41 |
+
});
|
src/api/agent-canvas-updates.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Source of truth for "is a newer Agent Canvas published?".
|
| 3 |
+
*
|
| 4 |
+
* The npm registry `latest` dist-tag endpoint is CORS-open and returns the
|
| 5 |
+
* abbreviated packument for that version as JSON. Isolated here so the
|
| 6 |
+
* release channel can change (e.g. GitHub releases) without touching the
|
| 7 |
+
* query hook or the settings update card.
|
| 8 |
+
*/
|
| 9 |
+
const NPM_LATEST_VERSION_URL =
|
| 10 |
+
"https://registry.npmjs.org/@openhands/agent-canvas/latest";
|
| 11 |
+
|
| 12 |
+
export const AGENT_CANVAS_RELEASE_NOTES_URL =
|
| 13 |
+
"https://github.com/OpenHands/OpenHands/releases";
|
| 14 |
+
|
| 15 |
+
/** Literal shell commands — intentionally not localized. */
|
| 16 |
+
export const AGENT_CANVAS_UPDATE_COMMANDS = {
|
| 17 |
+
npm: "npm install -g @openhands/agent-canvas@latest",
|
| 18 |
+
docker: "docker pull ghcr.io/openhands/agent-canvas:latest",
|
| 19 |
+
} as const;
|
| 20 |
+
|
| 21 |
+
export async function fetchLatestAgentCanvasVersion(
|
| 22 |
+
signal?: AbortSignal,
|
| 23 |
+
): Promise<string> {
|
| 24 |
+
const response = await fetch(NPM_LATEST_VERSION_URL, {
|
| 25 |
+
signal,
|
| 26 |
+
headers: { Accept: "application/json" },
|
| 27 |
+
});
|
| 28 |
+
if (!response.ok) {
|
| 29 |
+
throw new Error(`npm registry responded ${response.status}`);
|
| 30 |
+
}
|
| 31 |
+
const body: unknown = await response.json();
|
| 32 |
+
const version = (body as { version?: unknown } | null)?.version;
|
| 33 |
+
if (typeof version !== "string" || !version.trim()) {
|
| 34 |
+
throw new Error("npm registry response missing version");
|
| 35 |
+
}
|
| 36 |
+
return version.trim();
|
| 37 |
+
}
|
src/api/agent-server-adapter.test.ts
ADDED
|
@@ -0,0 +1,704 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import { CANVAS_UI_CLIENT_TOOL_NAME } from "#/constants/canvas-ui";
|
| 3 |
+
import { LAUNCH_CHILD_CONVERSATION_TOOL_NAME } from "#/constants/child-conversation";
|
| 4 |
+
import { DEFAULT_SETTINGS } from "#/services/settings";
|
| 5 |
+
import type { Settings } from "#/types/settings";
|
| 6 |
+
import {
|
| 7 |
+
AGENT_CANVAS_SOURCE,
|
| 8 |
+
CLIENT_SOURCE_TAG_KEY,
|
| 9 |
+
buildStartConversationRequest,
|
| 10 |
+
buildStartPlanningConversationRequest,
|
| 11 |
+
buildStartPlanningConversationRequestWithEncryptedSettings,
|
| 12 |
+
toConversationPage,
|
| 13 |
+
} from "./agent-server-adapter";
|
| 14 |
+
import SettingsService from "./settings-service/settings-service.api";
|
| 15 |
+
import AgentProfilesService from "./agent-profiles-service/agent-profiles-service.api";
|
| 16 |
+
import ProfilesService from "./profiles-service/profiles-service.api";
|
| 17 |
+
|
| 18 |
+
vi.mock("./settings-service/settings-service.api", () => ({
|
| 19 |
+
default: { getSettingsForConversation: vi.fn() },
|
| 20 |
+
}));
|
| 21 |
+
vi.mock("./secrets-service", () => ({
|
| 22 |
+
SecretsService: { getSecrets: vi.fn().mockResolvedValue([]) },
|
| 23 |
+
}));
|
| 24 |
+
vi.mock("./agent-profiles-service/agent-profiles-service.api", () => ({
|
| 25 |
+
default: { listProfiles: vi.fn() },
|
| 26 |
+
}));
|
| 27 |
+
vi.mock("./profiles-service/profiles-service.api", () => ({
|
| 28 |
+
default: { getProfile: vi.fn() },
|
| 29 |
+
}));
|
| 30 |
+
|
| 31 |
+
const encryptedValue = "gAAAAAencrypted-mcp-header";
|
| 32 |
+
|
| 33 |
+
function makeSettings(agentSettings: Settings["agent_settings"]): Settings {
|
| 34 |
+
return {
|
| 35 |
+
...DEFAULT_SETTINGS,
|
| 36 |
+
agent_settings: agentSettings,
|
| 37 |
+
conversation_settings: {
|
| 38 |
+
confirmation_mode: false,
|
| 39 |
+
security_analyzer: null,
|
| 40 |
+
max_iterations: 20,
|
| 41 |
+
},
|
| 42 |
+
};
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
function getAgentContextSkillNames(
|
| 46 |
+
payload: ReturnType<typeof buildStartConversationRequest>,
|
| 47 |
+
): Array<string | undefined> {
|
| 48 |
+
const agentSettings = payload.agent_settings as
|
| 49 |
+
| {
|
| 50 |
+
agent_context?: {
|
| 51 |
+
skills?: Array<{ name?: string }>;
|
| 52 |
+
};
|
| 53 |
+
}
|
| 54 |
+
| undefined;
|
| 55 |
+
return agentSettings?.agent_context?.skills?.map((skill) => skill.name) ?? [];
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
function getPlannerAgentContextSkillNames(payload: {
|
| 59 |
+
agent: { agent_context?: { skills?: Array<{ name?: string }> } };
|
| 60 |
+
}): Array<string | undefined> {
|
| 61 |
+
return payload.agent.agent_context?.skills?.map((skill) => skill.name) ?? [];
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
describe("buildStartConversationRequest", () => {
|
| 65 |
+
it("marks OpenHands start requests as encrypted when MCP headers are encrypted", () => {
|
| 66 |
+
const agentSettings = {
|
| 67 |
+
agent_kind: "openhands",
|
| 68 |
+
llm: {
|
| 69 |
+
model: "litellm_proxy/openai/gpt-5.5",
|
| 70 |
+
api_key: "gAAAAAencrypted-llm-api-key",
|
| 71 |
+
},
|
| 72 |
+
mcp_config: {
|
| 73 |
+
linear: {
|
| 74 |
+
url: "https://mcp.linear.app/mcp",
|
| 75 |
+
transport: "http",
|
| 76 |
+
headers: {
|
| 77 |
+
Authorization: encryptedValue,
|
| 78 |
+
},
|
| 79 |
+
},
|
| 80 |
+
},
|
| 81 |
+
};
|
| 82 |
+
const settings = makeSettings(agentSettings);
|
| 83 |
+
|
| 84 |
+
const payload = buildStartConversationRequest({
|
| 85 |
+
settings,
|
| 86 |
+
encryptedAgentSettings: agentSettings,
|
| 87 |
+
encryptedConversationSettings: settings.conversation_settings!,
|
| 88 |
+
secretsEncrypted: true,
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
expect(payload.agent_settings!.agent_kind).toBe("openhands");
|
| 92 |
+
expect(payload.agent_settings!.mcp_config).toEqual(
|
| 93 |
+
agentSettings.mcp_config,
|
| 94 |
+
);
|
| 95 |
+
expect(payload.secrets_encrypted).toBe(true);
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
it("marks ACP start requests as encrypted when MCP headers are encrypted", () => {
|
| 99 |
+
const agentSettings = {
|
| 100 |
+
agent_kind: "acp",
|
| 101 |
+
acp_server: "codex",
|
| 102 |
+
acp_command: ["codex-acp"],
|
| 103 |
+
acp_model: "gpt-5.5/medium",
|
| 104 |
+
mcp_config: {
|
| 105 |
+
linear: {
|
| 106 |
+
url: "https://mcp.linear.app/mcp",
|
| 107 |
+
transport: "http",
|
| 108 |
+
headers: {
|
| 109 |
+
Authorization: encryptedValue,
|
| 110 |
+
},
|
| 111 |
+
},
|
| 112 |
+
},
|
| 113 |
+
};
|
| 114 |
+
const settings = makeSettings(agentSettings);
|
| 115 |
+
|
| 116 |
+
const payload = buildStartConversationRequest({
|
| 117 |
+
settings,
|
| 118 |
+
encryptedAgentSettings: agentSettings,
|
| 119 |
+
encryptedConversationSettings: settings.conversation_settings!,
|
| 120 |
+
secretsEncrypted: true,
|
| 121 |
+
});
|
| 122 |
+
|
| 123 |
+
expect(payload.agent_settings!.agent_kind).toBe("acp");
|
| 124 |
+
expect(payload.agent_settings!.mcp_config).toEqual(
|
| 125 |
+
agentSettings.mcp_config,
|
| 126 |
+
);
|
| 127 |
+
expect(payload.secrets_encrypted).toBe(true);
|
| 128 |
+
});
|
| 129 |
+
|
| 130 |
+
it("builds a raw planning agent request for local Planner", () => {
|
| 131 |
+
const agentSettings = {
|
| 132 |
+
agent_kind: "openhands",
|
| 133 |
+
llm: {
|
| 134 |
+
model: "openhands/minimax-m2.7",
|
| 135 |
+
api_key: "gAAAAAencrypted-llm-api-key",
|
| 136 |
+
},
|
| 137 |
+
};
|
| 138 |
+
|
| 139 |
+
const payload = buildStartPlanningConversationRequest({
|
| 140 |
+
encryptedAgentSettings: agentSettings,
|
| 141 |
+
workingDir: "/workspace/project/agent-canvas",
|
| 142 |
+
parentConversationId: "parent-1",
|
| 143 |
+
secretsEncrypted: true,
|
| 144 |
+
customSecrets: [{ name: "CUSTOM_TOKEN" }],
|
| 145 |
+
});
|
| 146 |
+
|
| 147 |
+
expect(payload.agent).toMatchObject({
|
| 148 |
+
kind: "Agent",
|
| 149 |
+
system_prompt_filename: "system_prompt_planning.j2",
|
| 150 |
+
system_prompt_kwargs: {
|
| 151 |
+
plan_structure: expect.stringContaining("OBJECTIVE"),
|
| 152 |
+
},
|
| 153 |
+
llm: {
|
| 154 |
+
model: "openhands/minimax-m2.7",
|
| 155 |
+
api_key: "gAAAAAencrypted-llm-api-key",
|
| 156 |
+
},
|
| 157 |
+
tools: [
|
| 158 |
+
{ name: "glob", params: {} },
|
| 159 |
+
{ name: "grep", params: {} },
|
| 160 |
+
{
|
| 161 |
+
name: "planning_file_editor",
|
| 162 |
+
params: {
|
| 163 |
+
plan_path: "/workspace/project/agent-canvas/.agents_tmp/PLAN.md",
|
| 164 |
+
},
|
| 165 |
+
},
|
| 166 |
+
],
|
| 167 |
+
// Matches the SDK planning preset's get_planning_condenser.
|
| 168 |
+
condenser: {
|
| 169 |
+
kind: "LLMSummarizingCondenser",
|
| 170 |
+
max_size: 100,
|
| 171 |
+
keep_first: 6,
|
| 172 |
+
llm: {
|
| 173 |
+
model: "openhands/minimax-m2.7",
|
| 174 |
+
usage_id: "planning_condenser",
|
| 175 |
+
},
|
| 176 |
+
},
|
| 177 |
+
});
|
| 178 |
+
expect(payload.agent_settings).toBeUndefined();
|
| 179 |
+
expect(payload.worktree).toBe(false);
|
| 180 |
+
expect(payload.tags).toEqual({ plannerparent: "parent-1" });
|
| 181 |
+
expect(payload.secrets_encrypted).toBe(true);
|
| 182 |
+
expect(payload.secrets).toHaveProperty("CUSTOM_TOKEN");
|
| 183 |
+
});
|
| 184 |
+
|
| 185 |
+
it("links the local planner to its parent server-side so the relationship survives storage loss", () => {
|
| 186 |
+
const payload = buildStartPlanningConversationRequest({
|
| 187 |
+
encryptedAgentSettings: {
|
| 188 |
+
agent_kind: "openhands",
|
| 189 |
+
llm: { model: "openhands/minimax-m2.7" },
|
| 190 |
+
},
|
| 191 |
+
workingDir: "/workspace/project",
|
| 192 |
+
parentConversationId: "parent-1",
|
| 193 |
+
});
|
| 194 |
+
|
| 195 |
+
// The agent-server derives the parent's `sub_conversation_ids` from this,
|
| 196 |
+
// which is what makes the planner recoverable without localStorage.
|
| 197 |
+
expect(payload.parent_conversation_id).toBe("parent-1");
|
| 198 |
+
});
|
| 199 |
+
|
| 200 |
+
it("defaults the planner's max_iterations to 500 when not provided", () => {
|
| 201 |
+
const payload = buildStartPlanningConversationRequest({
|
| 202 |
+
encryptedAgentSettings: {
|
| 203 |
+
agent_kind: "openhands",
|
| 204 |
+
llm: { model: "openhands/minimax-m2.7" },
|
| 205 |
+
},
|
| 206 |
+
workingDir: "/workspace/project",
|
| 207 |
+
parentConversationId: "parent-1",
|
| 208 |
+
});
|
| 209 |
+
|
| 210 |
+
expect(payload.max_iterations).toBe(500);
|
| 211 |
+
});
|
| 212 |
+
|
| 213 |
+
it("uses the caller-provided max_iterations instead of the hardcoded default", () => {
|
| 214 |
+
const payload = buildStartPlanningConversationRequest({
|
| 215 |
+
encryptedAgentSettings: {
|
| 216 |
+
agent_kind: "openhands",
|
| 217 |
+
llm: { model: "openhands/minimax-m2.7" },
|
| 218 |
+
},
|
| 219 |
+
workingDir: "/workspace/project",
|
| 220 |
+
parentConversationId: "parent-1",
|
| 221 |
+
maxIterations: 250,
|
| 222 |
+
});
|
| 223 |
+
|
| 224 |
+
expect(payload.max_iterations).toBe(250);
|
| 225 |
+
});
|
| 226 |
+
|
| 227 |
+
it("omits local planner helper conversations from paginated conversation results", () => {
|
| 228 |
+
const page = toConversationPage({
|
| 229 |
+
items: [
|
| 230 |
+
{
|
| 231 |
+
id: "main-1",
|
| 232 |
+
created_at: "2024-01-01T00:00:00.000Z",
|
| 233 |
+
updated_at: "2024-01-01T00:00:00.000Z",
|
| 234 |
+
execution_status: "idle",
|
| 235 |
+
tags: {},
|
| 236 |
+
},
|
| 237 |
+
{
|
| 238 |
+
id: "plan-1",
|
| 239 |
+
created_at: "2024-01-01T00:00:00.000Z",
|
| 240 |
+
updated_at: "2024-01-01T00:00:00.000Z",
|
| 241 |
+
execution_status: "idle",
|
| 242 |
+
tags: { plannerparent: "main-1" },
|
| 243 |
+
},
|
| 244 |
+
],
|
| 245 |
+
});
|
| 246 |
+
|
| 247 |
+
expect(page.items.map((item) => item.id)).toEqual(["main-1"]);
|
| 248 |
+
});
|
| 249 |
+
|
| 250 |
+
it("keeps ACP start requests unencrypted when no encrypted MCP values are present", () => {
|
| 251 |
+
const agentSettings = {
|
| 252 |
+
agent_kind: "acp",
|
| 253 |
+
acp_server: "codex",
|
| 254 |
+
acp_command: ["codex-acp"],
|
| 255 |
+
acp_model: "gpt-5.5/medium",
|
| 256 |
+
mcp_config: {
|
| 257 |
+
publicDocs: {
|
| 258 |
+
url: "https://docs.example.com/mcp",
|
| 259 |
+
transport: "http",
|
| 260 |
+
},
|
| 261 |
+
},
|
| 262 |
+
};
|
| 263 |
+
const settings = makeSettings(agentSettings);
|
| 264 |
+
|
| 265 |
+
const payload = buildStartConversationRequest({
|
| 266 |
+
settings,
|
| 267 |
+
encryptedAgentSettings: agentSettings,
|
| 268 |
+
encryptedConversationSettings: settings.conversation_settings!,
|
| 269 |
+
secretsEncrypted: true,
|
| 270 |
+
});
|
| 271 |
+
|
| 272 |
+
expect(payload.agent_settings!.agent_kind).toBe("acp");
|
| 273 |
+
expect(payload.secrets_encrypted).toBeUndefined();
|
| 274 |
+
});
|
| 275 |
+
|
| 276 |
+
it("ships only allow-listed catalog skills to a OpenHands conversation context", () => {
|
| 277 |
+
const settings = makeSettings({
|
| 278 |
+
agent_kind: "openhands",
|
| 279 |
+
llm: {
|
| 280 |
+
model: "litellm_proxy/openai/gpt-5.5",
|
| 281 |
+
api_key: "sk-test",
|
| 282 |
+
},
|
| 283 |
+
agent_context: {
|
| 284 |
+
skills: [
|
| 285 |
+
{ name: "disabled-custom", content: "disabled" },
|
| 286 |
+
{ name: "enabled-custom", content: "enabled" },
|
| 287 |
+
],
|
| 288 |
+
},
|
| 289 |
+
});
|
| 290 |
+
settings.disabled_skills = ["agent-memory", "disabled-custom"];
|
| 291 |
+
|
| 292 |
+
const payload = buildStartConversationRequest({ settings });
|
| 293 |
+
const skillNames = getAgentContextSkillNames(payload);
|
| 294 |
+
|
| 295 |
+
// `agent-memory` is default-enabled, so this asserts the deny-list still
|
| 296 |
+
// wins over the allow-list — the case that matters before the one-shot
|
| 297 |
+
// migration has run.
|
| 298 |
+
expect(skillNames).not.toContain("agent-memory");
|
| 299 |
+
expect(skillNames).not.toContain("disabled-custom");
|
| 300 |
+
// Skills already on the agent context are user-authored and stay opt-out.
|
| 301 |
+
expect(skillNames).toContain("enabled-custom");
|
| 302 |
+
// No `enabled_skills` on the settings means the curated default applies:
|
| 303 |
+
// `add-skill` is flagged `defaultEnabled` in the catalog, `add-javadoc` is
|
| 304 |
+
// not, and shipping every catalog skill is what OpenHands#16302 reported.
|
| 305 |
+
expect(skillNames).toContain("add-skill");
|
| 306 |
+
expect(skillNames).not.toContain("add-javadoc");
|
| 307 |
+
|
| 308 |
+
expect(payload.agent_settings?.agent_context?.disabled_skills).toEqual([
|
| 309 |
+
"agent-memory",
|
| 310 |
+
"disabled-custom",
|
| 311 |
+
]);
|
| 312 |
+
});
|
| 313 |
+
|
| 314 |
+
it("ships only allow-listed catalog skills to a ACP conversation context", () => {
|
| 315 |
+
const settings = makeSettings({
|
| 316 |
+
agent_kind: "acp",
|
| 317 |
+
acp_server: "codex",
|
| 318 |
+
acp_command: ["codex-acp"],
|
| 319 |
+
acp_model: "gpt-5.5/medium",
|
| 320 |
+
agent_context: {
|
| 321 |
+
skills: [
|
| 322 |
+
{ name: "disabled-custom", content: "disabled" },
|
| 323 |
+
{ name: "enabled-custom", content: "enabled" },
|
| 324 |
+
],
|
| 325 |
+
},
|
| 326 |
+
});
|
| 327 |
+
settings.disabled_skills = ["agent-memory", "disabled-custom"];
|
| 328 |
+
|
| 329 |
+
const payload = buildStartConversationRequest({ settings });
|
| 330 |
+
const skillNames = getAgentContextSkillNames(payload);
|
| 331 |
+
|
| 332 |
+
// `agent-memory` is default-enabled, so this asserts the deny-list still
|
| 333 |
+
// wins over the allow-list — the case that matters before the one-shot
|
| 334 |
+
// migration has run.
|
| 335 |
+
expect(skillNames).not.toContain("agent-memory");
|
| 336 |
+
expect(skillNames).not.toContain("disabled-custom");
|
| 337 |
+
// Skills already on the agent context are user-authored and stay opt-out.
|
| 338 |
+
expect(skillNames).toContain("enabled-custom");
|
| 339 |
+
// No `enabled_skills` on the settings means the curated default applies:
|
| 340 |
+
// `add-skill` is flagged `defaultEnabled` in the catalog, `add-javadoc` is
|
| 341 |
+
// not, and shipping every catalog skill is what OpenHands#16302 reported.
|
| 342 |
+
expect(skillNames).toContain("add-skill");
|
| 343 |
+
expect(skillNames).not.toContain("add-javadoc");
|
| 344 |
+
|
| 345 |
+
expect(payload.agent_settings?.agent_context?.disabled_skills).toEqual([
|
| 346 |
+
"agent-memory",
|
| 347 |
+
"disabled-custom",
|
| 348 |
+
]);
|
| 349 |
+
});
|
| 350 |
+
|
| 351 |
+
it("loads a catalog skill the opening slash command invokes", () => {
|
| 352 |
+
// An automation card fills the chat input with the skill's own command
|
| 353 |
+
// (`findAutomationCommand`), and 18 of the catalog's 24 slash commands
|
| 354 |
+
// belong to skills that are off by default — without this the card would
|
| 355 |
+
// silently do nothing.
|
| 356 |
+
const settings = makeSettings({
|
| 357 |
+
agent_kind: "openhands",
|
| 358 |
+
llm: { model: "litellm_proxy/openai/gpt-5.5", api_key: "sk-test" },
|
| 359 |
+
});
|
| 360 |
+
|
| 361 |
+
const payload = buildStartConversationRequest({
|
| 362 |
+
settings,
|
| 363 |
+
query: "/standup-digest:setup",
|
| 364 |
+
});
|
| 365 |
+
|
| 366 |
+
expect(getAgentContextSkillNames(payload)).toContain(
|
| 367 |
+
"slack-standup-digest",
|
| 368 |
+
);
|
| 369 |
+
});
|
| 370 |
+
|
| 371 |
+
it("does not admit a skill named mid-sentence rather than invoked", () => {
|
| 372 |
+
const settings = makeSettings({
|
| 373 |
+
agent_kind: "openhands",
|
| 374 |
+
llm: { model: "litellm_proxy/openai/gpt-5.5", api_key: "sk-test" },
|
| 375 |
+
});
|
| 376 |
+
|
| 377 |
+
const payload = buildStartConversationRequest({
|
| 378 |
+
settings,
|
| 379 |
+
query: "tell me what /standup-digest:setup would do",
|
| 380 |
+
});
|
| 381 |
+
|
| 382 |
+
expect(getAgentContextSkillNames(payload)).not.toContain(
|
| 383 |
+
"slack-standup-digest",
|
| 384 |
+
);
|
| 385 |
+
});
|
| 386 |
+
|
| 387 |
+
it("lets an invoked skill override a stored deny entry for that conversation", () => {
|
| 388 |
+
const settings = makeSettings({
|
| 389 |
+
agent_kind: "openhands",
|
| 390 |
+
llm: { model: "litellm_proxy/openai/gpt-5.5", api_key: "sk-test" },
|
| 391 |
+
});
|
| 392 |
+
settings.disabled_skills = ["slack-standup-digest"];
|
| 393 |
+
|
| 394 |
+
const payload = buildStartConversationRequest({
|
| 395 |
+
settings,
|
| 396 |
+
query: "/standup-digest:setup",
|
| 397 |
+
});
|
| 398 |
+
|
| 399 |
+
expect(getAgentContextSkillNames(payload)).toContain(
|
| 400 |
+
"slack-standup-digest",
|
| 401 |
+
);
|
| 402 |
+
});
|
| 403 |
+
});
|
| 404 |
+
|
| 405 |
+
describe("buildStartPlanningConversationRequestWithEncryptedSettings", () => {
|
| 406 |
+
const globallyActiveSettings = {
|
| 407 |
+
agentSettings: {
|
| 408 |
+
agent_kind: "openhands",
|
| 409 |
+
llm: {
|
| 410 |
+
model: "openhands/globally-active-model",
|
| 411 |
+
api_key: "gAAAAAglobal-key",
|
| 412 |
+
base_url: "https://global.example.com",
|
| 413 |
+
},
|
| 414 |
+
},
|
| 415 |
+
conversationSettings: {},
|
| 416 |
+
secretsEncrypted: true,
|
| 417 |
+
skillEnablement: { disabledSkills: [] },
|
| 418 |
+
};
|
| 419 |
+
|
| 420 |
+
beforeEach(() => {
|
| 421 |
+
vi.clearAllMocks();
|
| 422 |
+
vi.mocked(SettingsService.getSettingsForConversation).mockResolvedValue(
|
| 423 |
+
globallyActiveSettings,
|
| 424 |
+
);
|
| 425 |
+
vi.mocked(AgentProfilesService.listProfiles).mockResolvedValue({
|
| 426 |
+
profiles: [
|
| 427 |
+
{
|
| 428 |
+
id: "profile-parent",
|
| 429 |
+
name: "parent-profile",
|
| 430 |
+
agent_kind: "openhands",
|
| 431 |
+
revision: 1,
|
| 432 |
+
llm_profile_ref: "parent-llm",
|
| 433 |
+
mcp_server_refs: null,
|
| 434 |
+
},
|
| 435 |
+
],
|
| 436 |
+
active_agent_profile_id: "profile-other",
|
| 437 |
+
});
|
| 438 |
+
vi.mocked(ProfilesService.getProfile).mockResolvedValue({
|
| 439 |
+
name: "parent-llm",
|
| 440 |
+
api_key_set: true,
|
| 441 |
+
config: {
|
| 442 |
+
model: "openhands/parent-profile-model",
|
| 443 |
+
api_key: "gAAAAAparent-key",
|
| 444 |
+
base_url: "https://parent.example.com",
|
| 445 |
+
},
|
| 446 |
+
});
|
| 447 |
+
});
|
| 448 |
+
|
| 449 |
+
it("pins the planner to the parent's launched profile, not the globally active one", async () => {
|
| 450 |
+
const payload =
|
| 451 |
+
await buildStartPlanningConversationRequestWithEncryptedSettings({
|
| 452 |
+
workingDir: "/workspace/project",
|
| 453 |
+
parentConversationId: "parent-1",
|
| 454 |
+
parentAgentProfileId: "profile-parent",
|
| 455 |
+
});
|
| 456 |
+
|
| 457 |
+
expect(ProfilesService.getProfile).toHaveBeenCalledWith(
|
| 458 |
+
"parent-llm",
|
| 459 |
+
"encrypted",
|
| 460 |
+
);
|
| 461 |
+
expect(payload.agent.llm).toMatchObject({
|
| 462 |
+
model: "openhands/parent-profile-model",
|
| 463 |
+
api_key: "gAAAAAparent-key",
|
| 464 |
+
base_url: "https://parent.example.com",
|
| 465 |
+
});
|
| 466 |
+
});
|
| 467 |
+
|
| 468 |
+
it("falls back to global settings when the parent was not launched from a profile", async () => {
|
| 469 |
+
const payload =
|
| 470 |
+
await buildStartPlanningConversationRequestWithEncryptedSettings({
|
| 471 |
+
workingDir: "/workspace/project",
|
| 472 |
+
parentConversationId: "parent-1",
|
| 473 |
+
parentAgentProfileId: null,
|
| 474 |
+
});
|
| 475 |
+
|
| 476 |
+
expect(AgentProfilesService.listProfiles).not.toHaveBeenCalled();
|
| 477 |
+
expect(payload.agent.llm).toMatchObject({
|
| 478 |
+
model: "openhands/globally-active-model",
|
| 479 |
+
});
|
| 480 |
+
});
|
| 481 |
+
|
| 482 |
+
it("prefers the parent conversation's current active_profile over its launched agent profile", async () => {
|
| 483 |
+
// The conversation launched under "parent-profile" (-> "parent-llm"), but
|
| 484 |
+
// was later switched to a different LLM profile via /model or the
|
| 485 |
+
// agent's own SwitchLLMTool — active_profile tracks that switch.
|
| 486 |
+
vi.mocked(ProfilesService.getProfile).mockImplementation(async (name) =>
|
| 487 |
+
name === "switched-llm"
|
| 488 |
+
? {
|
| 489 |
+
name: "switched-llm",
|
| 490 |
+
api_key_set: true,
|
| 491 |
+
config: {
|
| 492 |
+
model: "openhands/switched-model",
|
| 493 |
+
api_key: "gAAAAAswitched-key",
|
| 494 |
+
base_url: "https://switched.example.com",
|
| 495 |
+
},
|
| 496 |
+
}
|
| 497 |
+
: {
|
| 498 |
+
name: "parent-llm",
|
| 499 |
+
api_key_set: true,
|
| 500 |
+
config: {
|
| 501 |
+
model: "openhands/parent-profile-model",
|
| 502 |
+
api_key: "gAAAAAparent-key",
|
| 503 |
+
base_url: "https://parent.example.com",
|
| 504 |
+
},
|
| 505 |
+
},
|
| 506 |
+
);
|
| 507 |
+
|
| 508 |
+
const payload =
|
| 509 |
+
await buildStartPlanningConversationRequestWithEncryptedSettings({
|
| 510 |
+
workingDir: "/workspace/project",
|
| 511 |
+
parentConversationId: "parent-1",
|
| 512 |
+
parentActiveProfileName: "switched-llm",
|
| 513 |
+
parentAgentProfileId: "profile-parent",
|
| 514 |
+
});
|
| 515 |
+
|
| 516 |
+
expect(ProfilesService.getProfile).toHaveBeenCalledWith(
|
| 517 |
+
"switched-llm",
|
| 518 |
+
"encrypted",
|
| 519 |
+
);
|
| 520 |
+
// The launched agent profile must not even be consulted once
|
| 521 |
+
// active_profile resolves successfully.
|
| 522 |
+
expect(AgentProfilesService.listProfiles).not.toHaveBeenCalled();
|
| 523 |
+
expect(payload.agent.llm).toMatchObject({
|
| 524 |
+
model: "openhands/switched-model",
|
| 525 |
+
api_key: "gAAAAAswitched-key",
|
| 526 |
+
base_url: "https://switched.example.com",
|
| 527 |
+
});
|
| 528 |
+
});
|
| 529 |
+
|
| 530 |
+
it("falls back to the launched agent profile when active_profile can't be resolved", async () => {
|
| 531 |
+
vi.mocked(ProfilesService.getProfile).mockImplementation(async (name) => {
|
| 532 |
+
if (name === "dangling-llm") throw new Error("not found");
|
| 533 |
+
return {
|
| 534 |
+
name: "parent-llm",
|
| 535 |
+
api_key_set: true,
|
| 536 |
+
config: {
|
| 537 |
+
model: "openhands/parent-profile-model",
|
| 538 |
+
api_key: "gAAAAAparent-key",
|
| 539 |
+
base_url: "https://parent.example.com",
|
| 540 |
+
},
|
| 541 |
+
};
|
| 542 |
+
});
|
| 543 |
+
|
| 544 |
+
const payload =
|
| 545 |
+
await buildStartPlanningConversationRequestWithEncryptedSettings({
|
| 546 |
+
workingDir: "/workspace/project",
|
| 547 |
+
parentConversationId: "parent-1",
|
| 548 |
+
parentActiveProfileName: "dangling-llm",
|
| 549 |
+
parentAgentProfileId: "profile-parent",
|
| 550 |
+
});
|
| 551 |
+
|
| 552 |
+
expect(payload.agent.llm).toMatchObject({
|
| 553 |
+
model: "openhands/parent-profile-model",
|
| 554 |
+
});
|
| 555 |
+
});
|
| 556 |
+
|
| 557 |
+
it("falls back to global settings when the parent's profile reference dangles", async () => {
|
| 558 |
+
vi.mocked(AgentProfilesService.listProfiles).mockResolvedValue({
|
| 559 |
+
profiles: [],
|
| 560 |
+
active_agent_profile_id: null,
|
| 561 |
+
});
|
| 562 |
+
|
| 563 |
+
const payload =
|
| 564 |
+
await buildStartPlanningConversationRequestWithEncryptedSettings({
|
| 565 |
+
workingDir: "/workspace/project",
|
| 566 |
+
parentConversationId: "parent-1",
|
| 567 |
+
parentAgentProfileId: "profile-parent",
|
| 568 |
+
});
|
| 569 |
+
|
| 570 |
+
expect(ProfilesService.getProfile).not.toHaveBeenCalled();
|
| 571 |
+
expect(payload.agent.llm).toMatchObject({
|
| 572 |
+
model: "openhands/globally-active-model",
|
| 573 |
+
});
|
| 574 |
+
});
|
| 575 |
+
|
| 576 |
+
it("mirrors the parent's configured max_iterations instead of hardcoding a lower cap", async () => {
|
| 577 |
+
vi.mocked(SettingsService.getSettingsForConversation).mockResolvedValue({
|
| 578 |
+
...globallyActiveSettings,
|
| 579 |
+
conversationSettings: { max_iterations: 1000 },
|
| 580 |
+
});
|
| 581 |
+
|
| 582 |
+
const payload =
|
| 583 |
+
await buildStartPlanningConversationRequestWithEncryptedSettings({
|
| 584 |
+
workingDir: "/workspace/project",
|
| 585 |
+
parentConversationId: "parent-1",
|
| 586 |
+
});
|
| 587 |
+
|
| 588 |
+
expect(payload.max_iterations).toBe(1000);
|
| 589 |
+
});
|
| 590 |
+
|
| 591 |
+
it("falls back to 500 when the parent has no configured max_iterations", async () => {
|
| 592 |
+
const payload =
|
| 593 |
+
await buildStartPlanningConversationRequestWithEncryptedSettings({
|
| 594 |
+
workingDir: "/workspace/project",
|
| 595 |
+
parentConversationId: "parent-1",
|
| 596 |
+
});
|
| 597 |
+
|
| 598 |
+
expect(payload.max_iterations).toBe(500);
|
| 599 |
+
});
|
| 600 |
+
|
| 601 |
+
it("applies the full skill enablement to the planner's agent context, mirroring the code agent", async () => {
|
| 602 |
+
// Both lists, because they cover different populations (#16302): the
|
| 603 |
+
// allow-list gates the bundled catalog, the deny-list still wins over it.
|
| 604 |
+
// `add-javadoc` is a catalog skill outside the recommended defaults, so
|
| 605 |
+
// it only loads because the allow-list names it.
|
| 606 |
+
vi.mocked(SettingsService.getSettingsForConversation).mockResolvedValue({
|
| 607 |
+
...globallyActiveSettings,
|
| 608 |
+
skillEnablement: {
|
| 609 |
+
enabledSkills: ["add-javadoc", "agent-memory"],
|
| 610 |
+
disabledSkills: ["agent-memory"],
|
| 611 |
+
},
|
| 612 |
+
});
|
| 613 |
+
|
| 614 |
+
const payload =
|
| 615 |
+
await buildStartPlanningConversationRequestWithEncryptedSettings({
|
| 616 |
+
workingDir: "/workspace/project",
|
| 617 |
+
parentConversationId: "parent-1",
|
| 618 |
+
});
|
| 619 |
+
|
| 620 |
+
const skillNames = getPlannerAgentContextSkillNames(payload);
|
| 621 |
+
expect(skillNames).not.toContain("agent-memory");
|
| 622 |
+
expect(skillNames).toContain("add-javadoc");
|
| 623 |
+
});
|
| 624 |
+
});
|
| 625 |
+
|
| 626 |
+
describe("buildStartConversationRequest — agentProfileId path", () => {
|
| 627 |
+
it("sends agent_profile_id and omits agent_settings (mutually exclusive)", () => {
|
| 628 |
+
const settings = makeSettings({
|
| 629 |
+
agent_kind: "openhands",
|
| 630 |
+
llm: { model: "litellm_proxy/openai/gpt-5.5", api_key: "sk-test" },
|
| 631 |
+
});
|
| 632 |
+
|
| 633 |
+
const payload = buildStartConversationRequest({
|
| 634 |
+
settings,
|
| 635 |
+
agentProfileId: "profile-xyz",
|
| 636 |
+
agentProfileKind: "openhands",
|
| 637 |
+
});
|
| 638 |
+
|
| 639 |
+
expect(payload.agent_profile_id).toBe("profile-xyz");
|
| 640 |
+
expect(payload.agent_settings).toBeUndefined();
|
| 641 |
+
expect(payload.client_tools.map((tool) => tool.name)).toEqual([
|
| 642 |
+
CANVAS_UI_CLIENT_TOOL_NAME,
|
| 643 |
+
LAUNCH_CHILD_CONVERSATION_TOOL_NAME,
|
| 644 |
+
]);
|
| 645 |
+
});
|
| 646 |
+
|
| 647 |
+
it("suppresses the ACP server tag when launching from a profile", () => {
|
| 648 |
+
const agentSettings = {
|
| 649 |
+
agent_kind: "acp",
|
| 650 |
+
acp_server: "codex",
|
| 651 |
+
acp_command: ["codex-acp"],
|
| 652 |
+
acp_model: "gpt-5.5/medium",
|
| 653 |
+
};
|
| 654 |
+
|
| 655 |
+
// Without a profile the ACP server tag is stamped from settings...
|
| 656 |
+
expect(
|
| 657 |
+
buildStartConversationRequest({ settings: makeSettings(agentSettings) })
|
| 658 |
+
.tags,
|
| 659 |
+
).toBeDefined();
|
| 660 |
+
|
| 661 |
+
// ...but a profile launch resolves the server server-side, so the tag
|
| 662 |
+
// (which may not match the launched profile) is omitted while the client
|
| 663 |
+
// source telemetry tag is still stamped.
|
| 664 |
+
const payload = buildStartConversationRequest({
|
| 665 |
+
settings: makeSettings(agentSettings),
|
| 666 |
+
agentProfileId: "profile-xyz",
|
| 667 |
+
});
|
| 668 |
+
expect(payload.tags).toEqual({
|
| 669 |
+
[CLIENT_SOURCE_TAG_KEY]: AGENT_CANVAS_SOURCE,
|
| 670 |
+
});
|
| 671 |
+
});
|
| 672 |
+
|
| 673 |
+
it("suppresses secrets_encrypted when launching from a profile", () => {
|
| 674 |
+
const agentSettings = {
|
| 675 |
+
agent_kind: "openhands",
|
| 676 |
+
llm: {
|
| 677 |
+
model: "litellm_proxy/openai/gpt-5.5",
|
| 678 |
+
api_key: "gAAAAAencrypted-llm-api-key",
|
| 679 |
+
},
|
| 680 |
+
mcp_config: {
|
| 681 |
+
mcpServers: {
|
| 682 |
+
linear: {
|
| 683 |
+
url: "https://mcp.linear.app/mcp",
|
| 684 |
+
transport: "http",
|
| 685 |
+
headers: { Authorization: encryptedValue },
|
| 686 |
+
},
|
| 687 |
+
},
|
| 688 |
+
},
|
| 689 |
+
};
|
| 690 |
+
const settings = makeSettings(agentSettings);
|
| 691 |
+
|
| 692 |
+
// Same inputs without a profile would set secrets_encrypted (covered
|
| 693 |
+
// above); the profile path defers secret resolution to the server.
|
| 694 |
+
const payload = buildStartConversationRequest({
|
| 695 |
+
settings,
|
| 696 |
+
encryptedAgentSettings: agentSettings,
|
| 697 |
+
encryptedConversationSettings: settings.conversation_settings!,
|
| 698 |
+
secretsEncrypted: true,
|
| 699 |
+
agentProfileId: "profile-xyz",
|
| 700 |
+
});
|
| 701 |
+
|
| 702 |
+
expect(payload.secrets_encrypted).toBeUndefined();
|
| 703 |
+
});
|
| 704 |
+
});
|
src/api/agent-server-adapter.ts
ADDED
|
@@ -0,0 +1,1638 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ACP_SETTINGS_KEYS } from "@openhands/typescript-client";
|
| 2 |
+
import type { HookConfig } from "@openhands/typescript-client";
|
| 3 |
+
import { ServerClient } from "@openhands/typescript-client/clients";
|
| 4 |
+
import { SKILLS_CATALOG } from "@openhands/extensions/skills";
|
| 5 |
+
import { DEFAULT_SETTINGS } from "#/services/settings";
|
| 6 |
+
import { ExecutionStatus } from "#/types/agent-server/core";
|
| 7 |
+
import { AgentKind, Settings, SettingsValue } from "#/types/settings";
|
| 8 |
+
import {
|
| 9 |
+
getAcpPreferredDefaultModel,
|
| 10 |
+
getAcpProvider,
|
| 11 |
+
resolveEffectiveAcpModel,
|
| 12 |
+
} from "#/constants/acp-providers";
|
| 13 |
+
import { getAgentServerClientOptions } from "./agent-server-client-options";
|
| 14 |
+
import {
|
| 15 |
+
getCachedAgentServerInfo,
|
| 16 |
+
isAgentServerToolAvailable,
|
| 17 |
+
} from "./agent-server-compatibility";
|
| 18 |
+
import { getAgentServerWorkingDir } from "./agent-server-config";
|
| 19 |
+
import { getEffectiveLocalBackend } from "./backend-registry/active-store";
|
| 20 |
+
import { buildAuthHeaders } from "./backend-registry/auth";
|
| 21 |
+
import {
|
| 22 |
+
GetHooksResponse,
|
| 23 |
+
PluginSpec,
|
| 24 |
+
AppConversation,
|
| 25 |
+
AppConversationPage,
|
| 26 |
+
RuntimeConversationStats,
|
| 27 |
+
SandboxStatus,
|
| 28 |
+
} from "./conversation-service/agent-server-conversation-service.types";
|
| 29 |
+
import { combineUsageMetrics } from "#/utils/conversation-metrics";
|
| 30 |
+
import {
|
| 31 |
+
buildSkillEnablementFilter,
|
| 32 |
+
findInvokedCatalogSkill,
|
| 33 |
+
toSkillEnablement,
|
| 34 |
+
type SkillEnablement,
|
| 35 |
+
} from "#/utils/skill-enablement";
|
| 36 |
+
import SettingsService from "./settings-service/settings-service.api";
|
| 37 |
+
import { getStoredConversationMetadata } from "./conversation-metadata-store";
|
| 38 |
+
import LLMSubscriptionService from "./llm-subscription-service";
|
| 39 |
+
import {
|
| 40 |
+
LLM_AUTH_TYPE_SUBSCRIPTION,
|
| 41 |
+
OPENAI_SUBSCRIPTION_VENDOR,
|
| 42 |
+
isSubscriptionLlmConfig,
|
| 43 |
+
} from "#/constants/llm-subscription";
|
| 44 |
+
import {
|
| 45 |
+
CANVAS_UI_CLIENT_TOOL,
|
| 46 |
+
CANVAS_UI_CLIENT_TOOL_NAME,
|
| 47 |
+
LEGACY_CANVAS_UI_TOOL_NAME,
|
| 48 |
+
type ClientToolSpec,
|
| 49 |
+
} from "./canvas-ui-client-tool";
|
| 50 |
+
import {
|
| 51 |
+
LAUNCH_CHILD_CONVERSATION_CLIENT_TOOL,
|
| 52 |
+
LAUNCH_CHILD_CONVERSATION_TOOL_NAME,
|
| 53 |
+
} from "./launch-child-conversation-client-tool";
|
| 54 |
+
import {
|
| 55 |
+
buildPlanPath,
|
| 56 |
+
LOCAL_PLANNER_PARENT_TAG_KEY,
|
| 57 |
+
PLAN_STRUCTURE_TEXT,
|
| 58 |
+
PLANNING_AGENT_INSTRUCTION,
|
| 59 |
+
PLANNING_FILE_EDITOR_TOOL_NAME,
|
| 60 |
+
PLANNING_SYSTEM_PROMPT_FILENAME,
|
| 61 |
+
} from "#/utils/plan-file";
|
| 62 |
+
|
| 63 |
+
export interface DirectConversationInfo {
|
| 64 |
+
id: string;
|
| 65 |
+
title?: string | null;
|
| 66 |
+
created_at: string;
|
| 67 |
+
updated_at: string;
|
| 68 |
+
execution_status?: string | null;
|
| 69 |
+
/** Cloud-only sandbox lifecycle state. Omitted / null for local agent-server conversations. */
|
| 70 |
+
sandbox_status?: string | null;
|
| 71 |
+
metrics?: {
|
| 72 |
+
accumulated_cost?: number | null;
|
| 73 |
+
max_budget_per_task?: number | null;
|
| 74 |
+
accumulated_token_usage?: {
|
| 75 |
+
prompt_tokens?: number;
|
| 76 |
+
completion_tokens?: number;
|
| 77 |
+
cache_read_tokens?: number;
|
| 78 |
+
cache_write_tokens?: number;
|
| 79 |
+
context_window?: number;
|
| 80 |
+
per_turn_token?: number;
|
| 81 |
+
} | null;
|
| 82 |
+
} | null;
|
| 83 |
+
/**
|
| 84 |
+
* Raw per-usage-id LLM stats from the agent-server. Search/list responses
|
| 85 |
+
* often carry real usage here even when `metrics` above comes back unset;
|
| 86 |
+
* {@link toAppConversation} combines this as a fallback in that case.
|
| 87 |
+
*/
|
| 88 |
+
stats?: RuntimeConversationStats | null;
|
| 89 |
+
agent?: {
|
| 90 |
+
/**
|
| 91 |
+
* Pydantic discriminator from the SDK union: ``"ACPAgent"`` for ACP CLI
|
| 92 |
+
* subprocesses (model lives on the subprocess via ``acp_model``),
|
| 93 |
+
* ``"Agent"`` for direct litellm. Read by {@link toAppConversation}.
|
| 94 |
+
*/
|
| 95 |
+
kind?: string | null;
|
| 96 |
+
acp_model?: string | null;
|
| 97 |
+
/**
|
| 98 |
+
* ACP CLI identity (``claude-code`` / ``codex`` / ``gemini-cli``) from the
|
| 99 |
+
* SDK's ``ACPAgent.acp_server`` (#3692). Preferred fallback when the
|
| 100 |
+
* ``acpserver`` tag is absent — e.g. a profile launch doesn't stamp the tag
|
| 101 |
+
* client-side and the server may not repopulate it. Read by {@link toAppConversation}.
|
| 102 |
+
*/
|
| 103 |
+
acp_server?: string | null;
|
| 104 |
+
llm?: {
|
| 105 |
+
model?: string | null;
|
| 106 |
+
} | null;
|
| 107 |
+
} | null;
|
| 108 |
+
current_model_id?: string | null;
|
| 109 |
+
current_model_name?: string | null;
|
| 110 |
+
workspace?: {
|
| 111 |
+
working_dir?: string | null;
|
| 112 |
+
} | null;
|
| 113 |
+
/**
|
| 114 |
+
* Arbitrary string-keyed conversation tags surfaced by the agent-server
|
| 115 |
+
* (see ``ConversationInfo.tags``). Canvas only consumes one key today —
|
| 116 |
+
* ``ACP_SERVER_TAG_KEY`` ("acpserver") — but the field is typed as a
|
| 117 |
+
* generic record so future readers don't need another wire-shape change.
|
| 118 |
+
* Keys are constrained to ``^[a-z0-9]+$`` by the agent-server validator;
|
| 119 |
+
* values are opaque strings.
|
| 120 |
+
*/
|
| 121 |
+
tags?: Record<string, string> | null;
|
| 122 |
+
launched_agent_profile?: {
|
| 123 |
+
agent_profile_id: string;
|
| 124 |
+
revision: number;
|
| 125 |
+
} | null;
|
| 126 |
+
/**
|
| 127 |
+
* Server-owned, derived from the catalog's ``parent_conversation_id`` link
|
| 128 |
+
* (agent-server >= 1.37.1, SDK #4188) — a conversation started with a
|
| 129 |
+
* parent is discoverable here on any browser, which is what makes the
|
| 130 |
+
* local planner's relationship server state rather than a browser-local
|
| 131 |
+
* hint. Absent on older agent-servers and on the cloud wire shape.
|
| 132 |
+
*/
|
| 133 |
+
sub_conversation_ids?: string[] | null;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
const DEFAULT_TOOL_NAMES = ["terminal", "file_editor", "task_tracker"];
|
| 137 |
+
const BROWSER_TOOL_SET_NAME = "browser_tool_set";
|
| 138 |
+
const TASK_TOOL_SET_NAME = "task_tool_set";
|
| 139 |
+
// Falls back to the same default the code agent uses when the user has not
|
| 140 |
+
// configured `conversation_settings.max_iterations` (see buildConfiguredConversationSettings).
|
| 141 |
+
const DEFAULT_MAX_ITERATIONS = 500;
|
| 142 |
+
|
| 143 |
+
function resolveMaxIterations(value: unknown): number {
|
| 144 |
+
return typeof value === "number" ? value : DEFAULT_MAX_ITERATIONS;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
function browserToolsEnabled() {
|
| 148 |
+
return import.meta.env.VITE_ENABLE_BROWSER_TOOLS !== "false";
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
/**
|
| 152 |
+
* Shape of the runtime services info served by Agent Canvas backends in
|
| 153 |
+
* `/server_info.runtime_services`. All URLs are written from the agent's point of
|
| 154 |
+
* view, not the browser's. The block is rendered into the agent's system prompt
|
| 155 |
+
* via `AgentContext.system_message_suffix` so the agent knows what's reachable
|
| 156 |
+
* from inside its sandbox without having to probe.
|
| 157 |
+
*/
|
| 158 |
+
export interface RuntimeServicesInfo {
|
| 159 |
+
mode?: string;
|
| 160 |
+
agent_host_alias?: string;
|
| 161 |
+
services?: {
|
| 162 |
+
agent_server?: { description?: string; url_from_agent?: string };
|
| 163 |
+
ingress?: { description?: string; url_from_agent?: string };
|
| 164 |
+
frontend?: {
|
| 165 |
+
kind?: "vite" | "static";
|
| 166 |
+
description?: string;
|
| 167 |
+
url_from_agent?: string;
|
| 168 |
+
};
|
| 169 |
+
// `vite` is the legacy key name for the frontend entry, accepted for
|
| 170 |
+
// one release while older dev-stack launchers may still emit it.
|
| 171 |
+
vite?: { description?: string; url_from_agent?: string };
|
| 172 |
+
automation?: {
|
| 173 |
+
description?: string;
|
| 174 |
+
url_from_agent?: string;
|
| 175 |
+
api_prefix?: string;
|
| 176 |
+
docs_url?: string;
|
| 177 |
+
openapi_url?: string;
|
| 178 |
+
auth_env_var?: string;
|
| 179 |
+
};
|
| 180 |
+
};
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
export function parseRuntimeServicesInfo(
|
| 184 |
+
value: unknown,
|
| 185 |
+
): RuntimeServicesInfo | null {
|
| 186 |
+
if (typeof value === "string") {
|
| 187 |
+
const raw = value.trim();
|
| 188 |
+
if (!raw) return null;
|
| 189 |
+
try {
|
| 190 |
+
return parseRuntimeServicesInfo(JSON.parse(raw));
|
| 191 |
+
} catch {
|
| 192 |
+
return null;
|
| 193 |
+
}
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
| 197 |
+
return null;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
const parsed = value as RuntimeServicesInfo;
|
| 201 |
+
if (!parsed.services || typeof parsed.services !== "object") return null;
|
| 202 |
+
return parsed;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
export async function fetchBackendRuntimeServicesInfo(): Promise<RuntimeServicesInfo | null> {
|
| 206 |
+
let clientOptions: ReturnType<typeof getAgentServerClientOptions>;
|
| 207 |
+
try {
|
| 208 |
+
clientOptions = getAgentServerClientOptions({ timeout: 3000 });
|
| 209 |
+
} catch {
|
| 210 |
+
return null;
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
const cached = parseRuntimeServicesInfo(
|
| 214 |
+
getCachedAgentServerInfo({ host: clientOptions.host })?.runtime_services,
|
| 215 |
+
);
|
| 216 |
+
if (cached) return cached;
|
| 217 |
+
|
| 218 |
+
try {
|
| 219 |
+
const serverInfo = await new ServerClient(clientOptions).getServerInfo();
|
| 220 |
+
return parseRuntimeServicesInfo(
|
| 221 |
+
(serverInfo as { runtime_services?: unknown }).runtime_services,
|
| 222 |
+
);
|
| 223 |
+
} catch {
|
| 224 |
+
return null;
|
| 225 |
+
}
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
/**
|
| 229 |
+
* Return the deployment mode from the runtime services info, e.g. "docker",
|
| 230 |
+
* "dev:automation", etc. Returns `null` when no runtime info is supplied.
|
| 231 |
+
*/
|
| 232 |
+
export function getDeploymentMode(
|
| 233 |
+
runtimeServicesInfo?: RuntimeServicesInfo | null,
|
| 234 |
+
): string | null {
|
| 235 |
+
return runtimeServicesInfo?.mode ?? null;
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
/**
|
| 239 |
+
* Render the runtime services info into a markdown block suitable for
|
| 240 |
+
* appending to the system prompt via `AgentContext.system_message_suffix`.
|
| 241 |
+
*
|
| 242 |
+
* Returns `undefined` when no runtime info is available, so callers can safely
|
| 243 |
+
* omit the field when the selected backend does not advertise runtime services.
|
| 244 |
+
*/
|
| 245 |
+
export function buildRuntimeServicesSystemSuffix(
|
| 246 |
+
runtimeServicesInfo?: RuntimeServicesInfo | null,
|
| 247 |
+
): string | undefined {
|
| 248 |
+
const info = parseRuntimeServicesInfo(runtimeServicesInfo);
|
| 249 |
+
if (!info?.services) return undefined;
|
| 250 |
+
|
| 251 |
+
const lines: string[] = [];
|
| 252 |
+
lines.push("<RUNTIME_SERVICES>");
|
| 253 |
+
if (info.mode) {
|
| 254 |
+
lines.push(
|
| 255 |
+
`You are running inside an agent-canvas dev stack started in '${info.mode}' mode.`,
|
| 256 |
+
);
|
| 257 |
+
} else {
|
| 258 |
+
lines.push("You are running inside an agent-canvas dev stack.");
|
| 259 |
+
}
|
| 260 |
+
lines.push(
|
| 261 |
+
"The following services are reachable from your sandbox. URLs are written",
|
| 262 |
+
"from your point of view (i.e., as you should curl/fetch them).",
|
| 263 |
+
"",
|
| 264 |
+
);
|
| 265 |
+
|
| 266 |
+
const { agent_server, ingress, automation } = info.services;
|
| 267 |
+
const { frontend } = info.services;
|
| 268 |
+
|
| 269 |
+
if (agent_server?.url_from_agent) {
|
| 270 |
+
lines.push(
|
| 271 |
+
`* Agent Server (you): ${agent_server.url_from_agent}`,
|
| 272 |
+
` ${agent_server.description ?? "The agent-server hosting your tool calls."}`,
|
| 273 |
+
);
|
| 274 |
+
}
|
| 275 |
+
if (ingress?.url_from_agent) {
|
| 276 |
+
lines.push(
|
| 277 |
+
`* Ingress: ${ingress.url_from_agent}`,
|
| 278 |
+
` ${ingress.description ?? "Unified entry point for browser-facing traffic."}`,
|
| 279 |
+
);
|
| 280 |
+
}
|
| 281 |
+
if (frontend?.url_from_agent) {
|
| 282 |
+
lines.push(
|
| 283 |
+
`* Frontend: ${frontend.url_from_agent}`,
|
| 284 |
+
` ${frontend.description ?? "Frontend dev server."}`,
|
| 285 |
+
);
|
| 286 |
+
}
|
| 287 |
+
if (automation?.url_from_agent) {
|
| 288 |
+
lines.push(
|
| 289 |
+
`* Automation backend: ${automation.url_from_agent}`,
|
| 290 |
+
` ${automation.description ?? "OpenHands Automations service."}`,
|
| 291 |
+
);
|
| 292 |
+
if (automation.docs_url) {
|
| 293 |
+
lines.push(` Docs: ${automation.docs_url}`);
|
| 294 |
+
}
|
| 295 |
+
if (automation.openapi_url) {
|
| 296 |
+
lines.push(` OpenAPI: ${automation.openapi_url}`);
|
| 297 |
+
}
|
| 298 |
+
if (automation.auth_env_var) {
|
| 299 |
+
// X-Session-API-Key is the local convention shared by the agent-server
|
| 300 |
+
// and automation backend (see openhands-automation auth.py).
|
| 301 |
+
lines.push(
|
| 302 |
+
` Auth: header 'X-Session-API-Key: $${automation.auth_env_var}'`,
|
| 303 |
+
);
|
| 304 |
+
}
|
| 305 |
+
} else {
|
| 306 |
+
lines.push(
|
| 307 |
+
"* Automation backend: not running in this dev mode (skip /api/automation calls).",
|
| 308 |
+
);
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
// Anchor the "don't guess" warning to the actual agent-server URL for
|
| 312 |
+
// this stack instead of a hardcoded port. The agent-server listens on
|
| 313 |
+
// different ports across dev modes, and baking the wrong port into the
|
| 314 |
+
// system prompt is exactly the kind of confusion this block is meant to
|
| 315 |
+
// prevent.
|
| 316 |
+
const agentServerUrl = agent_server?.url_from_agent;
|
| 317 |
+
lines.push(
|
| 318 |
+
"",
|
| 319 |
+
"Trust this block over guessing: do not assume any other URLs are running.",
|
| 320 |
+
);
|
| 321 |
+
if (agentServerUrl) {
|
| 322 |
+
lines.push(
|
| 323 |
+
`In particular, ${agentServerUrl} inside your sandbox is the Agent Server`,
|
| 324 |
+
"you are running inside of — NOT the automation backend.",
|
| 325 |
+
);
|
| 326 |
+
}
|
| 327 |
+
lines.push("</RUNTIME_SERVICES>");
|
| 328 |
+
|
| 329 |
+
return lines.join("\n");
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
export function toConversationUrl(conversationId: string): string {
|
| 333 |
+
// Local-format conversation URL — points at whichever local agent-server
|
| 334 |
+
// is actually serving the conversation (the bundled one when the active
|
| 335 |
+
// selection is cloud).
|
| 336 |
+
const { host } = getAgentServerClientOptions();
|
| 337 |
+
return `${host}/api/conversations/${conversationId}`;
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
// TODO(i18n): extract "Conversation" once we add CONVERSATION$DEFAULT_TITLE
|
| 341 |
+
// with `{{shortId}}` interpolation. Kept as a literal for now to keep the
|
| 342 |
+
// fallback inside this pure adapter rather than fanning out to display sites.
|
| 343 |
+
export function getDefaultConversationTitle(conversationId: string): string {
|
| 344 |
+
return `Conversation ${conversationId.slice(0, 5)}`;
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
export function toAppConversation(
|
| 348 |
+
info: DirectConversationInfo,
|
| 349 |
+
): AppConversation {
|
| 350 |
+
const metadata = getStoredConversationMetadata(info.id);
|
| 351 |
+
// ACPAgent conversations carry a sentinel ``llm`` on older SDKs. Prefer the
|
| 352 |
+
// runtime model fields when available, then the configured ``acp_model`` that
|
| 353 |
+
// Canvas saves for built-in providers. ``agent_kind`` still gates model
|
| 354 |
+
// switching, so surfacing this string is display-only.
|
| 355 |
+
const isAcp = info.agent?.kind === "ACPAgent";
|
| 356 |
+
// Only surface ``acp_server`` for ACP conversations even if the wire
|
| 357 |
+
// payload accidentally carries an ``acpserver`` tag on an OpenHands
|
| 358 |
+
// conversation — the chip is identity info for the ACP CLI subprocess,
|
| 359 |
+
// and showing it on a non-ACP conversation would be a lie. Fall back to the
|
| 360 |
+
// agent's own ``acp_server`` (#3692) when the tag is missing — a profile
|
| 361 |
+
// launch doesn't stamp the tag, so tag-only reads would drop the ACP model
|
| 362 |
+
// picker and degrade the chip to a generic "ACP" (#1571).
|
| 363 |
+
const acpServer = isAcp
|
| 364 |
+
? (info.tags?.[ACP_SERVER_TAG_KEY] ?? info.agent?.acp_server ?? null)
|
| 365 |
+
: null;
|
| 366 |
+
return {
|
| 367 |
+
id: info.id,
|
| 368 |
+
created_by_user_id: null,
|
| 369 |
+
selected_repository: metadata?.selected_repository ?? null,
|
| 370 |
+
selected_branch: metadata?.selected_branch ?? null,
|
| 371 |
+
git_provider: metadata?.git_provider ?? null,
|
| 372 |
+
selected_workspace: metadata?.selected_workspace ?? null,
|
| 373 |
+
active_profile: metadata?.active_profile ?? null,
|
| 374 |
+
title: info.title?.trim()
|
| 375 |
+
? info.title
|
| 376 |
+
: getDefaultConversationTitle(info.id),
|
| 377 |
+
trigger: null,
|
| 378 |
+
pr_number: [],
|
| 379 |
+
agent_kind: isAcp ? "acp" : "openhands",
|
| 380 |
+
acp_server: acpServer,
|
| 381 |
+
tags: info.tags ?? null,
|
| 382 |
+
launched_agent_profile: info.launched_agent_profile ?? null,
|
| 383 |
+
// Chip path: omit ``providerDefault`` so that when no concrete model
|
| 384 |
+
// resolves, the chip falls back to the provider display name in
|
| 385 |
+
// ConversationCardFooter rather than a registry default the session may
|
| 386 |
+
// not actually be running.
|
| 387 |
+
llm_model: isAcp
|
| 388 |
+
? resolveEffectiveAcpModel({
|
| 389 |
+
runtimeName: info.current_model_name,
|
| 390 |
+
runtimeId: info.current_model_id,
|
| 391 |
+
configured: info.agent?.acp_model,
|
| 392 |
+
sdkLlm: info.agent?.llm?.model,
|
| 393 |
+
})
|
| 394 |
+
: (info.agent?.llm?.model ?? DEFAULT_SETTINGS.llm_model),
|
| 395 |
+
metrics: info.metrics
|
| 396 |
+
? {
|
| 397 |
+
accumulated_cost: info.metrics.accumulated_cost ?? null,
|
| 398 |
+
max_budget_per_task: info.metrics.max_budget_per_task ?? null,
|
| 399 |
+
accumulated_token_usage: info.metrics.accumulated_token_usage
|
| 400 |
+
? {
|
| 401 |
+
prompt_tokens:
|
| 402 |
+
info.metrics.accumulated_token_usage.prompt_tokens ?? 0,
|
| 403 |
+
completion_tokens:
|
| 404 |
+
info.metrics.accumulated_token_usage.completion_tokens ?? 0,
|
| 405 |
+
cache_read_tokens:
|
| 406 |
+
info.metrics.accumulated_token_usage.cache_read_tokens ?? 0,
|
| 407 |
+
cache_write_tokens:
|
| 408 |
+
info.metrics.accumulated_token_usage.cache_write_tokens ?? 0,
|
| 409 |
+
context_window:
|
| 410 |
+
info.metrics.accumulated_token_usage.context_window ?? 0,
|
| 411 |
+
per_turn_token:
|
| 412 |
+
info.metrics.accumulated_token_usage.per_turn_token ?? 0,
|
| 413 |
+
}
|
| 414 |
+
: null,
|
| 415 |
+
}
|
| 416 |
+
: combineUsageMetrics(info.stats),
|
| 417 |
+
created_at: info.created_at,
|
| 418 |
+
updated_at: info.updated_at,
|
| 419 |
+
execution_status:
|
| 420 |
+
(info.execution_status as AppConversation["execution_status"]) ??
|
| 421 |
+
ExecutionStatus.IDLE,
|
| 422 |
+
sandbox_status: (info.sandbox_status as SandboxStatus | null) ?? null,
|
| 423 |
+
conversation_url: toConversationUrl(info.id),
|
| 424 |
+
session_api_key: getAgentServerClientOptions().apiKey ?? null,
|
| 425 |
+
sandbox_id: null,
|
| 426 |
+
workspace: {
|
| 427 |
+
working_dir: info.workspace?.working_dir ?? getAgentServerWorkingDir(),
|
| 428 |
+
},
|
| 429 |
+
public: false,
|
| 430 |
+
sub_conversation_ids: info.sub_conversation_ids ?? [],
|
| 431 |
+
};
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
export function toConversationPage(data: {
|
| 435 |
+
items: DirectConversationInfo[];
|
| 436 |
+
next_page_id?: string | null;
|
| 437 |
+
}): AppConversationPage {
|
| 438 |
+
return {
|
| 439 |
+
items: data.items
|
| 440 |
+
.filter((item) => !item.tags?.[LOCAL_PLANNER_PARENT_TAG_KEY])
|
| 441 |
+
.map(toAppConversation),
|
| 442 |
+
next_page_id: data.next_page_id ?? null,
|
| 443 |
+
};
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
type SettingsRecord = Record<string, unknown>;
|
| 447 |
+
|
| 448 |
+
interface AgentToolSpec {
|
| 449 |
+
name: string;
|
| 450 |
+
params: SettingsRecord;
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
type AgentSettingsPayload = SettingsRecord & {
|
| 454 |
+
llm?: SettingsRecord;
|
| 455 |
+
agent_context: SettingsRecord;
|
| 456 |
+
tools?: AgentToolSpec[];
|
| 457 |
+
};
|
| 458 |
+
|
| 459 |
+
interface LocalWorkspacePayload {
|
| 460 |
+
kind: "LocalWorkspace";
|
| 461 |
+
working_dir: string;
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
interface InitialMessagePayload {
|
| 465 |
+
role: "user";
|
| 466 |
+
content: Array<{ type: "text"; text: string }>;
|
| 467 |
+
run: true;
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
type ConversationSettingsPayload = SettingsRecord & {
|
| 471 |
+
workspace: LocalWorkspacePayload;
|
| 472 |
+
initial_message?: InitialMessagePayload;
|
| 473 |
+
};
|
| 474 |
+
|
| 475 |
+
export const ACP_SERVER_TAG_KEY = "acpserver";
|
| 476 |
+
export const CLIENT_SOURCE_TAG_KEY = "clientsource";
|
| 477 |
+
export const AGENT_CANVAS_SOURCE = "agentcanvas";
|
| 478 |
+
|
| 479 |
+
export const AUTOMATION_TRIGGER_TAG_KEY = "automationtrigger";
|
| 480 |
+
export const AUTOMATION_ID_TAG_KEY = "automationid";
|
| 481 |
+
export const AUTOMATION_NAME_TAG_KEY = "automationname";
|
| 482 |
+
export const AUTOMATION_RUN_ID_TAG_KEY = "automationrunid";
|
| 483 |
+
|
| 484 |
+
/**
|
| 485 |
+
* Tag keys stamped on conversations created by automation runs (see the SDK's
|
| 486 |
+
* `RemoteWorkspace.default_conversation_tags`). The presence of any of these
|
| 487 |
+
* marks a conversation as automation-born.
|
| 488 |
+
*/
|
| 489 |
+
export const AUTOMATION_TAG_KEYS: readonly string[] = [
|
| 490 |
+
AUTOMATION_TRIGGER_TAG_KEY,
|
| 491 |
+
AUTOMATION_ID_TAG_KEY,
|
| 492 |
+
AUTOMATION_NAME_TAG_KEY,
|
| 493 |
+
AUTOMATION_RUN_ID_TAG_KEY,
|
| 494 |
+
];
|
| 495 |
+
|
| 496 |
+
/**
|
| 497 |
+
* Conversation tag keys that must not appear as generic chips / hovercard
|
| 498 |
+
* rows. Each is either already surfaced by a first-class UI source or is
|
| 499 |
+
* internal routing data:
|
| 500 |
+
* - ``acpserver`` → ACP provider chip
|
| 501 |
+
* - ``clientsource`` → telemetry attribution
|
| 502 |
+
* - ``title`` → conversation card heading
|
| 503 |
+
* - git / repo / branch / workspace stamps → repo-branch metadata + directory
|
| 504 |
+
* footer / hovercard rows (``selected_repository``, ``selected_branch``,
|
| 505 |
+
* ``git_provider``, ``workspace.working_dir``)
|
| 506 |
+
* - the automation family (``automationtrigger`` / ``automationid`` /
|
| 507 |
+
* ``automationname`` / ``automationrunid``) → provenance the SDK stamps at
|
| 508 |
+
* creation; the conversation panel's automation filter is its first-class
|
| 509 |
+
* UI source. The tag surface is user organization data, so machine stamps
|
| 510 |
+
* stay out of it — and users can't edit or spoof automation classification.
|
| 511 |
+
* - ``localplannerparent`` → internal routing for the local planner; already
|
| 512 |
+
* surfaced by the hidden-from-list planner filter
|
| 513 |
+
*/
|
| 514 |
+
export const RESERVED_CONVERSATION_TAG_KEYS: ReadonlySet<string> = new Set([
|
| 515 |
+
ACP_SERVER_TAG_KEY,
|
| 516 |
+
CLIENT_SOURCE_TAG_KEY,
|
| 517 |
+
AUTOMATION_TRIGGER_TAG_KEY,
|
| 518 |
+
AUTOMATION_ID_TAG_KEY,
|
| 519 |
+
AUTOMATION_NAME_TAG_KEY,
|
| 520 |
+
AUTOMATION_RUN_ID_TAG_KEY,
|
| 521 |
+
"title",
|
| 522 |
+
"git_provider",
|
| 523 |
+
"repo_name",
|
| 524 |
+
"repo",
|
| 525 |
+
"repository",
|
| 526 |
+
"selected_branch",
|
| 527 |
+
"branch",
|
| 528 |
+
"archiveworkspacepath",
|
| 529 |
+
"workspace",
|
| 530 |
+
"working_dir",
|
| 531 |
+
LOCAL_PLANNER_PARENT_TAG_KEY,
|
| 532 |
+
]);
|
| 533 |
+
|
| 534 |
+
/**
|
| 535 |
+
* High-signal tag keys shown first in the chip row (before A–Z). Automations
|
| 536 |
+
* often stamp ``origin``; remaining free-form tags sort alphabetically.
|
| 537 |
+
*/
|
| 538 |
+
export const PRIORITY_CONVERSATION_TAG_KEYS: readonly string[] = ["origin"];
|
| 539 |
+
|
| 540 |
+
/**
|
| 541 |
+
* User-facing subset of a conversation's server-side tags: everything except
|
| 542 |
+
* {@link RESERVED_CONVERSATION_TAG_KEYS}, as stable ``[key, value]`` entries.
|
| 543 |
+
* Priority keys come first (in {@link PRIORITY_CONVERSATION_TAG_KEYS} order);
|
| 544 |
+
* the rest sort A–Z so chip order doesn't shuffle between refetches.
|
| 545 |
+
*/
|
| 546 |
+
export function getDisplayConversationTags(
|
| 547 |
+
tags: Record<string, string> | null | undefined,
|
| 548 |
+
): Array<[string, string]> {
|
| 549 |
+
if (!tags) {
|
| 550 |
+
return [];
|
| 551 |
+
}
|
| 552 |
+
// Both the reserved-key check and the priority lookup must see the same
|
| 553 |
+
// normalized key: a cloud backend can stamp ``Origin`` / `` origin``, and
|
| 554 |
+
// ranking those off the raw key would silently drop them out of first place.
|
| 555 |
+
const priorityRank = (key: string): number => {
|
| 556 |
+
const index = PRIORITY_CONVERSATION_TAG_KEYS.indexOf(
|
| 557 |
+
key.trim().toLowerCase(),
|
| 558 |
+
);
|
| 559 |
+
return index === -1 ? Number.POSITIVE_INFINITY : index;
|
| 560 |
+
};
|
| 561 |
+
|
| 562 |
+
return Object.entries(tags)
|
| 563 |
+
.filter(
|
| 564 |
+
([key, value]) =>
|
| 565 |
+
!RESERVED_CONVERSATION_TAG_KEYS.has(key.trim().toLowerCase()) &&
|
| 566 |
+
typeof value === "string" &&
|
| 567 |
+
// Bare tags (empty value) are displayable — chips/tooltips render
|
| 568 |
+
// the key. Whitespace-only values stay dropped (raw-write junk).
|
| 569 |
+
(value === "" || value.trim().length > 0),
|
| 570 |
+
)
|
| 571 |
+
.sort(([a], [b]) => {
|
| 572 |
+
const aRank = priorityRank(a);
|
| 573 |
+
const bRank = priorityRank(b);
|
| 574 |
+
if (aRank !== bRank) {
|
| 575 |
+
return aRank - bRank;
|
| 576 |
+
}
|
| 577 |
+
return a.localeCompare(b);
|
| 578 |
+
});
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
const FERNET_TOKEN_PREFIX = "gAAAAA";
|
| 582 |
+
|
| 583 |
+
const CONVERSATION_SETTINGS_METADATA_KEYS = new Set([
|
| 584 |
+
"schema_version",
|
| 585 |
+
"agent_settings",
|
| 586 |
+
"workspace",
|
| 587 |
+
"conversation_id",
|
| 588 |
+
"initial_message",
|
| 589 |
+
"plugins",
|
| 590 |
+
]);
|
| 591 |
+
|
| 592 |
+
function toRecord(value: unknown): SettingsRecord {
|
| 593 |
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
| 594 |
+
return {};
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
return structuredClone(value as SettingsRecord);
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
+
function normalizeSecretString(value: unknown): string | undefined {
|
| 601 |
+
if (typeof value !== "string") {
|
| 602 |
+
return undefined;
|
| 603 |
+
}
|
| 604 |
+
|
| 605 |
+
const trimmed = value.trim();
|
| 606 |
+
return trimmed.length > 0 ? trimmed : undefined;
|
| 607 |
+
}
|
| 608 |
+
|
| 609 |
+
function buildNormalizedLlmSettings(value: unknown): SettingsRecord {
|
| 610 |
+
const llm = toRecord(value);
|
| 611 |
+
|
| 612 |
+
llm.model =
|
| 613 |
+
typeof llm.model === "string" && llm.model.trim().length > 0
|
| 614 |
+
? llm.model
|
| 615 |
+
: DEFAULT_SETTINGS.llm_model;
|
| 616 |
+
|
| 617 |
+
const apiKey = normalizeSecretString(llm.api_key);
|
| 618 |
+
if (apiKey) {
|
| 619 |
+
llm.api_key = apiKey;
|
| 620 |
+
} else {
|
| 621 |
+
delete llm.api_key;
|
| 622 |
+
}
|
| 623 |
+
|
| 624 |
+
const baseUrl = normalizeSecretString(llm.base_url);
|
| 625 |
+
if (baseUrl) {
|
| 626 |
+
llm.base_url = baseUrl;
|
| 627 |
+
} else {
|
| 628 |
+
delete llm.base_url;
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
if (isSubscriptionLlmConfig(llm)) {
|
| 632 |
+
llm.auth_type = LLM_AUTH_TYPE_SUBSCRIPTION;
|
| 633 |
+
llm.subscription_vendor = OPENAI_SUBSCRIPTION_VENDOR;
|
| 634 |
+
delete llm.api_key;
|
| 635 |
+
} else {
|
| 636 |
+
delete llm.auth_type;
|
| 637 |
+
delete llm.subscription_vendor;
|
| 638 |
+
}
|
| 639 |
+
|
| 640 |
+
return llm;
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
| 644 |
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
| 645 |
+
}
|
| 646 |
+
|
| 647 |
+
function hasEncryptedString(value: unknown): boolean {
|
| 648 |
+
if (typeof value === "string") {
|
| 649 |
+
return value.startsWith(FERNET_TOKEN_PREFIX);
|
| 650 |
+
}
|
| 651 |
+
if (Array.isArray(value)) {
|
| 652 |
+
return value.some(hasEncryptedString);
|
| 653 |
+
}
|
| 654 |
+
if (isPlainRecord(value)) {
|
| 655 |
+
return Object.values(value).some(hasEncryptedString);
|
| 656 |
+
}
|
| 657 |
+
return false;
|
| 658 |
+
}
|
| 659 |
+
|
| 660 |
+
function hasEncryptedMcpSecrets(mcpConfig: unknown): boolean {
|
| 661 |
+
if (!isPlainRecord(mcpConfig)) {
|
| 662 |
+
return false;
|
| 663 |
+
}
|
| 664 |
+
|
| 665 |
+
return Object.values(mcpConfig).some(hasEncryptedString);
|
| 666 |
+
}
|
| 667 |
+
|
| 668 |
+
function getConversationConfirmationPolicy(
|
| 669 |
+
conversationSettings: SettingsRecord,
|
| 670 |
+
) {
|
| 671 |
+
if (conversationSettings.confirmation_mode !== true) {
|
| 672 |
+
return { kind: "NeverConfirm" };
|
| 673 |
+
}
|
| 674 |
+
|
| 675 |
+
if (conversationSettings.security_analyzer === "llm") {
|
| 676 |
+
return { kind: "ConfirmRisky", threshold: "HIGH", confirm_unknown: true };
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
return { kind: "AlwaysConfirm" };
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
function getConversationSecurityAnalyzer(conversationSettings: SettingsRecord) {
|
| 683 |
+
switch (conversationSettings.security_analyzer) {
|
| 684 |
+
case "llm":
|
| 685 |
+
return { kind: "LLMSecurityAnalyzer" };
|
| 686 |
+
case "pattern":
|
| 687 |
+
return { kind: "PatternSecurityAnalyzer" };
|
| 688 |
+
case "policy_rail":
|
| 689 |
+
return { kind: "PolicyRailSecurityAnalyzer" };
|
| 690 |
+
default:
|
| 691 |
+
return undefined;
|
| 692 |
+
}
|
| 693 |
+
}
|
| 694 |
+
|
| 695 |
+
function isToolRecord(
|
| 696 |
+
value: unknown,
|
| 697 |
+
): value is { name: string; params?: unknown } {
|
| 698 |
+
return (
|
| 699 |
+
!!value &&
|
| 700 |
+
typeof value === "object" &&
|
| 701 |
+
!Array.isArray(value) &&
|
| 702 |
+
typeof (value as { name?: unknown }).name === "string"
|
| 703 |
+
);
|
| 704 |
+
}
|
| 705 |
+
|
| 706 |
+
function shouldIncludeTool(name: string, agentSettings: SettingsRecord) {
|
| 707 |
+
if (name === BROWSER_TOOL_SET_NAME) {
|
| 708 |
+
return browserToolsEnabled() && isAgentServerToolAvailable(name);
|
| 709 |
+
}
|
| 710 |
+
|
| 711 |
+
if (name === TASK_TOOL_SET_NAME) {
|
| 712 |
+
return (
|
| 713 |
+
agentSettings.enable_sub_agents === true &&
|
| 714 |
+
isAgentServerToolAvailable(name)
|
| 715 |
+
);
|
| 716 |
+
}
|
| 717 |
+
|
| 718 |
+
return true;
|
| 719 |
+
}
|
| 720 |
+
|
| 721 |
+
function getAgentTools(agentSettings: SettingsRecord): AgentToolSpec[] {
|
| 722 |
+
const tools = new Map<string, AgentToolSpec>();
|
| 723 |
+
|
| 724 |
+
for (const name of DEFAULT_TOOL_NAMES) {
|
| 725 |
+
if (shouldIncludeTool(name, agentSettings)) {
|
| 726 |
+
tools.set(name, { name, params: {} });
|
| 727 |
+
}
|
| 728 |
+
}
|
| 729 |
+
|
| 730 |
+
for (const name of [BROWSER_TOOL_SET_NAME, TASK_TOOL_SET_NAME]) {
|
| 731 |
+
if (shouldIncludeTool(name, agentSettings)) {
|
| 732 |
+
tools.set(name, { name, params: {} });
|
| 733 |
+
}
|
| 734 |
+
}
|
| 735 |
+
|
| 736 |
+
const configuredTools = agentSettings.tools;
|
| 737 |
+
if (
|
| 738 |
+
Array.isArray(configuredTools) &&
|
| 739 |
+
configuredTools.every((tool) => isToolRecord(tool))
|
| 740 |
+
) {
|
| 741 |
+
for (const tool of configuredTools) {
|
| 742 |
+
if (shouldIncludeTool(tool.name, agentSettings)) {
|
| 743 |
+
tools.set(tool.name, {
|
| 744 |
+
name: tool.name,
|
| 745 |
+
params: toRecord(tool.params),
|
| 746 |
+
});
|
| 747 |
+
}
|
| 748 |
+
}
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
return Array.from(tools.values());
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
+
function buildInitialMessage(
|
| 755 |
+
query?: string,
|
| 756 |
+
conversationInstructions?: string,
|
| 757 |
+
): InitialMessagePayload | null {
|
| 758 |
+
const parts = [query?.trim(), conversationInstructions?.trim()].filter(
|
| 759 |
+
Boolean,
|
| 760 |
+
);
|
| 761 |
+
if (parts.length === 0) {
|
| 762 |
+
return null;
|
| 763 |
+
}
|
| 764 |
+
|
| 765 |
+
return {
|
| 766 |
+
role: "user",
|
| 767 |
+
content: [{ type: "text", text: parts.join("\n\n") }],
|
| 768 |
+
run: true,
|
| 769 |
+
};
|
| 770 |
+
}
|
| 771 |
+
|
| 772 |
+
/**
|
| 773 |
+
* Shape of a bundled skill entry passed to the agent-server SDK via
|
| 774 |
+
* `agent_context.skills`. Mirrors the SDK's `Skill` model fields that
|
| 775 |
+
* the server uses for trigger matching, activation, and system-prompt
|
| 776 |
+
* injection.
|
| 777 |
+
*/
|
| 778 |
+
interface BundledSkill {
|
| 779 |
+
name: string;
|
| 780 |
+
content: string;
|
| 781 |
+
trigger: { type: "keyword"; keywords: string[] } | null;
|
| 782 |
+
source: string;
|
| 783 |
+
description: string | null;
|
| 784 |
+
is_agentskills_format: true;
|
| 785 |
+
license?: string;
|
| 786 |
+
compatibility?: string;
|
| 787 |
+
}
|
| 788 |
+
|
| 789 |
+
/**
|
| 790 |
+
* Convert the bundled `SKILLS_CATALOG` entries into the SDK `Skill` JSON
|
| 791 |
+
* shape so the agent-server can perform trigger matching, skill activation,
|
| 792 |
+
* and system-prompt injection without cloning the extensions repo.
|
| 793 |
+
*
|
| 794 |
+
* The SDK discriminates triggers via `{ type: "keyword", keywords: [...] }`.
|
| 795 |
+
* Skills with no triggers get `trigger: null` (always-active / on-demand).
|
| 796 |
+
*/
|
| 797 |
+
function buildBundledSkills(): BundledSkill[] {
|
| 798 |
+
return SKILLS_CATALOG.map((entry) => {
|
| 799 |
+
const trigger: BundledSkill["trigger"] =
|
| 800 |
+
entry.triggers?.length > 0
|
| 801 |
+
? { type: "keyword", keywords: entry.triggers }
|
| 802 |
+
: null;
|
| 803 |
+
|
| 804 |
+
// Use the absolute path to the skill's SKILL.md so the Python
|
| 805 |
+
// agent-server can resolve bundled resources (scripts/, references/).
|
| 806 |
+
// Falls back to "public" in library builds where the path isn't known.
|
| 807 |
+
const source = __EXTENSIONS_SKILLS_DIR__
|
| 808 |
+
? `${__EXTENSIONS_SKILLS_DIR__}/${entry.name}/SKILL.md`
|
| 809 |
+
: "public";
|
| 810 |
+
|
| 811 |
+
return {
|
| 812 |
+
name: entry.name,
|
| 813 |
+
content: entry.content,
|
| 814 |
+
trigger,
|
| 815 |
+
source,
|
| 816 |
+
description: entry.description ?? null,
|
| 817 |
+
is_agentskills_format: true as const,
|
| 818 |
+
...(entry.license ? { license: entry.license } : {}),
|
| 819 |
+
...(entry.compatibility ? { compatibility: entry.compatibility } : {}),
|
| 820 |
+
};
|
| 821 |
+
});
|
| 822 |
+
}
|
| 823 |
+
|
| 824 |
+
function buildAgentContext(
|
| 825 |
+
agentSettings: SettingsRecord,
|
| 826 |
+
runtimeServicesInfo?: RuntimeServicesInfo | null,
|
| 827 |
+
enablement: SkillEnablement = {},
|
| 828 |
+
invokedCatalogSkill?: string,
|
| 829 |
+
): SettingsRecord {
|
| 830 |
+
const runtimeServicesSuffix =
|
| 831 |
+
buildRuntimeServicesSystemSuffix(runtimeServicesInfo);
|
| 832 |
+
const existingContext = toRecord(agentSettings.agent_context);
|
| 833 |
+
|
| 834 |
+
// Merge bundled public skills with any skills already present in the
|
| 835 |
+
// agent context (e.g. user-defined skills set via the settings API).
|
| 836 |
+
const existingSkills = Array.isArray(existingContext.skills)
|
| 837 |
+
? (existingContext.skills as SettingsRecord[])
|
| 838 |
+
: [];
|
| 839 |
+
const disabledSkills = enablement.disabledSkills ?? [];
|
| 840 |
+
const disabledSkillNames = new Set(disabledSkills);
|
| 841 |
+
const isSkillEnabled = buildSkillEnablementFilter(enablement);
|
| 842 |
+
|
| 843 |
+
// The bundled catalog is allow-listed, not deny-listed: it is a build-time
|
| 844 |
+
// snapshot of ~60 skills, so a deny-list puts every future addition into
|
| 845 |
+
// every system prompt (OpenHands#16302). Skills the agent context already
|
| 846 |
+
// carries are user-authored and stay opt-out. A skill the opening message
|
| 847 |
+
// invokes by name overrides both, for this conversation only.
|
| 848 |
+
const mergedSkills = [
|
| 849 |
+
...existingSkills.filter(
|
| 850 |
+
(skill) =>
|
| 851 |
+
typeof skill.name !== "string" || !disabledSkillNames.has(skill.name),
|
| 852 |
+
),
|
| 853 |
+
...buildBundledSkills().filter(
|
| 854 |
+
(skill) =>
|
| 855 |
+
skill.name === invokedCatalogSkill || isSkillEnabled(skill.name),
|
| 856 |
+
),
|
| 857 |
+
];
|
| 858 |
+
|
| 859 |
+
return {
|
| 860 |
+
...existingContext,
|
| 861 |
+
// Public skills are bundled at build time from the @openhands/extensions
|
| 862 |
+
// npm package and passed directly in agent_context.skills. Setting
|
| 863 |
+
// load_public_skills to false tells the agent-server SDK to skip its own
|
| 864 |
+
// extensions-repo clone — the frontend is the sole source of public
|
| 865 |
+
// skills now.
|
| 866 |
+
//
|
| 867 |
+
// Migration: the former VITE_LOAD_PUBLIC_SKILLS env var was removed
|
| 868 |
+
// because bundled skills have no clone latency. Users who previously set
|
| 869 |
+
// VITE_LOAD_PUBLIC_SKILLS=false to avoid clone delays no longer need it.
|
| 870 |
+
skills: mergedSkills,
|
| 871 |
+
load_public_skills: false,
|
| 872 |
+
load_user_skills: true,
|
| 873 |
+
load_project_skills: true,
|
| 874 |
+
// The backend also auto-loads user/project skills; the deny-list must
|
| 875 |
+
// travel with the context so those skills are excluded from the system
|
| 876 |
+
// prompt too. The allow-list has no counterpart to send: the backend
|
| 877 |
+
// loads no catalog skills of its own (`load_public_skills` is false).
|
| 878 |
+
disabled_skills: disabledSkills,
|
| 879 |
+
...(runtimeServicesSuffix
|
| 880 |
+
? { system_message_suffix: runtimeServicesSuffix }
|
| 881 |
+
: {}),
|
| 882 |
+
};
|
| 883 |
+
}
|
| 884 |
+
|
| 885 |
+
function isAcpAgent(settings: Settings): boolean {
|
| 886 |
+
const agentSettings = toRecord(settings.agent_settings);
|
| 887 |
+
return agentSettings.agent_kind === "acp";
|
| 888 |
+
}
|
| 889 |
+
|
| 890 |
+
function getAcpServerTag(settings: Settings): string | undefined {
|
| 891 |
+
const agentSettings = toRecord(settings.agent_settings);
|
| 892 |
+
const value = agentSettings.acp_server;
|
| 893 |
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
| 894 |
+
}
|
| 895 |
+
|
| 896 |
+
function resolveAcpCommand(agentSettings: SettingsRecord): unknown {
|
| 897 |
+
const cmd = agentSettings.acp_command;
|
| 898 |
+
const isEmpty = Array.isArray(cmd) && cmd.length === 0;
|
| 899 |
+
const noCommand = cmd === undefined;
|
| 900 |
+
if (!isEmpty && !noCommand) {
|
| 901 |
+
return cmd;
|
| 902 |
+
}
|
| 903 |
+
|
| 904 |
+
const serverKey =
|
| 905 |
+
typeof agentSettings.acp_server === "string"
|
| 906 |
+
? agentSettings.acp_server
|
| 907 |
+
: undefined;
|
| 908 |
+
const provider = getAcpProvider(serverKey);
|
| 909 |
+
return provider ? [...provider.default_command] : cmd;
|
| 910 |
+
}
|
| 911 |
+
|
| 912 |
+
function buildConfiguredAcpAgentSettings(
|
| 913 |
+
settings: Settings,
|
| 914 |
+
runtimeServicesInfo?: RuntimeServicesInfo | null,
|
| 915 |
+
query?: string,
|
| 916 |
+
): AgentSettingsPayload {
|
| 917 |
+
const agentSettings = toRecord(settings.agent_settings);
|
| 918 |
+
const payload: AgentSettingsPayload = {
|
| 919 |
+
agent_kind: "acp",
|
| 920 |
+
agent_context: buildAgentContext(
|
| 921 |
+
agentSettings,
|
| 922 |
+
runtimeServicesInfo,
|
| 923 |
+
toSkillEnablement(settings),
|
| 924 |
+
findInvokedCatalogSkill(query),
|
| 925 |
+
),
|
| 926 |
+
};
|
| 927 |
+
|
| 928 |
+
// TODO(#1019): set ``acp_isolate_data_dir: true`` here for a containerized
|
| 929 |
+
// backend so concurrent same-provider conversations don't race on a shared
|
| 930 |
+
// HOME. The SDK supports it (software-agent-sdk#3492), but the released
|
| 931 |
+
// ``@openhands/typescript-client`` (1.24.3) doesn't surface it on
|
| 932 |
+
// ``ACPAgentSettings`` yet, so sending it risks a validation error on older
|
| 933 |
+
// servers. Cloud grouping isolation is separate (agent-canvas#1016).
|
| 934 |
+
|
| 935 |
+
for (const key of ACP_SETTINGS_KEYS) {
|
| 936 |
+
// ``acp_model`` is resolved separately below so a saved ``null`` still
|
| 937 |
+
// falls back to the provider's default rather than being dropped.
|
| 938 |
+
if (key === "acp_model") continue;
|
| 939 |
+
// ``acp_env`` is deprecated — provider creds now route via ``request.secrets``.
|
| 940 |
+
if (key === "acp_env") continue;
|
| 941 |
+
const value =
|
| 942 |
+
key === "acp_command"
|
| 943 |
+
? resolveAcpCommand(agentSettings)
|
| 944 |
+
: agentSettings[key];
|
| 945 |
+
if (value !== undefined && value !== null) {
|
| 946 |
+
payload[key] = value;
|
| 947 |
+
}
|
| 948 |
+
}
|
| 949 |
+
|
| 950 |
+
// ``mcp_config`` is a shared field (not in ACP_SETTINGS_KEYS): forward it
|
| 951 |
+
// so the ACP subprocess connects to the configured MCP servers at session
|
| 952 |
+
// creation. Only include it when it actually carries servers — an empty or
|
| 953 |
+
// malformed value is dropped rather than sending ``mcp_config: {}``.
|
| 954 |
+
const mcpConfig = toRecord(agentSettings.mcp_config);
|
| 955 |
+
if (Object.keys(mcpConfig).length > 0) {
|
| 956 |
+
payload.mcp_config = mcpConfig;
|
| 957 |
+
}
|
| 958 |
+
|
| 959 |
+
// Saved settings may carry ``acp_model: null`` (existing users predating
|
| 960 |
+
// the default-model registry, or saved fields the agent-server stripped).
|
| 961 |
+
// Fall back to the *preferred* default (Vertex-safe for Gemini) so the
|
| 962 |
+
// conversation starts with whatever the Settings → Agent UI shows — without
|
| 963 |
+
// that, the form's displayed default would silently not take effect at
|
| 964 |
+
// runtime until the user re-saved the page.
|
| 965 |
+
const serverKey =
|
| 966 |
+
typeof agentSettings.acp_server === "string"
|
| 967 |
+
? agentSettings.acp_server
|
| 968 |
+
: undefined;
|
| 969 |
+
const effectiveModel = resolveEffectiveAcpModel({
|
| 970 |
+
configured: agentSettings.acp_model as string | null | undefined,
|
| 971 |
+
providerDefault: getAcpPreferredDefaultModel(serverKey),
|
| 972 |
+
});
|
| 973 |
+
if (effectiveModel) {
|
| 974 |
+
payload.acp_model = effectiveModel;
|
| 975 |
+
}
|
| 976 |
+
|
| 977 |
+
return payload;
|
| 978 |
+
}
|
| 979 |
+
|
| 980 |
+
function buildConfiguredOpenHandsAgentSettings(
|
| 981 |
+
settings: Settings,
|
| 982 |
+
runtimeServicesInfo?: RuntimeServicesInfo | null,
|
| 983 |
+
query?: string,
|
| 984 |
+
): AgentSettingsPayload {
|
| 985 |
+
const agentSettings = toRecord(settings.agent_settings);
|
| 986 |
+
const llm = buildNormalizedLlmSettings(agentSettings.llm);
|
| 987 |
+
|
| 988 |
+
// Stream assistant tokens (parity with ACP agents). The agent-server only
|
| 989 |
+
// emits StreamingDeltaEvents for SDK LLM agents when an LLM has stream=True.
|
| 990 |
+
llm.stream = true;
|
| 991 |
+
|
| 992 |
+
const mcpConfig = toRecord(agentSettings.mcp_config);
|
| 993 |
+
if (Object.keys(mcpConfig).length === 0) {
|
| 994 |
+
delete agentSettings.mcp_config;
|
| 995 |
+
}
|
| 996 |
+
|
| 997 |
+
delete agentSettings.acp_server;
|
| 998 |
+
for (const key of ACP_SETTINGS_KEYS) {
|
| 999 |
+
delete agentSettings[key];
|
| 1000 |
+
}
|
| 1001 |
+
// ``acp_env`` is no longer a forwarded ACP setting (provider creds ride the
|
| 1002 |
+
// Secrets panel), but a legacy value may linger on persisted settings —
|
| 1003 |
+
// scrub it so it never leaks onto the OpenHands payload.
|
| 1004 |
+
delete agentSettings.acp_env;
|
| 1005 |
+
|
| 1006 |
+
return {
|
| 1007 |
+
...agentSettings,
|
| 1008 |
+
llm,
|
| 1009 |
+
agent_context: buildAgentContext(
|
| 1010 |
+
agentSettings,
|
| 1011 |
+
runtimeServicesInfo,
|
| 1012 |
+
toSkillEnablement(settings),
|
| 1013 |
+
findInvokedCatalogSkill(query),
|
| 1014 |
+
),
|
| 1015 |
+
tools: getAgentTools(agentSettings),
|
| 1016 |
+
};
|
| 1017 |
+
}
|
| 1018 |
+
|
| 1019 |
+
function buildConfiguredAgentSettings(
|
| 1020 |
+
settings: Settings,
|
| 1021 |
+
runtimeServicesInfo?: RuntimeServicesInfo | null,
|
| 1022 |
+
query?: string,
|
| 1023 |
+
): AgentSettingsPayload {
|
| 1024 |
+
return isAcpAgent(settings)
|
| 1025 |
+
? buildConfiguredAcpAgentSettings(settings, runtimeServicesInfo, query)
|
| 1026 |
+
: buildConfiguredOpenHandsAgentSettings(
|
| 1027 |
+
settings,
|
| 1028 |
+
runtimeServicesInfo,
|
| 1029 |
+
query,
|
| 1030 |
+
);
|
| 1031 |
+
}
|
| 1032 |
+
|
| 1033 |
+
function buildConfiguredConversationSettings(options: {
|
| 1034 |
+
settings: Settings;
|
| 1035 |
+
query?: string;
|
| 1036 |
+
conversationInstructions?: string;
|
| 1037 |
+
plugins?: PluginSpec[];
|
| 1038 |
+
workingDir?: string;
|
| 1039 |
+
}): ConversationSettingsPayload {
|
| 1040 |
+
const { settings, query, conversationInstructions, plugins, workingDir } =
|
| 1041 |
+
options;
|
| 1042 |
+
const conversationSettings = toRecord(settings.conversation_settings);
|
| 1043 |
+
const initialMessage = buildInitialMessage(query, conversationInstructions);
|
| 1044 |
+
|
| 1045 |
+
CONVERSATION_SETTINGS_METADATA_KEYS.forEach(
|
| 1046 |
+
(key) => delete conversationSettings[key],
|
| 1047 |
+
);
|
| 1048 |
+
|
| 1049 |
+
const payload: ConversationSettingsPayload = {
|
| 1050 |
+
...conversationSettings,
|
| 1051 |
+
workspace: {
|
| 1052 |
+
kind: "LocalWorkspace",
|
| 1053 |
+
working_dir: workingDir ?? getAgentServerWorkingDir(),
|
| 1054 |
+
},
|
| 1055 |
+
...(initialMessage ? { initial_message: initialMessage } : {}),
|
| 1056 |
+
...(plugins?.length
|
| 1057 |
+
? {
|
| 1058 |
+
plugins: plugins.map((plugin) => ({
|
| 1059 |
+
source: plugin.source,
|
| 1060 |
+
...(plugin.ref ? { ref: plugin.ref } : {}),
|
| 1061 |
+
...(plugin.repo_path ? { repo_path: plugin.repo_path } : {}),
|
| 1062 |
+
})),
|
| 1063 |
+
}
|
| 1064 |
+
: {}),
|
| 1065 |
+
};
|
| 1066 |
+
|
| 1067 |
+
return payload;
|
| 1068 |
+
}
|
| 1069 |
+
|
| 1070 |
+
interface LookupSecret {
|
| 1071 |
+
kind: "LookupSecret";
|
| 1072 |
+
url: string;
|
| 1073 |
+
headers?: Record<string, string>;
|
| 1074 |
+
description?: string;
|
| 1075 |
+
}
|
| 1076 |
+
|
| 1077 |
+
/** A custom secret's public identity — name + optional description, no value. */
|
| 1078 |
+
type CustomSecretInput = { name: string; description?: string };
|
| 1079 |
+
|
| 1080 |
+
type StartConversationPayloadBase = Record<string, unknown> & {
|
| 1081 |
+
workspace: LocalWorkspacePayload;
|
| 1082 |
+
confirmation_policy: SettingsRecord;
|
| 1083 |
+
security_analyzer?: SettingsRecord;
|
| 1084 |
+
initial_message?: InitialMessagePayload;
|
| 1085 |
+
max_iterations: number;
|
| 1086 |
+
stuck_detection: true;
|
| 1087 |
+
autotitle: boolean;
|
| 1088 |
+
title_llm_profile?: string;
|
| 1089 |
+
worktree: boolean;
|
| 1090 |
+
secrets_encrypted?: true;
|
| 1091 |
+
conversation_id?: string;
|
| 1092 |
+
parent_conversation_id?: string;
|
| 1093 |
+
secrets?: Record<string, LookupSecret>;
|
| 1094 |
+
tags?: Record<string, string>;
|
| 1095 |
+
client_tools: ClientToolSpec[];
|
| 1096 |
+
tool_module_qualnames?: Record<string, string>;
|
| 1097 |
+
};
|
| 1098 |
+
|
| 1099 |
+
type AgentSettingsStartConversationPayload = StartConversationPayloadBase & {
|
| 1100 |
+
// Omitted when launching via ``agent_profile_id`` — the two are mutually
|
| 1101 |
+
// exclusive agent sources; the server resolves the profile server-side.
|
| 1102 |
+
agent_settings?: AgentSettingsPayload;
|
| 1103 |
+
agent_profile_id?: string;
|
| 1104 |
+
agent?: never;
|
| 1105 |
+
};
|
| 1106 |
+
|
| 1107 |
+
// The local planner bypasses agent assembly entirely and sends a raw agent
|
| 1108 |
+
// spec, so neither of the two managed agent sources applies.
|
| 1109 |
+
type RawAgentStartConversationPayload = StartConversationPayloadBase & {
|
| 1110 |
+
agent: SettingsRecord;
|
| 1111 |
+
agent_settings?: never;
|
| 1112 |
+
agent_profile_id?: never;
|
| 1113 |
+
parent_conversation_id?: string;
|
| 1114 |
+
};
|
| 1115 |
+
|
| 1116 |
+
export interface StartConversationOptions {
|
| 1117 |
+
settings: Settings;
|
| 1118 |
+
query?: string;
|
| 1119 |
+
conversationInstructions?: string;
|
| 1120 |
+
plugins?: PluginSpec[];
|
| 1121 |
+
conversationId?: string;
|
| 1122 |
+
// Links the new conversation to an existing one as its child. The
|
| 1123 |
+
// agent-server requires the parent to exist and to share this
|
| 1124 |
+
// conversation's requested `workspace.working_dir` (software-agent-sdk
|
| 1125 |
+
// #4188, agent-server >= 1.37.1); older servers ignore the field.
|
| 1126 |
+
parentConversationId?: string;
|
| 1127 |
+
workingDir?: string;
|
| 1128 |
+
worktree?: boolean;
|
| 1129 |
+
encryptedAgentSettings?: Record<string, SettingsValue>;
|
| 1130 |
+
encryptedConversationSettings?: Record<string, SettingsValue>;
|
| 1131 |
+
secretsEncrypted?: boolean;
|
| 1132 |
+
customSecrets?: CustomSecretInput[];
|
| 1133 |
+
// When set, the conversation launches from this AgentProfile (resolved
|
| 1134 |
+
// server-side) instead of an inline ``agent_settings`` dump (#3727).
|
| 1135 |
+
agentProfileId?: string;
|
| 1136 |
+
agentProfileKind?: AgentKind;
|
| 1137 |
+
titleLlmProfile?: string;
|
| 1138 |
+
runtimeServicesInfo?: RuntimeServicesInfo | null;
|
| 1139 |
+
workspaceHookConfig?: HookConfig | null;
|
| 1140 |
+
}
|
| 1141 |
+
|
| 1142 |
+
/**
|
| 1143 |
+
* Build the `request.secrets` map shared by the standard and planning
|
| 1144 |
+
* conversation builders. Every saved secret rides as a LookupSecret the
|
| 1145 |
+
* agent-server resolves from its own store at spawn time — `request.secrets` is
|
| 1146 |
+
* the sole channel, uniform for ACP and non-ACP (agent-canvas#1039). For ACP the
|
| 1147 |
+
* resolution runs off the event loop (software-agent-sdk#3510, >=1.25.0), so the
|
| 1148 |
+
* loopback fetch can't deadlock. Returns `undefined` when there are no custom
|
| 1149 |
+
* secrets so callers can omit the field.
|
| 1150 |
+
*/
|
| 1151 |
+
function buildCustomSecrets(
|
| 1152 |
+
customSecrets: CustomSecretInput[] | undefined,
|
| 1153 |
+
): Record<string, LookupSecret> | undefined {
|
| 1154 |
+
if (!customSecrets?.length) return undefined;
|
| 1155 |
+
|
| 1156 |
+
const backend = getEffectiveLocalBackend();
|
| 1157 |
+
const headers = backend ? buildAuthHeaders(backend) : {};
|
| 1158 |
+
|
| 1159 |
+
const secrets: Record<string, LookupSecret> = {};
|
| 1160 |
+
for (const secret of customSecrets) {
|
| 1161 |
+
const lookupSecret: LookupSecret = {
|
| 1162 |
+
kind: "LookupSecret",
|
| 1163 |
+
url: `/api/settings/secrets/${encodeURIComponent(secret.name)}`,
|
| 1164 |
+
description: secret.description,
|
| 1165 |
+
};
|
| 1166 |
+
if (Object.keys(headers).length > 0) {
|
| 1167 |
+
lookupSecret.headers = headers;
|
| 1168 |
+
}
|
| 1169 |
+
secrets[secret.name] = lookupSecret;
|
| 1170 |
+
}
|
| 1171 |
+
return secrets;
|
| 1172 |
+
}
|
| 1173 |
+
|
| 1174 |
+
export function buildStartConversationRequest(
|
| 1175 |
+
options: StartConversationOptions,
|
| 1176 |
+
): AgentSettingsStartConversationPayload {
|
| 1177 |
+
const sourceAgentSettings = options.encryptedAgentSettings
|
| 1178 |
+
? { ...options.settings, agent_settings: options.encryptedAgentSettings }
|
| 1179 |
+
: options.settings;
|
| 1180 |
+
|
| 1181 |
+
const acpMode = isAcpAgent(sourceAgentSettings);
|
| 1182 |
+
const launchAgentKind = options.agentProfileId
|
| 1183 |
+
? options.agentProfileKind
|
| 1184 |
+
: acpMode
|
| 1185 |
+
? "acp"
|
| 1186 |
+
: "openhands";
|
| 1187 |
+
const agentSettings = buildConfiguredAgentSettings(
|
| 1188 |
+
sourceAgentSettings,
|
| 1189 |
+
options.runtimeServicesInfo,
|
| 1190 |
+
options.query,
|
| 1191 |
+
);
|
| 1192 |
+
const acpServerTag = acpMode
|
| 1193 |
+
? getAcpServerTag(sourceAgentSettings)
|
| 1194 |
+
: undefined;
|
| 1195 |
+
|
| 1196 |
+
const sourceConversationOptions = options.encryptedConversationSettings
|
| 1197 |
+
? {
|
| 1198 |
+
...options,
|
| 1199 |
+
settings: {
|
| 1200 |
+
...options.settings,
|
| 1201 |
+
conversation_settings: options.encryptedConversationSettings,
|
| 1202 |
+
},
|
| 1203 |
+
}
|
| 1204 |
+
: options;
|
| 1205 |
+
|
| 1206 |
+
const conversationSettings = buildConfiguredConversationSettings(
|
| 1207 |
+
sourceConversationOptions,
|
| 1208 |
+
);
|
| 1209 |
+
|
| 1210 |
+
const payload: AgentSettingsStartConversationPayload = {
|
| 1211 |
+
// ``agent_profile_id`` and ``agent_settings`` are mutually exclusive agent
|
| 1212 |
+
// sources; the profile path lets the server resolve the profile (#3727).
|
| 1213 |
+
//
|
| 1214 |
+
// Enrichment boundary: on the profile path the server rebuilds the agent
|
| 1215 |
+
// purely from the stored profile fields, so the client-owned enrichments
|
| 1216 |
+
// this adapter folds into ``agent_settings`` do NOT apply. The exec toolset
|
| 1217 |
+
// (terminal/file_editor/task_tracker) and public-skill loading are the
|
| 1218 |
+
// server/SDK's responsibility to restore on the profile path — tracked in
|
| 1219 |
+
// software-agent-sdk#3967 (profile resolution must attach the default
|
| 1220 |
+
// toolset + public skills, else a profile-launched OpenHands agent has only
|
| 1221 |
+
// Finish/Think). The dev ``RUNTIME_SERVICES`` system-message suffix remains
|
| 1222 |
+
// agent-settings-only; the Canvas UI tool is a top-level client tool and
|
| 1223 |
+
// therefore works on both inline-agent and profile launch paths.
|
| 1224 |
+
//
|
| 1225 |
+
// Persistent memory is NOT on that boundary: ``load_memory`` is a global
|
| 1226 |
+
// user preference, so the agent-server stamps the stored
|
| 1227 |
+
// ``agent_settings.agent_context.load_memory`` onto the profile-resolved
|
| 1228 |
+
// agent the same way it already applies the global ``mcp_config``. The
|
| 1229 |
+
// toggle therefore applies to both launch paths, and the client must not
|
| 1230 |
+
// re-send it here (``agent_profile_id`` and ``agent_settings`` are
|
| 1231 |
+
// mutually exclusive).
|
| 1232 |
+
...(options.agentProfileId
|
| 1233 |
+
? { agent_profile_id: options.agentProfileId }
|
| 1234 |
+
: { agent_settings: agentSettings }),
|
| 1235 |
+
workspace: conversationSettings.workspace,
|
| 1236 |
+
// The agent-server caches each client tool's schema per tool *name* for the
|
| 1237 |
+
// life of the process and rejects a re-registration with a different schema
|
| 1238 |
+
// (`ClientToolSchemaConflictError`). Editing either schema below therefore
|
| 1239 |
+
// requires restarting a long-running dev agent-server before new
|
| 1240 |
+
// conversations can start.
|
| 1241 |
+
client_tools:
|
| 1242 |
+
launchAgentKind === "openhands"
|
| 1243 |
+
? [CANVAS_UI_CLIENT_TOOL, LAUNCH_CHILD_CONVERSATION_CLIENT_TOOL]
|
| 1244 |
+
: [],
|
| 1245 |
+
confirmation_policy:
|
| 1246 |
+
getConversationConfirmationPolicy(conversationSettings),
|
| 1247 |
+
max_iterations: resolveMaxIterations(conversationSettings.max_iterations),
|
| 1248 |
+
stuck_detection: true,
|
| 1249 |
+
autotitle: true,
|
| 1250 |
+
...(options.titleLlmProfile
|
| 1251 |
+
? { title_llm_profile: options.titleLlmProfile }
|
| 1252 |
+
: {}),
|
| 1253 |
+
worktree: options.worktree ?? true,
|
| 1254 |
+
};
|
| 1255 |
+
|
| 1256 |
+
// Stamp the client source tag so the agent-server can attribute the
|
| 1257 |
+
// conversation to Canvas in telemetry (conversation_source = "canvas").
|
| 1258 |
+
// A profile launch resolves the ACP server server-side, so don't stamp the
|
| 1259 |
+
// tag from current settings (it may not match the launched profile).
|
| 1260 |
+
if (!options.agentProfileId && acpServerTag) {
|
| 1261 |
+
payload.tags = {
|
| 1262 |
+
[ACP_SERVER_TAG_KEY]: acpServerTag,
|
| 1263 |
+
[CLIENT_SOURCE_TAG_KEY]: AGENT_CANVAS_SOURCE,
|
| 1264 |
+
};
|
| 1265 |
+
} else {
|
| 1266 |
+
payload.tags = { [CLIENT_SOURCE_TAG_KEY]: AGENT_CANVAS_SOURCE };
|
| 1267 |
+
}
|
| 1268 |
+
|
| 1269 |
+
// ``secrets_encrypted`` makes the agent-server decrypt request secrets at
|
| 1270 |
+
// conversation start. Non-ACP conversations need it for encrypted LLM keys.
|
| 1271 |
+
// ACP normally carries provider credentials as LookupSecrets, so avoid
|
| 1272 |
+
// forcing a cipher on fresh ACP-only backends. The exception is MCP:
|
| 1273 |
+
// encrypted settings round-trip mcp_config secrets as Fernet tokens,
|
| 1274 |
+
// and ACP forwards mcp_config directly to the subprocess.
|
| 1275 |
+
if (
|
| 1276 |
+
!options.agentProfileId &&
|
| 1277 |
+
options.secretsEncrypted &&
|
| 1278 |
+
(!acpMode || hasEncryptedMcpSecrets(agentSettings.mcp_config))
|
| 1279 |
+
) {
|
| 1280 |
+
payload.secrets_encrypted = true;
|
| 1281 |
+
}
|
| 1282 |
+
|
| 1283 |
+
if (options.conversationId) {
|
| 1284 |
+
payload.conversation_id = options.conversationId;
|
| 1285 |
+
}
|
| 1286 |
+
|
| 1287 |
+
if (options.parentConversationId) {
|
| 1288 |
+
payload.parent_conversation_id = options.parentConversationId;
|
| 1289 |
+
}
|
| 1290 |
+
|
| 1291 |
+
const securityAnalyzer =
|
| 1292 |
+
getConversationSecurityAnalyzer(conversationSettings);
|
| 1293 |
+
if (securityAnalyzer) {
|
| 1294 |
+
payload.security_analyzer = securityAnalyzer;
|
| 1295 |
+
}
|
| 1296 |
+
|
| 1297 |
+
if (conversationSettings.initial_message) {
|
| 1298 |
+
payload.initial_message = conversationSettings.initial_message;
|
| 1299 |
+
}
|
| 1300 |
+
|
| 1301 |
+
if (conversationSettings.plugins) {
|
| 1302 |
+
payload.plugins = conversationSettings.plugins;
|
| 1303 |
+
}
|
| 1304 |
+
|
| 1305 |
+
if (conversationSettings.hook_config) {
|
| 1306 |
+
payload.hook_config = conversationSettings.hook_config;
|
| 1307 |
+
} else if (options.workspaceHookConfig) {
|
| 1308 |
+
payload.hook_config = options.workspaceHookConfig;
|
| 1309 |
+
}
|
| 1310 |
+
|
| 1311 |
+
const toolModuleQualnames = {
|
| 1312 |
+
...((conversationSettings.tool_module_qualnames as
|
| 1313 |
+
| Record<string, string>
|
| 1314 |
+
| undefined) ?? {}),
|
| 1315 |
+
};
|
| 1316 |
+
delete toolModuleQualnames[LEGACY_CANVAS_UI_TOOL_NAME];
|
| 1317 |
+
delete toolModuleQualnames[CANVAS_UI_CLIENT_TOOL_NAME];
|
| 1318 |
+
delete toolModuleQualnames[LAUNCH_CHILD_CONVERSATION_TOOL_NAME];
|
| 1319 |
+
if (Object.keys(toolModuleQualnames).length > 0) {
|
| 1320 |
+
payload.tool_module_qualnames = toolModuleQualnames;
|
| 1321 |
+
}
|
| 1322 |
+
|
| 1323 |
+
if (conversationSettings.agent_definitions) {
|
| 1324 |
+
payload.agent_definitions = conversationSettings.agent_definitions;
|
| 1325 |
+
}
|
| 1326 |
+
|
| 1327 |
+
const secrets = buildCustomSecrets(options.customSecrets);
|
| 1328 |
+
if (secrets) {
|
| 1329 |
+
payload.secrets = secrets;
|
| 1330 |
+
}
|
| 1331 |
+
|
| 1332 |
+
return payload;
|
| 1333 |
+
}
|
| 1334 |
+
|
| 1335 |
+
export function buildStartPlanningConversationRequest(options: {
|
| 1336 |
+
encryptedAgentSettings: Record<string, SettingsValue>;
|
| 1337 |
+
workingDir: string;
|
| 1338 |
+
parentConversationId: string;
|
| 1339 |
+
initialMessage?: string;
|
| 1340 |
+
secretsEncrypted?: boolean;
|
| 1341 |
+
customSecrets?: CustomSecretInput[];
|
| 1342 |
+
/** Mirrors the parent's configured `conversation_settings.max_iterations` — see DEFAULT_MAX_ITERATIONS. */
|
| 1343 |
+
maxIterations?: number;
|
| 1344 |
+
/** Mirrors the code agent's skill filtering — see buildConfiguredOpenHandsAgentSettings. */
|
| 1345 |
+
skillEnablement?: SkillEnablement;
|
| 1346 |
+
}): RawAgentStartConversationPayload {
|
| 1347 |
+
const agentSettings = toRecord(options.encryptedAgentSettings);
|
| 1348 |
+
const llm = buildNormalizedLlmSettings(agentSettings.llm);
|
| 1349 |
+
// Stream assistant tokens, matching the code agent (see
|
| 1350 |
+
// buildConfiguredOpenHandsAgentSettings) — otherwise the agent-server never
|
| 1351 |
+
// emits StreamingDeltaEvents for the planner and its replies would appear
|
| 1352 |
+
// all at once instead of token-by-token.
|
| 1353 |
+
llm.stream = true;
|
| 1354 |
+
|
| 1355 |
+
const planPath = buildPlanPath(options.workingDir);
|
| 1356 |
+
|
| 1357 |
+
// Put the planner's directive + boundaries in the system prompt (matching the
|
| 1358 |
+
// OpenHands app-server's PLANNING_AGENT_INSTRUCTION), preserving any suffix
|
| 1359 |
+
// buildAgentContext already set (e.g. the runtime-services block).
|
| 1360 |
+
const agentContext = buildAgentContext(
|
| 1361 |
+
agentSettings,
|
| 1362 |
+
undefined,
|
| 1363 |
+
options.skillEnablement,
|
| 1364 |
+
);
|
| 1365 |
+
const existingSuffix = agentContext.system_message_suffix;
|
| 1366 |
+
agentContext.system_message_suffix =
|
| 1367 |
+
typeof existingSuffix === "string"
|
| 1368 |
+
? `${PLANNING_AGENT_INSTRUCTION}\n\n${existingSuffix}`
|
| 1369 |
+
: PLANNING_AGENT_INSTRUCTION;
|
| 1370 |
+
|
| 1371 |
+
// Idle planner: "Create a Plan" only switches to plan mode and provisions
|
| 1372 |
+
// this conversation; the user sends the first message themselves, so nothing
|
| 1373 |
+
// is injected into the chat. An explicit initialMessage is still honored.
|
| 1374 |
+
const initialMessage = buildInitialMessage(options.initialMessage);
|
| 1375 |
+
|
| 1376 |
+
const payload: RawAgentStartConversationPayload = {
|
| 1377 |
+
agent: {
|
| 1378 |
+
kind: "Agent",
|
| 1379 |
+
llm,
|
| 1380 |
+
tools: [
|
| 1381 |
+
{ name: "glob", params: {} },
|
| 1382 |
+
{ name: "grep", params: {} },
|
| 1383 |
+
{
|
| 1384 |
+
name: PLANNING_FILE_EDITOR_TOOL_NAME,
|
| 1385 |
+
params: { plan_path: planPath },
|
| 1386 |
+
},
|
| 1387 |
+
],
|
| 1388 |
+
system_prompt_filename: PLANNING_SYSTEM_PROMPT_FILENAME,
|
| 1389 |
+
system_prompt_kwargs: { plan_structure: PLAN_STRUCTURE_TEXT },
|
| 1390 |
+
// agent_context (skills + project context) is intentionally included so
|
| 1391 |
+
// the planner shares the code agent's project context. This goes beyond
|
| 1392 |
+
// the bare SDK `get_planning_agent()` preset (which sets no context); it
|
| 1393 |
+
// mirrors how the frontend builds every agent, so the local planner is
|
| 1394 |
+
// not context-starved relative to the code agent.
|
| 1395 |
+
agent_context: agentContext,
|
| 1396 |
+
// Mirror the SDK planning preset's condenser (openhands-tools preset
|
| 1397 |
+
// `get_planning_condenser`): a larger rolling window and more pinned
|
| 1398 |
+
// initial context than the default, with its own usage_id so its
|
| 1399 |
+
// summarization LLM calls are attributed separately. The planning
|
| 1400 |
+
// conversation is sent as a raw agent spec (it bypasses the agent-server's
|
| 1401 |
+
// default agent assembly), so without this it would never condense —
|
| 1402 |
+
// diverging from the canonical planning agent on long sessions.
|
| 1403 |
+
condenser: {
|
| 1404 |
+
kind: "LLMSummarizingCondenser",
|
| 1405 |
+
llm: { ...llm, usage_id: "planning_condenser" },
|
| 1406 |
+
max_size: 100,
|
| 1407 |
+
keep_first: 6,
|
| 1408 |
+
},
|
| 1409 |
+
},
|
| 1410 |
+
workspace: {
|
| 1411 |
+
kind: "LocalWorkspace",
|
| 1412 |
+
working_dir: options.workingDir,
|
| 1413 |
+
},
|
| 1414 |
+
// No Canvas UI client tool: the planner's only output is PLAN.md, which the
|
| 1415 |
+
// Planner tab surfaces on its own via PlanningFileEditorObservation. Handing
|
| 1416 |
+
// it the panel-control tool would only let it navigate the right-side panel
|
| 1417 |
+
// away from the plan the user is reading.
|
| 1418 |
+
client_tools: [],
|
| 1419 |
+
confirmation_policy: { kind: "NeverConfirm" },
|
| 1420 |
+
max_iterations: options.maxIterations ?? DEFAULT_MAX_ITERATIONS,
|
| 1421 |
+
stuck_detection: true,
|
| 1422 |
+
autotitle: false,
|
| 1423 |
+
worktree: false,
|
| 1424 |
+
// Two independent links to the parent, for two different jobs:
|
| 1425 |
+
//
|
| 1426 |
+
// - ``parent_conversation_id`` is the server-owned relationship (SDK
|
| 1427 |
+
// #4188, agent-server >= 1.37.1). The parent's ``sub_conversation_ids``
|
| 1428 |
+
// is derived from it, so the planner is recoverable from the server on
|
| 1429 |
+
// any browser and after storage loss. Older agent-servers ignore the
|
| 1430 |
+
// field (``StartConversationRequest`` does not forbid extras), which is
|
| 1431 |
+
// why the client-side metadata hint is still written as a fallback.
|
| 1432 |
+
// - The tag is what hides the helper from the conversation list; it is
|
| 1433 |
+
// also the only marker on agent-servers too old for the parent link.
|
| 1434 |
+
parent_conversation_id: options.parentConversationId,
|
| 1435 |
+
tags: { [LOCAL_PLANNER_PARENT_TAG_KEY]: options.parentConversationId },
|
| 1436 |
+
...(initialMessage ? { initial_message: initialMessage } : {}),
|
| 1437 |
+
};
|
| 1438 |
+
|
| 1439 |
+
if (options.secretsEncrypted) {
|
| 1440 |
+
payload.secrets_encrypted = true;
|
| 1441 |
+
}
|
| 1442 |
+
|
| 1443 |
+
const secrets = buildCustomSecrets(options.customSecrets);
|
| 1444 |
+
if (secrets) {
|
| 1445 |
+
payload.secrets = secrets;
|
| 1446 |
+
}
|
| 1447 |
+
|
| 1448 |
+
return payload;
|
| 1449 |
+
}
|
| 1450 |
+
|
| 1451 |
+
/**
|
| 1452 |
+
* Resolve the encrypted LLM config for a named LLM profile, so a planner can
|
| 1453 |
+
* run the parent conversation's *current* model (`active_profile`, tracking
|
| 1454 |
+
* `/model` and `SwitchLLMTool`) rather than a stale launch-time value.
|
| 1455 |
+
* Returns `null` — caller falls back further — if unknown or lookup fails.
|
| 1456 |
+
*/
|
| 1457 |
+
async function resolveLlmProfileSettings(
|
| 1458 |
+
profileName: string,
|
| 1459 |
+
): Promise<SettingsRecord | null> {
|
| 1460 |
+
try {
|
| 1461 |
+
const { default: ProfilesService } =
|
| 1462 |
+
await import("./profiles-service/profiles-service.api");
|
| 1463 |
+
// ``encrypted`` matches ``secrets_encrypted`` on the payload: the
|
| 1464 |
+
// agent-server decrypts with the same cipher it encrypted with.
|
| 1465 |
+
const detail = await ProfilesService.getProfile(profileName, "encrypted");
|
| 1466 |
+
return isPlainRecord(detail.config) ? detail.config : null;
|
| 1467 |
+
} catch (error) {
|
| 1468 |
+
console.warn(
|
| 1469 |
+
`Falling back: could not resolve LLM profile ${profileName}`,
|
| 1470 |
+
error,
|
| 1471 |
+
);
|
| 1472 |
+
return null;
|
| 1473 |
+
}
|
| 1474 |
+
}
|
| 1475 |
+
|
| 1476 |
+
/**
|
| 1477 |
+
* Resolve the encrypted LLM config the given AgentProfile launches with, as a
|
| 1478 |
+
* fallback for `resolveLlmProfileSettings`. AgentProfiles hold a reference
|
| 1479 |
+
* (`llm_profile_ref`), not credentials, so this is a two-hop lookup: profile
|
| 1480 |
+
* id -> llm profile name -> encrypted LLM config. Returns `null` — caller
|
| 1481 |
+
* falls back to global settings — for an unknown/ACP/dangling-ref profile or
|
| 1482 |
+
* a failed lookup, none of which should block plan creation outright.
|
| 1483 |
+
*/
|
| 1484 |
+
async function resolveAgentProfileLlmSettings(
|
| 1485 |
+
agentProfileId: string,
|
| 1486 |
+
): Promise<SettingsRecord | null> {
|
| 1487 |
+
try {
|
| 1488 |
+
const { default: AgentProfilesService } =
|
| 1489 |
+
await import("./agent-profiles-service/agent-profiles-service.api");
|
| 1490 |
+
|
| 1491 |
+
const { profiles } = await AgentProfilesService.listProfiles();
|
| 1492 |
+
const summary = profiles.find(
|
| 1493 |
+
(profile) => profile.id === agentProfileId && profile.llm_profile_ref,
|
| 1494 |
+
);
|
| 1495 |
+
if (!summary?.llm_profile_ref) return null;
|
| 1496 |
+
|
| 1497 |
+
return await resolveLlmProfileSettings(summary.llm_profile_ref);
|
| 1498 |
+
} catch (error) {
|
| 1499 |
+
console.warn(
|
| 1500 |
+
`Falling back to global agent settings: could not resolve the LLM for agent profile ${agentProfileId}`,
|
| 1501 |
+
error,
|
| 1502 |
+
);
|
| 1503 |
+
return null;
|
| 1504 |
+
}
|
| 1505 |
+
}
|
| 1506 |
+
|
| 1507 |
+
export async function buildStartPlanningConversationRequestWithEncryptedSettings(options: {
|
| 1508 |
+
workingDir: string;
|
| 1509 |
+
parentConversationId: string;
|
| 1510 |
+
/**
|
| 1511 |
+
* The parent conversation's current LLM profile (`AppConversation.active_profile`,
|
| 1512 |
+
* tracking `/model` and `SwitchLLMTool`). Takes priority over
|
| 1513 |
+
* `parentAgentProfileId` so a model switch on the parent carries over to a
|
| 1514 |
+
* planner created afterward, without a *different* conversation's global
|
| 1515 |
+
* profile activation repointing it.
|
| 1516 |
+
*/
|
| 1517 |
+
parentActiveProfileName?: string | null;
|
| 1518 |
+
/**
|
| 1519 |
+
* `launched_agent_profile.agent_profile_id` of the parent, when started
|
| 1520 |
+
* from an AgentProfile. Fallback for when `parentActiveProfileName` can't
|
| 1521 |
+
* be resolved (e.g. an ACP parent, whose `active_profile` is a stale
|
| 1522 |
+
* launch-time snapshot rather than anything the ACP agent itself runs).
|
| 1523 |
+
*/
|
| 1524 |
+
parentAgentProfileId?: string | null;
|
| 1525 |
+
initialMessage?: string;
|
| 1526 |
+
}): Promise<RawAgentStartConversationPayload> {
|
| 1527 |
+
const { SecretsService } = await import("./secrets-service");
|
| 1528 |
+
|
| 1529 |
+
const [settingsResult, customSecrets] = await Promise.all([
|
| 1530 |
+
SettingsService.getSettingsForConversation(),
|
| 1531 |
+
SecretsService.getSecrets(),
|
| 1532 |
+
]);
|
| 1533 |
+
|
| 1534 |
+
const profileLlm =
|
| 1535 |
+
(options.parentActiveProfileName
|
| 1536 |
+
? await resolveLlmProfileSettings(options.parentActiveProfileName)
|
| 1537 |
+
: null) ??
|
| 1538 |
+
(options.parentAgentProfileId
|
| 1539 |
+
? await resolveAgentProfileLlmSettings(options.parentAgentProfileId)
|
| 1540 |
+
: null);
|
| 1541 |
+
const encryptedAgentSettings: Record<string, SettingsValue> = profileLlm
|
| 1542 |
+
? { ...settingsResult.agentSettings, llm: profileLlm as SettingsValue }
|
| 1543 |
+
: settingsResult.agentSettings;
|
| 1544 |
+
|
| 1545 |
+
await assertSubscriptionAuthReady(encryptedAgentSettings);
|
| 1546 |
+
|
| 1547 |
+
// Mirror the code agent's configured cap (see DEFAULT_MAX_ITERATIONS) rather
|
| 1548 |
+
// than hardcoding a different value the planner could silently stop against.
|
| 1549 |
+
const maxIterations = resolveMaxIterations(
|
| 1550 |
+
settingsResult.conversationSettings.max_iterations,
|
| 1551 |
+
);
|
| 1552 |
+
|
| 1553 |
+
return buildStartPlanningConversationRequest({
|
| 1554 |
+
...options,
|
| 1555 |
+
encryptedAgentSettings,
|
| 1556 |
+
secretsEncrypted: settingsResult.secretsEncrypted,
|
| 1557 |
+
customSecrets,
|
| 1558 |
+
maxIterations,
|
| 1559 |
+
skillEnablement: settingsResult.skillEnablement,
|
| 1560 |
+
});
|
| 1561 |
+
}
|
| 1562 |
+
|
| 1563 |
+
export const SUBSCRIPTION_LOGIN_REQUIRED_ERROR =
|
| 1564 |
+
"Connect your ChatGPT subscription before starting a conversation with this LLM profile.";
|
| 1565 |
+
|
| 1566 |
+
/**
|
| 1567 |
+
* Throws if a ChatGPT subscription LLM profile is not connected.
|
| 1568 |
+
* Called before conversation creation and LLM profile switch only — not on
|
| 1569 |
+
* subsequent message sends or conversation resume. The agent-server must handle
|
| 1570 |
+
* mid-conversation token expiry gracefully.
|
| 1571 |
+
*/
|
| 1572 |
+
export async function assertSubscriptionAuthReady(
|
| 1573 |
+
agentSettings: Record<string, unknown>,
|
| 1574 |
+
): Promise<void> {
|
| 1575 |
+
const llm = toRecord(agentSettings.llm);
|
| 1576 |
+
if (!isSubscriptionLlmConfig(llm)) return;
|
| 1577 |
+
|
| 1578 |
+
const status = await LLMSubscriptionService.getOpenAIStatus();
|
| 1579 |
+
if (!status.connected) {
|
| 1580 |
+
throw new Error(SUBSCRIPTION_LOGIN_REQUIRED_ERROR);
|
| 1581 |
+
}
|
| 1582 |
+
}
|
| 1583 |
+
|
| 1584 |
+
export async function buildStartConversationRequestWithEncryptedSettings(options: {
|
| 1585 |
+
settings: Settings;
|
| 1586 |
+
query?: string;
|
| 1587 |
+
conversationInstructions?: string;
|
| 1588 |
+
plugins?: PluginSpec[];
|
| 1589 |
+
conversationId?: string;
|
| 1590 |
+
parentConversationId?: string;
|
| 1591 |
+
workingDir?: string;
|
| 1592 |
+
/** Workspace root for the hooks lookup, not the per-conversation `workingDir` (#16907). */
|
| 1593 |
+
hooksProjectDir?: string;
|
| 1594 |
+
worktree?: boolean;
|
| 1595 |
+
agentProfileId?: string;
|
| 1596 |
+
agentProfileKind?: AgentKind;
|
| 1597 |
+
titleLlmProfile?: string;
|
| 1598 |
+
}): Promise<Record<string, unknown>> {
|
| 1599 |
+
const [{ SecretsService }, { default: HooksService }] = await Promise.all([
|
| 1600 |
+
import("./secrets-service"),
|
| 1601 |
+
import("./hooks-service"),
|
| 1602 |
+
]);
|
| 1603 |
+
|
| 1604 |
+
const [
|
| 1605 |
+
settingsResult,
|
| 1606 |
+
customSecrets,
|
| 1607 |
+
runtimeServicesInfo,
|
| 1608 |
+
workspaceHookConfig,
|
| 1609 |
+
] = await Promise.all([
|
| 1610 |
+
SettingsService.getSettingsForConversation(),
|
| 1611 |
+
SecretsService.getSecrets(),
|
| 1612 |
+
fetchBackendRuntimeServicesInfo(),
|
| 1613 |
+
HooksService.loadWorkspaceHooks(options.hooksProjectDir),
|
| 1614 |
+
]);
|
| 1615 |
+
|
| 1616 |
+
const { agentSettings, conversationSettings, secretsEncrypted } =
|
| 1617 |
+
settingsResult;
|
| 1618 |
+
|
| 1619 |
+
// A profile launch resolves the LLM server-side, so the current-settings
|
| 1620 |
+
// subscription check doesn't apply (and can't see the profile's LLM).
|
| 1621 |
+
if (!options.agentProfileId) {
|
| 1622 |
+
await assertSubscriptionAuthReady(agentSettings);
|
| 1623 |
+
}
|
| 1624 |
+
|
| 1625 |
+
return buildStartConversationRequest({
|
| 1626 |
+
...options,
|
| 1627 |
+
encryptedAgentSettings: agentSettings,
|
| 1628 |
+
encryptedConversationSettings: conversationSettings,
|
| 1629 |
+
secretsEncrypted,
|
| 1630 |
+
customSecrets,
|
| 1631 |
+
runtimeServicesInfo,
|
| 1632 |
+
workspaceHookConfig,
|
| 1633 |
+
});
|
| 1634 |
+
}
|
| 1635 |
+
|
| 1636 |
+
export function emptyHooksResponse(): GetHooksResponse {
|
| 1637 |
+
return { hooks: [] };
|
| 1638 |
+
}
|
src/api/agent-server-client-options.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { buildHttpBaseUrl } from "#/utils/websocket-url";
|
| 2 |
+
import { getAgentServerWorkingDir } from "./agent-server-config";
|
| 3 |
+
import { getEffectiveLocalBackend } from "./backend-registry/active-store";
|
| 4 |
+
import type { Backend } from "./backend-registry/types";
|
| 5 |
+
|
| 6 |
+
export interface AgentServerClientOverrides {
|
| 7 |
+
host?: string;
|
| 8 |
+
apiKey?: string | null;
|
| 9 |
+
sessionApiKey?: string | null;
|
| 10 |
+
workingDir?: string;
|
| 11 |
+
conversationUrl?: string | null;
|
| 12 |
+
timeout?: number;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export interface AgentServerClientOptions {
|
| 16 |
+
host: string;
|
| 17 |
+
apiKey?: string;
|
| 18 |
+
workingDir: string;
|
| 19 |
+
timeout?: number;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export class NoBackendAvailableError extends Error {
|
| 23 |
+
constructor() {
|
| 24 |
+
super("No backend is configured.");
|
| 25 |
+
this.name = "NoBackendAvailableError";
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export const isNoBackendAvailableError = (
|
| 30 |
+
error: unknown,
|
| 31 |
+
): error is NoBackendAvailableError =>
|
| 32 |
+
error instanceof NoBackendAvailableError ||
|
| 33 |
+
(typeof error === "object" &&
|
| 34 |
+
error !== null &&
|
| 35 |
+
"name" in error &&
|
| 36 |
+
error.name === "NoBackendAvailableError");
|
| 37 |
+
|
| 38 |
+
function normalizeHost(host: string): string {
|
| 39 |
+
return host.replace(/\/+$/, "");
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function resolveHost(
|
| 43 |
+
overrides: AgentServerClientOverrides,
|
| 44 |
+
backend: Backend | null,
|
| 45 |
+
): string {
|
| 46 |
+
if (overrides.host) return normalizeHost(overrides.host);
|
| 47 |
+
if (overrides.conversationUrl)
|
| 48 |
+
return normalizeHost(buildHttpBaseUrl(overrides.conversationUrl));
|
| 49 |
+
return normalizeHost(backend?.host ?? "");
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
export function getAgentServerClientOptions(
|
| 53 |
+
overrides: AgentServerClientOverrides = {},
|
| 54 |
+
): AgentServerClientOptions {
|
| 55 |
+
const backend = getEffectiveLocalBackend();
|
| 56 |
+
if (!backend && !overrides.host && !overrides.conversationUrl) {
|
| 57 |
+
throw new NoBackendAvailableError();
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
const apiKey =
|
| 61 |
+
overrides.sessionApiKey ?? overrides.apiKey ?? backend?.apiKey ?? undefined;
|
| 62 |
+
|
| 63 |
+
return {
|
| 64 |
+
host: resolveHost(overrides, backend),
|
| 65 |
+
...(apiKey ? { apiKey } : {}),
|
| 66 |
+
workingDir: overrides.workingDir ?? getAgentServerWorkingDir(),
|
| 67 |
+
...(overrides.timeout !== undefined ? { timeout: overrides.timeout } : {}),
|
| 68 |
+
};
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
export function getAgentServerHttpClientOptions(
|
| 72 |
+
overrides?: AgentServerClientOverrides,
|
| 73 |
+
) {
|
| 74 |
+
const { host, apiKey, timeout } = getAgentServerClientOptions(overrides);
|
| 75 |
+
return {
|
| 76 |
+
baseUrl: host,
|
| 77 |
+
...(apiKey ? { apiKey } : {}),
|
| 78 |
+
timeout: timeout ?? 60000,
|
| 79 |
+
};
|
| 80 |
+
}
|
src/api/agent-server-compatibility.test.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from "vitest";
|
| 2 |
+
import {
|
| 3 |
+
AgentServerUnknownVersionError,
|
| 4 |
+
AgentServerUnsupportedVersionError,
|
| 5 |
+
assertAgentServerVersionIsSupported,
|
| 6 |
+
getDisplayAgentServerSdkVersion,
|
| 7 |
+
getDisplayAgentServerVersion,
|
| 8 |
+
type AgentServerInfo,
|
| 9 |
+
} from "./agent-server-compatibility";
|
| 10 |
+
|
| 11 |
+
const serverInfo = (version?: string, sdkVersion?: string): AgentServerInfo =>
|
| 12 |
+
({ version, sdk_version: sdkVersion }) as AgentServerInfo;
|
| 13 |
+
|
| 14 |
+
describe("agent-server version compatibility", () => {
|
| 15 |
+
it("classifies missing and unknown versions separately from old versions", () => {
|
| 16 |
+
for (const version of [undefined, "", "unknown", " UNKNOWN "]) {
|
| 17 |
+
expect(() =>
|
| 18 |
+
assertAgentServerVersionIsSupported(serverInfo(version)),
|
| 19 |
+
).toThrow(AgentServerUnknownVersionError);
|
| 20 |
+
}
|
| 21 |
+
});
|
| 22 |
+
|
| 23 |
+
it("classifies malformed versions separately from old versions", () => {
|
| 24 |
+
expect(() =>
|
| 25 |
+
assertAgentServerVersionIsSupported(serverInfo("dev-build")),
|
| 26 |
+
).toThrow(AgentServerUnknownVersionError);
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
it("keeps valid but too-old versions on the unsupported-version path", () => {
|
| 30 |
+
expect(() =>
|
| 31 |
+
assertAgentServerVersionIsSupported(serverInfo("0.0.1")),
|
| 32 |
+
).toThrow(AgentServerUnsupportedVersionError);
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
it("does not render unknown or malformed versions as backend badges", () => {
|
| 36 |
+
expect(getDisplayAgentServerVersion(serverInfo("unknown"))).toBeNull();
|
| 37 |
+
expect(getDisplayAgentServerVersion(serverInfo("dev-build"))).toBeNull();
|
| 38 |
+
expect(getDisplayAgentServerVersion(serverInfo("1.28.0"))).toBe("1.28.0");
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
it("uses sdk_version as the agent-server version fallback", () => {
|
| 42 |
+
expect(getDisplayAgentServerVersion(serverInfo(undefined, "1.36.1"))).toBe(
|
| 43 |
+
"1.36.1",
|
| 44 |
+
);
|
| 45 |
+
expect(() =>
|
| 46 |
+
assertAgentServerVersionIsSupported(serverInfo(undefined, "1.36.1")),
|
| 47 |
+
).not.toThrow();
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
it("reports the dedicated agent-server SDK version when present", () => {
|
| 51 |
+
expect(
|
| 52 |
+
getDisplayAgentServerSdkVersion(serverInfo("1.36.0", "1.36.1")),
|
| 53 |
+
).toBe("1.36.1");
|
| 54 |
+
expect(getDisplayAgentServerSdkVersion(serverInfo("1.36.0"))).toBe(
|
| 55 |
+
"1.36.0",
|
| 56 |
+
);
|
| 57 |
+
});
|
| 58 |
+
});
|
src/api/agent-server-compatibility.ts
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
ServerClient,
|
| 3 |
+
SettingsClient,
|
| 4 |
+
} from "@openhands/typescript-client/clients";
|
| 5 |
+
import type { ServerInfo as BaseServerInfo } from "@openhands/typescript-client";
|
| 6 |
+
import { getAgentServerClientOptions } from "#/api/agent-server-client-options";
|
| 7 |
+
import { isAuthRequired } from "#/api/agent-server-config";
|
| 8 |
+
import {
|
| 9 |
+
getActiveBackend,
|
| 10 |
+
getEffectiveLocalBackend,
|
| 11 |
+
isNoBackend,
|
| 12 |
+
} from "#/api/backend-registry/active-store";
|
| 13 |
+
import type { Backend } from "#/api/backend-registry/types";
|
| 14 |
+
import defaults from "../../config/defaults.json";
|
| 15 |
+
|
| 16 |
+
const AGENT_SERVER_INFO_TIMEOUT_MS = 5000;
|
| 17 |
+
const UNKNOWN_AGENT_SERVER_VERSION = "unknown";
|
| 18 |
+
|
| 19 |
+
export const MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION =
|
| 20 |
+
defaults.compatibility.minimumAgentServer;
|
| 21 |
+
export const AGENT_SERVER_UNSUPPORTED_VERSION_ERROR_CODE =
|
| 22 |
+
"AGENT_SERVER_UNSUPPORTED_VERSION";
|
| 23 |
+
export const AGENT_SERVER_UNKNOWN_VERSION_ERROR_CODE =
|
| 24 |
+
"AGENT_SERVER_UNKNOWN_VERSION";
|
| 25 |
+
|
| 26 |
+
/**
|
| 27 |
+
* Sentinel string thrown (as an Error message) when a local backend
|
| 28 |
+
* rejects the configured session API key with HTTP 401.
|
| 29 |
+
* Shared between the backend health probe ({@link validateLocalBackend})
|
| 30 |
+
* and any consumer that needs to detect this specific failure.
|
| 31 |
+
*/
|
| 32 |
+
export const INVALID_BACKEND_API_KEY_ERROR = "Invalid API key";
|
| 33 |
+
|
| 34 |
+
export interface AgentServerInfo extends BaseServerInfo {
|
| 35 |
+
sdk_version?: string;
|
| 36 |
+
usable_tools?: string[] | null;
|
| 37 |
+
runtime_services?: unknown;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
let cachedAgentServerInfo: AgentServerInfo | null = null;
|
| 41 |
+
let cachedAgentServerInfoHost: string | null = null;
|
| 42 |
+
|
| 43 |
+
const getAdvertisedTools = (serverInfo: AgentServerInfo | null) => {
|
| 44 |
+
if (Array.isArray(serverInfo?.usable_tools)) {
|
| 45 |
+
return serverInfo.usable_tools;
|
| 46 |
+
}
|
| 47 |
+
return null;
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
export class AgentServerUnavailableError extends Error {
|
| 51 |
+
readonly details: string | null;
|
| 52 |
+
readonly noBackendConfigured: boolean;
|
| 53 |
+
|
| 54 |
+
constructor(
|
| 55 |
+
details?: string | null,
|
| 56 |
+
options?: { noBackendConfigured?: boolean },
|
| 57 |
+
) {
|
| 58 |
+
const noBackendConfigured = options?.noBackendConfigured ?? false;
|
| 59 |
+
super(
|
| 60 |
+
noBackendConfigured
|
| 61 |
+
? "No agent server backend is configured yet. Add a backend to get started."
|
| 62 |
+
: "Could not connect to the configured agent server. Make sure it is running and reachable, then reload the page.",
|
| 63 |
+
);
|
| 64 |
+
this.name = "AgentServerUnavailableError";
|
| 65 |
+
this.details = details ?? null;
|
| 66 |
+
this.noBackendConfigured = noBackendConfigured;
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
export const isAgentServerUnavailableError = (
|
| 71 |
+
error: unknown,
|
| 72 |
+
): error is AgentServerUnavailableError =>
|
| 73 |
+
error instanceof AgentServerUnavailableError ||
|
| 74 |
+
(typeof error === "object" &&
|
| 75 |
+
error !== null &&
|
| 76 |
+
"name" in error &&
|
| 77 |
+
(error.name === "AgentServerUnavailableError" ||
|
| 78 |
+
error.name === "AgentServerUnsupportedVersionError" ||
|
| 79 |
+
error.name === "AgentServerUnknownVersionError"));
|
| 80 |
+
|
| 81 |
+
export class AgentServerUnsupportedVersionError extends AgentServerUnavailableError {
|
| 82 |
+
readonly code = AGENT_SERVER_UNSUPPORTED_VERSION_ERROR_CODE;
|
| 83 |
+
readonly actualVersion: string;
|
| 84 |
+
readonly requiredVersion = MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION;
|
| 85 |
+
|
| 86 |
+
constructor(actualVersion: string) {
|
| 87 |
+
const message = `Agent Canvas requires agent-server ${MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION} or newer; this backend is running ${actualVersion}. Please upgrade the agent-server backend.`;
|
| 88 |
+
super(message);
|
| 89 |
+
this.name = "AgentServerUnsupportedVersionError";
|
| 90 |
+
this.message = message;
|
| 91 |
+
this.actualVersion = actualVersion;
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
export class AgentServerUnknownVersionError extends AgentServerUnavailableError {
|
| 96 |
+
readonly code = AGENT_SERVER_UNKNOWN_VERSION_ERROR_CODE;
|
| 97 |
+
readonly actualVersion: string | null;
|
| 98 |
+
readonly requiredVersion = MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION;
|
| 99 |
+
|
| 100 |
+
constructor(actualVersion: string | null) {
|
| 101 |
+
const reported = actualVersion ? ` It reported "${actualVersion}".` : "";
|
| 102 |
+
const message =
|
| 103 |
+
`Could not determine this backend's agent-server version.${reported} ` +
|
| 104 |
+
`Agent Canvas requires agent-server ${MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION} ` +
|
| 105 |
+
"or newer, but this backend did not return a valid version from " +
|
| 106 |
+
"/server_info. Restart or rebuild the agent-server backend, then try again.";
|
| 107 |
+
super(message);
|
| 108 |
+
this.name = "AgentServerUnknownVersionError";
|
| 109 |
+
this.message = message;
|
| 110 |
+
this.actualVersion = actualVersion;
|
| 111 |
+
}
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
export const isAgentServerUnsupportedVersionError = (
|
| 115 |
+
error: unknown,
|
| 116 |
+
): error is AgentServerUnsupportedVersionError =>
|
| 117 |
+
error instanceof AgentServerUnsupportedVersionError ||
|
| 118 |
+
(typeof error === "object" &&
|
| 119 |
+
error !== null &&
|
| 120 |
+
"code" in error &&
|
| 121 |
+
error.code === AGENT_SERVER_UNSUPPORTED_VERSION_ERROR_CODE);
|
| 122 |
+
|
| 123 |
+
export const isAgentServerUnknownVersionError = (
|
| 124 |
+
error: unknown,
|
| 125 |
+
): error is AgentServerUnknownVersionError =>
|
| 126 |
+
error instanceof AgentServerUnknownVersionError ||
|
| 127 |
+
(typeof error === "object" &&
|
| 128 |
+
error !== null &&
|
| 129 |
+
"code" in error &&
|
| 130 |
+
error.code === AGENT_SERVER_UNKNOWN_VERSION_ERROR_CODE);
|
| 131 |
+
|
| 132 |
+
/**
|
| 133 |
+
* Returns true when the agent-server probe failed with HTTP 401.
|
| 134 |
+
* In public mode this means the stored key is stale (server restarted
|
| 135 |
+
* with a different `LOCAL_BACKEND_API_KEY`). Only meaningful when
|
| 136 |
+
* auth is required — a 401 in local mode is a misconfiguration, not a
|
| 137 |
+
* key-rotation event. Uses {@link isAuthRequired} so both the build-time
|
| 138 |
+
* `VITE_AUTH_REQUIRED` flag and the runtime `window.__AGENT_CANVAS_AUTH_REQUIRED__`
|
| 139 |
+
* injection (used by pre-built static binaries) are honoured.
|
| 140 |
+
*/
|
| 141 |
+
export const isAgentServerAuthError = (error: unknown): boolean =>
|
| 142 |
+
isAuthRequired() && isSdkHttpStatusError(error, 401);
|
| 143 |
+
|
| 144 |
+
export function clearCachedAgentServerInfo() {
|
| 145 |
+
cachedAgentServerInfo = null;
|
| 146 |
+
cachedAgentServerInfoHost = null;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
export function getCachedAgentServerInfo(options?: {
|
| 150 |
+
host?: string | null;
|
| 151 |
+
}): AgentServerInfo | null {
|
| 152 |
+
if (options?.host && options.host !== cachedAgentServerInfoHost) {
|
| 153 |
+
return null;
|
| 154 |
+
}
|
| 155 |
+
return cachedAgentServerInfo;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
export function isAgentServerToolAvailable(toolName: string) {
|
| 159 |
+
const availableTools = getAdvertisedTools(cachedAgentServerInfo);
|
| 160 |
+
if (!Array.isArray(availableTools)) {
|
| 161 |
+
return true;
|
| 162 |
+
}
|
| 163 |
+
return availableTools.includes(toolName);
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
export function isSdkHttpError(error: unknown) {
|
| 167 |
+
return (
|
| 168 |
+
error instanceof Error &&
|
| 169 |
+
error.name === "HttpError" &&
|
| 170 |
+
"status" in error &&
|
| 171 |
+
typeof error.status === "number"
|
| 172 |
+
);
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
/**
|
| 176 |
+
* Narrows an SDK HTTP error to a specific status code.
|
| 177 |
+
* Use instead of manually casting `(err as { status: number }).status`.
|
| 178 |
+
*/
|
| 179 |
+
export function isSdkHttpStatusError(error: unknown, status: number): boolean {
|
| 180 |
+
return (
|
| 181 |
+
isSdkHttpError(error) && (error as { status: number }).status === status
|
| 182 |
+
);
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
function normalizeAgentServerInfoVersion(version: unknown): string | null {
|
| 186 |
+
if (typeof version !== "string") return null;
|
| 187 |
+
const trimmed = version.trim();
|
| 188 |
+
return trimmed || null;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
function getRawAgentServerSdkVersion(
|
| 192 |
+
serverInfo: AgentServerInfo,
|
| 193 |
+
): string | null {
|
| 194 |
+
return normalizeAgentServerInfoVersion(serverInfo.sdk_version);
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
function getRawAgentServerVersion(serverInfo: AgentServerInfo): string | null {
|
| 198 |
+
return (
|
| 199 |
+
normalizeAgentServerInfoVersion(serverInfo.version) ??
|
| 200 |
+
getRawAgentServerSdkVersion(serverInfo)
|
| 201 |
+
);
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
function getComparableAgentServerVersion(
|
| 205 |
+
serverInfo: AgentServerInfo,
|
| 206 |
+
): string | null {
|
| 207 |
+
const version = getRawAgentServerVersion(serverInfo);
|
| 208 |
+
if (!version || version.toLowerCase() === UNKNOWN_AGENT_SERVER_VERSION) {
|
| 209 |
+
return null;
|
| 210 |
+
}
|
| 211 |
+
return version;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
export function getDisplayAgentServerVersion(
|
| 215 |
+
serverInfo: AgentServerInfo,
|
| 216 |
+
): string | null {
|
| 217 |
+
const version = getComparableAgentServerVersion(serverInfo);
|
| 218 |
+
if (!version || !parseAgentServerVersion(version)) {
|
| 219 |
+
return null;
|
| 220 |
+
}
|
| 221 |
+
return version;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
export function getDisplayAgentServerSdkVersion(
|
| 225 |
+
serverInfo: AgentServerInfo,
|
| 226 |
+
): string | null {
|
| 227 |
+
const version =
|
| 228 |
+
getRawAgentServerSdkVersion(serverInfo) ??
|
| 229 |
+
getComparableAgentServerVersion(serverInfo);
|
| 230 |
+
if (!version || version.toLowerCase() === UNKNOWN_AGENT_SERVER_VERSION) {
|
| 231 |
+
return null;
|
| 232 |
+
}
|
| 233 |
+
return version;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
export function getCachedAgentServerSdkVersion(
|
| 237 |
+
host?: string | null,
|
| 238 |
+
): string | null {
|
| 239 |
+
if (!cachedAgentServerInfo) return null;
|
| 240 |
+
if (host && getEffectiveLocalBackend()?.host !== host) return null;
|
| 241 |
+
return getDisplayAgentServerSdkVersion(cachedAgentServerInfo);
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
export function getCachedAgentServerVersion(
|
| 245 |
+
host?: string | null,
|
| 246 |
+
): string | null {
|
| 247 |
+
if (!cachedAgentServerInfo) return null;
|
| 248 |
+
if (host && getEffectiveLocalBackend()?.host !== host) return null;
|
| 249 |
+
return getDisplayAgentServerVersion(cachedAgentServerInfo);
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
export function compareAgentServerVersions(actual: string, required: string) {
|
| 253 |
+
const parsedActual = parseAgentServerVersion(actual);
|
| 254 |
+
const parsedRequired = parseAgentServerVersion(required);
|
| 255 |
+
|
| 256 |
+
if (!parsedActual || !parsedRequired) {
|
| 257 |
+
return null;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
for (const key of ["major", "minor", "patch"] as const) {
|
| 261 |
+
if (parsedActual[key] > parsedRequired[key]) {
|
| 262 |
+
return 1;
|
| 263 |
+
}
|
| 264 |
+
if (parsedActual[key] < parsedRequired[key]) {
|
| 265 |
+
return -1;
|
| 266 |
+
}
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
if (parsedActual.prerelease && !parsedRequired.prerelease) {
|
| 270 |
+
return -1;
|
| 271 |
+
}
|
| 272 |
+
if (!parsedActual.prerelease && parsedRequired.prerelease) {
|
| 273 |
+
return 1;
|
| 274 |
+
}
|
| 275 |
+
if (parsedActual.prerelease && parsedRequired.prerelease) {
|
| 276 |
+
return parsedActual.prerelease.localeCompare(parsedRequired.prerelease);
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
return 0;
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
function parseAgentServerVersion(version: string) {
|
| 283 |
+
const trimmed = version.trim().replace(/^v/, "");
|
| 284 |
+
const [withoutBuild] = trimmed.split("+");
|
| 285 |
+
const [core, prerelease] = withoutBuild.split("-", 2);
|
| 286 |
+
const parts = core.split(".");
|
| 287 |
+
|
| 288 |
+
if (parts.length !== 3) {
|
| 289 |
+
return null;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
const [major, minor, patch] = parts.map((part) => Number(part));
|
| 293 |
+
if (
|
| 294 |
+
![major, minor, patch].every((part) => Number.isInteger(part) && part >= 0)
|
| 295 |
+
) {
|
| 296 |
+
return null;
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
return { major, minor, patch, prerelease };
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
export function assertAgentServerVersionIsSupported(
|
| 303 |
+
serverInfo: AgentServerInfo,
|
| 304 |
+
) {
|
| 305 |
+
const actualVersion = getComparableAgentServerVersion(serverInfo);
|
| 306 |
+
if (!actualVersion) {
|
| 307 |
+
clearCachedAgentServerInfo();
|
| 308 |
+
throw new AgentServerUnknownVersionError(
|
| 309 |
+
getRawAgentServerVersion(serverInfo),
|
| 310 |
+
);
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
const comparison = compareAgentServerVersions(
|
| 314 |
+
actualVersion,
|
| 315 |
+
MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION,
|
| 316 |
+
);
|
| 317 |
+
|
| 318 |
+
if (comparison === null) {
|
| 319 |
+
clearCachedAgentServerInfo();
|
| 320 |
+
throw new AgentServerUnknownVersionError(actualVersion);
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
if (comparison < 0) {
|
| 324 |
+
clearCachedAgentServerInfo();
|
| 325 |
+
throw new AgentServerUnsupportedVersionError(actualVersion);
|
| 326 |
+
}
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
/**
|
| 330 |
+
* Validates a local agent-server backend with a two-step probe:
|
| 331 |
+
* 1. GET /api/settings — authenticates the configured session API key;
|
| 332 |
+
* a 401 throws an Error with message {@link INVALID_BACKEND_API_KEY_ERROR}.
|
| 333 |
+
* 2. GET /server_info — asserts the server meets the minimum version floor.
|
| 334 |
+
*
|
| 335 |
+
* Returns the display version string reported by the server, or `null` when
|
| 336 |
+
* the server does not report a parseable version. Throws on any failure.
|
| 337 |
+
*
|
| 338 |
+
* Used by both the backend health poller and the backend-form connection test
|
| 339 |
+
* so that auth-check semantics (status codes, error messages) stay in one place.
|
| 340 |
+
*/
|
| 341 |
+
export async function validateLocalBackend(
|
| 342 |
+
backend: Pick<Backend, "host" | "apiKey">,
|
| 343 |
+
timeout: number,
|
| 344 |
+
): Promise<string | null> {
|
| 345 |
+
const clientOptions = getAgentServerClientOptions({
|
| 346 |
+
host: backend.host,
|
| 347 |
+
sessionApiKey: backend.apiKey || null,
|
| 348 |
+
timeout,
|
| 349 |
+
});
|
| 350 |
+
|
| 351 |
+
try {
|
| 352 |
+
await new SettingsClient(clientOptions).getSettings();
|
| 353 |
+
const serverInfo = await new ServerClient(clientOptions).getServerInfo();
|
| 354 |
+
assertAgentServerVersionIsSupported(serverInfo);
|
| 355 |
+
return getDisplayAgentServerVersion(serverInfo);
|
| 356 |
+
} catch (error) {
|
| 357 |
+
if (isSdkHttpStatusError(error, 401)) {
|
| 358 |
+
throw new Error(INVALID_BACKEND_API_KEY_ERROR);
|
| 359 |
+
}
|
| 360 |
+
throw error;
|
| 361 |
+
}
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
export async function loadAgentServerInfo() {
|
| 365 |
+
// The probe is a *local* agent-server concern — it verifies the runtime
|
| 366 |
+
// hosting the GUI is reachable. It must NEVER run against the active
|
| 367 |
+
// backend when that backend is cloud, because cloud hosts don't
|
| 368 |
+
// expose /api/server_info and would fail with a CORS error besides.
|
| 369 |
+
const local = getEffectiveLocalBackend();
|
| 370 |
+
if (!local) {
|
| 371 |
+
clearCachedAgentServerInfo();
|
| 372 |
+
|
| 373 |
+
// Empty registry (NO_BACKEND sentinel) — the user has no backend
|
| 374 |
+
// configured at all. Throw so root.tsx shows the manage-backends
|
| 375 |
+
// modal instead of silently rendering a broken home page.
|
| 376 |
+
if (isNoBackend(getActiveBackend().backend)) {
|
| 377 |
+
throw new AgentServerUnavailableError("No backend configured", {
|
| 378 |
+
noBackendConfigured: true,
|
| 379 |
+
});
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
// Active backend is cloud — no local probe needed.
|
| 383 |
+
return null;
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
const clientOptions = getAgentServerClientOptions({
|
| 387 |
+
host: local.host,
|
| 388 |
+
sessionApiKey: local.apiKey || null,
|
| 389 |
+
timeout: AGENT_SERVER_INFO_TIMEOUT_MS,
|
| 390 |
+
});
|
| 391 |
+
let serverInfo: AgentServerInfo;
|
| 392 |
+
|
| 393 |
+
try {
|
| 394 |
+
serverInfo = (await new ServerClient(
|
| 395 |
+
clientOptions,
|
| 396 |
+
).getServerInfo()) as AgentServerInfo;
|
| 397 |
+
} catch (error) {
|
| 398 |
+
clearCachedAgentServerInfo();
|
| 399 |
+
// Preserve 401 so root.tsx can show the auth screen (public mode).
|
| 400 |
+
// All other HTTP errors (502, 503, etc.) mean the server is unreachable
|
| 401 |
+
// or misconfigured — treat them as unavailable.
|
| 402 |
+
if (isSdkHttpStatusError(error, 401)) {
|
| 403 |
+
throw error;
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
const details = error instanceof Error ? error.message : null;
|
| 407 |
+
throw new AgentServerUnavailableError(details);
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
assertAgentServerVersionIsSupported(serverInfo);
|
| 411 |
+
|
| 412 |
+
// /server_info is unprotected, so a stale session key still gets 200.
|
| 413 |
+
// In public mode, validate the key against a protected endpoint so a
|
| 414 |
+
// server restart with a new LOCAL_BACKEND_API_KEY surfaces immediately
|
| 415 |
+
// instead of letting the app load and fail on every subsequent call.
|
| 416 |
+
if (isAuthRequired()) {
|
| 417 |
+
try {
|
| 418 |
+
await new SettingsClient(clientOptions).getSettings();
|
| 419 |
+
} catch (error) {
|
| 420 |
+
// Only rethrow 401 — that means the stored key is invalid /
|
| 421 |
+
// rotated. Other HTTP errors (403, 5xx) and non-HTTP errors
|
| 422 |
+
// (network, timeout) are swallowed: the server *is* up (we just
|
| 423 |
+
// reached /server_info), so let the app proceed with an
|
| 424 |
+
// unvalidated key rather than blocking the UI.
|
| 425 |
+
// NOTE: If the connection drops between the /server_info and
|
| 426 |
+
// getSettings() probes, the app loads with an unvalidated key and
|
| 427 |
+
// subsequent 401s won't trigger the auth screen (they come from
|
| 428 |
+
// React Query hooks, not this bootstrap path). Acceptable for now
|
| 429 |
+
// since the window is narrow and a page refresh recovers.
|
| 430 |
+
if (isSdkHttpStatusError(error, 401)) {
|
| 431 |
+
throw error;
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
console.warn(
|
| 435 |
+
"[agent-server] getSettings() probe failed (non-401):",
|
| 436 |
+
error,
|
| 437 |
+
);
|
| 438 |
+
}
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
cachedAgentServerInfo = serverInfo;
|
| 442 |
+
cachedAgentServerInfoHost = clientOptions.host;
|
| 443 |
+
return serverInfo;
|
| 444 |
+
}
|
src/api/agent-server-config.test.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import {
|
| 3 |
+
DEFAULT_WORKING_DIR,
|
| 4 |
+
buildConversationWorkingDir,
|
| 5 |
+
buildConversationWorkingDirForBackend,
|
| 6 |
+
buildRelativeConversationWorkingDir,
|
| 7 |
+
isServedOriginHost,
|
| 8 |
+
} from "./agent-server-config";
|
| 9 |
+
|
| 10 |
+
describe("buildRelativeConversationWorkingDir", () => {
|
| 11 |
+
afterEach(() => vi.unstubAllEnvs());
|
| 12 |
+
|
| 13 |
+
it("ignores a baked absolute VITE_WORKING_DIR and stays relative", () => {
|
| 14 |
+
vi.stubEnv("VITE_WORKING_DIR", "/Users/someone/.openhands/workspaces");
|
| 15 |
+
expect(buildRelativeConversationWorkingDir("abc-123")).toBe(
|
| 16 |
+
`${DEFAULT_WORKING_DIR}/abc123`,
|
| 17 |
+
);
|
| 18 |
+
});
|
| 19 |
+
|
| 20 |
+
it("strips dashes from the conversation id", () => {
|
| 21 |
+
expect(buildRelativeConversationWorkingDir("a-b-c")).toBe(
|
| 22 |
+
`${DEFAULT_WORKING_DIR}/abc`,
|
| 23 |
+
);
|
| 24 |
+
});
|
| 25 |
+
});
|
| 26 |
+
|
| 27 |
+
describe("buildConversationWorkingDir (baked default)", () => {
|
| 28 |
+
afterEach(() => vi.unstubAllEnvs());
|
| 29 |
+
|
| 30 |
+
it("uses a baked absolute dir as the base", () => {
|
| 31 |
+
vi.stubEnv("VITE_WORKING_DIR", "/Users/someone/workspaces");
|
| 32 |
+
expect(buildConversationWorkingDir("abc-123")).toBe(
|
| 33 |
+
"/Users/someone/workspaces/abc123",
|
| 34 |
+
);
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
it("falls back to the relative default when nothing is baked", () => {
|
| 38 |
+
vi.stubEnv("VITE_WORKING_DIR", "");
|
| 39 |
+
expect(buildConversationWorkingDir("abc-123")).toBe(
|
| 40 |
+
`${DEFAULT_WORKING_DIR}/abc123`,
|
| 41 |
+
);
|
| 42 |
+
});
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
describe("isServedOriginHost", () => {
|
| 46 |
+
afterEach(() => vi.unstubAllEnvs());
|
| 47 |
+
|
| 48 |
+
it("matches the host that served this frontend", () => {
|
| 49 |
+
vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000");
|
| 50 |
+
expect(isServedOriginHost("http://127.0.0.1:8000")).toBe(true);
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
it("normalizes a bare host against the served origin", () => {
|
| 54 |
+
vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000");
|
| 55 |
+
// getAgentServerBaseUrl returns the already-normalized configured URL;
|
| 56 |
+
// a bare candidate host is normalized to http:// off-window.
|
| 57 |
+
expect(isServedOriginHost("127.0.0.1:8000")).toBe(true);
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
+
it("does not match a different host", () => {
|
| 61 |
+
vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000");
|
| 62 |
+
expect(isServedOriginHost("https://cabin.liberty-labs.org")).toBe(false);
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
it("does not match when the backend host is empty", () => {
|
| 66 |
+
vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000");
|
| 67 |
+
expect(isServedOriginHost("")).toBe(false);
|
| 68 |
+
expect(isServedOriginHost(null)).toBe(false);
|
| 69 |
+
});
|
| 70 |
+
});
|
| 71 |
+
|
| 72 |
+
describe("buildConversationWorkingDirForBackend", () => {
|
| 73 |
+
afterEach(() => vi.unstubAllEnvs());
|
| 74 |
+
|
| 75 |
+
it("uses the baked absolute default for the served-origin backend", () => {
|
| 76 |
+
vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000");
|
| 77 |
+
vi.stubEnv(
|
| 78 |
+
"VITE_WORKING_DIR",
|
| 79 |
+
"/Users/me/.openhands/agent-canvas/workspaces",
|
| 80 |
+
);
|
| 81 |
+
expect(
|
| 82 |
+
buildConversationWorkingDirForBackend("abc-123", "http://127.0.0.1:8000"),
|
| 83 |
+
).toBe("/Users/me/.openhands/agent-canvas/workspaces/abc123");
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
// Regression for the reviewed bug: the seeded `default-local` backend is
|
| 87 |
+
// mutable. Editing its host to a remote VM (while its id stays
|
| 88 |
+
// `default-local`) must NOT send the baked macOS path to that VM — this is
|
| 89 |
+
// the exact case that failed on our remote Linux backend (Cabin) with
|
| 90 |
+
// `mkdir: cannot create directory '/Users': Permission denied`.
|
| 91 |
+
it("uses the relative default for an edited default-local pointing at a remote host", () => {
|
| 92 |
+
vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000");
|
| 93 |
+
vi.stubEnv(
|
| 94 |
+
"VITE_WORKING_DIR",
|
| 95 |
+
"/Users/me/.openhands/agent-canvas/workspaces",
|
| 96 |
+
);
|
| 97 |
+
// The user edited the seeded `default-local` entry to a remote Linux VM
|
| 98 |
+
// (home /home/daytona); its host no longer matches the served origin.
|
| 99 |
+
expect(
|
| 100 |
+
buildConversationWorkingDirForBackend(
|
| 101 |
+
"abc-123",
|
| 102 |
+
"https://cabin.liberty-labs.org",
|
| 103 |
+
),
|
| 104 |
+
).toBe(`${DEFAULT_WORKING_DIR}/abc123`);
|
| 105 |
+
});
|
| 106 |
+
|
| 107 |
+
it("uses the relative default for any separately-registered remote backend", () => {
|
| 108 |
+
vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000");
|
| 109 |
+
vi.stubEnv("VITE_WORKING_DIR", "/Users/me/workspaces");
|
| 110 |
+
expect(
|
| 111 |
+
buildConversationWorkingDirForBackend(
|
| 112 |
+
"abc-123",
|
| 113 |
+
"https://sandbox.example.dev",
|
| 114 |
+
),
|
| 115 |
+
).toBe(`${DEFAULT_WORKING_DIR}/abc123`);
|
| 116 |
+
});
|
| 117 |
+
});
|
src/api/agent-server-config.ts
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const DEFAULT_WORKING_DIR = "workspace/project";
|
| 2 |
+
|
| 3 |
+
export type LockedCloudAuthMode = "api-key" | "cookie";
|
| 4 |
+
|
| 5 |
+
export interface AgentServerFormDefaults {
|
| 6 |
+
baseUrl: string;
|
| 7 |
+
sessionApiKey: string;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
// Window-global key the static server injects `--lock-to-cloud` into; kept
|
| 11 |
+
// module-private because only `getLockedCloudHost()` reads it. The static
|
| 12 |
+
// server (`scripts/static-server.mjs`) and its tests reference the literal
|
| 13 |
+
// string directly, not this constant.
|
| 14 |
+
const LOCK_TO_CLOUD_WINDOW_KEY = "__AGENT_CANVAS_LOCK_TO_CLOUD__";
|
| 15 |
+
const LEGACY_CLOUD_DOMAIN = "all-hands.dev";
|
| 16 |
+
const CURRENT_CLOUD_DOMAIN = "openhands.dev";
|
| 17 |
+
const LEGACY_PRODUCTION_APP_HOST = `app.${LEGACY_CLOUD_DOMAIN}`;
|
| 18 |
+
const CURRENT_PRODUCTION_APP_HOST = CURRENT_CLOUD_DOMAIN;
|
| 19 |
+
const PRODUCTION_APP_HOST_ALIAS = `app.${CURRENT_CLOUD_DOMAIN}`;
|
| 20 |
+
|
| 21 |
+
function trimToNull(value?: string | null): string | null {
|
| 22 |
+
return value?.trim() || null;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
function normalizeBaseUrl(value?: string | null): string | null {
|
| 26 |
+
if (!value) return null;
|
| 27 |
+
|
| 28 |
+
const trimmed = value.trim().replace(/\/$/, "");
|
| 29 |
+
if (!trimmed) return null;
|
| 30 |
+
|
| 31 |
+
if (/^https?:\/\//i.test(trimmed)) {
|
| 32 |
+
return trimmed;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
if (typeof window !== "undefined") {
|
| 36 |
+
return `${window.location.protocol}//${trimmed}`;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
return `http://${trimmed}`;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function normalizeCloudHost(value?: string | null): string | null {
|
| 43 |
+
if (!value) return null;
|
| 44 |
+
|
| 45 |
+
const trimmed = value.trim().replace(/\/+$/, "");
|
| 46 |
+
if (!trimmed) return null;
|
| 47 |
+
|
| 48 |
+
if (/^https?:\/\//i.test(trimmed)) {
|
| 49 |
+
return trimmed;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
return `https://${trimmed}`;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function canonicalizeCloudHostname(hostname: string): string {
|
| 56 |
+
const lower = hostname.toLowerCase();
|
| 57 |
+
if (
|
| 58 |
+
lower === LEGACY_PRODUCTION_APP_HOST ||
|
| 59 |
+
lower === PRODUCTION_APP_HOST_ALIAS
|
| 60 |
+
) {
|
| 61 |
+
return CURRENT_PRODUCTION_APP_HOST;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
if (lower === LEGACY_CLOUD_DOMAIN) return CURRENT_CLOUD_DOMAIN;
|
| 65 |
+
if (lower.endsWith(`.${LEGACY_CLOUD_DOMAIN}`)) {
|
| 66 |
+
return `${lower.slice(0, -LEGACY_CLOUD_DOMAIN.length)}${CURRENT_CLOUD_DOMAIN}`;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
return lower;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
function getCloudHostComparisonKey(value?: string | null): string | null {
|
| 73 |
+
const normalized = normalizeCloudHost(value);
|
| 74 |
+
if (!normalized) return null;
|
| 75 |
+
|
| 76 |
+
try {
|
| 77 |
+
const url = new URL(normalized);
|
| 78 |
+
const port = url.port ? `:${url.port}` : "";
|
| 79 |
+
return `${url.protocol}//${canonicalizeCloudHostname(url.hostname)}${port}`;
|
| 80 |
+
} catch {
|
| 81 |
+
return normalized.toLowerCase();
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
export function getCookieAuthCloudHost(): string | null {
|
| 86 |
+
const lockedHost = getLockedCloudHost();
|
| 87 |
+
if (
|
| 88 |
+
!lockedHost ||
|
| 89 |
+
typeof window === "undefined" ||
|
| 90 |
+
!isSameCloudHost(window.location.origin, lockedHost)
|
| 91 |
+
) {
|
| 92 |
+
return null;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
return window.location.origin;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
function getConfiguredBaseUrl(): string | null {
|
| 99 |
+
return normalizeBaseUrl(import.meta.env.VITE_BACKEND_BASE_URL);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
/**
|
| 103 |
+
* Return the session API key supplied by the deployment host.
|
| 104 |
+
*
|
| 105 |
+
* Two sources are consulted, in order:
|
| 106 |
+
* 1. `VITE_SESSION_API_KEY` — baked into the bundle at build time (used by
|
| 107 |
+
* `npm run dev` so the dev server has the key without a round-trip).
|
| 108 |
+
* 2. `window.__AGENT_CANVAS_SESSION_API_KEY__` — injected into `index.html`
|
| 109 |
+
* at serve time by `scripts/static-server.mjs --session-api-key <key>`.
|
| 110 |
+
* This is the path used by the published `agent-canvas` binary, where
|
| 111 |
+
* `VITE_SESSION_API_KEY` is empty in the prebuilt bundle and the
|
| 112 |
+
* runtime key is generated when the user launches the CLI.
|
| 113 |
+
*
|
| 114 |
+
* Without the window-global fallback, the published binary cannot construct a
|
| 115 |
+
* default local backend (`makeDefaultLocalBackend()` returns null), the
|
| 116 |
+
* registry is left empty, and the user sees the Manage Backends modal
|
| 117 |
+
* instead of the onboarding flow.
|
| 118 |
+
*/
|
| 119 |
+
export function getBakedSessionApiKey(): string | null {
|
| 120 |
+
const envKey = trimToNull(import.meta.env.VITE_SESSION_API_KEY);
|
| 121 |
+
if (envKey) return envKey;
|
| 122 |
+
|
| 123 |
+
if (typeof window !== "undefined") {
|
| 124 |
+
const injected = (window as unknown as Record<string, unknown>)
|
| 125 |
+
.__AGENT_CANVAS_SESSION_API_KEY__;
|
| 126 |
+
if (typeof injected === "string") {
|
| 127 |
+
return trimToNull(injected);
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
return null;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
export function getAgentServerFormDefaults(): AgentServerFormDefaults {
|
| 135 |
+
return {
|
| 136 |
+
baseUrl: getAgentServerBaseUrl() ?? "",
|
| 137 |
+
sessionApiKey: getAgentServerSessionApiKey() ?? "",
|
| 138 |
+
};
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
export function getLockedCloudHost(): string | null {
|
| 142 |
+
const envHost = normalizeCloudHost(import.meta.env.VITE_LOCK_TO_CLOUD);
|
| 143 |
+
if (envHost) return envHost;
|
| 144 |
+
|
| 145 |
+
if (typeof window !== "undefined") {
|
| 146 |
+
const injected = (window as unknown as Record<string, unknown>)[
|
| 147 |
+
LOCK_TO_CLOUD_WINDOW_KEY
|
| 148 |
+
];
|
| 149 |
+
if (typeof injected === "string") {
|
| 150 |
+
return normalizeCloudHost(injected);
|
| 151 |
+
}
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
return null;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
/**
|
| 158 |
+
* Compare a backend host against the locked Cloud host, normalizing
|
| 159 |
+
* trailing slashes, protocol, and case so that e.g.
|
| 160 |
+
* `https://app.all-hands.dev/` matches `https://app.all-hands.dev`.
|
| 161 |
+
*
|
| 162 |
+
* Used by the locked-to-Cloud gates (`root.tsx`,
|
| 163 |
+
* `onboarding-modal.tsx`) to decide whether the active backend is the
|
| 164 |
+
* configured locked Cloud host — a Cloud backend on a *different* host
|
| 165 |
+
* (or a stale Local backend) must not be treated as the locked backend.
|
| 166 |
+
*/
|
| 167 |
+
export function isSameCloudHost(
|
| 168 |
+
host: string | null | undefined,
|
| 169 |
+
lockedHost: string | null | undefined,
|
| 170 |
+
): boolean {
|
| 171 |
+
const a = getCloudHostComparisonKey(host);
|
| 172 |
+
const b = getCloudHostComparisonKey(lockedHost);
|
| 173 |
+
if (!a || !b) return false;
|
| 174 |
+
return a === b;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
export function getLockedCloudAuthMode(): LockedCloudAuthMode {
|
| 178 |
+
return getCookieAuthCloudHost() ? "cookie" : "api-key";
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
export function getAgentServerBaseUrl(): string | null {
|
| 182 |
+
const configuredUrl = getConfiguredBaseUrl();
|
| 183 |
+
if (configuredUrl) return configuredUrl;
|
| 184 |
+
|
| 185 |
+
if (typeof window !== "undefined") {
|
| 186 |
+
return window.location.origin;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
return null;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
export function getAgentServerSessionApiKey(): string | null {
|
| 193 |
+
return getBakedSessionApiKey();
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
export function getAgentServerWorkingDir(): string {
|
| 197 |
+
const envDir = import.meta.env.VITE_WORKING_DIR?.trim();
|
| 198 |
+
if (envDir) return envDir;
|
| 199 |
+
|
| 200 |
+
return DEFAULT_WORKING_DIR;
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
function buildWorkingDir(base: string, conversationId: string): string {
|
| 204 |
+
const trimmed = base.replace(/\/+$/, "");
|
| 205 |
+
const hex = conversationId.replace(/-/g, "");
|
| 206 |
+
return `${trimmed}/${hex}`;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
export function buildConversationWorkingDir(conversationId: string): string {
|
| 210 |
+
return buildWorkingDir(getAgentServerWorkingDir(), conversationId);
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
/**
|
| 214 |
+
* Conversation working dir under the backend-relative default
|
| 215 |
+
* (`workspace/project/<hex>`), deliberately ignoring any baked absolute
|
| 216 |
+
* `VITE_WORKING_DIR`. `resolveAbsoluteAgentServerPath()` anchors this to the
|
| 217 |
+
* active backend's own home via `GET /api/file/home`, so it resolves to a
|
| 218 |
+
* writable path on whichever backend actually runs the conversation.
|
| 219 |
+
*/
|
| 220 |
+
export function buildRelativeConversationWorkingDir(
|
| 221 |
+
conversationId: string,
|
| 222 |
+
): string {
|
| 223 |
+
return buildWorkingDir(DEFAULT_WORKING_DIR, conversationId);
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
/**
|
| 227 |
+
* Whether `backendHost` is the same host that served this frontend.
|
| 228 |
+
*
|
| 229 |
+
* A launcher-baked `VITE_WORKING_DIR` is an absolute path on the serving
|
| 230 |
+
* host's filesystem, so it is only valid on that exact backend. The seeded
|
| 231 |
+
* `default-local` entry starts life pointing at the served origin, but its
|
| 232 |
+
* `host` is mutable — the user can edit it to a remote backend while its id
|
| 233 |
+
* stays `default-local`. Matching on the host (not the stable id) keeps the
|
| 234 |
+
* real invariant: the baked path is only safe for the host it was baked for.
|
| 235 |
+
*/
|
| 236 |
+
export function isServedOriginHost(
|
| 237 |
+
backendHost: string | null | undefined,
|
| 238 |
+
): boolean {
|
| 239 |
+
const served = normalizeBaseUrl(getAgentServerBaseUrl());
|
| 240 |
+
const candidate = normalizeBaseUrl(backendHost);
|
| 241 |
+
if (!served || !candidate) return false;
|
| 242 |
+
return served === candidate;
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
/**
|
| 246 |
+
* Base working dir for a new conversation on the backend at `backendHost`.
|
| 247 |
+
* The baked (possibly absolute) default is used only for the served-origin
|
| 248 |
+
* backend; every other backend gets the relative default, anchored to its
|
| 249 |
+
* own home by `resolveAbsoluteAgentServerPath()`.
|
| 250 |
+
*/
|
| 251 |
+
export function buildConversationWorkingDirForBackend(
|
| 252 |
+
conversationId: string,
|
| 253 |
+
backendHost: string | null | undefined,
|
| 254 |
+
): string {
|
| 255 |
+
return isServedOriginHost(backendHost)
|
| 256 |
+
? buildConversationWorkingDir(conversationId)
|
| 257 |
+
: buildRelativeConversationWorkingDir(conversationId);
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
/**
|
| 261 |
+
* Workspace root for `backendHost` — the dir each conversation's `<root>/<hex>`
|
| 262 |
+
* working dir is created under. Same per-backend rule as
|
| 263 |
+
* `buildConversationWorkingDirForBackend()`.
|
| 264 |
+
*/
|
| 265 |
+
export function getWorkspaceRootForBackend(
|
| 266 |
+
backendHost: string | null | undefined,
|
| 267 |
+
): string {
|
| 268 |
+
return isServedOriginHost(backendHost)
|
| 269 |
+
? getAgentServerWorkingDir()
|
| 270 |
+
: DEFAULT_WORKING_DIR;
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
export function getAgentServerHeaders(): Record<string, string> {
|
| 274 |
+
const sessionApiKey = getAgentServerSessionApiKey();
|
| 275 |
+
return sessionApiKey ? { "X-Session-API-Key": sessionApiKey } : {};
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
export function isAuthRequired(): boolean {
|
| 279 |
+
return (
|
| 280 |
+
import.meta.env.VITE_AUTH_REQUIRED === "true" ||
|
| 281 |
+
(typeof window !== "undefined" &&
|
| 282 |
+
(window as unknown as Record<string, unknown>)
|
| 283 |
+
.__AGENT_CANVAS_AUTH_REQUIRED__ === true)
|
| 284 |
+
);
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
export function isAuthRequiredAndMissing(): boolean {
|
| 288 |
+
if (!isAuthRequired()) return false;
|
| 289 |
+
return !getAgentServerSessionApiKey();
|
| 290 |
+
}
|
src/api/agent-server-home.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// @spec WUP-001 — Resolve relative working dirs against /api/file/home
|
| 2 |
+
import { FileClient } from "@openhands/typescript-client/clients";
|
| 3 |
+
import {
|
| 4 |
+
getAgentServerClientOptions,
|
| 5 |
+
type AgentServerClientOverrides,
|
| 6 |
+
} from "./agent-server-client-options";
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Cache the agent-server's home directory per host so we only round-trip
|
| 10 |
+
* `/api/file/home` once per backend. The home dir is effectively static for
|
| 11 |
+
* the lifetime of a running agent-server (it's `Path.home()` on the host),
|
| 12 |
+
* so caching is safe and avoids hammering the endpoint on every upload.
|
| 13 |
+
*
|
| 14 |
+
* The cache holds `Promise<string>` rather than `string` so concurrent
|
| 15 |
+
* callers share a single in-flight request.
|
| 16 |
+
*/
|
| 17 |
+
const homeDirCache = new Map<string, Promise<string>>();
|
| 18 |
+
|
| 19 |
+
function isAbsolutePath(path: string): boolean {
|
| 20 |
+
// Treat POSIX-style and Windows-style absolute paths as absolute.
|
| 21 |
+
// The agent-server itself is POSIX on Linux/macOS and uses `\\` style
|
| 22 |
+
// on Windows; we just need to know whether `Path(path).is_absolute()`
|
| 23 |
+
// would return true on the server.
|
| 24 |
+
//
|
| 25 |
+
// Patterns covered:
|
| 26 |
+
// POSIX absolute: /foo/bar
|
| 27 |
+
// Windows drive: C:\foo or C:/foo
|
| 28 |
+
// Windows UNC: \\server\share (starts with `\`, matches `[/\\]`)
|
| 29 |
+
return /^([/\\]|[a-zA-Z]:[/\\])/.test(path);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* Join a parent directory and a relative child segment with a forward slash,
|
| 34 |
+
* collapsing any duplicate separators that result from the join.
|
| 35 |
+
*/
|
| 36 |
+
function joinPath(parent: string, child: string): string {
|
| 37 |
+
const left = parent.replace(/[/\\]+$/, "");
|
| 38 |
+
const right = child.replace(/^[/\\]+/, "");
|
| 39 |
+
return `${left}/${right}`;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
/**
|
| 43 |
+
* Fetch and cache the agent-server's home directory via `GET /api/file/home`.
|
| 44 |
+
*
|
| 45 |
+
* The result is the absolute path returned by `Path.home()` on the
|
| 46 |
+
* agent-server host (e.g. `/Users/foo`, `/root`, or `C:\\Users\\Foo`). This
|
| 47 |
+
* is the most reliable absolute, writable anchor the agent-server API
|
| 48 |
+
* currently exposes — `/server_info` doesn't include the process CWD.
|
| 49 |
+
*
|
| 50 |
+
* @param overrides Same shape as `getAgentServerClientOptions` — lets cloud
|
| 51 |
+
* sandboxes pass a `conversationUrl` + `sessionApiKey` so the lookup goes
|
| 52 |
+
* to the per-conversation runtime rather than the bundled local backend.
|
| 53 |
+
*/
|
| 54 |
+
export async function getAgentServerHomeDir(
|
| 55 |
+
overrides: AgentServerClientOverrides = {},
|
| 56 |
+
): Promise<string> {
|
| 57 |
+
const options = getAgentServerClientOptions(overrides);
|
| 58 |
+
const cacheKey = options.host;
|
| 59 |
+
const cached = homeDirCache.get(cacheKey);
|
| 60 |
+
if (cached) return cached;
|
| 61 |
+
|
| 62 |
+
const lookup = (async () => {
|
| 63 |
+
const { home } = await new FileClient(options).getHome();
|
| 64 |
+
if (!home || typeof home !== "string") {
|
| 65 |
+
throw new Error("Agent server returned an empty home directory");
|
| 66 |
+
}
|
| 67 |
+
return home.replace(/[/\\]+$/, "");
|
| 68 |
+
})();
|
| 69 |
+
|
| 70 |
+
homeDirCache.set(cacheKey, lookup);
|
| 71 |
+
try {
|
| 72 |
+
return await lookup;
|
| 73 |
+
} catch (error) {
|
| 74 |
+
// Don't cache failures — let the next call retry.
|
| 75 |
+
homeDirCache.delete(cacheKey);
|
| 76 |
+
throw error;
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
/** Test-only helper. */
|
| 81 |
+
export function clearAgentServerHomeDirCache(): void {
|
| 82 |
+
homeDirCache.clear();
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
/**
|
| 86 |
+
* Resolve a (possibly relative) working dir to an absolute path the
|
| 87 |
+
* agent-server's file APIs will accept.
|
| 88 |
+
*
|
| 89 |
+
* - If `workingDir` is already absolute, returns it unchanged.
|
| 90 |
+
* - Otherwise prepends the agent-server's home dir (looked up via
|
| 91 |
+
* `/api/file/home` and cached). This matches how the published binary
|
| 92 |
+
* and Docker entrypoint expect to anchor relative working dirs: under
|
| 93 |
+
* `~/workspace/project` rather than the filesystem root.
|
| 94 |
+
*
|
| 95 |
+
* Why this matters: the agent-server's `/api/file/upload` endpoint requires
|
| 96 |
+
* an absolute path and `mkdir -p`s the parent. Naively prepending `/` to a
|
| 97 |
+
* relative dir like `workspace/project/<hex>` produces `/workspace/...`,
|
| 98 |
+
* which on macOS lives under the SIP-protected read-only root and fails
|
| 99 |
+
* with `Errno 30: Read-only file system: '/workspace'`. Resolving against
|
| 100 |
+
* `Path.home()` instead puts the path somewhere reliably writable.
|
| 101 |
+
*/
|
| 102 |
+
export async function resolveAbsoluteAgentServerPath(
|
| 103 |
+
workingDir: string,
|
| 104 |
+
overrides: AgentServerClientOverrides = {},
|
| 105 |
+
): Promise<string> {
|
| 106 |
+
const trimmed = workingDir.replace(/[/\\]+$/, "");
|
| 107 |
+
if (!trimmed) {
|
| 108 |
+
return getAgentServerHomeDir(overrides);
|
| 109 |
+
}
|
| 110 |
+
if (isAbsolutePath(trimmed)) return trimmed;
|
| 111 |
+
|
| 112 |
+
const home = await getAgentServerHomeDir(overrides);
|
| 113 |
+
return joinPath(home, trimmed);
|
| 114 |
+
}
|
src/api/canvas-extensions-service.test.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { AgentServerClient } from "@openhands/typescript-client/clients";
|
| 2 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 3 |
+
import CanvasExtensionsService, {
|
| 4 |
+
CanvasExtensionsUnsupportedError,
|
| 5 |
+
} from "#/api/canvas-extensions-service";
|
| 6 |
+
import {
|
| 7 |
+
__resetActiveStoreForTests,
|
| 8 |
+
setActiveSelection,
|
| 9 |
+
setRegisteredBackends,
|
| 10 |
+
} from "#/api/backend-registry/active-store";
|
| 11 |
+
import type { Backend } from "#/api/backend-registry/types";
|
| 12 |
+
import type { InstalledCanvasExtensionInfo } from "#/types/canvas-extension";
|
| 13 |
+
|
| 14 |
+
vi.mock("@openhands/typescript-client/clients", () => ({
|
| 15 |
+
AgentServerClient: vi.fn(),
|
| 16 |
+
}));
|
| 17 |
+
|
| 18 |
+
const get = vi.fn();
|
| 19 |
+
const post = vi.fn();
|
| 20 |
+
const patch = vi.fn();
|
| 21 |
+
const remove = vi.fn();
|
| 22 |
+
const request = vi.fn();
|
| 23 |
+
|
| 24 |
+
const localBackend: Backend = {
|
| 25 |
+
id: "local",
|
| 26 |
+
name: "Local",
|
| 27 |
+
host: "http://127.0.0.1:8000",
|
| 28 |
+
apiKey: "session-key",
|
| 29 |
+
kind: "local",
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
const extension: InstalledCanvasExtensionInfo = {
|
| 33 |
+
name: "demo-extension",
|
| 34 |
+
version: "0.1.0",
|
| 35 |
+
description: "Demo",
|
| 36 |
+
enabled: false,
|
| 37 |
+
source: "github:example/extensions",
|
| 38 |
+
resolved_ref: "abc123",
|
| 39 |
+
repo_path: "extensions/demo",
|
| 40 |
+
installed_at: "2026-08-01T00:00:00Z",
|
| 41 |
+
install_path: "/tmp/extensions/demo-extension",
|
| 42 |
+
manifest: {
|
| 43 |
+
schema_version: 1,
|
| 44 |
+
name: "demo-extension",
|
| 45 |
+
version: "0.1.0",
|
| 46 |
+
entrypoint: "dist/extension.js",
|
| 47 |
+
contributes: {
|
| 48 |
+
pages: [{ id: "home", title: "Home", path: "home" }],
|
| 49 |
+
},
|
| 50 |
+
},
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
beforeEach(() => {
|
| 54 |
+
vi.clearAllMocks();
|
| 55 |
+
__resetActiveStoreForTests();
|
| 56 |
+
setRegisteredBackends([localBackend]);
|
| 57 |
+
setActiveSelection({ backendId: localBackend.id });
|
| 58 |
+
vi.mocked(AgentServerClient).mockImplementation(
|
| 59 |
+
function MockAgentServerClient() {
|
| 60 |
+
return {
|
| 61 |
+
get,
|
| 62 |
+
post,
|
| 63 |
+
patch,
|
| 64 |
+
delete: remove,
|
| 65 |
+
request,
|
| 66 |
+
close: vi.fn(),
|
| 67 |
+
} as unknown as AgentServerClient;
|
| 68 |
+
} as unknown as typeof AgentServerClient,
|
| 69 |
+
);
|
| 70 |
+
});
|
| 71 |
+
|
| 72 |
+
afterEach(() => {
|
| 73 |
+
setActiveSelection(null);
|
| 74 |
+
setRegisteredBackends([]);
|
| 75 |
+
__resetActiveStoreForTests();
|
| 76 |
+
});
|
| 77 |
+
|
| 78 |
+
describe("CanvasExtensionsService", () => {
|
| 79 |
+
it("lists extensions installed on the active Agent Server", async () => {
|
| 80 |
+
get.mockResolvedValue({ canvas_extensions: [extension] });
|
| 81 |
+
|
| 82 |
+
await expect(CanvasExtensionsService.listInstalled()).resolves.toEqual([
|
| 83 |
+
extension,
|
| 84 |
+
]);
|
| 85 |
+
expect(get).toHaveBeenCalledWith("/api/canvas-extensions/installed");
|
| 86 |
+
});
|
| 87 |
+
|
| 88 |
+
it("preserves install coordinates without allowing an enabled flag", async () => {
|
| 89 |
+
post.mockResolvedValue(extension);
|
| 90 |
+
|
| 91 |
+
await CanvasExtensionsService.install({
|
| 92 |
+
source: "github:example/extensions",
|
| 93 |
+
ref: "main",
|
| 94 |
+
repo_path: "extensions/demo",
|
| 95 |
+
});
|
| 96 |
+
|
| 97 |
+
expect(post).toHaveBeenCalledWith("/api/canvas-extensions/install", {
|
| 98 |
+
source: "github:example/extensions",
|
| 99 |
+
ref: "main",
|
| 100 |
+
repo_path: "extensions/demo",
|
| 101 |
+
});
|
| 102 |
+
});
|
| 103 |
+
|
| 104 |
+
it.each([
|
| 105 |
+
{
|
| 106 |
+
source: "/repository-name/",
|
| 107 |
+
repoPath: "/demo-extension",
|
| 108 |
+
expectedSource: "/repository-name/demo-extension",
|
| 109 |
+
},
|
| 110 |
+
{
|
| 111 |
+
source: "/repository-name",
|
| 112 |
+
repoPath: "demo-extension",
|
| 113 |
+
expectedSource: "/repository-name/demo-extension",
|
| 114 |
+
},
|
| 115 |
+
{
|
| 116 |
+
source: "/repository-name/demo-extension",
|
| 117 |
+
repoPath: null,
|
| 118 |
+
expectedSource: "/repository-name/demo-extension",
|
| 119 |
+
},
|
| 120 |
+
])(
|
| 121 |
+
"resolves a local extension from source $source and path $repoPath",
|
| 122 |
+
async ({ source, repoPath, expectedSource }) => {
|
| 123 |
+
post.mockResolvedValue(extension);
|
| 124 |
+
|
| 125 |
+
await CanvasExtensionsService.install({
|
| 126 |
+
source,
|
| 127 |
+
ref: null,
|
| 128 |
+
repo_path: repoPath,
|
| 129 |
+
});
|
| 130 |
+
|
| 131 |
+
expect(post).toHaveBeenCalledWith("/api/canvas-extensions/install", {
|
| 132 |
+
source: expectedSource,
|
| 133 |
+
ref: null,
|
| 134 |
+
repo_path: null,
|
| 135 |
+
});
|
| 136 |
+
},
|
| 137 |
+
);
|
| 138 |
+
|
| 139 |
+
it("maps a missing router to an explicit unsupported-backend error", async () => {
|
| 140 |
+
get.mockRejectedValue(
|
| 141 |
+
Object.assign(new Error("Not Found"), {
|
| 142 |
+
name: "HttpError",
|
| 143 |
+
status: 404,
|
| 144 |
+
}),
|
| 145 |
+
);
|
| 146 |
+
|
| 147 |
+
await expect(CanvasExtensionsService.listInstalled()).rejects.toEqual(
|
| 148 |
+
expect.objectContaining<Partial<CanvasExtensionsUnsupportedError>>({
|
| 149 |
+
name: "CanvasExtensionsUnsupportedError",
|
| 150 |
+
reason: "missing-api",
|
| 151 |
+
}),
|
| 152 |
+
);
|
| 153 |
+
});
|
| 154 |
+
|
| 155 |
+
it("does not call the Agent Server protocol for a Cloud backend", async () => {
|
| 156 |
+
const cloud: Backend = {
|
| 157 |
+
...localBackend,
|
| 158 |
+
id: "cloud",
|
| 159 |
+
name: "Cloud",
|
| 160 |
+
kind: "cloud",
|
| 161 |
+
};
|
| 162 |
+
setRegisteredBackends([cloud]);
|
| 163 |
+
setActiveSelection({ backendId: cloud.id });
|
| 164 |
+
|
| 165 |
+
await expect(CanvasExtensionsService.listInstalled()).rejects.toEqual(
|
| 166 |
+
expect.objectContaining({ reason: "cloud-backend" }),
|
| 167 |
+
);
|
| 168 |
+
expect(AgentServerClient).not.toHaveBeenCalled();
|
| 169 |
+
});
|
| 170 |
+
|
| 171 |
+
it("fetches the bundle as authenticated text from the captured backend", async () => {
|
| 172 |
+
get.mockResolvedValue("export const activate = () => {};");
|
| 173 |
+
|
| 174 |
+
await expect(
|
| 175 |
+
CanvasExtensionsService.fetchBundle(extension.name, localBackend),
|
| 176 |
+
).resolves.toContain("activate");
|
| 177 |
+
|
| 178 |
+
expect(AgentServerClient).toHaveBeenCalledWith({
|
| 179 |
+
host: localBackend.host,
|
| 180 |
+
apiKey: localBackend.apiKey,
|
| 181 |
+
timeout: 60000,
|
| 182 |
+
});
|
| 183 |
+
expect(get).toHaveBeenCalledWith(
|
| 184 |
+
"/api/canvas-extensions/installed/demo-extension/bundle",
|
| 185 |
+
{ responseType: "text" },
|
| 186 |
+
);
|
| 187 |
+
});
|
| 188 |
+
|
| 189 |
+
it("encodes extension names in mutation paths", async () => {
|
| 190 |
+
patch.mockResolvedValue({ name: "a/b", enabled: true });
|
| 191 |
+
|
| 192 |
+
await CanvasExtensionsService.setEnabled("a/b", true);
|
| 193 |
+
|
| 194 |
+
expect(patch).toHaveBeenCalledWith(
|
| 195 |
+
"/api/canvas-extensions/installed/a%2Fb",
|
| 196 |
+
{ enabled: true },
|
| 197 |
+
);
|
| 198 |
+
});
|
| 199 |
+
|
| 200 |
+
it("does not send the backend session key to an absolute request URL", async () => {
|
| 201 |
+
await expect(
|
| 202 |
+
CanvasExtensionsService.requestAgentServer({
|
| 203 |
+
path: "https://example.com/collect",
|
| 204 |
+
}),
|
| 205 |
+
).rejects.toThrow("root-relative path");
|
| 206 |
+
expect(AgentServerClient).not.toHaveBeenCalled();
|
| 207 |
+
});
|
| 208 |
+
});
|
src/api/canvas-extensions-service.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { AgentServerClient } from "@openhands/typescript-client/clients";
|
| 2 |
+
import { getAgentServerClientOptions } from "#/api/agent-server-client-options";
|
| 3 |
+
import { isSdkHttpStatusError } from "#/api/agent-server-compatibility";
|
| 4 |
+
import {
|
| 5 |
+
getActiveBackend,
|
| 6 |
+
isNoBackend,
|
| 7 |
+
} from "#/api/backend-registry/active-store";
|
| 8 |
+
import type { Backend } from "#/api/backend-registry/types";
|
| 9 |
+
import type {
|
| 10 |
+
CanvasExtensionAgentServerRequest,
|
| 11 |
+
InstallCanvasExtensionRequest,
|
| 12 |
+
InstalledCanvasExtensionInfo,
|
| 13 |
+
} from "#/types/canvas-extension";
|
| 14 |
+
|
| 15 |
+
const CANVAS_EXTENSIONS_BASE_PATH = "/api/canvas-extensions";
|
| 16 |
+
const REMOTE_EXTENSION_SOURCE_PATTERN =
|
| 17 |
+
/^(?:github:|https?:\/\/|git:\/\/|file:\/\/|[\w.-]+@[\w.-]+:)/i;
|
| 18 |
+
|
| 19 |
+
export type CanvasExtensionsUnsupportedReason =
|
| 20 |
+
| "no-backend"
|
| 21 |
+
| "cloud-backend"
|
| 22 |
+
| "missing-api";
|
| 23 |
+
|
| 24 |
+
export class CanvasExtensionsUnsupportedError extends Error {
|
| 25 |
+
readonly reason: CanvasExtensionsUnsupportedReason;
|
| 26 |
+
|
| 27 |
+
constructor(reason: CanvasExtensionsUnsupportedReason) {
|
| 28 |
+
const message =
|
| 29 |
+
reason === "no-backend"
|
| 30 |
+
? "Add an Agent Server backend to use Apps."
|
| 31 |
+
: reason === "cloud-backend"
|
| 32 |
+
? "Apps are not available on Cloud backends yet."
|
| 33 |
+
: "This Agent Server does not support Apps yet. Upgrade the backend and try again.";
|
| 34 |
+
super(message);
|
| 35 |
+
this.name = "CanvasExtensionsUnsupportedError";
|
| 36 |
+
this.reason = reason;
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
export function isCanvasExtensionsUnsupportedError(
|
| 41 |
+
error: unknown,
|
| 42 |
+
): error is CanvasExtensionsUnsupportedError {
|
| 43 |
+
return (
|
| 44 |
+
error instanceof CanvasExtensionsUnsupportedError ||
|
| 45 |
+
(typeof error === "object" &&
|
| 46 |
+
error !== null &&
|
| 47 |
+
"name" in error &&
|
| 48 |
+
error.name === "CanvasExtensionsUnsupportedError")
|
| 49 |
+
);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
function requireSupportedBackend(): void {
|
| 53 |
+
const { backend } = getActiveBackend();
|
| 54 |
+
if (isNoBackend(backend)) {
|
| 55 |
+
throw new CanvasExtensionsUnsupportedError("no-backend");
|
| 56 |
+
}
|
| 57 |
+
if (backend.kind === "cloud") {
|
| 58 |
+
throw new CanvasExtensionsUnsupportedError("cloud-backend");
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
function getClient(): AgentServerClient {
|
| 63 |
+
requireSupportedBackend();
|
| 64 |
+
return new AgentServerClient(getAgentServerClientOptions());
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
function getClientForBackend(backend: Backend): AgentServerClient {
|
| 68 |
+
if (isNoBackend(backend)) {
|
| 69 |
+
throw new CanvasExtensionsUnsupportedError("no-backend");
|
| 70 |
+
}
|
| 71 |
+
if (backend.kind === "cloud") {
|
| 72 |
+
throw new CanvasExtensionsUnsupportedError("cloud-backend");
|
| 73 |
+
}
|
| 74 |
+
return new AgentServerClient({
|
| 75 |
+
host: backend.host,
|
| 76 |
+
...(backend.apiKey ? { apiKey: backend.apiKey } : {}),
|
| 77 |
+
timeout: 60000,
|
| 78 |
+
});
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
async function mapUnsupported<T>(operation: () => Promise<T>): Promise<T> {
|
| 82 |
+
try {
|
| 83 |
+
return await operation();
|
| 84 |
+
} catch (error) {
|
| 85 |
+
if (isSdkHttpStatusError(error, 404)) {
|
| 86 |
+
throw new CanvasExtensionsUnsupportedError("missing-api");
|
| 87 |
+
}
|
| 88 |
+
throw error;
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
function installedExtensionPath(name: string): string {
|
| 93 |
+
return `${CANVAS_EXTENSIONS_BASE_PATH}/installed/${encodeURIComponent(name)}`;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
function joinLocalExtensionPath(source: string, repoPath: string): string {
|
| 97 |
+
const separator = source.includes("\\") && !source.includes("/") ? "\\" : "/";
|
| 98 |
+
const basePath = source.replace(/[\\/]+$/, "");
|
| 99 |
+
const childPath = repoPath.replace(/^[\\/]+/, "");
|
| 100 |
+
|
| 101 |
+
if (!childPath) return source;
|
| 102 |
+
if (!basePath) return `${separator}${childPath}`;
|
| 103 |
+
return `${basePath}${separator}${childPath}`;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
function normalizeInstallRequest(
|
| 107 |
+
request: InstallCanvasExtensionRequest,
|
| 108 |
+
): InstallCanvasExtensionRequest {
|
| 109 |
+
const repoPath = request.repo_path?.trim();
|
| 110 |
+
if (!repoPath || REMOTE_EXTENSION_SOURCE_PATTERN.test(request.source)) {
|
| 111 |
+
return request;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
return {
|
| 115 |
+
...request,
|
| 116 |
+
source: joinLocalExtensionPath(request.source, repoPath),
|
| 117 |
+
repo_path: null,
|
| 118 |
+
};
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
class CanvasExtensionsService {
|
| 122 |
+
static async listInstalled(): Promise<InstalledCanvasExtensionInfo[]> {
|
| 123 |
+
const client = getClient();
|
| 124 |
+
const response = await mapUnsupported(() =>
|
| 125 |
+
client.get<{ canvas_extensions: InstalledCanvasExtensionInfo[] }>(
|
| 126 |
+
`${CANVAS_EXTENSIONS_BASE_PATH}/installed`,
|
| 127 |
+
),
|
| 128 |
+
);
|
| 129 |
+
return response.canvas_extensions ?? [];
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
static async install(
|
| 133 |
+
request: InstallCanvasExtensionRequest,
|
| 134 |
+
): Promise<InstalledCanvasExtensionInfo> {
|
| 135 |
+
const client = getClient();
|
| 136 |
+
return mapUnsupported(() =>
|
| 137 |
+
client.post<InstalledCanvasExtensionInfo>(
|
| 138 |
+
`${CANVAS_EXTENSIONS_BASE_PATH}/install`,
|
| 139 |
+
normalizeInstallRequest(request),
|
| 140 |
+
),
|
| 141 |
+
);
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
static async setEnabled(
|
| 145 |
+
name: string,
|
| 146 |
+
enabled: boolean,
|
| 147 |
+
): Promise<{ name: string; enabled: boolean }> {
|
| 148 |
+
const client = getClient();
|
| 149 |
+
return mapUnsupported(() =>
|
| 150 |
+
client.patch<{ name: string; enabled: boolean }>(
|
| 151 |
+
installedExtensionPath(name),
|
| 152 |
+
{ enabled },
|
| 153 |
+
),
|
| 154 |
+
);
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
static async uninstall(name: string): Promise<{ message: string }> {
|
| 158 |
+
const client = getClient();
|
| 159 |
+
return mapUnsupported(() =>
|
| 160 |
+
client.delete<{ message: string }>(installedExtensionPath(name)),
|
| 161 |
+
);
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
static async fetchBundle(name: string, backend?: Backend): Promise<string> {
|
| 165 |
+
const client = backend ? getClientForBackend(backend) : getClient();
|
| 166 |
+
return mapUnsupported(() =>
|
| 167 |
+
client.get<string>(`${installedExtensionPath(name)}/bundle`, {
|
| 168 |
+
responseType: "text",
|
| 169 |
+
}),
|
| 170 |
+
);
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
static async requestAgentServer<T = unknown>(
|
| 174 |
+
request: CanvasExtensionAgentServerRequest,
|
| 175 |
+
backend?: Backend,
|
| 176 |
+
): Promise<T> {
|
| 177 |
+
if (!request.path.startsWith("/") || request.path.startsWith("//")) {
|
| 178 |
+
throw new Error(
|
| 179 |
+
"Canvas Extension Agent Server requests must use a root-relative path.",
|
| 180 |
+
);
|
| 181 |
+
}
|
| 182 |
+
const client = backend ? getClientForBackend(backend) : getClient();
|
| 183 |
+
return client.request<T>({
|
| 184 |
+
method: request.method ?? "GET",
|
| 185 |
+
path: request.path,
|
| 186 |
+
body: request.body,
|
| 187 |
+
headers: request.headers,
|
| 188 |
+
});
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
export default CanvasExtensionsService;
|
src/api/canvas-ui-client-tool.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { CANVAS_UI_CLIENT_TOOL_NAME } from "#/constants/canvas-ui";
|
| 2 |
+
|
| 3 |
+
export {
|
| 4 |
+
CANVAS_UI_CLIENT_ACTION_KIND,
|
| 5 |
+
CANVAS_UI_CLIENT_TOOL_NAME,
|
| 6 |
+
LEGACY_CANVAS_UI_TOOL_NAME,
|
| 7 |
+
} from "#/constants/canvas-ui";
|
| 8 |
+
|
| 9 |
+
export interface ClientToolSpec {
|
| 10 |
+
name: string;
|
| 11 |
+
description: string;
|
| 12 |
+
parameters: Record<string, unknown>;
|
| 13 |
+
annotations?: {
|
| 14 |
+
title?: string | null;
|
| 15 |
+
readOnlyHint: boolean;
|
| 16 |
+
destructiveHint: boolean;
|
| 17 |
+
idempotentHint: boolean;
|
| 18 |
+
openWorldHint: boolean;
|
| 19 |
+
};
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
const CANVAS_UI_DESCRIPTION = `The user is interacting with you inside Agent Canvas — a web UI with a chat panel on the left and a tabbed right-side panel (files, terminal, browser, vscode, planner, tasklist). This tool lets you drive that right-side panel so the user sees what you just produced.
|
| 23 |
+
|
| 24 |
+
They will NOT see the files you wrote, the terminal output, or the browser
|
| 25 |
+
unless you call this tool to switch the right-side panel to the relevant
|
| 26 |
+
tab. Call this every time you finish work that produces something the user
|
| 27 |
+
should look at — don't rely on them noticing on their own.
|
| 28 |
+
|
| 29 |
+
When to call (pick the most specific option that matches your last action):
|
| 30 |
+
|
| 31 |
+
* You wrote or modified a single file (ANY language, ANY size — including
|
| 32 |
+
small scripts like a hello-world bash file) →
|
| 33 |
+
command="navigate_to_file", path=<workspace-relative path of that file>
|
| 34 |
+
|
| 35 |
+
* You generated an HTML page, image, SVG, PDF, markdown report, or other
|
| 36 |
+
previewable artifact →
|
| 37 |
+
command="show_preview", path=<that file>
|
| 38 |
+
|
| 39 |
+
* You finished editing multiple files in one logical step →
|
| 40 |
+
command="open_tab", tab="files"
|
| 41 |
+
(The Files tab automatically renders a diff view when the workspace has
|
| 42 |
+
uncommitted git changes, which covers the "highlight changes" case.)
|
| 43 |
+
|
| 44 |
+
* You ran a long-running terminal command, or one whose output the user
|
| 45 |
+
should inspect →
|
| 46 |
+
command="open_tab", tab="terminal"
|
| 47 |
+
|
| 48 |
+
* You browsed to a URL the user should see →
|
| 49 |
+
First call browser_get_state(include_screenshot=true) after your final
|
| 50 |
+
browser interaction so Agent Canvas has a screenshot to display, then call
|
| 51 |
+
command="open_tab", tab="browser"
|
| 52 |
+
(browser_navigate alone only updates the URL; without browser_get_state,
|
| 53 |
+
the Browser tab will open without a screenshot.)
|
| 54 |
+
|
| 55 |
+
Call this BEFORE writing your chat-message summary of the change, so the
|
| 56 |
+
artifact is visible while the user reads what you did. One canvas_ui_control
|
| 57 |
+
call per logical step is enough — don't repeat it for the same file or tab in
|
| 58 |
+
the same turn.`;
|
| 59 |
+
|
| 60 |
+
export const CANVAS_UI_CLIENT_TOOL: ClientToolSpec = {
|
| 61 |
+
name: CANVAS_UI_CLIENT_TOOL_NAME,
|
| 62 |
+
description: CANVAS_UI_DESCRIPTION,
|
| 63 |
+
parameters: {
|
| 64 |
+
type: "object",
|
| 65 |
+
additionalProperties: false,
|
| 66 |
+
properties: {
|
| 67 |
+
command: {
|
| 68 |
+
type: "string",
|
| 69 |
+
enum: ["navigate_to_file", "open_tab", "show_preview"],
|
| 70 |
+
description: "UI command to dispatch.",
|
| 71 |
+
},
|
| 72 |
+
path: {
|
| 73 |
+
type: "string",
|
| 74 |
+
description:
|
| 75 |
+
"Workspace-relative file path. Required for navigate_to_file and show_preview; ignored otherwise.",
|
| 76 |
+
},
|
| 77 |
+
tab: {
|
| 78 |
+
type: "string",
|
| 79 |
+
enum: ["files", "browser", "vscode", "terminal", "planner", "tasklist"],
|
| 80 |
+
description: "Tab to open. Required for open_tab; ignored otherwise.",
|
| 81 |
+
},
|
| 82 |
+
},
|
| 83 |
+
required: ["command"],
|
| 84 |
+
},
|
| 85 |
+
annotations: {
|
| 86 |
+
readOnlyHint: true,
|
| 87 |
+
destructiveHint: false,
|
| 88 |
+
idempotentHint: true,
|
| 89 |
+
openWorldHint: false,
|
| 90 |
+
},
|
| 91 |
+
};
|
src/api/client-source.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import packageJson from "../../package.json";
|
| 2 |
+
|
| 3 |
+
export const AGENT_CANVAS_CLIENT_SOURCE = "agent_canvas";
|
| 4 |
+
export const AGENT_CANVAS_CLIENT_VERSION = packageJson.version;
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* Coarse, non-user-identifying request metadata for Cloud observability.
|
| 8 |
+
*
|
| 9 |
+
* Cloud ingress can retain these headers as facets without logging request
|
| 10 |
+
* bodies, API keys, device codes, or conversation content.
|
| 11 |
+
*/
|
| 12 |
+
export const OPENHANDS_CLIENT_HEADER = "X-OpenHands-Client";
|
| 13 |
+
export const OPENHANDS_CLIENT_VERSION_HEADER = "X-OpenHands-Client-Version";
|
| 14 |
+
export const OPENHANDS_TELEMETRY_DISTINCT_ID_HEADER =
|
| 15 |
+
"X-OpenHands-Telemetry-Distinct-Id";
|
| 16 |
+
|
| 17 |
+
export const AGENT_CANVAS_CLIENT_HEADERS: Readonly<Record<string, string>> = {
|
| 18 |
+
[OPENHANDS_CLIENT_HEADER]: AGENT_CANVAS_CLIENT_SOURCE,
|
| 19 |
+
[OPENHANDS_CLIENT_VERSION_HEADER]: AGENT_CANVAS_CLIENT_VERSION,
|
| 20 |
+
};
|
src/api/conversation-file-upload.api.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace";
|
| 2 |
+
import { getAgentServerClientOptions } from "#/api/agent-server-client-options";
|
| 3 |
+
import { getActiveBackend } from "#/api/backend-registry/active-store";
|
| 4 |
+
import { batchGetCloudConversations } from "#/api/cloud/conversation-service.api";
|
| 5 |
+
import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types";
|
| 6 |
+
import type { FileUploadSuccessResponse } from "#/api/open-hands.types";
|
| 7 |
+
import {
|
| 8 |
+
buildWorkspaceUploadPath,
|
| 9 |
+
getSafeUploadFileName,
|
| 10 |
+
resolveConversationUploadWorkingDir,
|
| 11 |
+
} from "#/api/workspace-upload-path";
|
| 12 |
+
|
| 13 |
+
const FILE_UPLOAD_CONCURRENCY = 5;
|
| 14 |
+
|
| 15 |
+
export interface ConversationRuntimeContext {
|
| 16 |
+
conversationUrl: string | null;
|
| 17 |
+
sessionApiKey: string | null;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
/**
|
| 21 |
+
* Resolve the sandbox runtime URL + session key needed for file upload and
|
| 22 |
+
* send-event calls. Cloud conversations only exist on the provisioned runtime,
|
| 23 |
+
* not on the bundled local agent-server.
|
| 24 |
+
*/
|
| 25 |
+
export async function resolveConversationRuntime(
|
| 26 |
+
conversationId: string,
|
| 27 |
+
currentConversation?: AppConversation | null,
|
| 28 |
+
): Promise<ConversationRuntimeContext> {
|
| 29 |
+
if (
|
| 30 |
+
currentConversation?.id === conversationId &&
|
| 31 |
+
currentConversation.conversation_url?.trim() &&
|
| 32 |
+
currentConversation.session_api_key?.trim()
|
| 33 |
+
) {
|
| 34 |
+
return {
|
| 35 |
+
conversationUrl: currentConversation.conversation_url.trim(),
|
| 36 |
+
sessionApiKey: currentConversation.session_api_key.trim(),
|
| 37 |
+
};
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
if (getActiveBackend().backend.kind === "cloud") {
|
| 41 |
+
const [conversation] = await batchGetCloudConversations([conversationId]);
|
| 42 |
+
return {
|
| 43 |
+
conversationUrl: conversation?.conversation_url?.trim() ?? null,
|
| 44 |
+
sessionApiKey: conversation?.session_api_key?.trim() ?? null,
|
| 45 |
+
};
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
return { conversationUrl: null, sessionApiKey: null };
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
function requireCloudRuntime(
|
| 52 |
+
runtime: ConversationRuntimeContext,
|
| 53 |
+
): ConversationRuntimeContext & {
|
| 54 |
+
conversationUrl: string;
|
| 55 |
+
sessionApiKey: string;
|
| 56 |
+
} {
|
| 57 |
+
if (!runtime.conversationUrl || !runtime.sessionApiKey) {
|
| 58 |
+
throw new Error(
|
| 59 |
+
"Conversation sandbox is still starting. Wait for it to finish, then try again.",
|
| 60 |
+
);
|
| 61 |
+
}
|
| 62 |
+
return {
|
| 63 |
+
conversationUrl: runtime.conversationUrl,
|
| 64 |
+
sessionApiKey: runtime.sessionApiKey,
|
| 65 |
+
};
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
/**
|
| 69 |
+
* Upload attachments into the conversation workspace. Local conversations use
|
| 70 |
+
* the bundled agent-server; cloud conversations target the provisioned runtime.
|
| 71 |
+
*/
|
| 72 |
+
export async function uploadFilesToConversation(
|
| 73 |
+
conversationId: string,
|
| 74 |
+
files: File[],
|
| 75 |
+
currentConversation?: AppConversation | null,
|
| 76 |
+
): Promise<FileUploadSuccessResponse> {
|
| 77 |
+
const workingDir = await resolveConversationUploadWorkingDir(
|
| 78 |
+
conversationId,
|
| 79 |
+
currentConversation,
|
| 80 |
+
);
|
| 81 |
+
const runtime = await resolveConversationRuntime(
|
| 82 |
+
conversationId,
|
| 83 |
+
currentConversation,
|
| 84 |
+
);
|
| 85 |
+
const isCloud = getActiveBackend().backend.kind === "cloud";
|
| 86 |
+
|
| 87 |
+
const sessionApiKey =
|
| 88 |
+
currentConversation?.id === conversationId
|
| 89 |
+
? (currentConversation.session_api_key ?? runtime.sessionApiKey)
|
| 90 |
+
: runtime.sessionApiKey;
|
| 91 |
+
const conversationUrl =
|
| 92 |
+
currentConversation?.id === conversationId
|
| 93 |
+
? (currentConversation.conversation_url ?? runtime.conversationUrl)
|
| 94 |
+
: runtime.conversationUrl;
|
| 95 |
+
|
| 96 |
+
if (isCloud) {
|
| 97 |
+
const cloudRuntime = requireCloudRuntime({
|
| 98 |
+
conversationUrl,
|
| 99 |
+
sessionApiKey,
|
| 100 |
+
});
|
| 101 |
+
return uploadFilesToRuntime({
|
| 102 |
+
files,
|
| 103 |
+
workingDir,
|
| 104 |
+
conversationUrl: cloudRuntime.conversationUrl,
|
| 105 |
+
sessionApiKey: cloudRuntime.sessionApiKey,
|
| 106 |
+
});
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
return uploadFilesToRuntime({
|
| 110 |
+
files,
|
| 111 |
+
workingDir,
|
| 112 |
+
conversationUrl,
|
| 113 |
+
sessionApiKey,
|
| 114 |
+
});
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
async function uploadFilesToRuntime(options: {
|
| 118 |
+
files: File[];
|
| 119 |
+
workingDir: string;
|
| 120 |
+
conversationUrl: string | null;
|
| 121 |
+
sessionApiKey: string | null;
|
| 122 |
+
}): Promise<FileUploadSuccessResponse> {
|
| 123 |
+
const { files, workingDir, conversationUrl, sessionApiKey } = options;
|
| 124 |
+
const workspace = new RemoteWorkspace(
|
| 125 |
+
getAgentServerClientOptions({
|
| 126 |
+
conversationUrl,
|
| 127 |
+
sessionApiKey,
|
| 128 |
+
workingDir,
|
| 129 |
+
}),
|
| 130 |
+
);
|
| 131 |
+
|
| 132 |
+
const uploadFile = async (file: File) => {
|
| 133 |
+
try {
|
| 134 |
+
const safeName = getSafeUploadFileName(file.name);
|
| 135 |
+
// @spec WUP-001 — Build an absolute upload path that's anchored against
|
| 136 |
+
// the agent-server's home dir (when `workingDir` is relative) instead
|
| 137 |
+
// of the filesystem root. Without this, default conversations whose
|
| 138 |
+
// working_dir is `workspace/project/<hex>` (relative) land at
|
| 139 |
+
// `/workspace/project/<hex>/...` on the agent-server, which on macOS
|
| 140 |
+
// and fresh containers is a read-only mount.
|
| 141 |
+
const uploadPath = await buildWorkspaceUploadPath(file.name, workingDir, {
|
| 142 |
+
conversationUrl,
|
| 143 |
+
sessionApiKey,
|
| 144 |
+
});
|
| 145 |
+
await workspace.fileUpload(file, uploadPath);
|
| 146 |
+
return { uploadedFile: safeName, skippedFile: null };
|
| 147 |
+
} catch (error) {
|
| 148 |
+
return {
|
| 149 |
+
uploadedFile: null,
|
| 150 |
+
skippedFile: {
|
| 151 |
+
name: file.name,
|
| 152 |
+
reason: error instanceof Error ? error.message : "Upload failed",
|
| 153 |
+
},
|
| 154 |
+
};
|
| 155 |
+
}
|
| 156 |
+
};
|
| 157 |
+
|
| 158 |
+
const results: Awaited<ReturnType<typeof uploadFile>>[] = [];
|
| 159 |
+
for (let index = 0; index < files.length; index += FILE_UPLOAD_CONCURRENCY) {
|
| 160 |
+
const batch = files.slice(index, index + FILE_UPLOAD_CONCURRENCY);
|
| 161 |
+
results.push(...(await Promise.all(batch.map(uploadFile))));
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
return {
|
| 165 |
+
uploaded_files: results.flatMap((result) =>
|
| 166 |
+
result.uploadedFile ? [result.uploadedFile] : [],
|
| 167 |
+
),
|
| 168 |
+
skipped_files: results.flatMap((result) =>
|
| 169 |
+
result.skippedFile ? [result.skippedFile] : [],
|
| 170 |
+
),
|
| 171 |
+
};
|
| 172 |
+
}
|
src/api/conversation-metadata-store.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Provider } from "#/types/settings";
|
| 2 |
+
import type { PluginSpec } from "#/api/conversation-service/agent-server-conversation-service.types";
|
| 3 |
+
|
| 4 |
+
const STORAGE_KEY = "openhands-agent-server-conversation-metadata";
|
| 5 |
+
|
| 6 |
+
export type WorkspaceMode = "local_repo" | "new_worktree";
|
| 7 |
+
|
| 8 |
+
export interface ConversationMetadata {
|
| 9 |
+
selected_repository: string | null;
|
| 10 |
+
selected_branch: string | null;
|
| 11 |
+
git_provider: Provider | null;
|
| 12 |
+
/**
|
| 13 |
+
* The local workspace path the user explicitly attached at conversation
|
| 14 |
+
* creation time. Distinct from `selected_repository` (which is set by
|
| 15 |
+
* the repo picker on the home page). Used by the Files tab to decide
|
| 16 |
+
* whether to default to diff view: if the user attached *anything*
|
| 17 |
+
* (repo or local workspace), we lean diff-first because there's a real
|
| 18 |
+
* git baseline to compare against.
|
| 19 |
+
*/
|
| 20 |
+
selected_workspace?: string | null;
|
| 21 |
+
/**
|
| 22 |
+
* How the conversation should use `selected_workspace`.
|
| 23 |
+
*
|
| 24 |
+
* `local_repo` means the runtime should operate directly in the selected
|
| 25 |
+
* folder, even when it is not a git checkout. `new_worktree` preserves the
|
| 26 |
+
* historical agent-server behavior for conversations that should start in a
|
| 27 |
+
* generated per-conversation worktree.
|
| 28 |
+
*/
|
| 29 |
+
workspace_mode?: WorkspaceMode | null;
|
| 30 |
+
/**
|
| 31 |
+
* The LLM profile the conversation was created with (or last switched to).
|
| 32 |
+
* Client-side only. Lets the chat-header switcher show the exact profile
|
| 33 |
+
* name even when several profiles share the same underlying model — the
|
| 34 |
+
* agent-server only round-trips the model string, so matching on it alone
|
| 35 |
+
* is ambiguous (issue #1082).
|
| 36 |
+
*/
|
| 37 |
+
active_profile?: string | null;
|
| 38 |
+
/**
|
| 39 |
+
* When `active_profile` was stamped (ISO string). Written by the live
|
| 40 |
+
* WebSocket handler (the observation's server timestamp) and the user
|
| 41 |
+
* `/model` mutation (client clock). The history seed compares the latest
|
| 42 |
+
* SwitchLLM observation's timestamp against this so a reload can repair a
|
| 43 |
+
* missed agent switch without rolling back a newer manual switch — manual
|
| 44 |
+
* `/model` switches go through REST and leave no observation in history.
|
| 45 |
+
* Absent on stamps written before this field existed; treated as older
|
| 46 |
+
* than any observation so the seed can still repair them.
|
| 47 |
+
*/
|
| 48 |
+
stamped_at?: string | null;
|
| 49 |
+
/** Store plugin coordinates only; parameters may contain secrets. */
|
| 50 |
+
plugins?: PluginSpec[] | null;
|
| 51 |
+
/**
|
| 52 |
+
* Local-only client-managed planning conversation id. Cloud backends expose
|
| 53 |
+
* real sub-conversations through `sub_conversation_ids`; local agent-server
|
| 54 |
+
* backends do not, so Canvas stores the relationship here.
|
| 55 |
+
*/
|
| 56 |
+
local_planning_conversation_id?: string | null;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
export const toPluginCoordinates = (plugin: PluginSpec): PluginSpec => ({
|
| 60 |
+
source: plugin.source,
|
| 61 |
+
ref: plugin.ref ?? null,
|
| 62 |
+
repo_path: plugin.repo_path ?? null,
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
type StoredMetadata = Record<string, ConversationMetadata>;
|
| 66 |
+
|
| 67 |
+
const readAll = (): StoredMetadata => {
|
| 68 |
+
if (typeof window === "undefined") return {};
|
| 69 |
+
try {
|
| 70 |
+
const raw = window.localStorage.getItem(STORAGE_KEY);
|
| 71 |
+
if (!raw) return {};
|
| 72 |
+
const parsed = JSON.parse(raw) as unknown;
|
| 73 |
+
if (!parsed || typeof parsed !== "object") return {};
|
| 74 |
+
return parsed as StoredMetadata;
|
| 75 |
+
} catch {
|
| 76 |
+
return {};
|
| 77 |
+
}
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
const writeAll = (next: StoredMetadata): void => {
|
| 81 |
+
if (typeof window === "undefined") return;
|
| 82 |
+
if (Object.keys(next).length === 0) {
|
| 83 |
+
window.localStorage.removeItem(STORAGE_KEY);
|
| 84 |
+
return;
|
| 85 |
+
}
|
| 86 |
+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
export const getStoredConversationMetadata = (
|
| 90 |
+
conversationId: string,
|
| 91 |
+
): ConversationMetadata | null => readAll()[conversationId] ?? null;
|
| 92 |
+
|
| 93 |
+
export const setStoredConversationMetadata = (
|
| 94 |
+
conversationId: string,
|
| 95 |
+
metadata: ConversationMetadata,
|
| 96 |
+
): void => {
|
| 97 |
+
const all = readAll();
|
| 98 |
+
all[conversationId] = metadata;
|
| 99 |
+
writeAll(all);
|
| 100 |
+
};
|
| 101 |
+
|
| 102 |
+
/**
|
| 103 |
+
* Merge `patch` into a conversation's stored metadata, preserving every
|
| 104 |
+
* existing field — `setStoredConversationMetadata` does a full-object
|
| 105 |
+
* replace, which would otherwise silently drop fields the caller doesn't
|
| 106 |
+
* enumerate (e.g. `plugins`).
|
| 107 |
+
*/
|
| 108 |
+
export const mergeStoredConversationMetadata = (
|
| 109 |
+
conversationId: string,
|
| 110 |
+
patch: Partial<ConversationMetadata>,
|
| 111 |
+
): void => {
|
| 112 |
+
setStoredConversationMetadata(conversationId, {
|
| 113 |
+
selected_repository: null,
|
| 114 |
+
selected_branch: null,
|
| 115 |
+
git_provider: null,
|
| 116 |
+
...(getStoredConversationMetadata(conversationId) ?? {}),
|
| 117 |
+
...patch,
|
| 118 |
+
});
|
| 119 |
+
};
|
| 120 |
+
|
| 121 |
+
export const removeStoredConversationMetadata = (
|
| 122 |
+
conversationId: string,
|
| 123 |
+
): void => {
|
| 124 |
+
const all = readAll();
|
| 125 |
+
if (!(conversationId in all)) return;
|
| 126 |
+
delete all[conversationId];
|
| 127 |
+
writeAll(all);
|
| 128 |
+
};
|
src/api/device-flow-client.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
pollForToken as sdkPollForToken,
|
| 3 |
+
startDeviceFlow as sdkStartDeviceFlow,
|
| 4 |
+
} from "@openhands/typescript-client/client/device-flow-client";
|
| 5 |
+
import type {
|
| 6 |
+
DeviceAuthorizationResponse,
|
| 7 |
+
DeviceTokenResponse,
|
| 8 |
+
PollDeviceTokenOptions,
|
| 9 |
+
} from "@openhands/typescript-client/client/device-flow-client";
|
| 10 |
+
import { AGENT_CANVAS_CLIENT_HEADERS } from "./client-source";
|
| 11 |
+
|
| 12 |
+
export {
|
| 13 |
+
DeviceFlowError,
|
| 14 |
+
isOpenHandsCloudHost,
|
| 15 |
+
} from "@openhands/typescript-client/client/device-flow-client";
|
| 16 |
+
|
| 17 |
+
export function startDeviceFlow(
|
| 18 |
+
host: string,
|
| 19 |
+
): Promise<DeviceAuthorizationResponse> {
|
| 20 |
+
return sdkStartDeviceFlow(host, { headers: AGENT_CANVAS_CLIENT_HEADERS });
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export function pollForToken(
|
| 24 |
+
host: string,
|
| 25 |
+
deviceCode: string,
|
| 26 |
+
options: PollDeviceTokenOptions,
|
| 27 |
+
): Promise<DeviceTokenResponse> {
|
| 28 |
+
return sdkPollForToken(host, deviceCode, {
|
| 29 |
+
...options,
|
| 30 |
+
headers: AGENT_CANVAS_CLIENT_HEADERS,
|
| 31 |
+
});
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
export type {
|
| 35 |
+
DeviceAuthorizationResponse,
|
| 36 |
+
DeviceTokenResponse,
|
| 37 |
+
PollDeviceTokenOptions as PollOptions,
|
| 38 |
+
} from "@openhands/typescript-client/client/device-flow-client";
|
src/api/git-provider-items-service.ts
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { SettingsClient } from "@openhands/typescript-client/clients";
|
| 2 |
+
import { getAgentServerClientOptions } from "#/api/agent-server-client-options";
|
| 3 |
+
import { SecretsService } from "#/api/secrets-service";
|
| 4 |
+
import { getActiveBackend } from "#/api/backend-registry/active-store";
|
| 5 |
+
import type { Provider } from "#/types/settings";
|
| 6 |
+
import { constructPullRequestUrl, getGitProviderBaseUrl } from "#/utils/utils";
|
| 7 |
+
|
| 8 |
+
export interface GitProviderItem {
|
| 9 |
+
id: number;
|
| 10 |
+
number: number;
|
| 11 |
+
title: string;
|
| 12 |
+
url: string;
|
| 13 |
+
authorLogin: string | null;
|
| 14 |
+
updatedAt: string | null;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
const PROVIDER_TOKEN_SECRET_CANDIDATES: Partial<Record<Provider, string[]>> = {
|
| 18 |
+
github: ["GITHUB_TOKEN", "GH_TOKEN", "github"],
|
| 19 |
+
gitlab: ["GITLAB_TOKEN", "GL_TOKEN", "gitlab"],
|
| 20 |
+
bitbucket: ["BITBUCKET_TOKEN", "bitbucket"],
|
| 21 |
+
forgejo: ["FORGEJO_TOKEN", "forgejo"],
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
const LIST_LIMIT = 30;
|
| 25 |
+
|
| 26 |
+
async function resolveProviderToken(
|
| 27 |
+
provider: Provider,
|
| 28 |
+
): Promise<string | null> {
|
| 29 |
+
// Cloud backends keep provider tokens server-side; browser-side secret
|
| 30 |
+
// lookup is only meaningful for the local agent-server secrets store.
|
| 31 |
+
if (getActiveBackend().backend.kind !== "local") {
|
| 32 |
+
return null;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
const candidates = PROVIDER_TOKEN_SECRET_CANDIDATES[provider] ?? [];
|
| 36 |
+
if (candidates.length === 0) {
|
| 37 |
+
return null;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
const secrets = await SecretsService.getSecrets();
|
| 41 |
+
const available = new Set(secrets.map((secret) => secret.name));
|
| 42 |
+
const match = candidates.find((name) => available.has(name));
|
| 43 |
+
if (!match) {
|
| 44 |
+
return null;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
try {
|
| 48 |
+
return await new SettingsClient(getAgentServerClientOptions()).getSecret(
|
| 49 |
+
match,
|
| 50 |
+
);
|
| 51 |
+
} catch {
|
| 52 |
+
return null;
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
function constructIssueUrl(
|
| 57 |
+
issueNumber: number,
|
| 58 |
+
provider: Provider,
|
| 59 |
+
repositoryName: string,
|
| 60 |
+
): string {
|
| 61 |
+
const baseUrl = getGitProviderBaseUrl(provider);
|
| 62 |
+
switch (provider) {
|
| 63 |
+
case "gitlab":
|
| 64 |
+
return `${baseUrl}/${repositoryName}/-/issues/${issueNumber}`;
|
| 65 |
+
case "bitbucket":
|
| 66 |
+
return `${baseUrl}/${repositoryName}/issues/${issueNumber}`;
|
| 67 |
+
case "forgejo":
|
| 68 |
+
return `${baseUrl}/${repositoryName}/issues/${issueNumber}`;
|
| 69 |
+
case "github":
|
| 70 |
+
default:
|
| 71 |
+
return `${baseUrl}/${repositoryName}/issues/${issueNumber}`;
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
function constructIssuesListUrl(
|
| 76 |
+
provider: Provider,
|
| 77 |
+
repositoryName: string,
|
| 78 |
+
): string {
|
| 79 |
+
const baseUrl = getGitProviderBaseUrl(provider);
|
| 80 |
+
switch (provider) {
|
| 81 |
+
case "gitlab":
|
| 82 |
+
return `${baseUrl}/${repositoryName}/-/issues`;
|
| 83 |
+
case "bitbucket":
|
| 84 |
+
return `${baseUrl}/${repositoryName}/issues`;
|
| 85 |
+
case "forgejo":
|
| 86 |
+
return `${baseUrl}/${repositoryName}/issues`;
|
| 87 |
+
case "github":
|
| 88 |
+
default:
|
| 89 |
+
return `${baseUrl}/${repositoryName}/issues`;
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
function constructPullRequestsListUrl(
|
| 94 |
+
provider: Provider,
|
| 95 |
+
repositoryName: string,
|
| 96 |
+
): string {
|
| 97 |
+
const baseUrl = getGitProviderBaseUrl(provider);
|
| 98 |
+
switch (provider) {
|
| 99 |
+
case "gitlab":
|
| 100 |
+
return `${baseUrl}/${repositoryName}/-/merge_requests`;
|
| 101 |
+
case "bitbucket":
|
| 102 |
+
return `${baseUrl}/${repositoryName}/pull-requests`;
|
| 103 |
+
case "forgejo":
|
| 104 |
+
return `${baseUrl}/${repositoryName}/pulls`;
|
| 105 |
+
case "github":
|
| 106 |
+
default:
|
| 107 |
+
return `${baseUrl}/${repositoryName}/pulls`;
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
async function fetchGithubJson<T>(
|
| 112 |
+
path: string,
|
| 113 |
+
token: string | null,
|
| 114 |
+
): Promise<T> {
|
| 115 |
+
const headers: Record<string, string> = {
|
| 116 |
+
Accept: "application/vnd.github+json",
|
| 117 |
+
"X-GitHub-Api-Version": "2022-11-28",
|
| 118 |
+
};
|
| 119 |
+
if (token) {
|
| 120 |
+
headers.Authorization = `Bearer ${token}`;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
const response = await fetch(`https://api.github.com${path}`, { headers });
|
| 124 |
+
if (!response.ok) {
|
| 125 |
+
throw new Error(`GitHub API ${response.status}`);
|
| 126 |
+
}
|
| 127 |
+
return response.json() as Promise<T>;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
async function fetchGitlabJson<T>(
|
| 131 |
+
path: string,
|
| 132 |
+
token: string | null,
|
| 133 |
+
): Promise<T> {
|
| 134 |
+
const headers: Record<string, string> = {
|
| 135 |
+
Accept: "application/json",
|
| 136 |
+
};
|
| 137 |
+
if (token) {
|
| 138 |
+
headers["PRIVATE-TOKEN"] = token;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
// gitlab.com's public REST API — an external host, not the agent-server
|
| 142 |
+
// `/api` surface the rule guards (its `/api/v4` prefix just trips the
|
| 143 |
+
// substring heuristic; the companion no-direct-agent-server-calls test
|
| 144 |
+
// matches only relative `/api/...` URLs and is not affected).
|
| 145 |
+
// eslint-disable-next-line local/no-direct-agent-server-fetch
|
| 146 |
+
const response = await fetch(`https://gitlab.com/api/v4${path}`, { headers });
|
| 147 |
+
if (!response.ok) {
|
| 148 |
+
throw new Error(`GitLab API ${response.status}`);
|
| 149 |
+
}
|
| 150 |
+
return response.json() as Promise<T>;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
type GithubIssueOrPr = {
|
| 154 |
+
id: number;
|
| 155 |
+
number: number;
|
| 156 |
+
title: string;
|
| 157 |
+
html_url: string;
|
| 158 |
+
user?: { login?: string } | null;
|
| 159 |
+
updated_at?: string | null;
|
| 160 |
+
pull_request?: unknown;
|
| 161 |
+
};
|
| 162 |
+
|
| 163 |
+
type GitlabMergeRequest = {
|
| 164 |
+
id: number;
|
| 165 |
+
iid: number;
|
| 166 |
+
title: string;
|
| 167 |
+
web_url: string;
|
| 168 |
+
author?: { username?: string } | null;
|
| 169 |
+
updated_at?: string | null;
|
| 170 |
+
};
|
| 171 |
+
|
| 172 |
+
type GitlabIssue = {
|
| 173 |
+
id: number;
|
| 174 |
+
iid: number;
|
| 175 |
+
title: string;
|
| 176 |
+
web_url: string;
|
| 177 |
+
author?: { username?: string } | null;
|
| 178 |
+
updated_at?: string | null;
|
| 179 |
+
};
|
| 180 |
+
|
| 181 |
+
/**
|
| 182 |
+
* Lists open pull requests / merge requests and issues for a connected
|
| 183 |
+
* repository by calling the provider's public REST API from the browser.
|
| 184 |
+
* When a matching token secret exists locally (e.g. `GITHUB_TOKEN`), it is
|
| 185 |
+
* used for auth so private repos work; otherwise public-repo unauthenticated
|
| 186 |
+
* requests are attempted.
|
| 187 |
+
*/
|
| 188 |
+
export class GitProviderItemsService {
|
| 189 |
+
static constructIssuesListUrl = constructIssuesListUrl;
|
| 190 |
+
|
| 191 |
+
static constructPullRequestsListUrl = constructPullRequestsListUrl;
|
| 192 |
+
|
| 193 |
+
static async listPullRequests(
|
| 194 |
+
repository: string,
|
| 195 |
+
provider: Provider,
|
| 196 |
+
): Promise<GitProviderItem[]> {
|
| 197 |
+
const token = await resolveProviderToken(provider);
|
| 198 |
+
|
| 199 |
+
if (provider === "gitlab") {
|
| 200 |
+
const encoded = encodeURIComponent(repository);
|
| 201 |
+
const items = await fetchGitlabJson<GitlabMergeRequest[]>(
|
| 202 |
+
`/projects/${encoded}/merge_requests?state=opened&per_page=${LIST_LIMIT}`,
|
| 203 |
+
token,
|
| 204 |
+
);
|
| 205 |
+
return items.map((item) => ({
|
| 206 |
+
id: item.id,
|
| 207 |
+
number: item.iid,
|
| 208 |
+
title: item.title,
|
| 209 |
+
url: item.web_url,
|
| 210 |
+
authorLogin: item.author?.username ?? null,
|
| 211 |
+
updatedAt: item.updated_at ?? null,
|
| 212 |
+
}));
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
if (provider !== "github" && provider !== "forgejo") {
|
| 216 |
+
return [];
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
const [owner, repo] = repository.split("/");
|
| 220 |
+
if (!owner || !repo) {
|
| 221 |
+
return [];
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
const items = await fetchGithubJson<GithubIssueOrPr[]>(
|
| 225 |
+
`/repos/${owner}/${repo}/pulls?state=open&per_page=${LIST_LIMIT}`,
|
| 226 |
+
token,
|
| 227 |
+
);
|
| 228 |
+
|
| 229 |
+
return items.map((item) => ({
|
| 230 |
+
id: item.id,
|
| 231 |
+
number: item.number,
|
| 232 |
+
title: item.title,
|
| 233 |
+
url:
|
| 234 |
+
item.html_url ||
|
| 235 |
+
constructPullRequestUrl(item.number, provider, repository),
|
| 236 |
+
authorLogin: item.user?.login ?? null,
|
| 237 |
+
updatedAt: item.updated_at ?? null,
|
| 238 |
+
}));
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
static async listIssues(
|
| 242 |
+
repository: string,
|
| 243 |
+
provider: Provider,
|
| 244 |
+
): Promise<GitProviderItem[]> {
|
| 245 |
+
const token = await resolveProviderToken(provider);
|
| 246 |
+
|
| 247 |
+
if (provider === "gitlab") {
|
| 248 |
+
const encoded = encodeURIComponent(repository);
|
| 249 |
+
const items = await fetchGitlabJson<GitlabIssue[]>(
|
| 250 |
+
`/projects/${encoded}/issues?state=opened&per_page=${LIST_LIMIT}`,
|
| 251 |
+
token,
|
| 252 |
+
);
|
| 253 |
+
return items.map((item) => ({
|
| 254 |
+
id: item.id,
|
| 255 |
+
number: item.iid,
|
| 256 |
+
title: item.title,
|
| 257 |
+
url: item.web_url,
|
| 258 |
+
authorLogin: item.author?.username ?? null,
|
| 259 |
+
updatedAt: item.updated_at ?? null,
|
| 260 |
+
}));
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
if (provider !== "github" && provider !== "forgejo") {
|
| 264 |
+
return [];
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
const [owner, repo] = repository.split("/");
|
| 268 |
+
if (!owner || !repo) {
|
| 269 |
+
return [];
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
const items = await fetchGithubJson<GithubIssueOrPr[]>(
|
| 273 |
+
`/repos/${owner}/${repo}/issues?state=open&per_page=${LIST_LIMIT}`,
|
| 274 |
+
token,
|
| 275 |
+
);
|
| 276 |
+
|
| 277 |
+
// GitHub's issues endpoint also returns pull requests.
|
| 278 |
+
return items
|
| 279 |
+
.filter((item) => !item.pull_request)
|
| 280 |
+
.map((item) => ({
|
| 281 |
+
id: item.id,
|
| 282 |
+
number: item.number,
|
| 283 |
+
title: item.title,
|
| 284 |
+
url:
|
| 285 |
+
item.html_url || constructIssueUrl(item.number, provider, repository),
|
| 286 |
+
authorLogin: item.user?.login ?? null,
|
| 287 |
+
updatedAt: item.updated_at ?? null,
|
| 288 |
+
}));
|
| 289 |
+
}
|
| 290 |
+
}
|
src/api/hooks-service.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { HooksClient } from "@openhands/typescript-client/clients";
|
| 2 |
+
import type { HookConfig } from "@openhands/typescript-client";
|
| 3 |
+
import { getAgentServerWorkingDir } from "./agent-server-config";
|
| 4 |
+
import { getEffectiveLocalBackend } from "./backend-registry/active-store";
|
| 5 |
+
import { getAgentServerClientOptions } from "./agent-server-client-options";
|
| 6 |
+
|
| 7 |
+
// `loadHooks()` spends this twice (version probe, then the call) on the
|
| 8 |
+
// conversation-start critical path; the SDK's 60s default would stall a launch.
|
| 9 |
+
const HOOKS_LOAD_TIMEOUT_MS = 5000;
|
| 10 |
+
|
| 11 |
+
class HooksService {
|
| 12 |
+
/**
|
| 13 |
+
* Load workspace hooks from the agent-server by calling POST /api/hooks.
|
| 14 |
+
* Returns the HookConfig if the workspace has hooks, or null.
|
| 15 |
+
* Gracefully returns null when no usable local backend is available
|
| 16 |
+
* (cloud backend, unseeded registry, or older agent-server).
|
| 17 |
+
* `projectDir` must be the workspace root: the agent-server checks
|
| 18 |
+
* `<project_dir>/.openhands/hooks.json` literally and never walks parents.
|
| 19 |
+
*/
|
| 20 |
+
static async loadWorkspaceHooks(
|
| 21 |
+
projectDir?: string,
|
| 22 |
+
): Promise<HookConfig | null> {
|
| 23 |
+
// getEffectiveLocalBackend() returns null for cloud backends AND for the
|
| 24 |
+
// NO_BACKEND sentinel (unseeded/empty registry), avoiding the silent
|
| 25 |
+
// NoBackendAvailableError that getActiveBackend() would cause.
|
| 26 |
+
if (!getEffectiveLocalBackend()) {
|
| 27 |
+
return null;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
try {
|
| 31 |
+
const response = await new HooksClient(
|
| 32 |
+
getAgentServerClientOptions({ timeout: HOOKS_LOAD_TIMEOUT_MS }),
|
| 33 |
+
).loadHooks({
|
| 34 |
+
project_dir: projectDir ?? getAgentServerWorkingDir(),
|
| 35 |
+
});
|
| 36 |
+
return response?.hook_config ?? null;
|
| 37 |
+
} catch (error) {
|
| 38 |
+
// Agent-server may not support the hooks endpoint or may be
|
| 39 |
+
// unreachable; gracefully fall back to null.
|
| 40 |
+
console.warn(
|
| 41 |
+
"Failed to load workspace hooks, continuing without:",
|
| 42 |
+
error,
|
| 43 |
+
);
|
| 44 |
+
return null;
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
export default HooksService;
|
src/api/launch-child-conversation-client-tool.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
CHILD_CONVERSATION_ISOLATIONS,
|
| 3 |
+
CHILD_CONVERSATION_RESULT_PREFIX,
|
| 4 |
+
CHILD_CONVERSATION_TARGETS,
|
| 5 |
+
LAUNCH_CHILD_CONVERSATION_TOOL_NAME,
|
| 6 |
+
} from "#/constants/child-conversation";
|
| 7 |
+
import type { ClientToolSpec } from "./canvas-ui-client-tool";
|
| 8 |
+
|
| 9 |
+
export {
|
| 10 |
+
LAUNCH_CHILD_CONVERSATION_ACTION_KIND,
|
| 11 |
+
LAUNCH_CHILD_CONVERSATION_TOOL_NAME,
|
| 12 |
+
} from "#/constants/child-conversation";
|
| 13 |
+
|
| 14 |
+
const LAUNCH_CHILD_CONVERSATION_DESCRIPTION = `Start a NEW, independent conversation to work on a well-scoped task in parallel with this one, without the user having to run any CLI command. The new conversation is recorded as a child of this one.
|
| 15 |
+
|
| 16 |
+
The child runs on its own. It does not block you, you do not see its output,
|
| 17 |
+
and it cannot see this conversation's history — everything it needs must be in
|
| 18 |
+
the task brief.
|
| 19 |
+
|
| 20 |
+
Choosing target:
|
| 21 |
+
|
| 22 |
+
* target="local" — runs on the same machine, in this conversation's workspace.
|
| 23 |
+
Fast, no repository clone, no sandbox provisioning. Use this by default when
|
| 24 |
+
the work is on code that is already checked out here.
|
| 25 |
+
|
| 26 |
+
* target="cloud" — runs on OpenHands Cloud in its own isolated sandbox, from a
|
| 27 |
+
git repository. Use this when the work should not touch the user's machine or
|
| 28 |
+
when it needs a repository that is not checked out locally. Requires the user
|
| 29 |
+
to have an OpenHands Cloud backend connected in Agent Canvas; if none is
|
| 30 |
+
connected you will be told so and should fall back to target="local".
|
| 31 |
+
|
| 32 |
+
Writing the task brief:
|
| 33 |
+
|
| 34 |
+
* Put everything the child needs in "task": the goal, the relevant file paths,
|
| 35 |
+
the constraints, the expected deliverable, and how it should report back.
|
| 36 |
+
* Keep each child's scope independent of its siblings so parallel children do
|
| 37 |
+
not fight over the same files.
|
| 38 |
+
* One call per delegated task. Do NOT call this tool twice for the same task.
|
| 39 |
+
|
| 40 |
+
Parameter rules (a call that breaks one of these launches nothing and comes
|
| 41 |
+
back with corrective guidance):
|
| 42 |
+
|
| 43 |
+
* "repository" and "branch" apply to target="cloud" only. A local child always
|
| 44 |
+
inherits this conversation's workspace, so do not pass them with
|
| 45 |
+
target="local".
|
| 46 |
+
* "isolation" applies to target="local" only. Cloud sandboxes are always
|
| 47 |
+
isolated, so do not pass it with target="cloud".
|
| 48 |
+
* isolation="worktree" (the default) gives the child its own git worktree and
|
| 49 |
+
branch, cut from the repository's default branch — it will NOT see this
|
| 50 |
+
conversation's uncommitted or committed work. isolation="shared" puts the
|
| 51 |
+
child in this conversation's exact directory; only choose it when the child
|
| 52 |
+
genuinely must see work in progress, and expect the two agents to conflict.
|
| 53 |
+
|
| 54 |
+
What comes back: this tool is acknowledged immediately, before the conversation
|
| 55 |
+
exists. The real outcome arrives moments later as a follow-up message starting
|
| 56 |
+
with "${CHILD_CONVERSATION_RESULT_PREFIX.trim()}" that carries the child's
|
| 57 |
+
conversation id, URL, target and initial status — or an error with guidance.
|
| 58 |
+
Wait for it, then tell the user what you launched and give them the URL. If it
|
| 59 |
+
reports an error, fix the parameters and call this tool again.`;
|
| 60 |
+
|
| 61 |
+
export const LAUNCH_CHILD_CONVERSATION_CLIENT_TOOL: ClientToolSpec = {
|
| 62 |
+
name: LAUNCH_CHILD_CONVERSATION_TOOL_NAME,
|
| 63 |
+
description: LAUNCH_CHILD_CONVERSATION_DESCRIPTION,
|
| 64 |
+
parameters: {
|
| 65 |
+
type: "object",
|
| 66 |
+
additionalProperties: false,
|
| 67 |
+
properties: {
|
| 68 |
+
target: {
|
| 69 |
+
type: "string",
|
| 70 |
+
enum: [...CHILD_CONVERSATION_TARGETS],
|
| 71 |
+
description:
|
| 72 |
+
"Where the child runs. 'local' reuses this machine and this conversation's workspace; 'cloud' runs in an isolated OpenHands Cloud sandbox.",
|
| 73 |
+
},
|
| 74 |
+
task: {
|
| 75 |
+
type: "string",
|
| 76 |
+
description:
|
| 77 |
+
"Self-contained task brief, sent as the child's first message. The child cannot see this conversation, so state the goal, constraints, expected output and handoff criteria.",
|
| 78 |
+
},
|
| 79 |
+
title: {
|
| 80 |
+
type: "string",
|
| 81 |
+
description:
|
| 82 |
+
"Optional short title for the child conversation. Omit to let it title itself from the task.",
|
| 83 |
+
},
|
| 84 |
+
repository: {
|
| 85 |
+
type: "string",
|
| 86 |
+
description:
|
| 87 |
+
"target='cloud' only. Repository to clone into the sandbox, as 'owner/repo'. Defaults to this conversation's repository when it has one.",
|
| 88 |
+
},
|
| 89 |
+
branch: {
|
| 90 |
+
type: "string",
|
| 91 |
+
description:
|
| 92 |
+
"target='cloud' only, and only together with 'repository'. Defaults to the repository's default branch.",
|
| 93 |
+
},
|
| 94 |
+
isolation: {
|
| 95 |
+
type: "string",
|
| 96 |
+
enum: [...CHILD_CONVERSATION_ISOLATIONS],
|
| 97 |
+
description:
|
| 98 |
+
"target='local' only. 'worktree' (default) gives the child its own git worktree and branch; 'shared' runs it in this conversation's exact directory.",
|
| 99 |
+
},
|
| 100 |
+
},
|
| 101 |
+
required: ["target", "task"],
|
| 102 |
+
},
|
| 103 |
+
annotations: {
|
| 104 |
+
// Launching a conversation writes real state and is not repeatable, so the
|
| 105 |
+
// agent is asked to predict a security risk before every call.
|
| 106 |
+
readOnlyHint: false,
|
| 107 |
+
destructiveHint: false,
|
| 108 |
+
idempotentHint: false,
|
| 109 |
+
// The cloud target reaches OpenHands Cloud.
|
| 110 |
+
openWorldHint: true,
|
| 111 |
+
},
|
| 112 |
+
};
|
src/api/llm-balance-service.test.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import {
|
| 3 |
+
setActiveSelection,
|
| 4 |
+
setRegisteredBackends,
|
| 5 |
+
} from "#/api/backend-registry/active-store";
|
| 6 |
+
import type { Backend } from "#/api/backend-registry/types";
|
| 7 |
+
import {
|
| 8 |
+
LLM_BALANCE_PATH,
|
| 9 |
+
LLM_BALANCE_TIMEOUT_MS,
|
| 10 |
+
} from "#/constants/llm-balance";
|
| 11 |
+
import LLMBalanceService from "./llm-balance-service";
|
| 12 |
+
import { AgentServerClient } from "@openhands/typescript-client/clients";
|
| 13 |
+
|
| 14 |
+
const getMock = vi.hoisted(() => vi.fn());
|
| 15 |
+
|
| 16 |
+
vi.mock("@openhands/typescript-client/clients", () => ({
|
| 17 |
+
AgentServerClient: vi.fn(function AgentServerClientMock() {
|
| 18 |
+
return { get: getMock, close: vi.fn() };
|
| 19 |
+
}),
|
| 20 |
+
}));
|
| 21 |
+
|
| 22 |
+
const localBackend: Backend = {
|
| 23 |
+
id: "local-test",
|
| 24 |
+
name: "Local test backend",
|
| 25 |
+
host: "http://localhost:3000",
|
| 26 |
+
apiKey: "test-session-key",
|
| 27 |
+
kind: "local",
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
const balancePayload = {
|
| 31 |
+
provider: "openrouter",
|
| 32 |
+
limit: 20,
|
| 33 |
+
limit_remaining: 14.96,
|
| 34 |
+
usage: 5.04,
|
| 35 |
+
usage_daily: 1.2,
|
| 36 |
+
usage_weekly: 3.4,
|
| 37 |
+
usage_monthly: 5.04,
|
| 38 |
+
is_free_tier: false,
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
function httpError(status: number): Error & { status: number } {
|
| 42 |
+
return Object.assign(new Error(`HTTP ${status}`), { status });
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
function mockedClient(): AgentServerClient {
|
| 46 |
+
return vi.mocked(AgentServerClient).mock.results[0]
|
| 47 |
+
?.value as AgentServerClient;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
describe("LLMBalanceService.getBalance", () => {
|
| 51 |
+
beforeEach(() => {
|
| 52 |
+
getMock.mockReset();
|
| 53 |
+
vi.mocked(AgentServerClient).mockClear();
|
| 54 |
+
setRegisteredBackends([localBackend]);
|
| 55 |
+
setActiveSelection({ backendId: localBackend.id });
|
| 56 |
+
});
|
| 57 |
+
|
| 58 |
+
afterEach(() => {
|
| 59 |
+
setActiveSelection(null);
|
| 60 |
+
setRegisteredBackends([]);
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
it("normalizes a successful balance response via the typed client", async () => {
|
| 64 |
+
getMock.mockResolvedValue(balancePayload);
|
| 65 |
+
|
| 66 |
+
const balance = await LLMBalanceService.getBalance();
|
| 67 |
+
|
| 68 |
+
expect(balance).toEqual({
|
| 69 |
+
provider: "openrouter",
|
| 70 |
+
limit: 20,
|
| 71 |
+
limitRemaining: 14.96,
|
| 72 |
+
usage: 5.04,
|
| 73 |
+
usageDaily: 1.2,
|
| 74 |
+
usageWeekly: 3.4,
|
| 75 |
+
usageMonthly: 5.04,
|
| 76 |
+
isFreeTier: false,
|
| 77 |
+
});
|
| 78 |
+
|
| 79 |
+
expect(vi.mocked(AgentServerClient)).toHaveBeenCalledWith({
|
| 80 |
+
host: "http://localhost:3000",
|
| 81 |
+
apiKey: "test-session-key",
|
| 82 |
+
timeout: LLM_BALANCE_TIMEOUT_MS,
|
| 83 |
+
});
|
| 84 |
+
expect(getMock).toHaveBeenCalledWith(LLM_BALANCE_PATH, {
|
| 85 |
+
acceptableStatusCodes: new Set([200]),
|
| 86 |
+
responseType: "json",
|
| 87 |
+
timeoutSeconds: Math.floor(LLM_BALANCE_TIMEOUT_MS / 1000),
|
| 88 |
+
});
|
| 89 |
+
expect(mockedClient().close).toHaveBeenCalled();
|
| 90 |
+
});
|
| 91 |
+
|
| 92 |
+
it("treats null caps as an uncapped key", async () => {
|
| 93 |
+
getMock.mockResolvedValue({
|
| 94 |
+
...balancePayload,
|
| 95 |
+
limit: null,
|
| 96 |
+
limit_remaining: null,
|
| 97 |
+
});
|
| 98 |
+
|
| 99 |
+
const balance = await LLMBalanceService.getBalance();
|
| 100 |
+
|
| 101 |
+
expect(balance?.limit).toBeNull();
|
| 102 |
+
expect(balance?.limitRemaining).toBeNull();
|
| 103 |
+
expect(balance?.usage).toBe(5.04);
|
| 104 |
+
});
|
| 105 |
+
|
| 106 |
+
it("returns null when the endpoint is missing (404)", async () => {
|
| 107 |
+
getMock.mockRejectedValue(httpError(404));
|
| 108 |
+
|
| 109 |
+
await expect(LLMBalanceService.getBalance()).resolves.toBeNull();
|
| 110 |
+
});
|
| 111 |
+
|
| 112 |
+
it("returns null for a malformed response body", async () => {
|
| 113 |
+
getMock.mockResolvedValue({ unexpected: true });
|
| 114 |
+
|
| 115 |
+
await expect(LLMBalanceService.getBalance()).resolves.toBeNull();
|
| 116 |
+
});
|
| 117 |
+
|
| 118 |
+
it("throws on non-404 HTTP failures", async () => {
|
| 119 |
+
getMock.mockRejectedValue(httpError(500));
|
| 120 |
+
|
| 121 |
+
await expect(LLMBalanceService.getBalance()).rejects.toThrow(
|
| 122 |
+
"Balance request failed with 500",
|
| 123 |
+
);
|
| 124 |
+
});
|
| 125 |
+
|
| 126 |
+
it("throws a named error when the request times out", async () => {
|
| 127 |
+
getMock.mockRejectedValue(
|
| 128 |
+
new DOMException("The operation was aborted.", "TimeoutError"),
|
| 129 |
+
);
|
| 130 |
+
|
| 131 |
+
await expect(LLMBalanceService.getBalance()).rejects.toThrow(
|
| 132 |
+
"Balance request timed out",
|
| 133 |
+
);
|
| 134 |
+
});
|
| 135 |
+
});
|
src/api/llm-balance-service.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { AgentServerClient } from "@openhands/typescript-client/clients";
|
| 2 |
+
import { getAgentServerClientOptions } from "./agent-server-client-options";
|
| 3 |
+
import {
|
| 4 |
+
LLM_BALANCE_PATH,
|
| 5 |
+
LLM_BALANCE_TIMEOUT_MS,
|
| 6 |
+
} from "#/constants/llm-balance";
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Credit balance reported by the agent server for the active LLM provider
|
| 10 |
+
* (currently only OpenRouter). Amounts are in USD credits.
|
| 11 |
+
*/
|
| 12 |
+
export interface LLMBalance {
|
| 13 |
+
provider: string;
|
| 14 |
+
/** Credit cap configured for the key, or null when uncapped. */
|
| 15 |
+
limit: number | null;
|
| 16 |
+
/** Credits remaining under `limit`, or null when uncapped. */
|
| 17 |
+
limitRemaining: number | null;
|
| 18 |
+
/** Total credits consumed by the key. */
|
| 19 |
+
usage: number;
|
| 20 |
+
usageDaily: number | null;
|
| 21 |
+
usageWeekly: number | null;
|
| 22 |
+
usageMonthly: number | null;
|
| 23 |
+
isFreeTier: boolean;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
| 27 |
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
function numberOrNull(value: unknown): number | null {
|
| 31 |
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
/** `AbortSignal.timeout` rejects with a `TimeoutError`; a manual abort uses `AbortError`. */
|
| 35 |
+
function isAbortLike(error: unknown): boolean {
|
| 36 |
+
return (
|
| 37 |
+
isRecord(error) &&
|
| 38 |
+
(error.name === "TimeoutError" || error.name === "AbortError")
|
| 39 |
+
);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function normalizeBalance(raw: unknown): LLMBalance | null {
|
| 43 |
+
if (!isRecord(raw) || typeof raw.provider !== "string") return null;
|
| 44 |
+
|
| 45 |
+
return {
|
| 46 |
+
provider: raw.provider,
|
| 47 |
+
limit: numberOrNull(raw.limit),
|
| 48 |
+
limitRemaining: numberOrNull(raw.limit_remaining),
|
| 49 |
+
usage: numberOrNull(raw.usage) ?? 0,
|
| 50 |
+
usageDaily: numberOrNull(raw.usage_daily),
|
| 51 |
+
usageWeekly: numberOrNull(raw.usage_weekly),
|
| 52 |
+
usageMonthly: numberOrNull(raw.usage_monthly),
|
| 53 |
+
isFreeTier: raw.is_free_tier === true,
|
| 54 |
+
};
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
class LLMBalanceService {
|
| 58 |
+
/**
|
| 59 |
+
* Fetch the provider balance from the agent server.
|
| 60 |
+
*
|
| 61 |
+
* Returns `null` when the server answers 404 (older servers without the
|
| 62 |
+
* endpoint, or a provider that doesn't support balance reporting) or when
|
| 63 |
+
* the response doesn't match the expected shape — callers hide the balance
|
| 64 |
+
* UI in that case. Other HTTP failures throw, as does a request that outlives
|
| 65 |
+
* `LLM_BALANCE_TIMEOUT_MS`. A timeout stays an error rather than collapsing
|
| 66 |
+
* into `null`: `null` means "this server has no balance to report" and is
|
| 67 |
+
* cached under `staleTime: Infinity`, so treating a transient stall as an
|
| 68 |
+
* absent endpoint would hide the card for the rest of the conversation.
|
| 69 |
+
*/
|
| 70 |
+
static async getBalance(): Promise<LLMBalance | null> {
|
| 71 |
+
const { host, apiKey } = getAgentServerClientOptions();
|
| 72 |
+
const client = new AgentServerClient({
|
| 73 |
+
host,
|
| 74 |
+
apiKey,
|
| 75 |
+
timeout: LLM_BALANCE_TIMEOUT_MS,
|
| 76 |
+
});
|
| 77 |
+
try {
|
| 78 |
+
const data = await client.get<unknown>(LLM_BALANCE_PATH, {
|
| 79 |
+
acceptableStatusCodes: new Set([200]),
|
| 80 |
+
responseType: "json",
|
| 81 |
+
timeoutSeconds: Math.floor(LLM_BALANCE_TIMEOUT_MS / 1000),
|
| 82 |
+
});
|
| 83 |
+
return normalizeBalance(data);
|
| 84 |
+
} catch (error) {
|
| 85 |
+
// Older servers without balance support answer 404 — hide the UI.
|
| 86 |
+
if (
|
| 87 |
+
error instanceof Error &&
|
| 88 |
+
"status" in error &&
|
| 89 |
+
(error as { status: number }).status === 404
|
| 90 |
+
) {
|
| 91 |
+
return null;
|
| 92 |
+
}
|
| 93 |
+
if (isAbortLike(error)) {
|
| 94 |
+
throw new Error(
|
| 95 |
+
`Balance request timed out after ${LLM_BALANCE_TIMEOUT_MS}ms`,
|
| 96 |
+
);
|
| 97 |
+
}
|
| 98 |
+
const status =
|
| 99 |
+
error instanceof Error &&
|
| 100 |
+
"status" in error &&
|
| 101 |
+
(error as { status: number }).status;
|
| 102 |
+
throw new Error(
|
| 103 |
+
status
|
| 104 |
+
? `Balance request failed with ${status}`
|
| 105 |
+
: `Balance request failed: ${error instanceof Error ? error.message : String(error)}`,
|
| 106 |
+
);
|
| 107 |
+
} finally {
|
| 108 |
+
client.close();
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
export default LLMBalanceService;
|
src/api/llm-subscription-service.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { AgentServerClient } from "@openhands/typescript-client/clients";
|
| 2 |
+
import { getAgentServerClientOptions } from "./agent-server-client-options";
|
| 3 |
+
import {
|
| 4 |
+
OPENAI_SUBSCRIPTION_DEVICE_POLL_PATH,
|
| 5 |
+
OPENAI_SUBSCRIPTION_DEVICE_START_PATH,
|
| 6 |
+
OPENAI_SUBSCRIPTION_LOGOUT_PATH,
|
| 7 |
+
OPENAI_SUBSCRIPTION_MODELS_PATH,
|
| 8 |
+
OPENAI_SUBSCRIPTION_STATUS_PATH,
|
| 9 |
+
OPENAI_SUBSCRIPTION_VENDOR,
|
| 10 |
+
} from "#/constants/llm-subscription";
|
| 11 |
+
|
| 12 |
+
type RawSubscriptionStatus = Record<string, unknown>;
|
| 13 |
+
type RawDeviceStart = Record<string, unknown>;
|
| 14 |
+
|
| 15 |
+
export interface LLMSubscriptionStatus {
|
| 16 |
+
vendor: typeof OPENAI_SUBSCRIPTION_VENDOR;
|
| 17 |
+
connected: boolean;
|
| 18 |
+
accountEmail: string | null;
|
| 19 |
+
expiresAt: string | number | null;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export interface LLMSubscriptionDeviceChallenge {
|
| 23 |
+
deviceCode: string;
|
| 24 |
+
userCode: string;
|
| 25 |
+
verificationUri: string;
|
| 26 |
+
verificationUriComplete: string | null;
|
| 27 |
+
expiresAt: string | number | null;
|
| 28 |
+
intervalSeconds: number | null;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
| 32 |
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
const readString = (
|
| 36 |
+
value: Record<string, unknown>,
|
| 37 |
+
keys: string[],
|
| 38 |
+
): string | null => {
|
| 39 |
+
for (const key of keys) {
|
| 40 |
+
const candidate = value[key];
|
| 41 |
+
if (typeof candidate === "string" && candidate.trim().length > 0) {
|
| 42 |
+
return candidate.trim();
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
return null;
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
const readNumber = (
|
| 49 |
+
value: Record<string, unknown>,
|
| 50 |
+
keys: string[],
|
| 51 |
+
): number | null => {
|
| 52 |
+
for (const key of keys) {
|
| 53 |
+
const candidate = value[key];
|
| 54 |
+
if (typeof candidate === "number" && Number.isFinite(candidate)) {
|
| 55 |
+
return candidate;
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
return null;
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
const readBoolean = (
|
| 62 |
+
value: Record<string, unknown>,
|
| 63 |
+
keys: string[],
|
| 64 |
+
): boolean => {
|
| 65 |
+
for (const key of keys) {
|
| 66 |
+
const candidate = value[key];
|
| 67 |
+
if (typeof candidate === "boolean") {
|
| 68 |
+
return candidate;
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
return false;
|
| 72 |
+
};
|
| 73 |
+
|
| 74 |
+
interface SubscriptionRequestOptions {
|
| 75 |
+
method?: "GET" | "POST";
|
| 76 |
+
jsonBody?: unknown;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
async function requestSubscriptionEndpoint(
|
| 80 |
+
path: string,
|
| 81 |
+
options: SubscriptionRequestOptions = {},
|
| 82 |
+
): Promise<unknown> {
|
| 83 |
+
const { host, apiKey } = getAgentServerClientOptions();
|
| 84 |
+
const client = new AgentServerClient({ host, apiKey });
|
| 85 |
+
try {
|
| 86 |
+
return await client.request<unknown>({
|
| 87 |
+
method: options.method ?? "GET",
|
| 88 |
+
path,
|
| 89 |
+
body: options.jsonBody,
|
| 90 |
+
responseType: "json",
|
| 91 |
+
});
|
| 92 |
+
} finally {
|
| 93 |
+
client.close();
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
function normalizeModels(raw: unknown): string[] {
|
| 98 |
+
if (Array.isArray(raw)) {
|
| 99 |
+
return raw.filter((item): item is string => typeof item === "string");
|
| 100 |
+
}
|
| 101 |
+
if (isRecord(raw) && Array.isArray(raw.models)) {
|
| 102 |
+
return raw.models.filter(
|
| 103 |
+
(item): item is string => typeof item === "string",
|
| 104 |
+
);
|
| 105 |
+
}
|
| 106 |
+
return [];
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
function normalizeStatus(raw: RawSubscriptionStatus): LLMSubscriptionStatus {
|
| 110 |
+
return {
|
| 111 |
+
vendor: OPENAI_SUBSCRIPTION_VENDOR,
|
| 112 |
+
connected: readBoolean(raw, ["connected", "authenticated", "is_connected"]),
|
| 113 |
+
accountEmail: readString(raw, ["account_email", "email", "account"]),
|
| 114 |
+
expiresAt:
|
| 115 |
+
readString(raw, ["expires_at", "expiresAt"]) ??
|
| 116 |
+
readNumber(raw, ["expires_at", "expiresAt"]),
|
| 117 |
+
};
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
function normalizeDeviceChallenge(
|
| 121 |
+
raw: RawDeviceStart,
|
| 122 |
+
): LLMSubscriptionDeviceChallenge {
|
| 123 |
+
const deviceCode = readString(raw, ["device_code", "deviceCode"]);
|
| 124 |
+
const userCode = readString(raw, ["user_code", "userCode"]);
|
| 125 |
+
const verificationUri = readString(raw, [
|
| 126 |
+
"verification_uri",
|
| 127 |
+
"verificationUri",
|
| 128 |
+
"verification_url",
|
| 129 |
+
"verificationUrl",
|
| 130 |
+
]);
|
| 131 |
+
|
| 132 |
+
if (!deviceCode || !userCode || !verificationUri) {
|
| 133 |
+
throw new Error("Subscription device login response is incomplete");
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
return {
|
| 137 |
+
deviceCode,
|
| 138 |
+
userCode,
|
| 139 |
+
verificationUri,
|
| 140 |
+
verificationUriComplete: readString(raw, [
|
| 141 |
+
"verification_uri_complete",
|
| 142 |
+
"verificationUriComplete",
|
| 143 |
+
"verification_url_complete",
|
| 144 |
+
"verificationUrlComplete",
|
| 145 |
+
]),
|
| 146 |
+
expiresAt:
|
| 147 |
+
readString(raw, ["expires_at", "expiresAt"]) ??
|
| 148 |
+
readNumber(raw, ["expires_at", "expiresAt", "expires_in", "expiresIn"]),
|
| 149 |
+
intervalSeconds: readNumber(raw, [
|
| 150 |
+
"interval",
|
| 151 |
+
"interval_seconds",
|
| 152 |
+
"intervalSeconds",
|
| 153 |
+
]),
|
| 154 |
+
};
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
class LLMSubscriptionService {
|
| 158 |
+
static async getOpenAIModels(): Promise<string[]> {
|
| 159 |
+
const response = await requestSubscriptionEndpoint(
|
| 160 |
+
OPENAI_SUBSCRIPTION_MODELS_PATH,
|
| 161 |
+
);
|
| 162 |
+
return normalizeModels(response);
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
static async getOpenAIStatus(): Promise<LLMSubscriptionStatus> {
|
| 166 |
+
const response = await requestSubscriptionEndpoint(
|
| 167 |
+
OPENAI_SUBSCRIPTION_STATUS_PATH,
|
| 168 |
+
);
|
| 169 |
+
return normalizeStatus(response as RawSubscriptionStatus);
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
static async startOpenAIDeviceLogin(): Promise<LLMSubscriptionDeviceChallenge> {
|
| 173 |
+
const response = await requestSubscriptionEndpoint(
|
| 174 |
+
OPENAI_SUBSCRIPTION_DEVICE_START_PATH,
|
| 175 |
+
{ method: "POST" },
|
| 176 |
+
);
|
| 177 |
+
return normalizeDeviceChallenge(response as RawDeviceStart);
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
static async pollOpenAIDeviceLogin(
|
| 181 |
+
deviceCode: string,
|
| 182 |
+
): Promise<LLMSubscriptionStatus> {
|
| 183 |
+
const response = await requestSubscriptionEndpoint(
|
| 184 |
+
OPENAI_SUBSCRIPTION_DEVICE_POLL_PATH,
|
| 185 |
+
{
|
| 186 |
+
method: "POST",
|
| 187 |
+
jsonBody: { device_code: deviceCode },
|
| 188 |
+
},
|
| 189 |
+
);
|
| 190 |
+
return normalizeStatus(response as RawSubscriptionStatus);
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
static async logoutOpenAI(): Promise<LLMSubscriptionStatus> {
|
| 194 |
+
const response = await requestSubscriptionEndpoint(
|
| 195 |
+
OPENAI_SUBSCRIPTION_LOGOUT_PATH,
|
| 196 |
+
{ method: "POST" },
|
| 197 |
+
);
|
| 198 |
+
return normalizeStatus(response as RawSubscriptionStatus);
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
export default LLMSubscriptionService;
|
src/api/main-app-auth.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import axios from "axios";
|
| 2 |
+
import { AGENT_CANVAS_CLIENT_VERSION } from "./client-source";
|
| 3 |
+
import { getLockedCloudAuthMode } from "./agent-server-config";
|
| 4 |
+
|
| 5 |
+
export const MAIN_APP_AUTHENTICATE_PATH = "/api/authenticate";
|
| 6 |
+
export const MAIN_APP_ANALYTICS_EVENTS_PATH = "/api/analytics/events";
|
| 7 |
+
export const MAIN_APP_LOGIN_PATH = "/login";
|
| 8 |
+
export const MAIN_APP_LOGIN_REDIRECT_PARAM = "returnTo";
|
| 9 |
+
|
| 10 |
+
const LOCAL_BROWSER_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]);
|
| 11 |
+
|
| 12 |
+
function getCurrentPathForRedirect(): string {
|
| 13 |
+
if (typeof window === "undefined") return "/";
|
| 14 |
+
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export function isLocalBrowserHost(): boolean {
|
| 18 |
+
if (typeof window === "undefined") return false;
|
| 19 |
+
return LOCAL_BROWSER_HOSTNAMES.has(window.location.hostname);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export function shouldUseMainAppCookieAuth(): boolean {
|
| 23 |
+
return getLockedCloudAuthMode() === "cookie" && !isLocalBrowserHost();
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
let canvasAuthenticatedReported = false;
|
| 27 |
+
|
| 28 |
+
export function _resetCanvasAuthReported(): void {
|
| 29 |
+
canvasAuthenticatedReported = false;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export async function reportCanvasAuthenticated(): Promise<void> {
|
| 33 |
+
if (canvasAuthenticatedReported) return;
|
| 34 |
+
canvasAuthenticatedReported = true;
|
| 35 |
+
try {
|
| 36 |
+
await axios.post(
|
| 37 |
+
MAIN_APP_ANALYTICS_EVENTS_PATH,
|
| 38 |
+
{
|
| 39 |
+
event_type: "canvas_authenticated",
|
| 40 |
+
client_version: AGENT_CANVAS_CLIENT_VERSION,
|
| 41 |
+
},
|
| 42 |
+
{ withCredentials: true },
|
| 43 |
+
);
|
| 44 |
+
} catch {
|
| 45 |
+
// Analytics must never affect authentication or application startup.
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
export async function authenticateWithMainAppCookie(): Promise<boolean> {
|
| 50 |
+
try {
|
| 51 |
+
await axios.post(MAIN_APP_AUTHENTICATE_PATH, null, {
|
| 52 |
+
withCredentials: true,
|
| 53 |
+
});
|
| 54 |
+
void reportCanvasAuthenticated();
|
| 55 |
+
return true;
|
| 56 |
+
} catch (error) {
|
| 57 |
+
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
| 58 |
+
return false;
|
| 59 |
+
}
|
| 60 |
+
throw error;
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
export function buildMainAppLoginRedirect(
|
| 65 |
+
redirectPath = getCurrentPathForRedirect(),
|
| 66 |
+
): string {
|
| 67 |
+
const params = new URLSearchParams({
|
| 68 |
+
[MAIN_APP_LOGIN_REDIRECT_PARAM]: redirectPath,
|
| 69 |
+
});
|
| 70 |
+
return `${MAIN_APP_LOGIN_PATH}?${params.toString()}`;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
export function redirectToMainAppLogin(): void {
|
| 74 |
+
if (typeof window === "undefined") return;
|
| 75 |
+
window.location.assign(buildMainAppLoginRedirect());
|
| 76 |
+
}
|
src/api/no-direct-agent-server-calls.test.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from "vitest";
|
| 2 |
+
import { readdirSync, readFileSync } from "node:fs";
|
| 3 |
+
import { join, relative } from "node:path";
|
| 4 |
+
|
| 5 |
+
const SRC_ROOT = join(process.cwd(), "src");
|
| 6 |
+
const EXCLUDED_SEGMENTS = new Set(["mocks", "routeTree.gen.ts"]);
|
| 7 |
+
const ALLOWED_AD_HOC_HTTP_FILES = new Set([
|
| 8 |
+
"api/automation-service/automation-service.api.ts",
|
| 9 |
+
"api/cloud/proxy.ts",
|
| 10 |
+
"api/main-app-auth.ts",
|
| 11 |
+
]);
|
| 12 |
+
|
| 13 |
+
function collectSourceFiles(dir: string): string[] {
|
| 14 |
+
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
| 15 |
+
const fullPath = join(dir, entry.name);
|
| 16 |
+
// Normalize to forward slashes so the path matches ALLOWED_AD_HOC_HTTP_FILES
|
| 17 |
+
// entries on Windows where path.relative() returns backslash-separated paths.
|
| 18 |
+
const relPath = relative(SRC_ROOT, fullPath).replace(/\\/g, "/");
|
| 19 |
+
|
| 20 |
+
if (entry.isDirectory()) {
|
| 21 |
+
if (EXCLUDED_SEGMENTS.has(entry.name)) return [];
|
| 22 |
+
return collectSourceFiles(fullPath);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
if (EXCLUDED_SEGMENTS.has(entry.name)) return [];
|
| 26 |
+
if (!/\.(ts|tsx)$/.test(entry.name)) return [];
|
| 27 |
+
if (/\.(test|spec)\.(ts|tsx)$/.test(entry.name)) return [];
|
| 28 |
+
return [relPath];
|
| 29 |
+
});
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
describe("agent-server API access", () => {
|
| 33 |
+
it("uses typed @openhands/typescript-client access instead of ad-hoc HTTP", () => {
|
| 34 |
+
const violations = collectSourceFiles(SRC_ROOT).flatMap((relPath) => {
|
| 35 |
+
const source = readFileSync(join(SRC_ROOT, relPath), "utf8");
|
| 36 |
+
const fileViolations: string[] = [];
|
| 37 |
+
|
| 38 |
+
if (/openHands\s*\./.test(source)) {
|
| 39 |
+
fileViolations.push("uses the shared axios instance directly");
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
if (/\bcreateHttpClient\s*\(/.test(source)) {
|
| 43 |
+
fileViolations.push("uses createHttpClient directly");
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
if (
|
| 47 |
+
/from\s+["']@openhands\/typescript-client\/client\/http-client["']/.test(
|
| 48 |
+
source,
|
| 49 |
+
)
|
| 50 |
+
) {
|
| 51 |
+
fileViolations.push("imports the low-level SDK HttpClient directly");
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
if (/\bnew\s+HttpClient\s*\(/.test(source)) {
|
| 55 |
+
fileViolations.push("constructs HttpClient directly");
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
if (
|
| 59 |
+
(/\baxios\s*\(/.test(source) ||
|
| 60 |
+
/\baxios\s*\.\s*(?:create|get|post|put|patch|delete|request)\s*\(/.test(
|
| 61 |
+
source,
|
| 62 |
+
)) &&
|
| 63 |
+
!ALLOWED_AD_HOC_HTTP_FILES.has(relPath)
|
| 64 |
+
) {
|
| 65 |
+
fileViolations.push("uses axios directly for HTTP calls");
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
if (
|
| 69 |
+
/\bfetch\s*\([\s\S]{0,200}['"`]\/api\//.test(source) &&
|
| 70 |
+
!ALLOWED_AD_HOC_HTTP_FILES.has(relPath)
|
| 71 |
+
) {
|
| 72 |
+
fileViolations.push("calls an /api path with fetch directly");
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
return fileViolations.map((violation) => `${relPath}: ${violation}`);
|
| 76 |
+
});
|
| 77 |
+
|
| 78 |
+
expect(violations).toEqual([]);
|
| 79 |
+
});
|
| 80 |
+
});
|
src/api/open-hands.types.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ConversationStatus } from "#/types/conversation-status";
|
| 2 |
+
import { RuntimeStatus } from "#/types/runtime-status";
|
| 3 |
+
import { Provider } from "#/types/settings";
|
| 4 |
+
|
| 5 |
+
export interface ErrorResponse {
|
| 6 |
+
error: string;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export interface SaveFileSuccessResponse {
|
| 10 |
+
message: string;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export interface FileUploadSuccessResponse {
|
| 14 |
+
uploaded_files: string[];
|
| 15 |
+
skipped_files: { name: string; reason: string }[];
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export interface FeedbackBodyResponse {
|
| 19 |
+
message: string;
|
| 20 |
+
feedback_id: string;
|
| 21 |
+
password: string;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export interface FeedbackResponse {
|
| 25 |
+
statusCode: number;
|
| 26 |
+
body: FeedbackBodyResponse;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export interface AuthenticationResponse {
|
| 30 |
+
message: string;
|
| 31 |
+
login?: string; // Only present when allow list is enabled
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
export interface Feedback {
|
| 35 |
+
version: string;
|
| 36 |
+
email: string;
|
| 37 |
+
token: string;
|
| 38 |
+
polarity: "positive" | "negative";
|
| 39 |
+
permissions: "public" | "private";
|
| 40 |
+
trajectory: unknown[];
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export interface GetVSCodeUrlResponse {
|
| 44 |
+
vscode_url: string | null;
|
| 45 |
+
error?: string;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
export interface GetTrajectoryResponse {
|
| 49 |
+
trajectory: unknown[] | null;
|
| 50 |
+
error?: string;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export interface RepositorySelection {
|
| 54 |
+
selected_repository: string | null;
|
| 55 |
+
selected_branch: string | null;
|
| 56 |
+
git_provider: Provider | null;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
export type ConversationTrigger =
|
| 60 |
+
| "resolver"
|
| 61 |
+
| "gui"
|
| 62 |
+
| "suggested_task"
|
| 63 |
+
| "microagent_management"
|
| 64 |
+
| "automation";
|
| 65 |
+
|
| 66 |
+
export interface Conversation {
|
| 67 |
+
conversation_id: string;
|
| 68 |
+
title: string;
|
| 69 |
+
selected_repository: string | null;
|
| 70 |
+
selected_branch: string | null;
|
| 71 |
+
git_provider: Provider | null;
|
| 72 |
+
last_updated_at: string;
|
| 73 |
+
created_at: string;
|
| 74 |
+
status: ConversationStatus;
|
| 75 |
+
runtime_status: RuntimeStatus | null;
|
| 76 |
+
trigger?: ConversationTrigger;
|
| 77 |
+
url: string | null;
|
| 78 |
+
session_api_key: string | null;
|
| 79 |
+
pr_number?: number[] | null;
|
| 80 |
+
conversation_version?: "V0" | "V1";
|
| 81 |
+
sub_conversation_ids?: string[];
|
| 82 |
+
public?: boolean;
|
| 83 |
+
llm_model?: string | null;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
export interface ResultSet<T> {
|
| 87 |
+
results: T[];
|
| 88 |
+
next_page_id: string | null;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
/**
|
| 92 |
+
* @deprecated Use AgentServerGitChangeStatus for new code. This type is maintained for backward compatibility with V0 API.
|
| 93 |
+
*/
|
| 94 |
+
export type GitChangeStatus = "M" | "A" | "D" | "R" | "U";
|
| 95 |
+
|
| 96 |
+
export type AgentServerGitChangeStatus =
|
| 97 |
+
| "MOVED"
|
| 98 |
+
| "ADDED"
|
| 99 |
+
| "DELETED"
|
| 100 |
+
| "UPDATED";
|
| 101 |
+
|
| 102 |
+
export interface GitChange {
|
| 103 |
+
status: GitChangeStatus;
|
| 104 |
+
path: string;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
export interface GitChangeDiff {
|
| 108 |
+
modified: string;
|
| 109 |
+
original: string;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
export interface GitCommit {
|
| 113 |
+
sha: string;
|
| 114 |
+
shortSha: string;
|
| 115 |
+
subject: string;
|
| 116 |
+
author: string;
|
| 117 |
+
// ISO 8601 author date with UTC offset (git log %aI).
|
| 118 |
+
timestamp: string;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
export interface GitCommitsPage {
|
| 122 |
+
commits: GitCommit[];
|
| 123 |
+
hasMore: boolean;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
export interface InputMetadata {
|
| 127 |
+
name: string;
|
| 128 |
+
description: string;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
export interface IOption<T> {
|
| 132 |
+
label: string;
|
| 133 |
+
value: T;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
export type GetFilesResponse = string[];
|
| 137 |
+
|
| 138 |
+
export interface GetFileResponse {
|
| 139 |
+
code: string;
|
| 140 |
+
}
|
src/api/plugins-management-service.test.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { PluginsClient } from "@openhands/typescript-client/clients";
|
| 2 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 3 |
+
import {
|
| 4 |
+
__resetActiveStoreForTests,
|
| 5 |
+
setActiveSelection,
|
| 6 |
+
setRegisteredBackends,
|
| 7 |
+
} from "#/api/backend-registry/active-store";
|
| 8 |
+
import type { Backend } from "#/api/backend-registry/types";
|
| 9 |
+
import PluginsManagementService from "#/api/plugins-management-service";
|
| 10 |
+
|
| 11 |
+
vi.mock("@openhands/typescript-client/clients", () => ({
|
| 12 |
+
PluginsClient: vi.fn(),
|
| 13 |
+
}));
|
| 14 |
+
|
| 15 |
+
const listInstalledPlugins = vi.fn();
|
| 16 |
+
const installPlugin = vi.fn();
|
| 17 |
+
const setPluginEnabled = vi.fn();
|
| 18 |
+
const uninstallPlugin = vi.fn();
|
| 19 |
+
const refreshPlugin = vi.fn();
|
| 20 |
+
const close = vi.fn();
|
| 21 |
+
|
| 22 |
+
function useBackend(kind: "local" | "cloud"): void {
|
| 23 |
+
const backend: Backend = {
|
| 24 |
+
id: kind,
|
| 25 |
+
name: kind,
|
| 26 |
+
host: "http://127.0.0.1:8001",
|
| 27 |
+
apiKey: "session-key",
|
| 28 |
+
kind,
|
| 29 |
+
};
|
| 30 |
+
setRegisteredBackends([backend]);
|
| 31 |
+
setActiveSelection({ backendId: kind, orgId: null });
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
beforeEach(() => {
|
| 35 |
+
vi.clearAllMocks();
|
| 36 |
+
__resetActiveStoreForTests();
|
| 37 |
+
vi.mocked(PluginsClient).mockImplementation(function MockPluginsClient() {
|
| 38 |
+
return {
|
| 39 |
+
listInstalledPlugins,
|
| 40 |
+
installPlugin,
|
| 41 |
+
setPluginEnabled,
|
| 42 |
+
uninstallPlugin,
|
| 43 |
+
refreshPlugin,
|
| 44 |
+
close,
|
| 45 |
+
} as unknown as PluginsClient;
|
| 46 |
+
} as unknown as typeof PluginsClient);
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
afterEach(() => {
|
| 50 |
+
__resetActiveStoreForTests();
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
const installedPlugin = {
|
| 54 |
+
name: "demo-plugin",
|
| 55 |
+
version: "1.0.0",
|
| 56 |
+
description: "A demo plugin",
|
| 57 |
+
enabled: true,
|
| 58 |
+
source: "github:OpenHands/extensions",
|
| 59 |
+
resolved_ref: null,
|
| 60 |
+
repo_path: "plugins/demo-plugin",
|
| 61 |
+
installed_at: "2026-06-01T00:00:00Z",
|
| 62 |
+
install_path: "/home/.openhands/plugins/installed/demo-plugin",
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
describe("PluginsManagementService", () => {
|
| 66 |
+
it("lists installed plugins from the local agent-server", async () => {
|
| 67 |
+
useBackend("local");
|
| 68 |
+
listInstalledPlugins.mockResolvedValue({ plugins: [installedPlugin] });
|
| 69 |
+
|
| 70 |
+
const result = await PluginsManagementService.listInstalledPlugins();
|
| 71 |
+
|
| 72 |
+
expect(result).toEqual([installedPlugin]);
|
| 73 |
+
expect(listInstalledPlugins).toHaveBeenCalledTimes(1);
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
+
it("returns an empty installed list on a cloud backend without calling the client", async () => {
|
| 77 |
+
useBackend("cloud");
|
| 78 |
+
|
| 79 |
+
const result = await PluginsManagementService.listInstalledPlugins();
|
| 80 |
+
|
| 81 |
+
expect(result).toEqual([]);
|
| 82 |
+
expect(PluginsClient).not.toHaveBeenCalled();
|
| 83 |
+
});
|
| 84 |
+
|
| 85 |
+
it("returns an empty installed list when the local request fails", async () => {
|
| 86 |
+
useBackend("local");
|
| 87 |
+
listInstalledPlugins.mockRejectedValue(new Error("unreachable"));
|
| 88 |
+
|
| 89 |
+
const result = await PluginsManagementService.listInstalledPlugins();
|
| 90 |
+
|
| 91 |
+
expect(result).toEqual([]);
|
| 92 |
+
});
|
| 93 |
+
|
| 94 |
+
it("forwards source, ref, and repo_path when installing a plugin", async () => {
|
| 95 |
+
useBackend("local");
|
| 96 |
+
installPlugin.mockResolvedValue(installedPlugin);
|
| 97 |
+
|
| 98 |
+
await PluginsManagementService.installPlugin({
|
| 99 |
+
source: "github:OpenHands/extensions",
|
| 100 |
+
ref: "main",
|
| 101 |
+
repo_path: "plugins/demo-plugin",
|
| 102 |
+
});
|
| 103 |
+
|
| 104 |
+
expect(installPlugin).toHaveBeenCalledWith({
|
| 105 |
+
source: "github:OpenHands/extensions",
|
| 106 |
+
ref: "main",
|
| 107 |
+
repo_path: "plugins/demo-plugin",
|
| 108 |
+
});
|
| 109 |
+
});
|
| 110 |
+
|
| 111 |
+
it("forwards the name and enabled flag when toggling a plugin", async () => {
|
| 112 |
+
useBackend("local");
|
| 113 |
+
setPluginEnabled.mockResolvedValue({ name: "demo-plugin", enabled: false });
|
| 114 |
+
|
| 115 |
+
await PluginsManagementService.setPluginEnabled("demo-plugin", false);
|
| 116 |
+
|
| 117 |
+
expect(setPluginEnabled).toHaveBeenCalledWith("demo-plugin", false);
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
it("forwards the plugin name when uninstalling", async () => {
|
| 121 |
+
useBackend("local");
|
| 122 |
+
uninstallPlugin.mockResolvedValue({ message: "ok" });
|
| 123 |
+
|
| 124 |
+
await PluginsManagementService.uninstallPlugin("demo-plugin");
|
| 125 |
+
|
| 126 |
+
expect(uninstallPlugin).toHaveBeenCalledWith("demo-plugin");
|
| 127 |
+
});
|
| 128 |
+
|
| 129 |
+
it("forwards the plugin name when refreshing", async () => {
|
| 130 |
+
useBackend("local");
|
| 131 |
+
refreshPlugin.mockResolvedValue({ message: "ok", plugin: installedPlugin });
|
| 132 |
+
|
| 133 |
+
await PluginsManagementService.refreshPlugin("demo-plugin");
|
| 134 |
+
|
| 135 |
+
expect(refreshPlugin).toHaveBeenCalledWith("demo-plugin");
|
| 136 |
+
});
|
| 137 |
+
});
|
src/api/plugins-management-service.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { PluginsClient } from "@openhands/typescript-client/clients";
|
| 2 |
+
import { getActiveBackend } from "./backend-registry/active-store";
|
| 3 |
+
import { getAgentServerClientOptions } from "./agent-server-client-options";
|
| 4 |
+
import type { PluginBundledSkill } from "./plugins-service";
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* An installed plugin, as returned by the agent-server management router
|
| 8 |
+
* (`GET /api/plugins/installed`). Matches the typescript-client
|
| 9 |
+
* `InstalledPluginInfo`. The contents fields (`skills`, `files`, relative to
|
| 10 |
+
* `install_path`) are absent on older agent-servers and null when the
|
| 11 |
+
* installed plugin directory failed to load.
|
| 12 |
+
*/
|
| 13 |
+
export interface InstalledPluginInfo {
|
| 14 |
+
name: string;
|
| 15 |
+
version: string;
|
| 16 |
+
description: string | null;
|
| 17 |
+
enabled: boolean;
|
| 18 |
+
source: string;
|
| 19 |
+
resolved_ref?: string | null;
|
| 20 |
+
repo_path?: string | null;
|
| 21 |
+
installed_at: string;
|
| 22 |
+
install_path: string;
|
| 23 |
+
skills?: PluginBundledSkill[] | null;
|
| 24 |
+
files?: string[] | null;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
/** Coordinates for installing a plugin from a git source or local path. */
|
| 28 |
+
export interface InstallPluginRequest {
|
| 29 |
+
source: string;
|
| 30 |
+
ref?: string | null;
|
| 31 |
+
repo_path?: string | null;
|
| 32 |
+
force?: boolean;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
/**
|
| 36 |
+
* The slice of the typescript-client `PluginsClient` this service drives. The
|
| 37 |
+
* installed `@openhands/typescript-client` package does not yet export these
|
| 38 |
+
* management methods (they ship in typescript-client PRs #222/#223); narrowing
|
| 39 |
+
* to this local interface keeps `vitest` (types stripped) and `eslint` green
|
| 40 |
+
* until the client is republished, at which point the cast below can be dropped.
|
| 41 |
+
*/
|
| 42 |
+
interface PluginsManagementClient {
|
| 43 |
+
listInstalledPlugins(): Promise<{ plugins: InstalledPluginInfo[] }>;
|
| 44 |
+
installPlugin(request: InstallPluginRequest): Promise<InstalledPluginInfo>;
|
| 45 |
+
setPluginEnabled(
|
| 46 |
+
name: string,
|
| 47 |
+
enabled: boolean,
|
| 48 |
+
): Promise<{ name: string; enabled: boolean }>;
|
| 49 |
+
uninstallPlugin(name: string): Promise<{ message: string }>;
|
| 50 |
+
refreshPlugin(
|
| 51 |
+
name: string,
|
| 52 |
+
): Promise<{ message: string; plugin: InstalledPluginInfo }>;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function isCloudBackend(): boolean {
|
| 56 |
+
return getActiveBackend().backend.kind === "cloud";
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
function getManagementClient(): PluginsManagementClient {
|
| 60 |
+
return new PluginsClient(
|
| 61 |
+
getAgentServerClientOptions(),
|
| 62 |
+
) as unknown as PluginsManagementClient;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
/**
|
| 66 |
+
* Front-end management layer for installed plugins: list / install / enable /
|
| 67 |
+
* disable / uninstall / refresh. Kept separate from the read-only catalog
|
| 68 |
+
* service (`plugins-service.ts`), exactly as skills separate the marketplace
|
| 69 |
+
* catalog from install actions.
|
| 70 |
+
*
|
| 71 |
+
* Local backend only for now (per Appendix C Q5): installed plugins live on the
|
| 72 |
+
* local agent-server's `~/.openhands/plugins/installed/`. A cloud backend has no
|
| 73 |
+
* per-user installed store yet, so reads return an empty list and mutating
|
| 74 |
+
* actions throw (the UI also disables them on cloud).
|
| 75 |
+
*/
|
| 76 |
+
class PluginsManagementService {
|
| 77 |
+
static async listInstalledPlugins(): Promise<InstalledPluginInfo[]> {
|
| 78 |
+
if (isCloudBackend()) {
|
| 79 |
+
return [];
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
try {
|
| 83 |
+
const response = await getManagementClient().listInstalledPlugins();
|
| 84 |
+
return response.plugins ?? [];
|
| 85 |
+
} catch {
|
| 86 |
+
// Agent-server may predate the plugins router or be unreachable; surface
|
| 87 |
+
// an empty list rather than throwing (mirrors the catalog service).
|
| 88 |
+
return [];
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
static async installPlugin(
|
| 93 |
+
request: InstallPluginRequest,
|
| 94 |
+
): Promise<InstalledPluginInfo> {
|
| 95 |
+
if (isCloudBackend()) {
|
| 96 |
+
throw new Error(
|
| 97 |
+
"Installing plugins is only available on a local backend.",
|
| 98 |
+
);
|
| 99 |
+
}
|
| 100 |
+
return getManagementClient().installPlugin(request);
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
static async setPluginEnabled(
|
| 104 |
+
name: string,
|
| 105 |
+
enabled: boolean,
|
| 106 |
+
): Promise<{ name: string; enabled: boolean }> {
|
| 107 |
+
if (isCloudBackend()) {
|
| 108 |
+
throw new Error(
|
| 109 |
+
"Enabling and disabling plugins is only available on a local backend.",
|
| 110 |
+
);
|
| 111 |
+
}
|
| 112 |
+
return getManagementClient().setPluginEnabled(name, enabled);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
static async uninstallPlugin(name: string): Promise<{ message: string }> {
|
| 116 |
+
if (isCloudBackend()) {
|
| 117 |
+
throw new Error(
|
| 118 |
+
"Uninstalling plugins is only available on a local backend.",
|
| 119 |
+
);
|
| 120 |
+
}
|
| 121 |
+
return getManagementClient().uninstallPlugin(name);
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
static async refreshPlugin(
|
| 125 |
+
name: string,
|
| 126 |
+
): Promise<{ message: string; plugin: InstalledPluginInfo }> {
|
| 127 |
+
if (isCloudBackend()) {
|
| 128 |
+
throw new Error(
|
| 129 |
+
"Refreshing plugins is only available on a local backend.",
|
| 130 |
+
);
|
| 131 |
+
}
|
| 132 |
+
return getManagementClient().refreshPlugin(name);
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
export default PluginsManagementService;
|
src/api/plugins-service.test.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import {
|
| 3 |
+
FileClient,
|
| 4 |
+
PluginsClient,
|
| 5 |
+
} from "@openhands/typescript-client/clients";
|
| 6 |
+
import {
|
| 7 |
+
setActiveSelection,
|
| 8 |
+
setRegisteredBackends,
|
| 9 |
+
} from "./backend-registry/active-store";
|
| 10 |
+
import PluginsService from "./plugins-service";
|
| 11 |
+
|
| 12 |
+
vi.mock("@openhands/typescript-client/clients", () => ({
|
| 13 |
+
PluginsClient: vi.fn(),
|
| 14 |
+
FileClient: vi.fn(),
|
| 15 |
+
}));
|
| 16 |
+
|
| 17 |
+
const getPluginsMarketplace = vi.fn();
|
| 18 |
+
const getPlugins = vi.fn();
|
| 19 |
+
const downloadFile = vi.fn();
|
| 20 |
+
const close = vi.fn();
|
| 21 |
+
|
| 22 |
+
function useBackend(kind: "local" | "cloud"): void {
|
| 23 |
+
setRegisteredBackends([
|
| 24 |
+
{
|
| 25 |
+
id: kind,
|
| 26 |
+
name: kind,
|
| 27 |
+
host: "http://127.0.0.1:8001",
|
| 28 |
+
apiKey: "session-key",
|
| 29 |
+
kind,
|
| 30 |
+
},
|
| 31 |
+
]);
|
| 32 |
+
setActiveSelection({ backendId: kind, orgId: null });
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
describe("PluginsService.getPluginsMarketplace", () => {
|
| 36 |
+
beforeEach(() => {
|
| 37 |
+
vi.clearAllMocks();
|
| 38 |
+
vi.mocked(PluginsClient).mockImplementation(function MockPluginsClient() {
|
| 39 |
+
return { getPluginsMarketplace, close } as unknown as PluginsClient;
|
| 40 |
+
} as unknown as typeof PluginsClient);
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
it("returns the catalog from the local agent-server", async () => {
|
| 44 |
+
useBackend("local");
|
| 45 |
+
const plugin = {
|
| 46 |
+
name: "city-weather",
|
| 47 |
+
description: "Weather plugin",
|
| 48 |
+
source: "github:OpenHands/extensions",
|
| 49 |
+
ref: null,
|
| 50 |
+
repo_path: "plugins/city-weather",
|
| 51 |
+
installed: false,
|
| 52 |
+
};
|
| 53 |
+
getPluginsMarketplace.mockResolvedValue({ plugins: [plugin] });
|
| 54 |
+
|
| 55 |
+
const result = await PluginsService.getPluginsMarketplace();
|
| 56 |
+
|
| 57 |
+
expect(result).toEqual([plugin]);
|
| 58 |
+
expect(getPluginsMarketplace).toHaveBeenCalledTimes(1);
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
it("returns an empty catalog on a cloud backend without calling the client", async () => {
|
| 62 |
+
useBackend("cloud");
|
| 63 |
+
|
| 64 |
+
const result = await PluginsService.getPluginsMarketplace();
|
| 65 |
+
|
| 66 |
+
expect(result).toEqual([]);
|
| 67 |
+
expect(PluginsClient).not.toHaveBeenCalled();
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
it("returns an empty catalog when the local request fails", async () => {
|
| 71 |
+
useBackend("local");
|
| 72 |
+
getPluginsMarketplace.mockRejectedValue(new Error("unreachable"));
|
| 73 |
+
|
| 74 |
+
const result = await PluginsService.getPluginsMarketplace();
|
| 75 |
+
|
| 76 |
+
expect(result).toEqual([]);
|
| 77 |
+
});
|
| 78 |
+
});
|
| 79 |
+
|
| 80 |
+
describe("PluginsService.getLocalPlugins", () => {
|
| 81 |
+
beforeEach(() => {
|
| 82 |
+
vi.clearAllMocks();
|
| 83 |
+
vi.mocked(PluginsClient).mockImplementation(function MockPluginsClient() {
|
| 84 |
+
return { getPlugins, close } as unknown as PluginsClient;
|
| 85 |
+
} as unknown as typeof PluginsClient);
|
| 86 |
+
});
|
| 87 |
+
|
| 88 |
+
it("requests user-level local plugins from the local agent-server", async () => {
|
| 89 |
+
useBackend("local");
|
| 90 |
+
const plugin = {
|
| 91 |
+
name: "hello-local",
|
| 92 |
+
version: "1.0.0",
|
| 93 |
+
description: "A local plugin",
|
| 94 |
+
};
|
| 95 |
+
getPlugins.mockResolvedValue({ plugins: [plugin] });
|
| 96 |
+
|
| 97 |
+
const result = await PluginsService.getLocalPlugins();
|
| 98 |
+
|
| 99 |
+
expect(result).toEqual([plugin]);
|
| 100 |
+
expect(getPlugins).toHaveBeenCalledWith({
|
| 101 |
+
load_user: true,
|
| 102 |
+
load_project: false,
|
| 103 |
+
});
|
| 104 |
+
});
|
| 105 |
+
|
| 106 |
+
it("returns an empty list on a cloud backend without calling the client", async () => {
|
| 107 |
+
useBackend("cloud");
|
| 108 |
+
|
| 109 |
+
const result = await PluginsService.getLocalPlugins();
|
| 110 |
+
|
| 111 |
+
expect(result).toEqual([]);
|
| 112 |
+
expect(PluginsClient).not.toHaveBeenCalled();
|
| 113 |
+
});
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
describe("PluginsService.getPluginFileContent", () => {
|
| 117 |
+
beforeEach(() => {
|
| 118 |
+
vi.clearAllMocks();
|
| 119 |
+
vi.mocked(FileClient).mockImplementation(function MockFileClient() {
|
| 120 |
+
return { downloadFile, close } as unknown as FileClient;
|
| 121 |
+
} as unknown as typeof FileClient);
|
| 122 |
+
});
|
| 123 |
+
|
| 124 |
+
it("downloads the file under the plugin directory and decodes it as text", async () => {
|
| 125 |
+
useBackend("local");
|
| 126 |
+
downloadFile.mockResolvedValue(new TextEncoder().encode("# Hello").buffer);
|
| 127 |
+
|
| 128 |
+
const result = await PluginsService.getPluginFileContent(
|
| 129 |
+
"/plugins/demo",
|
| 130 |
+
"docs/README.md",
|
| 131 |
+
);
|
| 132 |
+
|
| 133 |
+
expect(result).toEqual({ kind: "text", text: "# Hello" });
|
| 134 |
+
expect(downloadFile).toHaveBeenCalledWith("/plugins/demo/docs/README.md");
|
| 135 |
+
});
|
| 136 |
+
|
| 137 |
+
it("flags content containing NUL bytes as binary", async () => {
|
| 138 |
+
useBackend("local");
|
| 139 |
+
downloadFile.mockResolvedValue(
|
| 140 |
+
new Uint8Array([0x89, 0x50, 0x00, 0x47]).buffer,
|
| 141 |
+
);
|
| 142 |
+
|
| 143 |
+
const result = await PluginsService.getPluginFileContent(
|
| 144 |
+
"/plugins/demo",
|
| 145 |
+
"logo.png",
|
| 146 |
+
);
|
| 147 |
+
|
| 148 |
+
expect(result).toEqual({ kind: "binary", text: null });
|
| 149 |
+
});
|
| 150 |
+
|
| 151 |
+
it("rejects on a cloud backend without calling the client", async () => {
|
| 152 |
+
useBackend("cloud");
|
| 153 |
+
|
| 154 |
+
await expect(
|
| 155 |
+
PluginsService.getPluginFileContent("/plugins/demo", "README.md"),
|
| 156 |
+
).rejects.toThrow();
|
| 157 |
+
expect(FileClient).not.toHaveBeenCalled();
|
| 158 |
+
});
|
| 159 |
+
});
|
src/api/plugins-service.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
FileClient,
|
| 3 |
+
PluginsClient,
|
| 4 |
+
} from "@openhands/typescript-client/clients";
|
| 5 |
+
import { getActiveBackend } from "./backend-registry/active-store";
|
| 6 |
+
import { getAgentServerClientOptions } from "./agent-server-client-options";
|
| 7 |
+
|
| 8 |
+
/** Summary of a skill bundled in a plugin (agent-server `PluginSkillSummary`). */
|
| 9 |
+
export interface PluginBundledSkill {
|
| 10 |
+
name: string;
|
| 11 |
+
description?: string | null;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* A plugin in the dynamic marketplace catalog, with attachable coordinates and
|
| 16 |
+
* install state. Matches the agent-server `MarketplacePluginInfo` / the
|
| 17 |
+
* typescript-client `MarketplacePlugin`. The contents fields (`path`, `skills`,
|
| 18 |
+
* `files`) are populated when the entry resolves to a directory in the
|
| 19 |
+
* server's local marketplace clone, and are absent on older agent-servers.
|
| 20 |
+
*/
|
| 21 |
+
export interface MarketplacePlugin {
|
| 22 |
+
name: string;
|
| 23 |
+
description: string | null;
|
| 24 |
+
source: string;
|
| 25 |
+
ref?: string | null;
|
| 26 |
+
repo_path?: string | null;
|
| 27 |
+
installed: boolean;
|
| 28 |
+
path?: string | null;
|
| 29 |
+
skills?: PluginBundledSkill[] | null;
|
| 30 |
+
files?: string[] | null;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
/**
|
| 34 |
+
* A locally-discovered ("ambient") plugin reported by the agent-server — one
|
| 35 |
+
* found in the user's local plugin directories (e.g. `~/.agents/plugins`).
|
| 36 |
+
* These auto-load into conversations and are not managed via install/uninstall,
|
| 37 |
+
* so the Plugins page renders them as a read-only "Local" group. Matches the
|
| 38 |
+
* typescript-client `PluginInfo`; the contents fields are absent on older
|
| 39 |
+
* agent-servers.
|
| 40 |
+
*/
|
| 41 |
+
export interface LocalPlugin {
|
| 42 |
+
name: string;
|
| 43 |
+
version: string;
|
| 44 |
+
description: string;
|
| 45 |
+
path?: string;
|
| 46 |
+
skills?: PluginBundledSkill[];
|
| 47 |
+
files?: string[];
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/** Content of a single plugin file fetched for the detail-modal viewer. */
|
| 51 |
+
export interface PluginFileContent {
|
| 52 |
+
kind: "text" | "binary";
|
| 53 |
+
text: string | null;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
function isLikelyBinary(buffer: ArrayBuffer): boolean {
|
| 57 |
+
// Same heuristic git uses: presence of a NUL byte in the first ~8KB. Small
|
| 58 |
+
// private copy of `isLikelyBinary` in `use-workspace-file-content.ts` — that
|
| 59 |
+
// module is conversation-workspace-specific and heavy to import from here.
|
| 60 |
+
const view = new Uint8Array(buffer, 0, Math.min(buffer.byteLength, 8000));
|
| 61 |
+
for (let i = 0; i < view.length; i += 1) {
|
| 62 |
+
if (view[i] === 0) return true;
|
| 63 |
+
}
|
| 64 |
+
return false;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
class PluginsService {
|
| 68 |
+
/**
|
| 69 |
+
* Fetch the dynamic plugins marketplace catalog.
|
| 70 |
+
*
|
| 71 |
+
* Local backend only for now: the catalog is fetched at run time from the
|
| 72 |
+
* agent-server via the typed client (no bundled catalog, so the list stays
|
| 73 |
+
* dynamic). On a cloud backend an empty catalog is returned — there is no
|
| 74 |
+
* cloud plugins-marketplace endpoint yet (tracked as a follow-up ticket).
|
| 75 |
+
*/
|
| 76 |
+
static async getPluginsMarketplace(): Promise<MarketplacePlugin[]> {
|
| 77 |
+
if (getActiveBackend().backend.kind === "cloud") {
|
| 78 |
+
return [];
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
try {
|
| 82 |
+
const response = await new PluginsClient(
|
| 83 |
+
getAgentServerClientOptions(),
|
| 84 |
+
).getPluginsMarketplace();
|
| 85 |
+
return (response.plugins ?? []) as MarketplacePlugin[];
|
| 86 |
+
} catch {
|
| 87 |
+
// Agent-server may not support the plugins endpoint or be unreachable;
|
| 88 |
+
// surface an empty catalog rather than throwing.
|
| 89 |
+
return [];
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
/**
|
| 94 |
+
* Fetch the locally-discovered ("ambient") plugins from the agent-server.
|
| 95 |
+
*
|
| 96 |
+
* Only user-level plugins are requested (`~/.agents/plugins`,
|
| 97 |
+
* `~/.openhands/plugins`, plus enabled installed plugins): the Plugins page is
|
| 98 |
+
* global, so there is no project workspace to scope project plugins to.
|
| 99 |
+
*
|
| 100 |
+
* Local backend only — a cloud backend has no local plugin directories, so an
|
| 101 |
+
* empty list is returned. Errors surface as an empty list (mirrors the
|
| 102 |
+
* catalog) rather than throwing.
|
| 103 |
+
*/
|
| 104 |
+
static async getLocalPlugins(): Promise<LocalPlugin[]> {
|
| 105 |
+
if (getActiveBackend().backend.kind === "cloud") {
|
| 106 |
+
return [];
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
try {
|
| 110 |
+
const response = await new PluginsClient(
|
| 111 |
+
getAgentServerClientOptions(),
|
| 112 |
+
).getPlugins({ load_user: true, load_project: false });
|
| 113 |
+
return (response.plugins ?? []) as LocalPlugin[];
|
| 114 |
+
} catch {
|
| 115 |
+
return [];
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
/**
|
| 120 |
+
* Fetch one plugin file's content for the detail-modal viewer. `basePath` is
|
| 121 |
+
* the plugin directory reported by the agent-server (`path`/`install_path`)
|
| 122 |
+
* and `relativePath` a POSIX path from the plugin's `files` listing.
|
| 123 |
+
*
|
| 124 |
+
* Local backend only — plugin files live on the local agent-server's disk.
|
| 125 |
+
* Errors propagate so the caller can render a load-error state.
|
| 126 |
+
*/
|
| 127 |
+
static async getPluginFileContent(
|
| 128 |
+
basePath: string,
|
| 129 |
+
relativePath: string,
|
| 130 |
+
): Promise<PluginFileContent> {
|
| 131 |
+
if (getActiveBackend().backend.kind === "cloud") {
|
| 132 |
+
throw new Error(
|
| 133 |
+
"Reading plugin files is only available on a local backend.",
|
| 134 |
+
);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
const buffer = await new FileClient(
|
| 138 |
+
getAgentServerClientOptions(),
|
| 139 |
+
).downloadFile(`${basePath}/${relativePath}`);
|
| 140 |
+
if (isLikelyBinary(buffer)) {
|
| 141 |
+
return { kind: "binary", text: null };
|
| 142 |
+
}
|
| 143 |
+
return {
|
| 144 |
+
kind: "text",
|
| 145 |
+
text: new TextDecoder("utf-8", { fatal: false }).decode(buffer),
|
| 146 |
+
};
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
export default PluginsService;
|
src/api/secrets-service.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { SettingsClient } from "@openhands/typescript-client/clients";
|
| 2 |
+
import { isSdkHttpStatusError } from "./agent-server-compatibility";
|
| 3 |
+
import { getActiveBackend } from "./backend-registry/active-store";
|
| 4 |
+
import {
|
| 5 |
+
deleteCloudSecret,
|
| 6 |
+
fetchCloudSecrets,
|
| 7 |
+
saveCloudSecret,
|
| 8 |
+
} from "./cloud/secrets-service.api";
|
| 9 |
+
import { getAgentServerClientOptions } from "./agent-server-client-options";
|
| 10 |
+
import { CustomSecretWithoutValue } from "./secrets-service.types";
|
| 11 |
+
import { withRetry } from "./with-retry";
|
| 12 |
+
|
| 13 |
+
async function fetchSecrets(): Promise<CustomSecretWithoutValue[]> {
|
| 14 |
+
if (getActiveBackend().backend.kind === "cloud") {
|
| 15 |
+
return withRetry(() => fetchCloudSecrets());
|
| 16 |
+
}
|
| 17 |
+
const response = await withRetry(() =>
|
| 18 |
+
new SettingsClient(getAgentServerClientOptions()).listSecrets(),
|
| 19 |
+
);
|
| 20 |
+
return response.secrets.map((s) => ({
|
| 21 |
+
name: s.name,
|
| 22 |
+
description: s.description,
|
| 23 |
+
}));
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export class SecretsService {
|
| 27 |
+
/**
|
| 28 |
+
* List all custom secrets (names and descriptions only, no values).
|
| 29 |
+
* Uses the agent-server API endpoint: GET /api/settings/secrets
|
| 30 |
+
*
|
| 31 |
+
* Note: The agent-server API doesn't support pagination or search filtering.
|
| 32 |
+
* All secrets are returned in a single response.
|
| 33 |
+
*/
|
| 34 |
+
static async getSecrets(): Promise<CustomSecretWithoutValue[]> {
|
| 35 |
+
try {
|
| 36 |
+
return await fetchSecrets();
|
| 37 |
+
} catch (error) {
|
| 38 |
+
console.error("Failed to fetch secrets after retries:", error);
|
| 39 |
+
return [];
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/**
|
| 44 |
+
* List all custom secrets, surfacing failures to callers that must
|
| 45 |
+
* distinguish an unavailable list from an empty one.
|
| 46 |
+
*/
|
| 47 |
+
static async getSecretsOrThrow(): Promise<CustomSecretWithoutValue[]> {
|
| 48 |
+
return fetchSecrets();
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/**
|
| 52 |
+
* Create or update a custom secret (upsert by name).
|
| 53 |
+
* Uses the agent-server API endpoint: PUT /api/settings/secrets
|
| 54 |
+
*
|
| 55 |
+
* @param name - Secret name (must start with letter, contain only letters/numbers/underscores, 1-64 chars)
|
| 56 |
+
* @param value - Secret value
|
| 57 |
+
* @param description - Optional description
|
| 58 |
+
* @throws Error if the API call fails after retries
|
| 59 |
+
*/
|
| 60 |
+
static async createSecret(
|
| 61 |
+
name: string,
|
| 62 |
+
value: string,
|
| 63 |
+
description?: string,
|
| 64 |
+
): Promise<void> {
|
| 65 |
+
if (getActiveBackend().backend.kind === "cloud") {
|
| 66 |
+
await saveCloudSecret({ name, value, description });
|
| 67 |
+
return;
|
| 68 |
+
}
|
| 69 |
+
await withRetry(() =>
|
| 70 |
+
new SettingsClient(getAgentServerClientOptions()).upsertSecret({
|
| 71 |
+
name,
|
| 72 |
+
value,
|
| 73 |
+
description,
|
| 74 |
+
}),
|
| 75 |
+
);
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
/**
|
| 79 |
+
* Update a secret's name and/or description, and optionally overwrite its
|
| 80 |
+
* value. When no value is given the existing one is preserved: the
|
| 81 |
+
* agent-server only exposes an upsert endpoint, so we fetch the existing
|
| 82 |
+
* value and re-upsert it under the updated name/description.
|
| 83 |
+
*
|
| 84 |
+
* @param secretToEdit - Existing secret name
|
| 85 |
+
* @param name - New (or same) secret name
|
| 86 |
+
* @param description - Optional new description
|
| 87 |
+
* @param value - Optional new value; when omitted the stored value is kept
|
| 88 |
+
* @throws Error if the API call fails after retries
|
| 89 |
+
*/
|
| 90 |
+
static async updateSecret(
|
| 91 |
+
secretToEdit: string,
|
| 92 |
+
name: string,
|
| 93 |
+
description?: string,
|
| 94 |
+
value?: string,
|
| 95 |
+
): Promise<void> {
|
| 96 |
+
if (getActiveBackend().backend.kind === "cloud") {
|
| 97 |
+
await saveCloudSecret({
|
| 98 |
+
name,
|
| 99 |
+
value,
|
| 100 |
+
description,
|
| 101 |
+
previousName: secretToEdit,
|
| 102 |
+
});
|
| 103 |
+
return;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
const client = new SettingsClient(getAgentServerClientOptions());
|
| 107 |
+
const nextValue =
|
| 108 |
+
value ?? (await withRetry(() => client.getSecret(secretToEdit)));
|
| 109 |
+
|
| 110 |
+
await withRetry(() =>
|
| 111 |
+
client.upsertSecret({
|
| 112 |
+
name,
|
| 113 |
+
value: nextValue,
|
| 114 |
+
description,
|
| 115 |
+
}),
|
| 116 |
+
);
|
| 117 |
+
|
| 118 |
+
if (name !== secretToEdit) {
|
| 119 |
+
await this.deleteSecret(secretToEdit);
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
/**
|
| 124 |
+
* Delete a custom secret by name.
|
| 125 |
+
* Uses the agent-server API endpoint: DELETE /api/settings/secrets/{name}
|
| 126 |
+
*
|
| 127 |
+
* @param name - Secret name to delete
|
| 128 |
+
* @throws Error if the API call fails (except 404, which is treated as success)
|
| 129 |
+
*/
|
| 130 |
+
static async deleteSecret(name: string): Promise<void> {
|
| 131 |
+
try {
|
| 132 |
+
if (getActiveBackend().backend.kind === "cloud") {
|
| 133 |
+
await withRetry(() => deleteCloudSecret(name));
|
| 134 |
+
return;
|
| 135 |
+
}
|
| 136 |
+
await withRetry(() =>
|
| 137 |
+
new SettingsClient(getAgentServerClientOptions()).deleteSecret(name),
|
| 138 |
+
);
|
| 139 |
+
} catch (error) {
|
| 140 |
+
// 404 means secret doesn't exist - treat as successful deletion.
|
| 141 |
+
// Both the SDK's HttpError (status on the error itself) and
|
| 142 |
+
// axios-style errors (status under `response`) count.
|
| 143 |
+
if (
|
| 144 |
+
isSdkHttpStatusError(error, 404) ||
|
| 145 |
+
(error &&
|
| 146 |
+
typeof error === "object" &&
|
| 147 |
+
"response" in error &&
|
| 148 |
+
(error as { response?: { status?: number } }).response?.status ===
|
| 149 |
+
404)
|
| 150 |
+
) {
|
| 151 |
+
return;
|
| 152 |
+
}
|
| 153 |
+
throw error;
|
| 154 |
+
}
|
| 155 |
+
}
|
| 156 |
+
}
|
src/api/secrets-service.types.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Custom secret with name, value, and optional description.
|
| 3 |
+
* Used for creating/updating secrets via PUT /api/settings/secrets.
|
| 4 |
+
*/
|
| 5 |
+
export type CustomSecret = {
|
| 6 |
+
name: string;
|
| 7 |
+
value: string;
|
| 8 |
+
description?: string;
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* Custom secret metadata without the secret value.
|
| 13 |
+
* Used for listing secrets via GET /api/settings/secrets.
|
| 14 |
+
*/
|
| 15 |
+
export type CustomSecretWithoutValue = Omit<CustomSecret, "value">;
|
src/api/skills-service.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { SkillsClient } from "@openhands/typescript-client/clients";
|
| 2 |
+
import {
|
| 3 |
+
SKILLS_CATALOG,
|
| 4 |
+
type SkillCatalogEntry,
|
| 5 |
+
} from "@openhands/extensions/skills";
|
| 6 |
+
import { SkillInfo } from "#/types/settings";
|
| 7 |
+
import { getAgentServerWorkingDir } from "./agent-server-config";
|
| 8 |
+
import { getActiveBackend } from "./backend-registry/active-store";
|
| 9 |
+
import {
|
| 10 |
+
fetchCloudConversationSkills,
|
| 11 |
+
fetchCloudSkills,
|
| 12 |
+
} from "./cloud/skills-service.api";
|
| 13 |
+
import { getAgentServerClientOptions } from "./agent-server-client-options";
|
| 14 |
+
|
| 15 |
+
function catalogEntryToSkillInfo(entry: SkillCatalogEntry): SkillInfo {
|
| 16 |
+
return {
|
| 17 |
+
name: entry.name,
|
| 18 |
+
type: "knowledge",
|
| 19 |
+
source: "public",
|
| 20 |
+
description: entry.description,
|
| 21 |
+
triggers: entry.triggers,
|
| 22 |
+
category: entry.category,
|
| 23 |
+
content: entry.content,
|
| 24 |
+
license: entry.license ?? null,
|
| 25 |
+
compatibility: entry.compatibility ?? null,
|
| 26 |
+
};
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
/**
|
| 30 |
+
* Public skills loaded from the `@openhands/extensions` npm package.
|
| 31 |
+
*
|
| 32 |
+
* This is an **immutable build-time snapshot**: the catalog is baked into the
|
| 33 |
+
* bundle at `npm run build` / `vite build` time and does not change at
|
| 34 |
+
* runtime. Updating the catalog requires bumping the `@openhands/extensions`
|
| 35 |
+
* dependency and rebuilding.
|
| 36 |
+
*/
|
| 37 |
+
const PUBLIC_SKILLS: SkillInfo[] = SKILLS_CATALOG.map(catalogEntryToSkillInfo);
|
| 38 |
+
|
| 39 |
+
class SkillsService {
|
| 40 |
+
static async getSkills(projectDir?: string): Promise<SkillInfo[]> {
|
| 41 |
+
if (getActiveBackend().backend.kind === "cloud") {
|
| 42 |
+
return fetchCloudSkills();
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// Public skills come from the bundled @openhands/extensions npm package —
|
| 46 |
+
// no agent-server round-trip or GitHub fetch needed. Only ask the agent-
|
| 47 |
+
// server for user and project skills so local .agents/skills/ content is
|
| 48 |
+
// still picked up.
|
| 49 |
+
let localSkills: SkillInfo[] = [];
|
| 50 |
+
try {
|
| 51 |
+
const response = await new SkillsClient(
|
| 52 |
+
getAgentServerClientOptions(),
|
| 53 |
+
).getSkills({
|
| 54 |
+
load_public: false,
|
| 55 |
+
load_user: true,
|
| 56 |
+
load_project: true,
|
| 57 |
+
load_org: false,
|
| 58 |
+
project_dir: projectDir ?? getAgentServerWorkingDir(),
|
| 59 |
+
});
|
| 60 |
+
localSkills = (response.skills ?? []) as SkillInfo[];
|
| 61 |
+
} catch {
|
| 62 |
+
// Agent-server may not support the skills endpoint or may be
|
| 63 |
+
// unreachable; fall back to the bundled public catalog alone.
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
return [...localSkills, ...PUBLIC_SKILLS];
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
/**
|
| 70 |
+
* Skills loaded into a running cloud conversation (see
|
| 71 |
+
* `fetchCloudConversationSkills`). Cloud-only: local conversations keep
|
| 72 |
+
* using `getSkills(projectDir)`, whose agent-server call already scopes to
|
| 73 |
+
* the conversation's workspace.
|
| 74 |
+
*/
|
| 75 |
+
static getConversationSkills(conversationId: string): Promise<SkillInfo[]> {
|
| 76 |
+
return fetchCloudConversationSkills(conversationId);
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
export default SkillsService;
|
src/api/with-retry.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Retry helper for API calls with exponential backoff.
|
| 3 |
+
*/
|
| 4 |
+
export async function withRetry<T>(
|
| 5 |
+
fn: () => Promise<T>,
|
| 6 |
+
maxRetries: number = 3,
|
| 7 |
+
baseDelayMs: number = 500,
|
| 8 |
+
): Promise<T> {
|
| 9 |
+
for (let attempt = 0; attempt < maxRetries; attempt += 1) {
|
| 10 |
+
try {
|
| 11 |
+
return await fn();
|
| 12 |
+
} catch (error) {
|
| 13 |
+
if (attempt >= maxRetries - 1) {
|
| 14 |
+
throw error;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
const delay = baseDelayMs * 2 ** attempt;
|
| 18 |
+
|
| 19 |
+
await new Promise<void>((resolve) => {
|
| 20 |
+
setTimeout(resolve, delay);
|
| 21 |
+
});
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
throw new Error("Retry attempts exhausted");
|
| 26 |
+
}
|
src/api/workspace-path-resolution.test.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it, vi } from "vitest";
|
| 2 |
+
|
| 3 |
+
const {
|
| 4 |
+
fileClientConstructorMock,
|
| 5 |
+
getAgentServerClientOptionsMock,
|
| 6 |
+
getHomeMock,
|
| 7 |
+
} = vi.hoisted(() => ({
|
| 8 |
+
fileClientConstructorMock: vi.fn(),
|
| 9 |
+
getAgentServerClientOptionsMock: vi.fn(),
|
| 10 |
+
getHomeMock: vi.fn(),
|
| 11 |
+
}));
|
| 12 |
+
|
| 13 |
+
vi.mock("@openhands/typescript-client/clients", () => ({
|
| 14 |
+
FileClient: class {
|
| 15 |
+
constructor(options: unknown) {
|
| 16 |
+
fileClientConstructorMock(options);
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
getHome() {
|
| 20 |
+
return getHomeMock();
|
| 21 |
+
}
|
| 22 |
+
},
|
| 23 |
+
}));
|
| 24 |
+
|
| 25 |
+
vi.mock("./agent-server-client-options", () => ({
|
| 26 |
+
getAgentServerClientOptions: getAgentServerClientOptionsMock,
|
| 27 |
+
}));
|
| 28 |
+
|
| 29 |
+
import {
|
| 30 |
+
clearAgentServerHomeDirCache,
|
| 31 |
+
getAgentServerHomeDir,
|
| 32 |
+
resolveAbsoluteAgentServerPath,
|
| 33 |
+
} from "./agent-server-home";
|
| 34 |
+
|
| 35 |
+
const defaultClientOptions = {
|
| 36 |
+
host: "https://agent.example.com",
|
| 37 |
+
apiKey: "agent-key",
|
| 38 |
+
workingDir: "workspace/project",
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
function arrangeHomeLookup(): void {
|
| 42 |
+
clearAgentServerHomeDirCache();
|
| 43 |
+
vi.resetAllMocks();
|
| 44 |
+
getAgentServerClientOptionsMock.mockReturnValue(defaultClientOptions);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
describe("agent-server workspace path resolution", () => {
|
| 48 |
+
it("looks up the server home with the resolved client options and normalizes its separator", async () => {
|
| 49 |
+
arrangeHomeLookup();
|
| 50 |
+
getHomeMock.mockResolvedValue({ home: "/Users/alice///" });
|
| 51 |
+
const overrides = {
|
| 52 |
+
conversationUrl: "wss://runtime.example.com/conversations/1",
|
| 53 |
+
sessionApiKey: "session-key",
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
await expect(getAgentServerHomeDir(overrides)).resolves.toBe(
|
| 57 |
+
"/Users/alice",
|
| 58 |
+
);
|
| 59 |
+
|
| 60 |
+
expect(getAgentServerClientOptionsMock).toHaveBeenCalledWith(overrides);
|
| 61 |
+
expect(fileClientConstructorMock).toHaveBeenCalledWith(
|
| 62 |
+
defaultClientOptions,
|
| 63 |
+
);
|
| 64 |
+
expect(getHomeMock).toHaveBeenCalledOnce();
|
| 65 |
+
});
|
| 66 |
+
|
| 67 |
+
it("shares one in-flight home lookup between callers for the same host", async () => {
|
| 68 |
+
arrangeHomeLookup();
|
| 69 |
+
getHomeMock.mockResolvedValue({ home: "/home/agent" });
|
| 70 |
+
|
| 71 |
+
await expect(
|
| 72 |
+
Promise.all([getAgentServerHomeDir(), getAgentServerHomeDir()]),
|
| 73 |
+
).resolves.toEqual(["/home/agent", "/home/agent"]);
|
| 74 |
+
|
| 75 |
+
expect(getHomeMock).toHaveBeenCalledOnce();
|
| 76 |
+
});
|
| 77 |
+
|
| 78 |
+
it("caches home directories independently for each agent-server host", async () => {
|
| 79 |
+
arrangeHomeLookup();
|
| 80 |
+
getAgentServerClientOptionsMock.mockImplementation(
|
| 81 |
+
(overrides: { host?: string } = {}) => ({
|
| 82 |
+
...defaultClientOptions,
|
| 83 |
+
host: overrides.host ?? defaultClientOptions.host,
|
| 84 |
+
}),
|
| 85 |
+
);
|
| 86 |
+
getHomeMock
|
| 87 |
+
.mockResolvedValueOnce({ home: "/home/first" })
|
| 88 |
+
.mockResolvedValueOnce({ home: "/home/second" });
|
| 89 |
+
|
| 90 |
+
await expect(
|
| 91 |
+
Promise.all([
|
| 92 |
+
getAgentServerHomeDir({ host: "https://first.example.com" }),
|
| 93 |
+
getAgentServerHomeDir({ host: "https://second.example.com" }),
|
| 94 |
+
]),
|
| 95 |
+
).resolves.toEqual(["/home/first", "/home/second"]);
|
| 96 |
+
await expect(
|
| 97 |
+
getAgentServerHomeDir({ host: "https://first.example.com" }),
|
| 98 |
+
).resolves.toBe("/home/first");
|
| 99 |
+
|
| 100 |
+
expect(getHomeMock).toHaveBeenCalledTimes(2);
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
it("retries a home lookup after a transient failure", async () => {
|
| 104 |
+
arrangeHomeLookup();
|
| 105 |
+
getHomeMock
|
| 106 |
+
.mockRejectedValueOnce(new Error("agent server unavailable"))
|
| 107 |
+
.mockResolvedValueOnce({ home: "/home/recovered" });
|
| 108 |
+
|
| 109 |
+
await expect(getAgentServerHomeDir()).rejects.toThrow(
|
| 110 |
+
"agent server unavailable",
|
| 111 |
+
);
|
| 112 |
+
await expect(getAgentServerHomeDir()).resolves.toBe("/home/recovered");
|
| 113 |
+
|
| 114 |
+
expect(getHomeMock).toHaveBeenCalledTimes(2);
|
| 115 |
+
});
|
| 116 |
+
|
| 117 |
+
it.each([undefined, null, "", 0, 42])(
|
| 118 |
+
"rejects an invalid home directory response (%s)",
|
| 119 |
+
async (home) => {
|
| 120 |
+
arrangeHomeLookup();
|
| 121 |
+
getHomeMock.mockResolvedValue({ home });
|
| 122 |
+
|
| 123 |
+
await expect(getAgentServerHomeDir()).rejects.toThrow(
|
| 124 |
+
"Agent server returned an empty home directory",
|
| 125 |
+
);
|
| 126 |
+
},
|
| 127 |
+
);
|
| 128 |
+
|
| 129 |
+
it("clears a successful cached lookup on request", async () => {
|
| 130 |
+
arrangeHomeLookup();
|
| 131 |
+
getHomeMock
|
| 132 |
+
.mockResolvedValueOnce({ home: "/home/first" })
|
| 133 |
+
.mockResolvedValueOnce({ home: "/home/refreshed" });
|
| 134 |
+
|
| 135 |
+
await expect(getAgentServerHomeDir()).resolves.toBe("/home/first");
|
| 136 |
+
clearAgentServerHomeDirCache();
|
| 137 |
+
await expect(getAgentServerHomeDir()).resolves.toBe("/home/refreshed");
|
| 138 |
+
|
| 139 |
+
expect(getHomeMock).toHaveBeenCalledTimes(2);
|
| 140 |
+
});
|
| 141 |
+
|
| 142 |
+
it.each([
|
| 143 |
+
["/workspace/project///", "/workspace/project"],
|
| 144 |
+
["C:\\workspace\\project\\", "C:\\workspace\\project"],
|
| 145 |
+
["D:/workspace/project///", "D:/workspace/project"],
|
| 146 |
+
["\\\\server\\share\\", "\\\\server\\share"],
|
| 147 |
+
])(
|
| 148 |
+
"keeps the absolute path %s without a home lookup",
|
| 149 |
+
async (path, expected) => {
|
| 150 |
+
arrangeHomeLookup();
|
| 151 |
+
|
| 152 |
+
await expect(resolveAbsoluteAgentServerPath(path)).resolves.toBe(
|
| 153 |
+
expected,
|
| 154 |
+
);
|
| 155 |
+
|
| 156 |
+
expect(getAgentServerClientOptionsMock).not.toHaveBeenCalled();
|
| 157 |
+
expect(getHomeMock).not.toHaveBeenCalled();
|
| 158 |
+
},
|
| 159 |
+
);
|
| 160 |
+
|
| 161 |
+
it.each(["", "/", "\\", "///", "\\\\"])(
|
| 162 |
+
"resolves an empty path (%s) to the server home",
|
| 163 |
+
async (path) => {
|
| 164 |
+
arrangeHomeLookup();
|
| 165 |
+
getHomeMock.mockResolvedValue({ home: "/home/agent/" });
|
| 166 |
+
|
| 167 |
+
await expect(resolveAbsoluteAgentServerPath(path)).resolves.toBe(
|
| 168 |
+
"/home/agent",
|
| 169 |
+
);
|
| 170 |
+
|
| 171 |
+
expect(getHomeMock).toHaveBeenCalledOnce();
|
| 172 |
+
},
|
| 173 |
+
);
|
| 174 |
+
|
| 175 |
+
it("anchors a relative workspace below the server home", async () => {
|
| 176 |
+
arrangeHomeLookup();
|
| 177 |
+
getHomeMock.mockResolvedValue({ home: "/Users/alice///" });
|
| 178 |
+
const overrides = {
|
| 179 |
+
host: "https://runtime.example.com/",
|
| 180 |
+
sessionApiKey: "session-key",
|
| 181 |
+
};
|
| 182 |
+
|
| 183 |
+
await expect(
|
| 184 |
+
resolveAbsoluteAgentServerPath("workspace/project///", overrides),
|
| 185 |
+
).resolves.toBe("/Users/alice/workspace/project");
|
| 186 |
+
|
| 187 |
+
expect(getAgentServerClientOptionsMock).toHaveBeenCalledWith(overrides);
|
| 188 |
+
});
|
| 189 |
+
});
|
src/api/workspace-upload-path.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// @spec WUP-001 — Resolve relative working dirs against /api/file/home
|
| 2 |
+
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
|
| 3 |
+
import { getAgentServerWorkingDir } from "#/api/agent-server-config";
|
| 4 |
+
import {
|
| 5 |
+
type AgentServerClientOverrides,
|
| 6 |
+
getAgentServerClientOptions,
|
| 7 |
+
} from "#/api/agent-server-client-options";
|
| 8 |
+
import { resolveAbsoluteAgentServerPath } from "#/api/agent-server-home";
|
| 9 |
+
import { getStoredConversationMetadata } from "#/api/conversation-metadata-store";
|
| 10 |
+
import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types";
|
| 11 |
+
|
| 12 |
+
const UUID_PATTERN =
|
| 13 |
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
| 14 |
+
|
| 15 |
+
export function getSafeUploadFileName(fileName: string): string {
|
| 16 |
+
const parts = fileName.split(/[\\/]+/).filter(Boolean);
|
| 17 |
+
const safeName = parts[parts.length - 1];
|
| 18 |
+
|
| 19 |
+
if (!safeName || safeName === "." || safeName === "..") {
|
| 20 |
+
throw new Error("Invalid file name");
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
return safeName;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
/**
|
| 27 |
+
* Resolve `workingDir` to an absolute path the agent-server's file APIs
|
| 28 |
+
* accept. Relative paths are joined against `/api/file/home` (cached per
|
| 29 |
+
* backend); absolute paths pass through.
|
| 30 |
+
*/
|
| 31 |
+
export async function resolveAbsoluteWorkspacePath(
|
| 32 |
+
workingDir: string,
|
| 33 |
+
overrides: AgentServerClientOverrides = {},
|
| 34 |
+
): Promise<string> {
|
| 35 |
+
return resolveAbsoluteAgentServerPath(workingDir, overrides);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/**
|
| 39 |
+
* Build the absolute destination path for a file upload, resolving the
|
| 40 |
+
* working-dir leg via {@link resolveAbsoluteWorkspacePath}.
|
| 41 |
+
*/
|
| 42 |
+
export async function buildWorkspaceUploadPath(
|
| 43 |
+
fileName: string,
|
| 44 |
+
workingDir: string,
|
| 45 |
+
overrides: AgentServerClientOverrides = {},
|
| 46 |
+
): Promise<string> {
|
| 47 |
+
const safeName = getSafeUploadFileName(fileName);
|
| 48 |
+
const absoluteDir = await resolveAbsoluteWorkspacePath(workingDir, overrides);
|
| 49 |
+
return `${absoluteDir.replace(/[/\\]+$/, "")}/${safeName}`;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/**
|
| 53 |
+
* Resolve the working directory for a file upload into a conversation's
|
| 54 |
+
* workspace.
|
| 55 |
+
*
|
| 56 |
+
* **Returns the raw working dir string** — which may be relative (e.g.
|
| 57 |
+
* `workspace/project/<hex>`) when the default `DEFAULT_WORKING_DIR` is in
|
| 58 |
+
* use. Callers that need an actual filesystem-absolute path (e.g. to pass to
|
| 59 |
+
* the agent-server's `/api/file/upload` endpoint) **must** funnel this result
|
| 60 |
+
* through {@link buildWorkspaceUploadPath}, which calls
|
| 61 |
+
* {@link resolveAbsoluteWorkspacePath} to anchor any relative segment against
|
| 62 |
+
* the agent-server's home directory.
|
| 63 |
+
*
|
| 64 |
+
* Why not resolve here? Because this function is also called by cloud-runtime
|
| 65 |
+
* upload paths where the overrides (conversationUrl, sessionApiKey) aren't
|
| 66 |
+
* available until {@link uploadFilesToConversation} assembles them. Keeping
|
| 67 |
+
* the resolution step in {@link buildWorkspaceUploadPath} means both the
|
| 68 |
+
* local and cloud legs share a single resolution point with the correct
|
| 69 |
+
* override context.
|
| 70 |
+
*/
|
| 71 |
+
export async function resolveConversationUploadWorkingDir(
|
| 72 |
+
conversationId: string,
|
| 73 |
+
currentConversation?: AppConversation | null,
|
| 74 |
+
): Promise<string> {
|
| 75 |
+
if (
|
| 76 |
+
currentConversation?.id === conversationId &&
|
| 77 |
+
currentConversation.workspace?.working_dir?.trim()
|
| 78 |
+
) {
|
| 79 |
+
return currentConversation.workspace.working_dir.trim();
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
const stored = getStoredConversationMetadata(conversationId);
|
| 83 |
+
if (stored?.selected_workspace?.trim()) {
|
| 84 |
+
return stored.selected_workspace.trim();
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
if (UUID_PATTERN.test(conversationId)) {
|
| 88 |
+
return AgentServerConversationService.resolveConversationWorkingDir(
|
| 89 |
+
conversationId,
|
| 90 |
+
);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
return getAgentServerWorkingDir();
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
// Re-export so callers can construct overrides matching what
|
| 97 |
+
// {@link buildWorkspaceUploadPath} expects without importing two modules.
|
| 98 |
+
export type { AgentServerClientOverrides };
|
| 99 |
+
export { getAgentServerClientOptions };
|
src/dev/seed-pending-chat-preview.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
| 2 |
+
|
| 3 |
+
const PREVIEW_SENDING_TEXT = "can you review the readme";
|
| 4 |
+
|
| 5 |
+
const PREVIEW_SENDING_MULTILINE_TEXT =
|
| 6 |
+
"can you review the readme and then update the contributing guide " +
|
| 7 |
+
"to match, making sure the setup steps and scripts are consistent " +
|
| 8 |
+
"across both files";
|
| 9 |
+
|
| 10 |
+
const PREVIEW_ERROR_TEXT =
|
| 11 |
+
"here's a long message. here's a long message. here's a long message. " +
|
| 12 |
+
"here's a long message. here's a long message. here's a long message.";
|
| 13 |
+
|
| 14 |
+
export function seedPendingChatPreview(conversationId: string): void {
|
| 15 |
+
const store = useOptimisticUserMessageStore.getState();
|
| 16 |
+
store.clearPendingMessages();
|
| 17 |
+
|
| 18 |
+
store.enqueuePendingMessage({
|
| 19 |
+
conversationId,
|
| 20 |
+
text: PREVIEW_SENDING_TEXT,
|
| 21 |
+
});
|
| 22 |
+
|
| 23 |
+
store.enqueuePendingMessage({
|
| 24 |
+
conversationId,
|
| 25 |
+
text: PREVIEW_SENDING_MULTILINE_TEXT,
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
const errorId = store.enqueuePendingMessage({
|
| 29 |
+
conversationId,
|
| 30 |
+
text: PREVIEW_ERROR_TEXT,
|
| 31 |
+
});
|
| 32 |
+
store.markPendingMessageError(errorId, "Failed to send message");
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export function installPendingChatPreview(): void {
|
| 36 |
+
(
|
| 37 |
+
window as Window &
|
| 38 |
+
typeof globalThis & {
|
| 39 |
+
__OH_SEED_PENDING_PREVIEW__?: typeof seedPendingChatPreview;
|
| 40 |
+
__OH_CLEAR_PENDING_PREVIEW__?: () => void;
|
| 41 |
+
}
|
| 42 |
+
).__OH_SEED_PENDING_PREVIEW__ = seedPendingChatPreview;
|
| 43 |
+
(
|
| 44 |
+
window as Window &
|
| 45 |
+
typeof globalThis & {
|
| 46 |
+
__OH_CLEAR_PENDING_PREVIEW__?: () => void;
|
| 47 |
+
}
|
| 48 |
+
).__OH_CLEAR_PENDING_PREVIEW__ = () => {
|
| 49 |
+
useOptimisticUserMessageStore.getState().clearPendingMessages();
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
const params = new URLSearchParams(window.location.search);
|
| 53 |
+
const preview = params.get("previewPendingChat");
|
| 54 |
+
if (!preview) {
|
| 55 |
+
return;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
const conversationId = preview === "true" ? "1" : preview;
|
| 59 |
+
const targetPath = `/conversations/${conversationId}`;
|
| 60 |
+
|
| 61 |
+
if (!window.location.pathname.startsWith(targetPath)) {
|
| 62 |
+
window.location.assign(
|
| 63 |
+
`${targetPath}?previewPendingChat=${encodeURIComponent(conversationId)}`,
|
| 64 |
+
);
|
| 65 |
+
return;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
seedPendingChatPreview(conversationId);
|
| 69 |
+
}
|
src/fixtures/canvas-demo-conversation.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Generated-canvas demo fixture — models an agent creating a Markdown artifact
|
| 3 |
+
* so mock mode can demonstrate chat-to-drawer file navigation without an LLM.
|
| 4 |
+
*/
|
| 5 |
+
import type {
|
| 6 |
+
ActionEvent,
|
| 7 |
+
MessageEvent,
|
| 8 |
+
ObservationEvent,
|
| 9 |
+
OpenHandsEvent,
|
| 10 |
+
} from "#/types/agent-server/core";
|
| 11 |
+
import { SecurityRisk } from "#/types/agent-server/core";
|
| 12 |
+
import type { FileEditorAction } from "#/types/agent-server/core/base/action";
|
| 13 |
+
import type { FileEditorObservation } from "#/types/agent-server/core/base/observation";
|
| 14 |
+
|
| 15 |
+
export const CANVAS_DEMO_CONVERSATION_ID = "canvas-demo";
|
| 16 |
+
export const CANVAS_DEMO_FILE_PATH = "canvas.md";
|
| 17 |
+
|
| 18 |
+
export const CANVAS_DEMO_MARKDOWN = [
|
| 19 |
+
"# Agent Canvas Demo",
|
| 20 |
+
"",
|
| 21 |
+
"This report was generated by the agent and opened from the file chip in chat.",
|
| 22 |
+
"",
|
| 23 |
+
"## Release snapshot",
|
| 24 |
+
"",
|
| 25 |
+
"| Area | Status | Next step |",
|
| 26 |
+
"| --- | --- | --- |",
|
| 27 |
+
"| Conversation UX | Ready | Validate the drawer flow |",
|
| 28 |
+
"| Markdown preview | Ready | Review rich rendering |",
|
| 29 |
+
"| Accessibility | Ready | Confirm keyboard navigation |",
|
| 30 |
+
"",
|
| 31 |
+
"## Highlights",
|
| 32 |
+
"",
|
| 33 |
+
"- The artifact stays in the conversation workspace.",
|
| 34 |
+
"- The chat chip is the compact entry point.",
|
| 35 |
+
"- The Files drawer provides the full rich Markdown view.",
|
| 36 |
+
].join("\n");
|
| 37 |
+
|
| 38 |
+
const BASE_TIME = Date.UTC(2026, 6, 29, 12, 0, 0);
|
| 39 |
+
const timestamp = (offsetSeconds: number) =>
|
| 40 |
+
new Date(BASE_TIME + offsetSeconds * 1000).toISOString();
|
| 41 |
+
|
| 42 |
+
/**
|
| 43 |
+
* Creates a minimal message event with the same SDK shape used by history.
|
| 44 |
+
*/
|
| 45 |
+
function createMessage(
|
| 46 |
+
id: string,
|
| 47 |
+
role: "user" | "assistant",
|
| 48 |
+
text: string,
|
| 49 |
+
offsetSeconds: number,
|
| 50 |
+
): MessageEvent {
|
| 51 |
+
return {
|
| 52 |
+
id,
|
| 53 |
+
timestamp: timestamp(offsetSeconds),
|
| 54 |
+
source: role === "user" ? "user" : "agent",
|
| 55 |
+
llm_message: {
|
| 56 |
+
role,
|
| 57 |
+
content: [{ type: "text", text }],
|
| 58 |
+
},
|
| 59 |
+
activated_skills: [],
|
| 60 |
+
extended_content: [],
|
| 61 |
+
};
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
const createCanvasAction: ActionEvent<FileEditorAction> = {
|
| 65 |
+
id: "canvas-demo-action",
|
| 66 |
+
timestamp: timestamp(1),
|
| 67 |
+
source: "agent",
|
| 68 |
+
thought: [],
|
| 69 |
+
thinking_blocks: [],
|
| 70 |
+
action: {
|
| 71 |
+
kind: "FileEditorAction",
|
| 72 |
+
command: "create",
|
| 73 |
+
path: CANVAS_DEMO_FILE_PATH,
|
| 74 |
+
file_text: CANVAS_DEMO_MARKDOWN,
|
| 75 |
+
old_str: null,
|
| 76 |
+
new_str: null,
|
| 77 |
+
insert_line: null,
|
| 78 |
+
view_range: null,
|
| 79 |
+
},
|
| 80 |
+
tool_name: "file_editor",
|
| 81 |
+
tool_call_id: "canvas-demo-tool-call",
|
| 82 |
+
tool_call: {
|
| 83 |
+
id: "canvas-demo-tool-call",
|
| 84 |
+
type: "function",
|
| 85 |
+
function: {
|
| 86 |
+
name: "file_editor",
|
| 87 |
+
arguments: JSON.stringify({
|
| 88 |
+
command: "create",
|
| 89 |
+
path: CANVAS_DEMO_FILE_PATH,
|
| 90 |
+
}),
|
| 91 |
+
},
|
| 92 |
+
},
|
| 93 |
+
llm_response_id: "canvas-demo-response",
|
| 94 |
+
security_risk: SecurityRisk.LOW,
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
+
const createCanvasObservation: ObservationEvent<FileEditorObservation> = {
|
| 98 |
+
id: "canvas-demo-observation",
|
| 99 |
+
timestamp: timestamp(2),
|
| 100 |
+
source: "environment",
|
| 101 |
+
tool_name: "file_editor",
|
| 102 |
+
tool_call_id: "canvas-demo-tool-call",
|
| 103 |
+
action_id: createCanvasAction.id,
|
| 104 |
+
observation: {
|
| 105 |
+
kind: "FileEditorObservation",
|
| 106 |
+
command: "create",
|
| 107 |
+
output: `Created ${CANVAS_DEMO_FILE_PATH}`,
|
| 108 |
+
path: CANVAS_DEMO_FILE_PATH,
|
| 109 |
+
prev_exist: false,
|
| 110 |
+
old_content: null,
|
| 111 |
+
new_content: CANVAS_DEMO_MARKDOWN,
|
| 112 |
+
error: null,
|
| 113 |
+
},
|
| 114 |
+
};
|
| 115 |
+
|
| 116 |
+
export const CANVAS_DEMO_EVENTS: OpenHandsEvent[] = [
|
| 117 |
+
createMessage(
|
| 118 |
+
"canvas-demo-user",
|
| 119 |
+
"user",
|
| 120 |
+
"Create a concise project canvas as a Markdown report.",
|
| 121 |
+
0,
|
| 122 |
+
),
|
| 123 |
+
createCanvasAction,
|
| 124 |
+
createCanvasObservation,
|
| 125 |
+
createMessage(
|
| 126 |
+
"canvas-demo-agent",
|
| 127 |
+
"assistant",
|
| 128 |
+
"I generated the project canvas. Open it from the file chip above.",
|
| 129 |
+
3,
|
| 130 |
+
),
|
| 131 |
+
];
|
src/fixtures/home-automations-demo.ts
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
AUTOMATION_RUN_ACTIVITY_LIMIT,
|
| 3 |
+
type LatestAutomationRunState,
|
| 4 |
+
} from "#/hooks/query/use-latest-automation-runs";
|
| 5 |
+
import {
|
| 6 |
+
AutomationRunStatus,
|
| 7 |
+
type Automation,
|
| 8 |
+
type AutomationRun,
|
| 9 |
+
} from "#/types/automation";
|
| 10 |
+
|
| 11 |
+
/** Must stay equal to `MAX_HOME_AUTOMATION_CHIPS` in use-home-automations. */
|
| 12 |
+
const DEMO_LIST_LENGTH = 20;
|
| 13 |
+
|
| 14 |
+
const DEMO_HISTORY_PATTERN: AutomationRunStatus[] = [
|
| 15 |
+
AutomationRunStatus.COMPLETED,
|
| 16 |
+
AutomationRunStatus.COMPLETED,
|
| 17 |
+
AutomationRunStatus.FAILED,
|
| 18 |
+
AutomationRunStatus.COMPLETED,
|
| 19 |
+
AutomationRunStatus.CANCELLED,
|
| 20 |
+
AutomationRunStatus.COMPLETED,
|
| 21 |
+
AutomationRunStatus.COMPLETED,
|
| 22 |
+
AutomationRunStatus.FAILED,
|
| 23 |
+
AutomationRunStatus.COMPLETED,
|
| 24 |
+
AutomationRunStatus.SKIPPED,
|
| 25 |
+
AutomationRunStatus.COMPLETED,
|
| 26 |
+
];
|
| 27 |
+
|
| 28 |
+
/**
|
| 29 |
+
* Local preview of every pinned-card + activity-list state.
|
| 30 |
+
*
|
| 31 |
+
* Enable with either:
|
| 32 |
+
* - `HOME_AUTOMATIONS_FORCE_DEMO = true` below
|
| 33 |
+
* - `VITE_HOME_AUTOMATIONS_DEMO=true`
|
| 34 |
+
*/
|
| 35 |
+
export const HOME_AUTOMATIONS_FORCE_DEMO = false;
|
| 36 |
+
|
| 37 |
+
export const HOME_AUTOMATIONS_DEMO_KEY = "oh:home-automations-demo";
|
| 38 |
+
|
| 39 |
+
export function isHomeAutomationsDemoEnabled(): boolean {
|
| 40 |
+
if (HOME_AUTOMATIONS_FORCE_DEMO) return true;
|
| 41 |
+
return import.meta.env.VITE_HOME_AUTOMATIONS_DEMO === "true";
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
const DEMO_CONVERSATION_TITLES: Record<string, string | null> = {
|
| 45 |
+
"demo-conv-titled": "Fix flaky CI on main",
|
| 46 |
+
"demo-conv-untitled": "Summarize open pull requests",
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
/** Returns a demo conversation title, or `undefined` if not a demo id. */
|
| 50 |
+
export function getDemoConversationTitle(
|
| 51 |
+
conversationId: string,
|
| 52 |
+
): string | null | undefined {
|
| 53 |
+
if (!(conversationId in DEMO_CONVERSATION_TITLES)) return undefined;
|
| 54 |
+
return DEMO_CONVERSATION_TITLES[conversationId];
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
function hoursAgo(hours: number): string {
|
| 58 |
+
return new Date(Date.now() - hours * 3_600_000).toISOString();
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
function makeAutomation(
|
| 62 |
+
id: string,
|
| 63 |
+
name: string,
|
| 64 |
+
trigger: Automation["trigger"],
|
| 65 |
+
): Automation {
|
| 66 |
+
return {
|
| 67 |
+
id,
|
| 68 |
+
name,
|
| 69 |
+
trigger,
|
| 70 |
+
enabled: true,
|
| 71 |
+
created_at: hoursAgo(720),
|
| 72 |
+
updated_at: hoursAgo(24),
|
| 73 |
+
prompt: "demo",
|
| 74 |
+
};
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
function makeRun(
|
| 78 |
+
id: string,
|
| 79 |
+
status: AutomationRunStatus,
|
| 80 |
+
options: {
|
| 81 |
+
conversationId?: string | null;
|
| 82 |
+
errorDetail?: string | null;
|
| 83 |
+
startedHoursAgo?: number;
|
| 84 |
+
completedHoursAgo?: number | null;
|
| 85 |
+
} = {},
|
| 86 |
+
): AutomationRun {
|
| 87 |
+
const startedHoursAgo = options.startedHoursAgo ?? 2;
|
| 88 |
+
const completedHoursAgo =
|
| 89 |
+
options.completedHoursAgo === undefined ? 1 : options.completedHoursAgo;
|
| 90 |
+
return {
|
| 91 |
+
id,
|
| 92 |
+
status,
|
| 93 |
+
conversation_id:
|
| 94 |
+
options.conversationId === undefined ? null : options.conversationId,
|
| 95 |
+
bash_command_id: null,
|
| 96 |
+
error_detail:
|
| 97 |
+
options.errorDetail === undefined ? null : options.errorDetail,
|
| 98 |
+
started_at: hoursAgo(startedHoursAgo),
|
| 99 |
+
completed_at:
|
| 100 |
+
completedHoursAgo === null ? null : hoursAgo(completedHoursAgo),
|
| 101 |
+
};
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
type DemoRunStateInput = Omit<LatestAutomationRunState, "recentRuns"> & {
|
| 105 |
+
recentRuns?: AutomationRun[];
|
| 106 |
+
};
|
| 107 |
+
|
| 108 |
+
type DemoSpec = {
|
| 109 |
+
automation: Automation;
|
| 110 |
+
runState: DemoRunStateInput;
|
| 111 |
+
/** When true, this automation is pinned in the dashboard. */
|
| 112 |
+
pin?: boolean;
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
function buildDemoRecentRuns(latestRun: AutomationRun | null): AutomationRun[] {
|
| 116 |
+
if (!latestRun) return [];
|
| 117 |
+
// Varied durations (minutes) so sparkline bar heights differ in demo mode.
|
| 118 |
+
const durationMinutes = [2, 8, 45, 3, 15, 1, 25, 90, 5, 12, 6];
|
| 119 |
+
const older = DEMO_HISTORY_PATTERN.slice(
|
| 120 |
+
0,
|
| 121 |
+
AUTOMATION_RUN_ACTIVITY_LIMIT - 1,
|
| 122 |
+
).map((status, index) => {
|
| 123 |
+
const startedHoursAgo = 24 + index * 6;
|
| 124 |
+
const durationHours = (durationMinutes[index] ?? 5) / 60;
|
| 125 |
+
return makeRun(`${latestRun.id}-hist-${index}`, status, {
|
| 126 |
+
startedHoursAgo,
|
| 127 |
+
completedHoursAgo: startedHoursAgo - durationHours,
|
| 128 |
+
conversationId: null,
|
| 129 |
+
errorDetail:
|
| 130 |
+
status === AutomationRunStatus.FAILED ? "Prior failure" : null,
|
| 131 |
+
});
|
| 132 |
+
});
|
| 133 |
+
return [latestRun, ...older];
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
function toDemoRunState(state: DemoRunStateInput): LatestAutomationRunState {
|
| 137 |
+
return {
|
| 138 |
+
...state,
|
| 139 |
+
recentRuns: state.recentRuns ?? buildDemoRecentRuns(state.latestRun),
|
| 140 |
+
};
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
/**
|
| 144 |
+
* Explicit card states first (pinned), then fillers to hit
|
| 145 |
+
* DEMO_LIST_LENGTH for list-length stress.
|
| 146 |
+
*/
|
| 147 |
+
const DEMO_SPECS: DemoSpec[] = [
|
| 148 |
+
{
|
| 149 |
+
pin: true,
|
| 150 |
+
automation: makeAutomation("demo-pin-loading", "Card: loading skeleton", {
|
| 151 |
+
type: "cron",
|
| 152 |
+
schedule: "0 * * * *",
|
| 153 |
+
schedule_human: "Every hour",
|
| 154 |
+
}),
|
| 155 |
+
runState: { latestRun: null, isLoading: true, isError: false },
|
| 156 |
+
},
|
| 157 |
+
{
|
| 158 |
+
pin: true,
|
| 159 |
+
automation: makeAutomation("demo-pin-error", "Card: status unavailable", {
|
| 160 |
+
type: "cron",
|
| 161 |
+
schedule: "0 9 * * *",
|
| 162 |
+
schedule_human: "Daily at 9am",
|
| 163 |
+
}),
|
| 164 |
+
runState: { latestRun: null, isLoading: false, isError: true },
|
| 165 |
+
},
|
| 166 |
+
{
|
| 167 |
+
pin: true,
|
| 168 |
+
automation: makeAutomation("demo-pin-no-runs", "Card: no runs yet", {
|
| 169 |
+
type: "event",
|
| 170 |
+
source: "github",
|
| 171 |
+
on: "pull_request.opened",
|
| 172 |
+
}),
|
| 173 |
+
runState: { latestRun: null, isLoading: false, isError: false },
|
| 174 |
+
},
|
| 175 |
+
{
|
| 176 |
+
pin: true,
|
| 177 |
+
automation: makeAutomation(
|
| 178 |
+
"demo-pin-failed-detail",
|
| 179 |
+
"Card: failed with error detail",
|
| 180 |
+
{
|
| 181 |
+
type: "cron",
|
| 182 |
+
schedule: "*/15 * * * *",
|
| 183 |
+
schedule_human: "Every 15 min",
|
| 184 |
+
},
|
| 185 |
+
),
|
| 186 |
+
runState: {
|
| 187 |
+
latestRun: makeRun("run-failed-detail", AutomationRunStatus.FAILED, {
|
| 188 |
+
errorDetail:
|
| 189 |
+
"Sandbox provisioning timed out after 120s while waiting for the runtime to become ready.",
|
| 190 |
+
startedHoursAgo: 3,
|
| 191 |
+
completedHoursAgo: 2.5,
|
| 192 |
+
}),
|
| 193 |
+
isLoading: false,
|
| 194 |
+
isError: false,
|
| 195 |
+
},
|
| 196 |
+
},
|
| 197 |
+
{
|
| 198 |
+
pin: true,
|
| 199 |
+
automation: makeAutomation(
|
| 200 |
+
"demo-pin-completed-title",
|
| 201 |
+
"Card: completed + conversation title",
|
| 202 |
+
{ type: "event", source: "github", on: "push" },
|
| 203 |
+
),
|
| 204 |
+
runState: {
|
| 205 |
+
latestRun: makeRun("run-completed-title", AutomationRunStatus.COMPLETED, {
|
| 206 |
+
conversationId: "demo-conv-titled",
|
| 207 |
+
startedHoursAgo: 5,
|
| 208 |
+
completedHoursAgo: 4,
|
| 209 |
+
}),
|
| 210 |
+
isLoading: false,
|
| 211 |
+
isError: false,
|
| 212 |
+
},
|
| 213 |
+
},
|
| 214 |
+
{
|
| 215 |
+
pin: true,
|
| 216 |
+
automation: makeAutomation(
|
| 217 |
+
"demo-pin-completed-untitled",
|
| 218 |
+
"Card: completed + conversation title",
|
| 219 |
+
{
|
| 220 |
+
type: "cron",
|
| 221 |
+
schedule: "0 0 * * 1",
|
| 222 |
+
schedule_human: "Mondays at midnight",
|
| 223 |
+
},
|
| 224 |
+
),
|
| 225 |
+
runState: {
|
| 226 |
+
latestRun: makeRun(
|
| 227 |
+
"run-completed-untitled",
|
| 228 |
+
AutomationRunStatus.COMPLETED,
|
| 229 |
+
{
|
| 230 |
+
conversationId: "demo-conv-untitled",
|
| 231 |
+
startedHoursAgo: 8,
|
| 232 |
+
completedHoursAgo: 7,
|
| 233 |
+
},
|
| 234 |
+
),
|
| 235 |
+
isLoading: false,
|
| 236 |
+
isError: false,
|
| 237 |
+
},
|
| 238 |
+
},
|
| 239 |
+
{
|
| 240 |
+
pin: true,
|
| 241 |
+
automation: makeAutomation(
|
| 242 |
+
"demo-pin-completed-no-conv",
|
| 243 |
+
"Card: completed, no conversation",
|
| 244 |
+
{ type: "cron", schedule: "0 12 * * *", schedule_human: "Daily at noon" },
|
| 245 |
+
),
|
| 246 |
+
runState: {
|
| 247 |
+
latestRun: makeRun(
|
| 248 |
+
"run-completed-no-conv",
|
| 249 |
+
AutomationRunStatus.COMPLETED,
|
| 250 |
+
{
|
| 251 |
+
conversationId: null,
|
| 252 |
+
startedHoursAgo: 10,
|
| 253 |
+
completedHoursAgo: 9,
|
| 254 |
+
},
|
| 255 |
+
),
|
| 256 |
+
isLoading: false,
|
| 257 |
+
isError: false,
|
| 258 |
+
},
|
| 259 |
+
},
|
| 260 |
+
{
|
| 261 |
+
pin: true,
|
| 262 |
+
automation: makeAutomation(
|
| 263 |
+
"demo-pin-failed-no-conv",
|
| 264 |
+
"Card: failed, no conversation",
|
| 265 |
+
{ type: "event", source: "slack", on: "message" },
|
| 266 |
+
),
|
| 267 |
+
runState: {
|
| 268 |
+
latestRun: makeRun("run-failed-no-conv", AutomationRunStatus.FAILED, {
|
| 269 |
+
conversationId: null,
|
| 270 |
+
errorDetail: null,
|
| 271 |
+
startedHoursAgo: 12,
|
| 272 |
+
completedHoursAgo: 11,
|
| 273 |
+
}),
|
| 274 |
+
isLoading: false,
|
| 275 |
+
isError: false,
|
| 276 |
+
},
|
| 277 |
+
},
|
| 278 |
+
{
|
| 279 |
+
pin: true,
|
| 280 |
+
automation: makeAutomation(
|
| 281 |
+
"demo-pin-pending",
|
| 282 |
+
"Card: pending (empty result panel)",
|
| 283 |
+
{
|
| 284 |
+
type: "cron",
|
| 285 |
+
schedule: "0 */6 * * *",
|
| 286 |
+
schedule_human: "Every 6 hours",
|
| 287 |
+
},
|
| 288 |
+
),
|
| 289 |
+
runState: {
|
| 290 |
+
latestRun: makeRun("run-pending", AutomationRunStatus.PENDING, {
|
| 291 |
+
conversationId: null,
|
| 292 |
+
startedHoursAgo: 0.1,
|
| 293 |
+
completedHoursAgo: null,
|
| 294 |
+
}),
|
| 295 |
+
isLoading: false,
|
| 296 |
+
isError: false,
|
| 297 |
+
},
|
| 298 |
+
},
|
| 299 |
+
{
|
| 300 |
+
pin: true,
|
| 301 |
+
automation: makeAutomation(
|
| 302 |
+
"demo-pin-running",
|
| 303 |
+
"Card: running (empty result panel)",
|
| 304 |
+
{ type: "event", source: "github", on: "issues.opened" },
|
| 305 |
+
),
|
| 306 |
+
runState: {
|
| 307 |
+
latestRun: makeRun("run-running", AutomationRunStatus.RUNNING, {
|
| 308 |
+
// No conversation yet — status strip shows the "Running" label.
|
| 309 |
+
conversationId: null,
|
| 310 |
+
startedHoursAgo: 0.25,
|
| 311 |
+
completedHoursAgo: null,
|
| 312 |
+
}),
|
| 313 |
+
isLoading: false,
|
| 314 |
+
isError: false,
|
| 315 |
+
},
|
| 316 |
+
},
|
| 317 |
+
// Remaining rows fill the list to MAX_HOME_AUTOMATION_CHIPS.
|
| 318 |
+
{
|
| 319 |
+
automation: makeAutomation("demo-list-cancelled", "List: cancelled run", {
|
| 320 |
+
type: "cron",
|
| 321 |
+
schedule: "30 2 * * *",
|
| 322 |
+
schedule_human: "Daily at 2:30am",
|
| 323 |
+
}),
|
| 324 |
+
runState: {
|
| 325 |
+
latestRun: makeRun("run-cancelled", AutomationRunStatus.CANCELLED, {
|
| 326 |
+
startedHoursAgo: 14,
|
| 327 |
+
completedHoursAgo: 13,
|
| 328 |
+
}),
|
| 329 |
+
isLoading: false,
|
| 330 |
+
isError: false,
|
| 331 |
+
},
|
| 332 |
+
},
|
| 333 |
+
{
|
| 334 |
+
automation: makeAutomation("demo-list-skipped", "List: skipped run", {
|
| 335 |
+
type: "event",
|
| 336 |
+
source: "github",
|
| 337 |
+
on: "pull_request.synchronize",
|
| 338 |
+
}),
|
| 339 |
+
runState: {
|
| 340 |
+
latestRun: makeRun("run-skipped", AutomationRunStatus.SKIPPED, {
|
| 341 |
+
startedHoursAgo: 16,
|
| 342 |
+
completedHoursAgo: 16,
|
| 343 |
+
}),
|
| 344 |
+
isLoading: false,
|
| 345 |
+
isError: false,
|
| 346 |
+
},
|
| 347 |
+
},
|
| 348 |
+
{
|
| 349 |
+
automation: makeAutomation(
|
| 350 |
+
"demo-list-running-2",
|
| 351 |
+
"List: second in-flight run",
|
| 352 |
+
{ type: "cron", schedule: "*/5 * * * *", schedule_human: "Every 5 min" },
|
| 353 |
+
),
|
| 354 |
+
runState: {
|
| 355 |
+
latestRun: makeRun("run-running-2", AutomationRunStatus.RUNNING, {
|
| 356 |
+
conversationId: null,
|
| 357 |
+
startedHoursAgo: 0.5,
|
| 358 |
+
completedHoursAgo: null,
|
| 359 |
+
}),
|
| 360 |
+
isLoading: false,
|
| 361 |
+
isError: false,
|
| 362 |
+
},
|
| 363 |
+
},
|
| 364 |
+
{
|
| 365 |
+
automation: makeAutomation("demo-list-success-old", "List: older success", {
|
| 366 |
+
type: "cron",
|
| 367 |
+
schedule: "0 8 * * 1-5",
|
| 368 |
+
schedule_human: "Weekdays at 8am",
|
| 369 |
+
}),
|
| 370 |
+
runState: {
|
| 371 |
+
latestRun: makeRun("run-success-old", AutomationRunStatus.COMPLETED, {
|
| 372 |
+
conversationId: "demo-conv-titled",
|
| 373 |
+
startedHoursAgo: 48,
|
| 374 |
+
completedHoursAgo: 47,
|
| 375 |
+
}),
|
| 376 |
+
isLoading: false,
|
| 377 |
+
isError: false,
|
| 378 |
+
},
|
| 379 |
+
},
|
| 380 |
+
{
|
| 381 |
+
automation: makeAutomation("demo-list-failed-old", "List: older failure", {
|
| 382 |
+
type: "event",
|
| 383 |
+
source: "github",
|
| 384 |
+
on: "release.published",
|
| 385 |
+
}),
|
| 386 |
+
runState: {
|
| 387 |
+
latestRun: makeRun("run-failed-old", AutomationRunStatus.FAILED, {
|
| 388 |
+
errorDetail: "Model returned an empty completion.",
|
| 389 |
+
startedHoursAgo: 72,
|
| 390 |
+
completedHoursAgo: 71,
|
| 391 |
+
}),
|
| 392 |
+
isLoading: false,
|
| 393 |
+
isError: false,
|
| 394 |
+
},
|
| 395 |
+
},
|
| 396 |
+
{
|
| 397 |
+
automation: makeAutomation("demo-list-no-runs-2", "List: never ran", {
|
| 398 |
+
type: "cron",
|
| 399 |
+
schedule: "0 0 1 * *",
|
| 400 |
+
schedule_human: "Monthly on the 1st",
|
| 401 |
+
}),
|
| 402 |
+
runState: { latestRun: null, isLoading: false, isError: false },
|
| 403 |
+
},
|
| 404 |
+
{
|
| 405 |
+
automation: makeAutomation("demo-list-loading-2", "List: loading status", {
|
| 406 |
+
type: "event",
|
| 407 |
+
source: "linear",
|
| 408 |
+
on: "Issue.create",
|
| 409 |
+
}),
|
| 410 |
+
runState: { latestRun: null, isLoading: true, isError: false },
|
| 411 |
+
},
|
| 412 |
+
{
|
| 413 |
+
automation: makeAutomation("demo-list-error-2", "List: fetch error", {
|
| 414 |
+
type: "cron",
|
| 415 |
+
schedule: "15 * * * *",
|
| 416 |
+
schedule_human: "Hourly at :15",
|
| 417 |
+
}),
|
| 418 |
+
runState: { latestRun: null, isLoading: false, isError: true },
|
| 419 |
+
},
|
| 420 |
+
{
|
| 421 |
+
automation: makeAutomation(
|
| 422 |
+
"demo-list-pending-2",
|
| 423 |
+
"List: pending, long name that should truncate in the activity row when the viewport is narrow",
|
| 424 |
+
{ type: "cron", schedule: "0 3 * * 0", schedule_human: "Sundays at 3am" },
|
| 425 |
+
),
|
| 426 |
+
runState: {
|
| 427 |
+
latestRun: makeRun("run-pending-2", AutomationRunStatus.PENDING, {
|
| 428 |
+
startedHoursAgo: 0.05,
|
| 429 |
+
completedHoursAgo: null,
|
| 430 |
+
}),
|
| 431 |
+
isLoading: false,
|
| 432 |
+
isError: false,
|
| 433 |
+
},
|
| 434 |
+
},
|
| 435 |
+
{
|
| 436 |
+
automation: makeAutomation(
|
| 437 |
+
"demo-list-completed-fallback",
|
| 438 |
+
"List: completed with conversation",
|
| 439 |
+
{ type: "event", source: "github", on: "workflow_run.completed" },
|
| 440 |
+
),
|
| 441 |
+
runState: {
|
| 442 |
+
latestRun: makeRun(
|
| 443 |
+
"run-completed-fallback",
|
| 444 |
+
AutomationRunStatus.COMPLETED,
|
| 445 |
+
{
|
| 446 |
+
conversationId: "demo-conv-untitled",
|
| 447 |
+
startedHoursAgo: 20,
|
| 448 |
+
completedHoursAgo: 19,
|
| 449 |
+
},
|
| 450 |
+
),
|
| 451 |
+
isLoading: false,
|
| 452 |
+
isError: false,
|
| 453 |
+
},
|
| 454 |
+
},
|
| 455 |
+
];
|
| 456 |
+
|
| 457 |
+
if (DEMO_SPECS.length !== DEMO_LIST_LENGTH) {
|
| 458 |
+
throw new Error(
|
| 459 |
+
`home-automations-demo: expected ${DEMO_LIST_LENGTH} specs, got ${DEMO_SPECS.length}`,
|
| 460 |
+
);
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
export const HOME_AUTOMATIONS_DEMO_PINNED_IDS: string[] = DEMO_SPECS.filter(
|
| 464 |
+
(spec) => spec.pin,
|
| 465 |
+
).map((spec) => spec.automation.id);
|
| 466 |
+
|
| 467 |
+
export const HOME_AUTOMATIONS_DEMO_AUTOMATIONS: Automation[] = DEMO_SPECS.map(
|
| 468 |
+
(spec) => spec.automation,
|
| 469 |
+
);
|
| 470 |
+
|
| 471 |
+
export const HOME_AUTOMATIONS_DEMO_RUN_STATES: Map<
|
| 472 |
+
string,
|
| 473 |
+
LatestAutomationRunState
|
| 474 |
+
> = new Map(
|
| 475 |
+
DEMO_SPECS.map(
|
| 476 |
+
(spec) => [spec.automation.id, toDemoRunState(spec.runState)] as const,
|
| 477 |
+
),
|
| 478 |
+
);
|
src/fixtures/table-demo-conversation.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { MessageEvent, OpenHandsEvent } from "#/types/agent-server/core";
|
| 2 |
+
|
| 3 |
+
export const TABLE_DEMO_CONVERSATION_ID = "table-demo";
|
| 4 |
+
|
| 5 |
+
const TABLE_DEMO_USER_MESSAGE =
|
| 6 |
+
"Can you compare several coding agents in a wide table?";
|
| 7 |
+
|
| 8 |
+
/** Wide GFM table used to exercise horizontal scrolling in the chat feed. */
|
| 9 |
+
export const TABLE_DEMO_AGENT_MARKDOWN = [
|
| 10 |
+
"Here is a wide comparison table. Scroll horizontally to read every column:",
|
| 11 |
+
"",
|
| 12 |
+
"| Feature | OpenHands | Claude Code | Codex | Gemini CLI | Cursor | Windsurf | Cline | Aider | Devin | Continue |",
|
| 13 |
+
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
|
| 14 |
+
"| CLI | Yes | Yes | Yes | Yes | IDE | IDE | IDE | Terminal | Cloud | IDE |",
|
| 15 |
+
"| MCP support | Yes | Yes | Partial | Partial | Yes | Yes | Yes | No | Yes | Yes |",
|
| 16 |
+
"| Local models | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | No | Yes |",
|
| 17 |
+
"| Browser tools | Yes | Yes | Yes | Limited | Yes | Yes | Yes | No | Yes | Yes |",
|
| 18 |
+
"| Git integration | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |",
|
| 19 |
+
"| Best for | General coding | Anthropic stack | OpenAI stack | Google stack | IDE users | IDE users | VS Code | Terminal | Autonomous | IDE users |",
|
| 20 |
+
].join("\n");
|
| 21 |
+
|
| 22 |
+
const TABLE_DEMO_BASE_TIME = Date.UTC(2026, 5, 23, 12, 0, 0);
|
| 23 |
+
|
| 24 |
+
function createTableDemoMessageEvent(
|
| 25 |
+
id: string,
|
| 26 |
+
role: "user" | "assistant",
|
| 27 |
+
text: string,
|
| 28 |
+
offsetMinutes: number,
|
| 29 |
+
): MessageEvent {
|
| 30 |
+
return {
|
| 31 |
+
id,
|
| 32 |
+
timestamp: new Date(
|
| 33 |
+
TABLE_DEMO_BASE_TIME + offsetMinutes * 60_000,
|
| 34 |
+
).toISOString(),
|
| 35 |
+
source: role === "user" ? "user" : "agent",
|
| 36 |
+
llm_message: {
|
| 37 |
+
role,
|
| 38 |
+
content: [{ type: "text", text }],
|
| 39 |
+
},
|
| 40 |
+
activated_skills: [],
|
| 41 |
+
extended_content: [],
|
| 42 |
+
};
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
export const TABLE_DEMO_EVENTS: OpenHandsEvent[] = [
|
| 46 |
+
createTableDemoMessageEvent(
|
| 47 |
+
"table-demo-user-1",
|
| 48 |
+
"user",
|
| 49 |
+
TABLE_DEMO_USER_MESSAGE,
|
| 50 |
+
0,
|
| 51 |
+
),
|
| 52 |
+
createTableDemoMessageEvent(
|
| 53 |
+
"table-demo-agent-1",
|
| 54 |
+
"assistant",
|
| 55 |
+
TABLE_DEMO_AGENT_MARKDOWN,
|
| 56 |
+
1,
|
| 57 |
+
),
|
| 58 |
+
];
|
src/hooks/use-acp-model-context.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useTranslation } from "react-i18next";
|
| 2 |
+
import { useActiveBackend } from "#/contexts/active-backend-context";
|
| 3 |
+
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
| 4 |
+
import { useSettings } from "#/hooks/query/use-settings";
|
| 5 |
+
import { useActiveAgentKind } from "#/hooks/use-active-agent-profile";
|
| 6 |
+
import { I18nKey } from "#/i18n/declaration";
|
| 7 |
+
|
| 8 |
+
const AGENT_SETTINGS_PATH = "/settings/agents";
|
| 9 |
+
const LLM_SETTINGS_PATH = "/settings/llm";
|
| 10 |
+
|
| 11 |
+
export interface AcpModelContext {
|
| 12 |
+
/** The active conversation runs an ACP agent. */
|
| 13 |
+
isActiveAcpConversation: boolean;
|
| 14 |
+
/**
|
| 15 |
+
* No active conversation (home page) but the saved agent settings already
|
| 16 |
+
* select an ACP agent — the next conversation created here inherits it, so
|
| 17 |
+
* the model UI should reflect that now.
|
| 18 |
+
*/
|
| 19 |
+
isHomeAcp: boolean;
|
| 20 |
+
/** Either of the above: the model affordance should defer to the ACP picker. */
|
| 21 |
+
isAcpContext: boolean;
|
| 22 |
+
/** Where the model/settings link should navigate. */
|
| 23 |
+
destinationPath: typeof AGENT_SETTINGS_PATH | typeof LLM_SETTINGS_PATH;
|
| 24 |
+
/** Translated label for that link. */
|
| 25 |
+
destinationLabel: string;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/**
|
| 29 |
+
* Shared source of truth for "is this an ACP model context, and where does
|
| 30 |
+
* the model affordance link?". The chat model affordance consumes this through
|
| 31 |
+
* ``useChatInputModelState`` so inline and overflow surfaces can't drift on the
|
| 32 |
+
* home-page-ACP rule or on the destination path/label.
|
| 33 |
+
*/
|
| 34 |
+
export function useAcpModelContext(): AcpModelContext {
|
| 35 |
+
const { t } = useTranslation("openhands");
|
| 36 |
+
const { backend } = useActiveBackend();
|
| 37 |
+
const { data: conversation } = useActiveConversation();
|
| 38 |
+
const { data: settings } = useSettings();
|
| 39 |
+
const activeAgentKind = useActiveAgentKind();
|
| 40 |
+
|
| 41 |
+
const isActiveAcpConversation = conversation?.agent_kind === "acp";
|
| 42 |
+
// On the home page the "current agent" is the active AgentProfile, not the
|
| 43 |
+
// global agent settings (activate never writes them). Fall back to settings
|
| 44 |
+
// only while the profile list is loading.
|
| 45 |
+
const isHomeAcp =
|
| 46 |
+
!conversation &&
|
| 47 |
+
(activeAgentKind ?? settings?.agent_settings?.agent_kind) === "acp";
|
| 48 |
+
const isAcpContext = isActiveAcpConversation || isHomeAcp;
|
| 49 |
+
|
| 50 |
+
const destinationPath = isAcpContext
|
| 51 |
+
? AGENT_SETTINGS_PATH
|
| 52 |
+
: LLM_SETTINGS_PATH;
|
| 53 |
+
const destinationLabel = t(
|
| 54 |
+
isAcpContext
|
| 55 |
+
? I18nKey.SETTINGS$NAV_AGENT
|
| 56 |
+
: backend.kind === "cloud"
|
| 57 |
+
? I18nKey.SETTINGS$LLM_SETTINGS
|
| 58 |
+
: I18nKey.SETTINGS$LLM_PROFILES,
|
| 59 |
+
);
|
| 60 |
+
|
| 61 |
+
return {
|
| 62 |
+
isActiveAcpConversation,
|
| 63 |
+
isHomeAcp,
|
| 64 |
+
isAcpContext,
|
| 65 |
+
destinationPath,
|
| 66 |
+
destinationLabel,
|
| 67 |
+
};
|
| 68 |
+
}
|
src/hooks/use-agent-notification.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef } from "react";
|
| 2 |
+
import { AgentState } from "#/types/agent-state";
|
| 3 |
+
import { useSettings } from "#/hooks/query/use-settings";
|
| 4 |
+
import notificationSound from "#/assets/notification.mp3";
|
| 5 |
+
|
| 6 |
+
const NOTIFICATION_STATES: AgentState[] = [
|
| 7 |
+
AgentState.AWAITING_USER_INPUT,
|
| 8 |
+
AgentState.FINISHED,
|
| 9 |
+
AgentState.AWAITING_USER_CONFIRMATION,
|
| 10 |
+
];
|
| 11 |
+
|
| 12 |
+
/**
|
| 13 |
+
* Hook that plays a notification sound when the agent transitions into a
|
| 14 |
+
* state that requires user attention. The browser tab title itself is
|
| 15 |
+
* managed by `useAppTitle`, which prefixes the title with an emoji that
|
| 16 |
+
* reflects the current agent state.
|
| 17 |
+
*/
|
| 18 |
+
export function useAgentNotification(curAgentState: AgentState) {
|
| 19 |
+
const { data: settings } = useSettings();
|
| 20 |
+
const audioRef = useRef<HTMLAudioElement | undefined>(undefined);
|
| 21 |
+
const prevStateRef = useRef<AgentState | undefined>(undefined);
|
| 22 |
+
|
| 23 |
+
// Initialize audio only in browser environment, inside useEffect to
|
| 24 |
+
// avoid side effects during render (React 18 strict mode, SSR safety).
|
| 25 |
+
useEffect(() => {
|
| 26 |
+
if (typeof window !== "undefined" && !audioRef.current) {
|
| 27 |
+
audioRef.current = new Audio(notificationSound);
|
| 28 |
+
audioRef.current.volume = 0.5;
|
| 29 |
+
}
|
| 30 |
+
}, []);
|
| 31 |
+
|
| 32 |
+
const isSoundEnabled = settings?.enable_sound_notifications ?? false;
|
| 33 |
+
|
| 34 |
+
// Trigger notification only on actual state transitions into a
|
| 35 |
+
// notification-worthy state — not when unrelated deps (e.g. settings) change.
|
| 36 |
+
useEffect(() => {
|
| 37 |
+
if (prevStateRef.current === curAgentState) return;
|
| 38 |
+
prevStateRef.current = curAgentState;
|
| 39 |
+
|
| 40 |
+
if (!NOTIFICATION_STATES.includes(curAgentState)) return;
|
| 41 |
+
|
| 42 |
+
if (isSoundEnabled && audioRef.current) {
|
| 43 |
+
audioRef.current.currentTime = 0;
|
| 44 |
+
audioRef.current.play().catch(() => {
|
| 45 |
+
// Ignore autoplay errors (browsers may block autoplay)
|
| 46 |
+
});
|
| 47 |
+
}
|
| 48 |
+
}, [curAgentState, isSoundEnabled]);
|
| 49 |
+
}
|
src/hooks/use-app-title.test.tsx
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { renderHook, waitFor } from "@testing-library/react";
|
| 2 |
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
| 3 |
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
| 4 |
+
import { useParams } from "react-router";
|
| 5 |
+
import { useUserConversation } from "./query/use-user-conversation";
|
| 6 |
+
import { useAppTitle } from "./use-app-title";
|
| 7 |
+
import { useConversationStateStore } from "#/stores/conversation-state-store";
|
| 8 |
+
import { ExecutionStatus } from "#/types/agent-server/core/base/common";
|
| 9 |
+
|
| 10 |
+
const renderAppTitleHook = () =>
|
| 11 |
+
renderHook(() => useAppTitle(), {
|
| 12 |
+
wrapper: ({ children }) => (
|
| 13 |
+
<QueryClientProvider client={new QueryClient()}>
|
| 14 |
+
{children}
|
| 15 |
+
</QueryClientProvider>
|
| 16 |
+
),
|
| 17 |
+
});
|
| 18 |
+
|
| 19 |
+
vi.mock("./query/use-user-conversation");
|
| 20 |
+
vi.mock("react-router", async () => {
|
| 21 |
+
const actual = await vi.importActual("react-router");
|
| 22 |
+
return {
|
| 23 |
+
...actual,
|
| 24 |
+
useParams: vi.fn(),
|
| 25 |
+
};
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
describe("useAppTitle", () => {
|
| 29 |
+
const mockUseUserConversation = vi.mocked(useUserConversation);
|
| 30 |
+
const mockUseParams = vi.mocked(useParams);
|
| 31 |
+
|
| 32 |
+
beforeEach(() => {
|
| 33 |
+
// @ts-expect-error - only returning partial config for test
|
| 34 |
+
mockUseUserConversation.mockReturnValue({ data: null });
|
| 35 |
+
mockUseParams.mockReturnValue({});
|
| 36 |
+
useConversationStateStore.getState().reset();
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
it("returns the OSS app title outside conversations", async () => {
|
| 40 |
+
const { result } = renderAppTitleHook();
|
| 41 |
+
|
| 42 |
+
await waitFor(() => expect(result.current).toBe("OpenHands"));
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
it("returns the conversation title with the OSS app name", async () => {
|
| 46 |
+
mockUseParams.mockReturnValue({ conversationId: "123" });
|
| 47 |
+
mockUseUserConversation.mockReturnValue({
|
| 48 |
+
// @ts-expect-error - only returning partial config for test
|
| 49 |
+
data: { title: "My Conversation" },
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
const { result } = renderAppTitleHook();
|
| 53 |
+
|
| 54 |
+
await waitFor(() =>
|
| 55 |
+
expect(result.current).toBe("My Conversation | OpenHands"),
|
| 56 |
+
);
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
it("returns the app name while conversation data is loading", async () => {
|
| 60 |
+
mockUseParams.mockReturnValue({ conversationId: "123" });
|
| 61 |
+
// @ts-expect-error - only returning partial config for test
|
| 62 |
+
mockUseUserConversation.mockReturnValue({ data: undefined });
|
| 63 |
+
|
| 64 |
+
const { result } = renderAppTitleHook();
|
| 65 |
+
|
| 66 |
+
await waitFor(() => expect(result.current).toBe("OpenHands"));
|
| 67 |
+
});
|
| 68 |
+
|
| 69 |
+
it.each([
|
| 70 |
+
[ExecutionStatus.RUNNING, "🟢"],
|
| 71 |
+
[ExecutionStatus.FINISHED, "✅"],
|
| 72 |
+
[ExecutionStatus.IDLE, "✅"],
|
| 73 |
+
[ExecutionStatus.WAITING_FOR_CONFIRMATION, "✅"],
|
| 74 |
+
[ExecutionStatus.PAUSED, "⚪"],
|
| 75 |
+
[ExecutionStatus.ERROR, "🔴"],
|
| 76 |
+
[ExecutionStatus.STUCK, "🔴"],
|
| 77 |
+
])(
|
| 78 |
+
"prefixes the title with %s emoji for execution status %s",
|
| 79 |
+
async (status, emoji) => {
|
| 80 |
+
mockUseParams.mockReturnValue({ conversationId: "123" });
|
| 81 |
+
mockUseUserConversation.mockReturnValue({
|
| 82 |
+
// @ts-expect-error - only returning partial config for test
|
| 83 |
+
data: { title: "My Conversation" },
|
| 84 |
+
});
|
| 85 |
+
useConversationStateStore.getState().setExecutionStatus("123", status);
|
| 86 |
+
|
| 87 |
+
const { result } = renderAppTitleHook();
|
| 88 |
+
|
| 89 |
+
await waitFor(() =>
|
| 90 |
+
expect(result.current).toBe(`${emoji} My Conversation | OpenHands`),
|
| 91 |
+
);
|
| 92 |
+
},
|
| 93 |
+
);
|
| 94 |
+
|
| 95 |
+
it("falls back to the conversation's execution_status when the live store is empty", async () => {
|
| 96 |
+
mockUseParams.mockReturnValue({ conversationId: "123" });
|
| 97 |
+
mockUseUserConversation.mockReturnValue({
|
| 98 |
+
// @ts-expect-error - only returning partial config for test
|
| 99 |
+
data: {
|
| 100 |
+
title: "My Conversation",
|
| 101 |
+
execution_status: ExecutionStatus.RUNNING,
|
| 102 |
+
},
|
| 103 |
+
});
|
| 104 |
+
|
| 105 |
+
const { result } = renderAppTitleHook();
|
| 106 |
+
|
| 107 |
+
await waitFor(() =>
|
| 108 |
+
expect(result.current).toBe("🟢 My Conversation | OpenHands"),
|
| 109 |
+
);
|
| 110 |
+
});
|
| 111 |
+
|
| 112 |
+
it("does not add an emoji when in a conversation but execution status is unknown", async () => {
|
| 113 |
+
mockUseParams.mockReturnValue({ conversationId: "123" });
|
| 114 |
+
mockUseUserConversation.mockReturnValue({
|
| 115 |
+
// @ts-expect-error - only returning partial config for test
|
| 116 |
+
data: { title: "My Conversation" },
|
| 117 |
+
});
|
| 118 |
+
|
| 119 |
+
const { result } = renderAppTitleHook();
|
| 120 |
+
|
| 121 |
+
await waitFor(() =>
|
| 122 |
+
expect(result.current).toBe("My Conversation | OpenHands"),
|
| 123 |
+
);
|
| 124 |
+
});
|
| 125 |
+
});
|
src/hooks/use-app-title.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useParams } from "react-router";
|
| 2 |
+
import { useUserConversation } from "#/hooks/query/use-user-conversation";
|
| 3 |
+
import { useConversationStateStore } from "#/stores/conversation-state-store";
|
| 4 |
+
import { getAgentStateEmoji } from "#/utils/agent-state-emoji";
|
| 5 |
+
|
| 6 |
+
const APP_TITLE = "OpenHands";
|
| 7 |
+
|
| 8 |
+
export const useAppTitle = () => {
|
| 9 |
+
const { conversationId } = useParams<{ conversationId: string }>();
|
| 10 |
+
const { data: conversation } = useUserConversation(conversationId ?? null);
|
| 11 |
+
const liveExecutionStatus = useConversationStateStore((state) =>
|
| 12 |
+
conversationId
|
| 13 |
+
? (state.executionStatusByConversation[conversationId] ?? null)
|
| 14 |
+
: null,
|
| 15 |
+
);
|
| 16 |
+
|
| 17 |
+
const conversationTitle = conversation?.title;
|
| 18 |
+
const baseTitle =
|
| 19 |
+
conversationId && conversationTitle
|
| 20 |
+
? `${conversationTitle} | ${APP_TITLE}`
|
| 21 |
+
: APP_TITLE;
|
| 22 |
+
|
| 23 |
+
if (!conversationId) {
|
| 24 |
+
return baseTitle;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const executionStatus =
|
| 28 |
+
liveExecutionStatus ?? conversation?.execution_status ?? null;
|
| 29 |
+
const emoji = getAgentStateEmoji(executionStatus);
|
| 30 |
+
|
| 31 |
+
return emoji ? `${emoji} ${baseTitle}` : baseTitle;
|
| 32 |
+
};
|
src/hooks/use-auto-resize.ts
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useEffect, RefObject, useRef } from "react";
|
| 2 |
+
import { IMessageToSend } from "#/stores/conversation-store";
|
| 3 |
+
import { EPS } from "#/utils/constants";
|
| 4 |
+
import { getStyleHeightPx, setStyleHeightPx } from "#/utils/utils";
|
| 5 |
+
import { focusContentEditableAtEnd } from "#/components/features/chat/utils/chat-input.utils";
|
| 6 |
+
import { useDragResize } from "./use-drag-resize";
|
| 7 |
+
|
| 8 |
+
// Constants
|
| 9 |
+
const DEFAULT_MIN_HEIGHT = 20;
|
| 10 |
+
const DEFAULT_MAX_HEIGHT = 120;
|
| 11 |
+
const HEIGHT_INCREMENT = 20;
|
| 12 |
+
const MANUAL_OVERSIZE_THRESHOLD = 50;
|
| 13 |
+
|
| 14 |
+
// Manual height tracking utilities
|
| 15 |
+
const useManualHeight = () => {
|
| 16 |
+
const hasUserResizedRef = useRef(false);
|
| 17 |
+
const manualHeightRef = useRef<number | null>(null);
|
| 18 |
+
return { hasUserResizedRef, manualHeightRef };
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
interface UseAutoResizeOptions {
|
| 22 |
+
minHeight?: number;
|
| 23 |
+
maxHeight?: number;
|
| 24 |
+
enableManualResize?: boolean;
|
| 25 |
+
onGripDragStart?: () => void;
|
| 26 |
+
onGripDragEnd?: () => void;
|
| 27 |
+
onHeightChange?: (height: number) => void; // New callback for height changes
|
| 28 |
+
value?: IMessageToSend;
|
| 29 |
+
/** Called once `value` has been applied to the element (one-shot consume). */
|
| 30 |
+
onValueApplied?: () => void;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
interface UseAutoResizeReturn {
|
| 34 |
+
smartResize: () => void;
|
| 35 |
+
handleGripMouseDown: (e: React.MouseEvent) => void;
|
| 36 |
+
handleGripTouchStart: (e: React.TouchEvent) => void;
|
| 37 |
+
increaseHeightForEmptyContent: () => void;
|
| 38 |
+
resetManualResize: () => void;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
// Height management utilities
|
| 42 |
+
interface HeightConstraints {
|
| 43 |
+
minHeight: number;
|
| 44 |
+
maxHeight: number;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
interface HeightMeasurements {
|
| 48 |
+
currentHeight: number;
|
| 49 |
+
currentStyleHeight: number;
|
| 50 |
+
contentHeight: number;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
interface ResizeStrategy {
|
| 54 |
+
finalHeight: number;
|
| 55 |
+
overflowY: "hidden" | "auto";
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
const applyHeightToElement = (
|
| 59 |
+
element: HTMLElement,
|
| 60 |
+
height: number,
|
| 61 |
+
constraints: HeightConstraints,
|
| 62 |
+
): number => {
|
| 63 |
+
const { minHeight, maxHeight } = constraints;
|
| 64 |
+
const finalHeight = Math.max(minHeight, Math.min(height, maxHeight));
|
| 65 |
+
|
| 66 |
+
element.style.setProperty("height", `${finalHeight}px`);
|
| 67 |
+
element.style.setProperty(
|
| 68 |
+
"overflow-y",
|
| 69 |
+
finalHeight >= maxHeight ? "auto" : "hidden",
|
| 70 |
+
);
|
| 71 |
+
|
| 72 |
+
return finalHeight;
|
| 73 |
+
};
|
| 74 |
+
|
| 75 |
+
const isManuallyOversized = (
|
| 76 |
+
currentHeight: number,
|
| 77 |
+
contentHeight: number,
|
| 78 |
+
threshold = MANUAL_OVERSIZE_THRESHOLD,
|
| 79 |
+
): boolean => currentHeight > contentHeight + threshold;
|
| 80 |
+
|
| 81 |
+
const measureElementHeights = (
|
| 82 |
+
element: HTMLElement,
|
| 83 |
+
minHeight: number,
|
| 84 |
+
): HeightMeasurements => {
|
| 85 |
+
// Use the previous explicit style height as the "current" for restore, not offsetHeight
|
| 86 |
+
const currentStyleHeight = getStyleHeightPx(element, minHeight);
|
| 87 |
+
const currentHeight = currentStyleHeight;
|
| 88 |
+
|
| 89 |
+
// Temporarily reset to measure content
|
| 90 |
+
element.style.setProperty("height", "auto");
|
| 91 |
+
const contentHeight = element.scrollHeight;
|
| 92 |
+
|
| 93 |
+
// Restore height
|
| 94 |
+
setStyleHeightPx(element, currentStyleHeight);
|
| 95 |
+
|
| 96 |
+
return {
|
| 97 |
+
currentHeight,
|
| 98 |
+
currentStyleHeight,
|
| 99 |
+
contentHeight,
|
| 100 |
+
};
|
| 101 |
+
};
|
| 102 |
+
|
| 103 |
+
const applyResizeStrategy = (
|
| 104 |
+
element: HTMLElement,
|
| 105 |
+
strategy: ResizeStrategy,
|
| 106 |
+
): void => {
|
| 107 |
+
const { finalHeight, overflowY } = strategy;
|
| 108 |
+
const elementRef = element;
|
| 109 |
+
elementRef.style.height = `${finalHeight}px`;
|
| 110 |
+
elementRef.style.overflowY = overflowY;
|
| 111 |
+
};
|
| 112 |
+
|
| 113 |
+
const executeHeightCallback = (
|
| 114 |
+
height: number,
|
| 115 |
+
onHeightChange?: (height: number) => void,
|
| 116 |
+
): void => {
|
| 117 |
+
if (onHeightChange) {
|
| 118 |
+
onHeightChange(height);
|
| 119 |
+
}
|
| 120 |
+
};
|
| 121 |
+
|
| 122 |
+
// DOM manipulation utilities
|
| 123 |
+
export const useAutoResize = (
|
| 124 |
+
elementRef: RefObject<HTMLElement | null>,
|
| 125 |
+
options: UseAutoResizeOptions = {},
|
| 126 |
+
): UseAutoResizeReturn => {
|
| 127 |
+
const pendingSmartRef = useRef<number | null>(null);
|
| 128 |
+
|
| 129 |
+
const {
|
| 130 |
+
minHeight = DEFAULT_MIN_HEIGHT,
|
| 131 |
+
maxHeight = DEFAULT_MAX_HEIGHT,
|
| 132 |
+
enableManualResize = false,
|
| 133 |
+
value,
|
| 134 |
+
onValueApplied,
|
| 135 |
+
onGripDragStart,
|
| 136 |
+
onGripDragEnd,
|
| 137 |
+
onHeightChange,
|
| 138 |
+
} = options;
|
| 139 |
+
|
| 140 |
+
const constraints: HeightConstraints = { minHeight, maxHeight };
|
| 141 |
+
const { hasUserResizedRef, manualHeightRef } = useManualHeight();
|
| 142 |
+
|
| 143 |
+
const resetManualResize = () => {
|
| 144 |
+
hasUserResizedRef.current = false;
|
| 145 |
+
manualHeightRef.current = null;
|
| 146 |
+
};
|
| 147 |
+
|
| 148 |
+
// Wrap onHeightChange to track manual height during drag
|
| 149 |
+
const handleExternalHeightChange = useCallback(
|
| 150 |
+
(elementHeight: number) => {
|
| 151 |
+
onHeightChange?.(elementHeight);
|
| 152 |
+
if (hasUserResizedRef.current) {
|
| 153 |
+
manualHeightRef.current = elementHeight;
|
| 154 |
+
}
|
| 155 |
+
},
|
| 156 |
+
[onHeightChange],
|
| 157 |
+
);
|
| 158 |
+
|
| 159 |
+
// Handle drag start - set manual mode flag
|
| 160 |
+
const handleDragStart = useCallback(() => {
|
| 161 |
+
hasUserResizedRef.current = true;
|
| 162 |
+
onGripDragStart?.();
|
| 163 |
+
}, [onGripDragStart]);
|
| 164 |
+
|
| 165 |
+
// Handle drag end - clear manual mode if at minimum height
|
| 166 |
+
const handleDragEnd = useCallback(() => {
|
| 167 |
+
const textareaElement = elementRef.current;
|
| 168 |
+
if (textareaElement) {
|
| 169 |
+
const currentHeight = getStyleHeightPx(textareaElement, minHeight);
|
| 170 |
+
if (Math.abs(currentHeight - minHeight) <= EPS) {
|
| 171 |
+
hasUserResizedRef.current = false;
|
| 172 |
+
manualHeightRef.current = null;
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
onGripDragEnd?.();
|
| 176 |
+
}, [minHeight, onGripDragEnd]);
|
| 177 |
+
|
| 178 |
+
// Use the drag resize hook for manual resizing functionality
|
| 179 |
+
const { handleGripMouseDown, handleGripTouchStart } = useDragResize({
|
| 180 |
+
elementRef,
|
| 181 |
+
minHeight,
|
| 182 |
+
maxHeight,
|
| 183 |
+
onGripDragStart: enableManualResize ? handleDragStart : undefined,
|
| 184 |
+
onGripDragEnd: enableManualResize ? handleDragEnd : undefined,
|
| 185 |
+
onHeightChange: handleExternalHeightChange,
|
| 186 |
+
});
|
| 187 |
+
|
| 188 |
+
// Handle content that fits within current height
|
| 189 |
+
const handleContentFitsInCurrentHeight = useCallback(
|
| 190 |
+
(
|
| 191 |
+
element: HTMLElement,
|
| 192 |
+
currentHeight: number,
|
| 193 |
+
contentHeight: number,
|
| 194 |
+
): void => {
|
| 195 |
+
// If user manually resized and we're above min height, preserve their chosen height
|
| 196 |
+
if (hasUserResizedRef.current && currentHeight > minHeight) {
|
| 197 |
+
applyResizeStrategy(element, {
|
| 198 |
+
finalHeight: currentHeight,
|
| 199 |
+
overflowY: "hidden",
|
| 200 |
+
});
|
| 201 |
+
executeHeightCallback(currentHeight, onHeightChange);
|
| 202 |
+
return;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
// Otherwise allow shrinking towards content (respect minHeight)
|
| 206 |
+
const finalHeight = Math.max(contentHeight, minHeight);
|
| 207 |
+
applyResizeStrategy(element, {
|
| 208 |
+
finalHeight,
|
| 209 |
+
overflowY: "hidden",
|
| 210 |
+
});
|
| 211 |
+
executeHeightCallback(finalHeight, onHeightChange);
|
| 212 |
+
},
|
| 213 |
+
[minHeight, onHeightChange],
|
| 214 |
+
);
|
| 215 |
+
|
| 216 |
+
// Handle content that exceeds current height but within max height
|
| 217 |
+
const handleContentExceedsCurrentHeight = useCallback(
|
| 218 |
+
(
|
| 219 |
+
element: HTMLElement,
|
| 220 |
+
currentHeight: number,
|
| 221 |
+
contentHeight: number,
|
| 222 |
+
): void => {
|
| 223 |
+
// Grow unless the element is manually oversized beyond content significantly
|
| 224 |
+
if (!isManuallyOversized(currentHeight, contentHeight)) {
|
| 225 |
+
const finalHeight = Math.max(contentHeight, minHeight);
|
| 226 |
+
applyResizeStrategy(element, {
|
| 227 |
+
finalHeight,
|
| 228 |
+
overflowY: "hidden",
|
| 229 |
+
});
|
| 230 |
+
executeHeightCallback(finalHeight, onHeightChange);
|
| 231 |
+
return;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
// Keep manual height and allow scrolling as needed
|
| 235 |
+
applyResizeStrategy(element, {
|
| 236 |
+
finalHeight: currentHeight,
|
| 237 |
+
overflowY: "auto",
|
| 238 |
+
});
|
| 239 |
+
executeHeightCallback(currentHeight, onHeightChange);
|
| 240 |
+
},
|
| 241 |
+
[minHeight, onHeightChange],
|
| 242 |
+
);
|
| 243 |
+
|
| 244 |
+
// Handle content that exceeds max height
|
| 245 |
+
const handleContentExceedsMaxHeight = useCallback(
|
| 246 |
+
(element: HTMLElement) => {
|
| 247 |
+
applyResizeStrategy(element, {
|
| 248 |
+
finalHeight: maxHeight,
|
| 249 |
+
overflowY: "auto",
|
| 250 |
+
});
|
| 251 |
+
executeHeightCallback(maxHeight, onHeightChange);
|
| 252 |
+
},
|
| 253 |
+
[maxHeight, onHeightChange],
|
| 254 |
+
);
|
| 255 |
+
|
| 256 |
+
// Debounced smartResize body
|
| 257 |
+
const smartResizeBody = useCallback(() => {
|
| 258 |
+
const element = elementRef.current;
|
| 259 |
+
if (!element) return;
|
| 260 |
+
|
| 261 |
+
const textIsEmpty = (element.textContent ?? "").trim().length === 0;
|
| 262 |
+
|
| 263 |
+
// If empty content and we have a manual height above min, preserve it
|
| 264 |
+
if (
|
| 265 |
+
textIsEmpty &&
|
| 266 |
+
hasUserResizedRef.current &&
|
| 267 |
+
manualHeightRef.current &&
|
| 268 |
+
manualHeightRef.current > minHeight + EPS
|
| 269 |
+
) {
|
| 270 |
+
setStyleHeightPx(element, manualHeightRef.current);
|
| 271 |
+
element.style.overflowY = "hidden";
|
| 272 |
+
executeHeightCallback(manualHeightRef.current, onHeightChange);
|
| 273 |
+
return;
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
// Measure element heights
|
| 277 |
+
const measurements = measureElementHeights(element, minHeight);
|
| 278 |
+
const { currentHeight, contentHeight } = measurements;
|
| 279 |
+
|
| 280 |
+
// If content fits within current height
|
| 281 |
+
if (contentHeight <= currentHeight) {
|
| 282 |
+
handleContentFitsInCurrentHeight(element, currentHeight, contentHeight);
|
| 283 |
+
return;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
// If content exceeds current height but within max
|
| 287 |
+
if (contentHeight <= maxHeight) {
|
| 288 |
+
handleContentExceedsCurrentHeight(element, currentHeight, contentHeight);
|
| 289 |
+
return;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
// Content exceeds max height
|
| 293 |
+
handleContentExceedsMaxHeight(element);
|
| 294 |
+
}, [
|
| 295 |
+
elementRef,
|
| 296 |
+
minHeight,
|
| 297 |
+
maxHeight,
|
| 298 |
+
onHeightChange,
|
| 299 |
+
handleContentFitsInCurrentHeight,
|
| 300 |
+
handleContentExceedsCurrentHeight,
|
| 301 |
+
handleContentExceedsMaxHeight,
|
| 302 |
+
]);
|
| 303 |
+
|
| 304 |
+
// rAF-debounced smartResize wrapper to collapse bursts
|
| 305 |
+
const smartResize = useCallback(() => {
|
| 306 |
+
if (pendingSmartRef.current) cancelAnimationFrame(pendingSmartRef.current);
|
| 307 |
+
pendingSmartRef.current = requestAnimationFrame(() => {
|
| 308 |
+
pendingSmartRef.current = null;
|
| 309 |
+
smartResizeBody();
|
| 310 |
+
});
|
| 311 |
+
}, [smartResizeBody]);
|
| 312 |
+
|
| 313 |
+
// Function to increase height when content is empty
|
| 314 |
+
const increaseHeightForEmptyContent = () => {
|
| 315 |
+
const element = elementRef.current;
|
| 316 |
+
if (!element) return;
|
| 317 |
+
|
| 318 |
+
const currentHeight = getStyleHeightPx(element, minHeight);
|
| 319 |
+
const newHeight = Math.min(currentHeight + HEIGHT_INCREMENT, maxHeight);
|
| 320 |
+
|
| 321 |
+
if (newHeight > currentHeight) {
|
| 322 |
+
const finalHeight = applyHeightToElement(element, newHeight, constraints);
|
| 323 |
+
|
| 324 |
+
// Execute height change callback
|
| 325 |
+
executeHeightCallback(finalHeight, onHeightChange);
|
| 326 |
+
|
| 327 |
+
// Set manual mode for Shift+Enter height increases
|
| 328 |
+
hasUserResizedRef.current = true;
|
| 329 |
+
manualHeightRef.current = finalHeight;
|
| 330 |
+
}
|
| 331 |
+
};
|
| 332 |
+
|
| 333 |
+
// Update content and resize when value prop changes
|
| 334 |
+
useEffect(() => {
|
| 335 |
+
if (value === undefined) {
|
| 336 |
+
return undefined;
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
let rafId = 0;
|
| 340 |
+
|
| 341 |
+
const applyValue = () => {
|
| 342 |
+
const element = elementRef.current;
|
| 343 |
+
if (!element) {
|
| 344 |
+
return false;
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
element.textContent = value.text;
|
| 348 |
+
smartResize();
|
| 349 |
+
focusContentEditableAtEnd(element);
|
| 350 |
+
onValueApplied?.(); // one-shot: let the caller clear the value
|
| 351 |
+
return true;
|
| 352 |
+
};
|
| 353 |
+
|
| 354 |
+
if (!applyValue()) {
|
| 355 |
+
rafId = requestAnimationFrame(() => {
|
| 356 |
+
applyValue();
|
| 357 |
+
});
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
return () => {
|
| 361 |
+
if (rafId) {
|
| 362 |
+
cancelAnimationFrame(rafId);
|
| 363 |
+
}
|
| 364 |
+
};
|
| 365 |
+
}, [value, smartResize, onValueApplied]);
|
| 366 |
+
|
| 367 |
+
// Initialize auto-resize on mount
|
| 368 |
+
useEffect(() => {
|
| 369 |
+
smartResize();
|
| 370 |
+
}, [smartResize]);
|
| 371 |
+
|
| 372 |
+
return {
|
| 373 |
+
smartResize,
|
| 374 |
+
handleGripMouseDown,
|
| 375 |
+
handleGripTouchStart,
|
| 376 |
+
increaseHeightForEmptyContent,
|
| 377 |
+
resetManualResize,
|
| 378 |
+
};
|
| 379 |
+
};
|
src/hooks/use-automation-permissions.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useQuery } from "@tanstack/react-query";
|
| 2 |
+
import { getCloudOrganizationMe } from "#/api/cloud/organization-service.api";
|
| 3 |
+
import { useActiveBackend } from "#/contexts/active-backend-context";
|
| 4 |
+
import { useCloudCurrentUserId } from "./query/use-cloud-current-user-id";
|
| 5 |
+
import type { Automation } from "#/types/automation";
|
| 6 |
+
|
| 7 |
+
/**
|
| 8 |
+
* Server-defined permission strings (mirror the automation service's
|
| 9 |
+
* `view_automations` / `manage_automations` and the enterprise
|
| 10 |
+
* `authorization.Permission` enum). The `/me` endpoint returns whichever
|
| 11 |
+
* subset the caller's role grants.
|
| 12 |
+
*/
|
| 13 |
+
export const VIEW_AUTOMATIONS = "view_automations";
|
| 14 |
+
export const MANAGE_AUTOMATIONS = "manage_automations";
|
| 15 |
+
|
| 16 |
+
export interface AutomationPermissionsResult {
|
| 17 |
+
/** Read-only access (list, get, list runs, capabilities, git-sync status). */
|
| 18 |
+
canView: boolean;
|
| 19 |
+
/** Full write access (create, update, delete, dispatch, git-sync config). */
|
| 20 |
+
canManage: boolean;
|
| 21 |
+
/**
|
| 22 |
+
* `true` when permissions are still being resolved on cloud. Local always
|
| 23 |
+
* resolves synchronously. Mutating controls should stay hidden while this is
|
| 24 |
+
* `true` so a member never sees a flash of write controls before the
|
| 25 |
+
* server-defined permission set arrives.
|
| 26 |
+
*/
|
| 27 |
+
isLoading: boolean;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/**
|
| 31 |
+
* Resolve the caller's automation-level permissions on the active backend.
|
| 32 |
+
*
|
| 33 |
+
* - Local agent-server (OSS): always `{ canView: true, canManage: true }`.
|
| 34 |
+
* - Cloud: reads the server-defined `permissions` array from
|
| 35 |
+
* `GET /api/organizations/{orgId}/me` (same query `useCloudCurrentUserId`
|
| 36 |
+
* and `useCanManageOrgProfiles` use, so no extra request is issued) and
|
| 37 |
+
* falls back to a role check (`owner`/`admin` ⇒ both, `member` ⇒ view
|
| 38 |
+
* only) when an older app-server doesn't return `permissions`.
|
| 39 |
+
*/
|
| 40 |
+
export function useAutomationPermissions(): AutomationPermissionsResult {
|
| 41 |
+
const { backend, orgId } = useActiveBackend();
|
| 42 |
+
const isCloud = backend.kind === "cloud";
|
| 43 |
+
|
| 44 |
+
// eslint-disable-next-line @tanstack/query/exhaustive-deps
|
| 45 |
+
const { data, isLoading } = useQuery({
|
| 46 |
+
queryKey: [
|
| 47 |
+
"cloud-current-user",
|
| 48 |
+
backend.id,
|
| 49 |
+
orgId,
|
| 50 |
+
backend.connectionRevision ?? 0,
|
| 51 |
+
],
|
| 52 |
+
queryFn: () => getCloudOrganizationMe(orgId!, backend),
|
| 53 |
+
enabled: isCloud && !!orgId,
|
| 54 |
+
staleTime: 1000 * 60 * 5,
|
| 55 |
+
retry: false,
|
| 56 |
+
meta: { disableToast: true },
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
if (!isCloud) {
|
| 60 |
+
return { canView: true, canManage: true, isLoading: false };
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
if (isLoading || !data) {
|
| 64 |
+
return { canView: false, canManage: false, isLoading };
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
if (data.permissions) {
|
| 68 |
+
const canView = data.permissions.includes(VIEW_AUTOMATIONS);
|
| 69 |
+
const canManage = data.permissions.includes(MANAGE_AUTOMATIONS);
|
| 70 |
+
return { canView, canManage, isLoading: false };
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
// Fall back to role check for older app-servers without `permissions`.
|
| 74 |
+
const isOwnerOrAdmin = data.role === "owner" || data.role === "admin";
|
| 75 |
+
return {
|
| 76 |
+
canView: isOwnerOrAdmin || data.role === "member",
|
| 77 |
+
canManage: isOwnerOrAdmin,
|
| 78 |
+
isLoading: false,
|
| 79 |
+
};
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
/**
|
| 83 |
+
* Resolve whether the current user is the creator of the given automation.
|
| 84 |
+
*
|
| 85 |
+
* - Local: always `true` (the local user owns every automation; and since
|
| 86 |
+
* local always has `manage_automations`, the distinction is moot).
|
| 87 |
+
* - Cloud: compares `automation.user_id` with the caller's `user_id` from
|
| 88 |
+
* `GET /api/organizations/{orgId}/me`. Returns `false` while the id is
|
| 89 |
+
* still loading so the creator escape hatch never flashes open prematurely.
|
| 90 |
+
*/
|
| 91 |
+
export function useIsAutomationOwner(automation: Automation): boolean {
|
| 92 |
+
const { backend } = useActiveBackend();
|
| 93 |
+
// Always call the hook so rules-of-hooks is satisfied; it's a no-op for
|
| 94 |
+
// local backends since the early return below short-circuits before use.
|
| 95 |
+
const userIds = useCloudCurrentUserId();
|
| 96 |
+
|
| 97 |
+
if (backend.kind !== "cloud") return true;
|
| 98 |
+
|
| 99 |
+
const entry = userIds[backend.id];
|
| 100 |
+
if (!entry || entry.isLoading || !entry.userId) return false;
|
| 101 |
+
return automation.user_id === entry.userId;
|
| 102 |
+
}
|
src/hooks/use-await-context-compaction.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef } from "react";
|
| 2 |
+
import useMetricsStore from "#/stores/metrics-store";
|
| 3 |
+
import { useEventStore } from "#/stores/use-event-store";
|
| 4 |
+
import { isCondensationEvent } from "#/types/agent-server/type-guards";
|
| 5 |
+
|
| 6 |
+
/** How long to wait after a Condensation event for a lower per_turn_token. */
|
| 7 |
+
const METRICS_SETTLE_MS = 2_500;
|
| 8 |
+
/** Give up waiting for condensation / metrics and surface whatever we have. */
|
| 9 |
+
const DEFAULT_TIMEOUT_MS = 90_000;
|
| 10 |
+
|
| 11 |
+
export type ContextCompactionOutcome = "compacted" | "no_change" | "timeout";
|
| 12 |
+
|
| 13 |
+
export interface ContextCompactionResult {
|
| 14 |
+
beforeToken: number;
|
| 15 |
+
afterToken: number;
|
| 16 |
+
savedToken: number;
|
| 17 |
+
/**
|
| 18 |
+
* "compacted" — a Condensation event landed and per_turn_token dropped.
|
| 19 |
+
* "no_change" — a Condensation event landed but no token drop was measured.
|
| 20 |
+
* "timeout" — no Condensation event arrived in time; compaction did not
|
| 21 |
+
* happen (or was never processed), so this is a failure, not a no-op.
|
| 22 |
+
*/
|
| 23 |
+
outcome: ContextCompactionOutcome;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export function buildContextCompactionResult(
|
| 27 |
+
beforeToken: number,
|
| 28 |
+
afterToken: number,
|
| 29 |
+
outcome: ContextCompactionOutcome,
|
| 30 |
+
): ContextCompactionResult {
|
| 31 |
+
return {
|
| 32 |
+
beforeToken,
|
| 33 |
+
afterToken,
|
| 34 |
+
savedToken: Math.max(0, beforeToken - afterToken),
|
| 35 |
+
outcome,
|
| 36 |
+
};
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
interface UseAwaitContextCompactionOptions {
|
| 40 |
+
/**
|
| 41 |
+
* Snapshot of `per_turn_token` taken when compaction was requested.
|
| 42 |
+
* Pass `null` when not awaiting a result.
|
| 43 |
+
*/
|
| 44 |
+
beforeToken: number | null;
|
| 45 |
+
/**
|
| 46 |
+
* Event ids that predate the compaction *request*. The condense POST can
|
| 47 |
+
* return only after the server already emitted its Condensation event, so
|
| 48 |
+
* snapshotting "known" ids when this effect starts would miss fast
|
| 49 |
+
* condensations; the baseline must be captured before the request fires.
|
| 50 |
+
* When omitted, falls back to the ids present when the effect starts.
|
| 51 |
+
*/
|
| 52 |
+
baselineEventIds?: Set<string | number> | null;
|
| 53 |
+
onComplete: (result: ContextCompactionResult) => void;
|
| 54 |
+
timeoutMs?: number;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/**
|
| 58 |
+
* Waits for a post-request Condensation event (and ideally a lower
|
| 59 |
+
* `per_turn_token` in the live metrics store), then reports how many tokens
|
| 60 |
+
* were freed. The HTTP `/condense` ack only means work *started*.
|
| 61 |
+
*/
|
| 62 |
+
export function useAwaitContextCompaction({
|
| 63 |
+
beforeToken,
|
| 64 |
+
baselineEventIds,
|
| 65 |
+
onComplete,
|
| 66 |
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
| 67 |
+
}: UseAwaitContextCompactionOptions) {
|
| 68 |
+
const onCompleteRef = useRef(onComplete);
|
| 69 |
+
onCompleteRef.current = onComplete;
|
| 70 |
+
|
| 71 |
+
useEffect(() => {
|
| 72 |
+
if (beforeToken === null) {
|
| 73 |
+
return undefined;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
let completed = false;
|
| 77 |
+
let sawCondensation = false;
|
| 78 |
+
let settleTimer: ReturnType<typeof setTimeout> | undefined;
|
| 79 |
+
const knownEventIds = baselineEventIds
|
| 80 |
+
? new Set(baselineEventIds)
|
| 81 |
+
: new Set(useEventStore.getState().eventIds);
|
| 82 |
+
|
| 83 |
+
const readAfterToken = () =>
|
| 84 |
+
useMetricsStore.getState().usage?.per_turn_token ?? beforeToken;
|
| 85 |
+
|
| 86 |
+
const finish = (afterToken: number, outcome: ContextCompactionOutcome) => {
|
| 87 |
+
if (completed) {
|
| 88 |
+
return;
|
| 89 |
+
}
|
| 90 |
+
completed = true;
|
| 91 |
+
onCompleteRef.current(
|
| 92 |
+
buildContextCompactionResult(beforeToken, afterToken, outcome),
|
| 93 |
+
);
|
| 94 |
+
};
|
| 95 |
+
|
| 96 |
+
const finishFromMetricsIfReady = () => {
|
| 97 |
+
const afterToken = readAfterToken();
|
| 98 |
+
if (sawCondensation && afterToken < beforeToken) {
|
| 99 |
+
finish(afterToken, "compacted");
|
| 100 |
+
return true;
|
| 101 |
+
}
|
| 102 |
+
return false;
|
| 103 |
+
};
|
| 104 |
+
|
| 105 |
+
const scheduleSettleFinish = () => {
|
| 106 |
+
clearTimeout(settleTimer);
|
| 107 |
+
settleTimer = setTimeout(() => {
|
| 108 |
+
finish(readAfterToken(), "no_change");
|
| 109 |
+
}, METRICS_SETTLE_MS);
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
const scanForNewCondensation = () => {
|
| 113 |
+
for (const event of useEventStore.getState().events) {
|
| 114 |
+
const eventId = "id" in event ? event.id : undefined;
|
| 115 |
+
if (eventId !== undefined) {
|
| 116 |
+
if (knownEventIds.has(eventId)) {
|
| 117 |
+
continue;
|
| 118 |
+
}
|
| 119 |
+
knownEventIds.add(eventId);
|
| 120 |
+
}
|
| 121 |
+
if (!isCondensationEvent(event)) {
|
| 122 |
+
continue;
|
| 123 |
+
}
|
| 124 |
+
sawCondensation = true;
|
| 125 |
+
if (!finishFromMetricsIfReady()) {
|
| 126 |
+
scheduleSettleFinish();
|
| 127 |
+
}
|
| 128 |
+
return;
|
| 129 |
+
}
|
| 130 |
+
};
|
| 131 |
+
|
| 132 |
+
scanForNewCondensation();
|
| 133 |
+
|
| 134 |
+
const unsubscribeEvents = useEventStore.subscribe(() => {
|
| 135 |
+
if (completed) {
|
| 136 |
+
return;
|
| 137 |
+
}
|
| 138 |
+
scanForNewCondensation();
|
| 139 |
+
});
|
| 140 |
+
|
| 141 |
+
const unsubscribeMetrics = useMetricsStore.subscribe(() => {
|
| 142 |
+
if (completed) {
|
| 143 |
+
return;
|
| 144 |
+
}
|
| 145 |
+
if (finishFromMetricsIfReady()) {
|
| 146 |
+
clearTimeout(settleTimer);
|
| 147 |
+
}
|
| 148 |
+
});
|
| 149 |
+
|
| 150 |
+
const timeoutTimer = setTimeout(() => {
|
| 151 |
+
// No Condensation event in time means the compaction never landed —
|
| 152 |
+
// a failure, not a "nothing to compact" no-op.
|
| 153 |
+
finish(readAfterToken(), sawCondensation ? "no_change" : "timeout");
|
| 154 |
+
}, timeoutMs);
|
| 155 |
+
|
| 156 |
+
return () => {
|
| 157 |
+
completed = true;
|
| 158 |
+
unsubscribeEvents();
|
| 159 |
+
unsubscribeMetrics();
|
| 160 |
+
clearTimeout(settleTimer);
|
| 161 |
+
clearTimeout(timeoutTimer);
|
| 162 |
+
};
|
| 163 |
+
}, [beforeToken, baselineEventIds, timeoutMs]);
|
| 164 |
+
}
|
src/hooks/use-backend-scoped-path.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { isNoBackend } from "#/api/backend-registry/active-store";
|
| 3 |
+
import { withBackendSelectionParams } from "#/api/backend-registry/url-selection";
|
| 4 |
+
import { useActiveBackend } from "#/contexts/active-backend-context";
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* Build in-app paths that carry the active backend identity.
|
| 8 |
+
*
|
| 9 |
+
* Use it for links a user can plausibly open in a *new* browsing context
|
| 10 |
+
* (cmd/ctrl-click, middle click, "Open link in new tab"). Such a tab does not
|
| 11 |
+
* reliably inherit the opener's `sessionStorage`, so without the pinned
|
| 12 |
+
* identity it can boot on whichever backend `localStorage` last recorded and
|
| 13 |
+
* fail to resolve the linked conversation.
|
| 14 |
+
*/
|
| 15 |
+
export function useBackendScopedPath(): (path: string) => string {
|
| 16 |
+
const active = useActiveBackend();
|
| 17 |
+
|
| 18 |
+
return React.useCallback(
|
| 19 |
+
(path: string) => {
|
| 20 |
+
// The sentinel isn't a real backend and must never be pinned onto a
|
| 21 |
+
// link — a new tab would only ignore it and fall back to storage.
|
| 22 |
+
if (isNoBackend(active.backend)) return path;
|
| 23 |
+
return withBackendSelectionParams(path, active);
|
| 24 |
+
},
|
| 25 |
+
[active],
|
| 26 |
+
);
|
| 27 |
+
}
|
src/hooks/use-bash-command-runner.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useRef } from "react";
|
| 2 |
+
import type {
|
| 3 |
+
BashCommand,
|
| 4 |
+
BashError,
|
| 5 |
+
BashEvent,
|
| 6 |
+
BashOutput,
|
| 7 |
+
} from "@openhands/typescript-client";
|
| 8 |
+
import type { CommandResult } from "#/api/runtime-service/agent-server-runtime-service";
|
| 9 |
+
import { sendWebSocketAuth } from "#/utils/websocket-auth";
|
| 10 |
+
import { startHandshakeWatchdog } from "#/utils/websocket-handshake";
|
| 11 |
+
import { buildBashWebSocketUrl } from "#/utils/websocket-url";
|
| 12 |
+
|
| 13 |
+
interface WaitingCommand {
|
| 14 |
+
command: string;
|
| 15 |
+
cwd: string;
|
| 16 |
+
timeout: number;
|
| 17 |
+
resolve: (result: CommandResult) => void;
|
| 18 |
+
reject: (error: Error) => void;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
interface PendingCommand {
|
| 22 |
+
resolve: (result: CommandResult) => void;
|
| 23 |
+
reject: (error: Error) => void;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
interface ActiveCommand extends PendingCommand {
|
| 27 |
+
stdout: string[];
|
| 28 |
+
stderr: string[];
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export type BashCommandRunner = (
|
| 32 |
+
command: string,
|
| 33 |
+
cwd: string,
|
| 34 |
+
timeout: number,
|
| 35 |
+
) => Promise<CommandResult>;
|
| 36 |
+
|
| 37 |
+
function isBashCommand(event: BashEvent): event is BashCommand {
|
| 38 |
+
return event.kind === "BashCommand";
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function isBashOutput(event: BashEvent): event is BashOutput {
|
| 42 |
+
return event.kind === "BashOutput";
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
function isBashError(event: BashEvent): event is BashError {
|
| 46 |
+
return event.kind === "BashError";
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
/**
|
| 50 |
+
* Maintains a persistent WebSocket connection to the agent-server's
|
| 51 |
+
* `/sockets/bash-events` endpoint and exposes a `runCommand` function that
|
| 52 |
+
* executes a bash command and returns a Promise that resolves when the
|
| 53 |
+
* final `BashOutput` (non-null `exit_code`) arrives.
|
| 54 |
+
*
|
| 55 |
+
* Commands are correlated using a FIFO queue: each `BashCommand` echo
|
| 56 |
+
* received from the server is paired with the oldest outstanding request in
|
| 57 |
+
* the queue, and subsequent `BashOutput` events are matched by `command_id`.
|
| 58 |
+
*
|
| 59 |
+
* Commands are buffered until the socket's open handler sends authentication.
|
| 60 |
+
*/
|
| 61 |
+
export function useBashCommandRunner(
|
| 62 |
+
conversationUrl: string | null | undefined,
|
| 63 |
+
sessionApiKey: string | null | undefined,
|
| 64 |
+
enabled: boolean,
|
| 65 |
+
): BashCommandRunner {
|
| 66 |
+
const wsRef = useRef<WebSocket | null>(null);
|
| 67 |
+
const readyWsRef = useRef<WebSocket | null>(null);
|
| 68 |
+
const waitingQueueRef = useRef<WaitingCommand[]>([]);
|
| 69 |
+
// Commands whose request was sent; waiting for the BashCommand echo to get command_id
|
| 70 |
+
const pendingQueueRef = useRef<PendingCommand[]>([]);
|
| 71 |
+
// Commands whose command_id is known; waiting for BashOutput with non-null exit_code
|
| 72 |
+
const activeCommandsRef = useRef<Map<string, ActiveCommand>>(new Map());
|
| 73 |
+
|
| 74 |
+
useEffect(() => {
|
| 75 |
+
if (!enabled) return;
|
| 76 |
+
|
| 77 |
+
const wsUrl = buildBashWebSocketUrl(conversationUrl);
|
| 78 |
+
const ws = new WebSocket(wsUrl);
|
| 79 |
+
wsRef.current = ws;
|
| 80 |
+
readyWsRef.current = null;
|
| 81 |
+
|
| 82 |
+
// Abort a handshake stuck in CONNECTING — a hung bash-events handshake
|
| 83 |
+
// would also block the conversation's events socket (the one the chat
|
| 84 |
+
// needs) until it settles.
|
| 85 |
+
const cancelHandshakeWatchdog = startHandshakeWatchdog(ws);
|
| 86 |
+
|
| 87 |
+
ws.onopen = () => {
|
| 88 |
+
cancelHandshakeWatchdog();
|
| 89 |
+
sendWebSocketAuth(ws, sessionApiKey);
|
| 90 |
+
readyWsRef.current = ws;
|
| 91 |
+
for (const {
|
| 92 |
+
command,
|
| 93 |
+
cwd,
|
| 94 |
+
timeout,
|
| 95 |
+
resolve,
|
| 96 |
+
reject,
|
| 97 |
+
} of waitingQueueRef.current) {
|
| 98 |
+
pendingQueueRef.current.push({ resolve, reject });
|
| 99 |
+
ws.send(JSON.stringify({ command, cwd, timeout }));
|
| 100 |
+
}
|
| 101 |
+
waitingQueueRef.current = [];
|
| 102 |
+
};
|
| 103 |
+
|
| 104 |
+
ws.onmessage = (event: MessageEvent) => {
|
| 105 |
+
let data: BashEvent;
|
| 106 |
+
try {
|
| 107 |
+
data = JSON.parse(event.data as string) as BashEvent;
|
| 108 |
+
} catch {
|
| 109 |
+
return; // ignore malformed frames
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
if (isBashCommand(data)) {
|
| 113 |
+
// Associate the next pending request with the server-assigned command_id
|
| 114 |
+
const pending = pendingQueueRef.current.shift();
|
| 115 |
+
if (pending) {
|
| 116 |
+
activeCommandsRef.current.set(data.id, {
|
| 117 |
+
...pending,
|
| 118 |
+
stdout: [],
|
| 119 |
+
stderr: [],
|
| 120 |
+
});
|
| 121 |
+
}
|
| 122 |
+
} else if (isBashOutput(data) && data.command_id) {
|
| 123 |
+
const active = activeCommandsRef.current.get(data.command_id);
|
| 124 |
+
if (active) {
|
| 125 |
+
if (data.stdout) active.stdout.push(data.stdout);
|
| 126 |
+
if (data.stderr) active.stderr.push(data.stderr);
|
| 127 |
+
if (data.exit_code != null) {
|
| 128 |
+
activeCommandsRef.current.delete(data.command_id);
|
| 129 |
+
active.resolve({
|
| 130 |
+
exit_code: data.exit_code,
|
| 131 |
+
stdout: active.stdout.join(""),
|
| 132 |
+
stderr: active.stderr.join(""),
|
| 133 |
+
});
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
} else if (isBashError(data)) {
|
| 137 |
+
rejectAll(`Bash error: ${data.code}: ${data.detail}`);
|
| 138 |
+
}
|
| 139 |
+
};
|
| 140 |
+
|
| 141 |
+
function rejectAll(reason: string): void {
|
| 142 |
+
const err = new Error(reason);
|
| 143 |
+
for (const { reject: rej } of waitingQueueRef.current) rej(err);
|
| 144 |
+
waitingQueueRef.current = [];
|
| 145 |
+
for (const p of pendingQueueRef.current) p.reject(err);
|
| 146 |
+
pendingQueueRef.current = [];
|
| 147 |
+
for (const a of activeCommandsRef.current.values()) a.reject(err);
|
| 148 |
+
activeCommandsRef.current.clear();
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
ws.onclose = () => {
|
| 152 |
+
cancelHandshakeWatchdog();
|
| 153 |
+
wsRef.current = null;
|
| 154 |
+
readyWsRef.current = null;
|
| 155 |
+
rejectAll("Bash WebSocket closed");
|
| 156 |
+
};
|
| 157 |
+
|
| 158 |
+
ws.onerror = () => {
|
| 159 |
+
wsRef.current = null;
|
| 160 |
+
readyWsRef.current = null;
|
| 161 |
+
rejectAll("Bash WebSocket error");
|
| 162 |
+
};
|
| 163 |
+
|
| 164 |
+
return () => {
|
| 165 |
+
// The close handler is nulled below, so cancel the watchdog here.
|
| 166 |
+
cancelHandshakeWatchdog();
|
| 167 |
+
// Prevent the close/error handlers from double-rejecting after unmount
|
| 168 |
+
ws.onclose = null;
|
| 169 |
+
ws.onerror = null;
|
| 170 |
+
ws.close();
|
| 171 |
+
wsRef.current = null;
|
| 172 |
+
readyWsRef.current = null;
|
| 173 |
+
rejectAll("Bash WebSocket unmounted");
|
| 174 |
+
};
|
| 175 |
+
}, [enabled, conversationUrl, sessionApiKey]);
|
| 176 |
+
|
| 177 |
+
const runCommand: BashCommandRunner = useCallback(
|
| 178 |
+
(command: string, cwd: string, timeout: number) =>
|
| 179 |
+
new Promise<CommandResult>((resolve, reject) => {
|
| 180 |
+
const ws = wsRef.current;
|
| 181 |
+
if (
|
| 182 |
+
!ws ||
|
| 183 |
+
ws.readyState === WebSocket.CLOSED ||
|
| 184 |
+
ws.readyState === WebSocket.CLOSING
|
| 185 |
+
) {
|
| 186 |
+
reject(new Error("Bash WebSocket not available"));
|
| 187 |
+
return;
|
| 188 |
+
}
|
| 189 |
+
if (ws.readyState !== WebSocket.OPEN || readyWsRef.current !== ws) {
|
| 190 |
+
waitingQueueRef.current.push({
|
| 191 |
+
command,
|
| 192 |
+
cwd,
|
| 193 |
+
timeout,
|
| 194 |
+
resolve,
|
| 195 |
+
reject,
|
| 196 |
+
});
|
| 197 |
+
} else {
|
| 198 |
+
pendingQueueRef.current.push({ resolve, reject });
|
| 199 |
+
ws.send(JSON.stringify({ command, cwd, timeout }));
|
| 200 |
+
}
|
| 201 |
+
}),
|
| 202 |
+
[],
|
| 203 |
+
);
|
| 204 |
+
|
| 205 |
+
return runCommand;
|
| 206 |
+
}
|