diff --git a/src/api/README.md b/src/api/README.md new file mode 100644 index 0000000000000000000000000000000000000000..0c4a44d6c19ff5f5ddb810c475086310f5ca1985 --- /dev/null +++ b/src/api/README.md @@ -0,0 +1,98 @@ +# API Services Guide + +## Overview + +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. + +Cloud-specific APIs should use the cloud service modules/proxy helpers instead of local agent-server clients. + +Each service is a plain object with async methods. + +## Structure + +Each service lives in its own directory: + +``` +src/api/ +└── feature-service/ + ├── feature-service.api.ts # Service methods + └── feature.types.ts # Types and interfaces +``` + +## Creating a Service + +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. + +```typescript +// feature-service/feature-service.api.ts +import { FeatureClient } from "@openhands/typescript-client/clients"; +import { getAgentServerClientOptions } from "../agent-server-client-options"; +import { Feature, CreateFeatureParams } from "./feature.types"; + +export const featureService = { + getFeature: async ({ id }: { id: string }): Promise => { + return new FeatureClient(getAgentServerClientOptions()).getFeature(id); + }, + + createFeature: async (params: CreateFeatureParams): Promise => { + return new FeatureClient(getAgentServerClientOptions()).createFeature(params); + }, +}; +``` + +### Types + +Define app-specific types in a separate file within the same directory when the TypeScript client models are not sufficient: + +```typescript +// feature-service/feature.types.ts +export interface Feature { + id: string; + name: string; + description: string; +} + +export interface CreateFeatureParams { + name: string; + description: string; +} +``` + +## Usage + +> [!IMPORTANT] +> **Don't call services directly in components.** Wrap them in TanStack Query hooks. +> +> Why? TanStack Query provides: +> +> - **Caching** - Avoid redundant network requests +> - **Deduplication** - Multiple components requesting the same data share one request +> - **Loading/error states** - Built-in `isLoading`, `isError`, `data` states +> - **Background refetching** - Data stays fresh automatically +> +> Hooks location: +> +> - `src/hooks/query/` for data fetching (`useQuery`) +> - `src/hooks/mutation/` for writes/updates (`useMutation`) + +```typescript +// src/hooks/query/use-feature.ts +import { useQuery } from "@tanstack/react-query"; +import { featureService } from "#/api/feature-service/feature-service.api"; + +export const useFeature = (id: string) => { + return useQuery({ + queryKey: ["feature", id], + queryFn: () => featureService.getFeature({ id }), + }); +}; +``` + +## Naming Conventions + +| Item | Convention | Example | +| ------------ | ------------------------ | ------------------------ | +| Directory | `feature-service/` | `secrets-service/` | +| Service file | `feature-service.api.ts` | `secrets-service.api.ts` | +| Types file | `feature.types.ts` | `secrets.types.ts` | +| Export name | `featureService` | `secretsService` | diff --git a/src/api/agent-canvas-updates.test.ts b/src/api/agent-canvas-updates.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..62dfbb76dd98f54eef19a1f33846b6807a18691b --- /dev/null +++ b/src/api/agent-canvas-updates.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fetchLatestAgentCanvasVersion } from "./agent-canvas-updates"; + +function stubFetchResponse(response: Partial) { + const fetchMock = vi.fn().mockResolvedValue(response as Response); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("fetchLatestAgentCanvasVersion", () => { + it("fetches the npm registry latest dist-tag and returns the trimmed version", async () => { + const fetchMock = stubFetchResponse({ + ok: true, + json: async () => ({ version: " 1.6.0 " }), + }); + + await expect(fetchLatestAgentCanvasVersion()).resolves.toBe("1.6.0"); + expect(fetchMock).toHaveBeenCalledWith( + "https://registry.npmjs.org/@openhands/agent-canvas/latest", + expect.objectContaining({ headers: { Accept: "application/json" } }), + ); + }); + + it("rejects when the registry responds with an error status", async () => { + stubFetchResponse({ ok: false, status: 503 }); + + await expect(fetchLatestAgentCanvasVersion()).rejects.toThrow("503"); + }); + + it("rejects when the response has no usable version field", async () => { + stubFetchResponse({ ok: true, json: async () => ({ version: 42 }) }); + + await expect(fetchLatestAgentCanvasVersion()).rejects.toThrow( + "missing version", + ); + }); +}); diff --git a/src/api/agent-canvas-updates.ts b/src/api/agent-canvas-updates.ts new file mode 100644 index 0000000000000000000000000000000000000000..7c4179b79e941b82e48adef197fa92d459a53927 --- /dev/null +++ b/src/api/agent-canvas-updates.ts @@ -0,0 +1,37 @@ +/** + * Source of truth for "is a newer Agent Canvas published?". + * + * The npm registry `latest` dist-tag endpoint is CORS-open and returns the + * abbreviated packument for that version as JSON. Isolated here so the + * release channel can change (e.g. GitHub releases) without touching the + * query hook or the settings update card. + */ +const NPM_LATEST_VERSION_URL = + "https://registry.npmjs.org/@openhands/agent-canvas/latest"; + +export const AGENT_CANVAS_RELEASE_NOTES_URL = + "https://github.com/OpenHands/OpenHands/releases"; + +/** Literal shell commands — intentionally not localized. */ +export const AGENT_CANVAS_UPDATE_COMMANDS = { + npm: "npm install -g @openhands/agent-canvas@latest", + docker: "docker pull ghcr.io/openhands/agent-canvas:latest", +} as const; + +export async function fetchLatestAgentCanvasVersion( + signal?: AbortSignal, +): Promise { + const response = await fetch(NPM_LATEST_VERSION_URL, { + signal, + headers: { Accept: "application/json" }, + }); + if (!response.ok) { + throw new Error(`npm registry responded ${response.status}`); + } + const body: unknown = await response.json(); + const version = (body as { version?: unknown } | null)?.version; + if (typeof version !== "string" || !version.trim()) { + throw new Error("npm registry response missing version"); + } + return version.trim(); +} diff --git a/src/api/agent-server-adapter.test.ts b/src/api/agent-server-adapter.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a23a154399f06ba59c2e0cc9232083d91c59b8e6 --- /dev/null +++ b/src/api/agent-server-adapter.test.ts @@ -0,0 +1,704 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CANVAS_UI_CLIENT_TOOL_NAME } from "#/constants/canvas-ui"; +import { LAUNCH_CHILD_CONVERSATION_TOOL_NAME } from "#/constants/child-conversation"; +import { DEFAULT_SETTINGS } from "#/services/settings"; +import type { Settings } from "#/types/settings"; +import { + AGENT_CANVAS_SOURCE, + CLIENT_SOURCE_TAG_KEY, + buildStartConversationRequest, + buildStartPlanningConversationRequest, + buildStartPlanningConversationRequestWithEncryptedSettings, + toConversationPage, +} from "./agent-server-adapter"; +import SettingsService from "./settings-service/settings-service.api"; +import AgentProfilesService from "./agent-profiles-service/agent-profiles-service.api"; +import ProfilesService from "./profiles-service/profiles-service.api"; + +vi.mock("./settings-service/settings-service.api", () => ({ + default: { getSettingsForConversation: vi.fn() }, +})); +vi.mock("./secrets-service", () => ({ + SecretsService: { getSecrets: vi.fn().mockResolvedValue([]) }, +})); +vi.mock("./agent-profiles-service/agent-profiles-service.api", () => ({ + default: { listProfiles: vi.fn() }, +})); +vi.mock("./profiles-service/profiles-service.api", () => ({ + default: { getProfile: vi.fn() }, +})); + +const encryptedValue = "gAAAAAencrypted-mcp-header"; + +function makeSettings(agentSettings: Settings["agent_settings"]): Settings { + return { + ...DEFAULT_SETTINGS, + agent_settings: agentSettings, + conversation_settings: { + confirmation_mode: false, + security_analyzer: null, + max_iterations: 20, + }, + }; +} + +function getAgentContextSkillNames( + payload: ReturnType, +): Array { + const agentSettings = payload.agent_settings as + | { + agent_context?: { + skills?: Array<{ name?: string }>; + }; + } + | undefined; + return agentSettings?.agent_context?.skills?.map((skill) => skill.name) ?? []; +} + +function getPlannerAgentContextSkillNames(payload: { + agent: { agent_context?: { skills?: Array<{ name?: string }> } }; +}): Array { + return payload.agent.agent_context?.skills?.map((skill) => skill.name) ?? []; +} + +describe("buildStartConversationRequest", () => { + it("marks OpenHands start requests as encrypted when MCP headers are encrypted", () => { + const agentSettings = { + agent_kind: "openhands", + llm: { + model: "litellm_proxy/openai/gpt-5.5", + api_key: "gAAAAAencrypted-llm-api-key", + }, + mcp_config: { + linear: { + url: "https://mcp.linear.app/mcp", + transport: "http", + headers: { + Authorization: encryptedValue, + }, + }, + }, + }; + const settings = makeSettings(agentSettings); + + const payload = buildStartConversationRequest({ + settings, + encryptedAgentSettings: agentSettings, + encryptedConversationSettings: settings.conversation_settings!, + secretsEncrypted: true, + }); + + expect(payload.agent_settings!.agent_kind).toBe("openhands"); + expect(payload.agent_settings!.mcp_config).toEqual( + agentSettings.mcp_config, + ); + expect(payload.secrets_encrypted).toBe(true); + }); + + it("marks ACP start requests as encrypted when MCP headers are encrypted", () => { + const agentSettings = { + agent_kind: "acp", + acp_server: "codex", + acp_command: ["codex-acp"], + acp_model: "gpt-5.5/medium", + mcp_config: { + linear: { + url: "https://mcp.linear.app/mcp", + transport: "http", + headers: { + Authorization: encryptedValue, + }, + }, + }, + }; + const settings = makeSettings(agentSettings); + + const payload = buildStartConversationRequest({ + settings, + encryptedAgentSettings: agentSettings, + encryptedConversationSettings: settings.conversation_settings!, + secretsEncrypted: true, + }); + + expect(payload.agent_settings!.agent_kind).toBe("acp"); + expect(payload.agent_settings!.mcp_config).toEqual( + agentSettings.mcp_config, + ); + expect(payload.secrets_encrypted).toBe(true); + }); + + it("builds a raw planning agent request for local Planner", () => { + const agentSettings = { + agent_kind: "openhands", + llm: { + model: "openhands/minimax-m2.7", + api_key: "gAAAAAencrypted-llm-api-key", + }, + }; + + const payload = buildStartPlanningConversationRequest({ + encryptedAgentSettings: agentSettings, + workingDir: "/workspace/project/agent-canvas", + parentConversationId: "parent-1", + secretsEncrypted: true, + customSecrets: [{ name: "CUSTOM_TOKEN" }], + }); + + expect(payload.agent).toMatchObject({ + kind: "Agent", + system_prompt_filename: "system_prompt_planning.j2", + system_prompt_kwargs: { + plan_structure: expect.stringContaining("OBJECTIVE"), + }, + llm: { + model: "openhands/minimax-m2.7", + api_key: "gAAAAAencrypted-llm-api-key", + }, + tools: [ + { name: "glob", params: {} }, + { name: "grep", params: {} }, + { + name: "planning_file_editor", + params: { + plan_path: "/workspace/project/agent-canvas/.agents_tmp/PLAN.md", + }, + }, + ], + // Matches the SDK planning preset's get_planning_condenser. + condenser: { + kind: "LLMSummarizingCondenser", + max_size: 100, + keep_first: 6, + llm: { + model: "openhands/minimax-m2.7", + usage_id: "planning_condenser", + }, + }, + }); + expect(payload.agent_settings).toBeUndefined(); + expect(payload.worktree).toBe(false); + expect(payload.tags).toEqual({ plannerparent: "parent-1" }); + expect(payload.secrets_encrypted).toBe(true); + expect(payload.secrets).toHaveProperty("CUSTOM_TOKEN"); + }); + + it("links the local planner to its parent server-side so the relationship survives storage loss", () => { + const payload = buildStartPlanningConversationRequest({ + encryptedAgentSettings: { + agent_kind: "openhands", + llm: { model: "openhands/minimax-m2.7" }, + }, + workingDir: "/workspace/project", + parentConversationId: "parent-1", + }); + + // The agent-server derives the parent's `sub_conversation_ids` from this, + // which is what makes the planner recoverable without localStorage. + expect(payload.parent_conversation_id).toBe("parent-1"); + }); + + it("defaults the planner's max_iterations to 500 when not provided", () => { + const payload = buildStartPlanningConversationRequest({ + encryptedAgentSettings: { + agent_kind: "openhands", + llm: { model: "openhands/minimax-m2.7" }, + }, + workingDir: "/workspace/project", + parentConversationId: "parent-1", + }); + + expect(payload.max_iterations).toBe(500); + }); + + it("uses the caller-provided max_iterations instead of the hardcoded default", () => { + const payload = buildStartPlanningConversationRequest({ + encryptedAgentSettings: { + agent_kind: "openhands", + llm: { model: "openhands/minimax-m2.7" }, + }, + workingDir: "/workspace/project", + parentConversationId: "parent-1", + maxIterations: 250, + }); + + expect(payload.max_iterations).toBe(250); + }); + + it("omits local planner helper conversations from paginated conversation results", () => { + const page = toConversationPage({ + items: [ + { + id: "main-1", + created_at: "2024-01-01T00:00:00.000Z", + updated_at: "2024-01-01T00:00:00.000Z", + execution_status: "idle", + tags: {}, + }, + { + id: "plan-1", + created_at: "2024-01-01T00:00:00.000Z", + updated_at: "2024-01-01T00:00:00.000Z", + execution_status: "idle", + tags: { plannerparent: "main-1" }, + }, + ], + }); + + expect(page.items.map((item) => item.id)).toEqual(["main-1"]); + }); + + it("keeps ACP start requests unencrypted when no encrypted MCP values are present", () => { + const agentSettings = { + agent_kind: "acp", + acp_server: "codex", + acp_command: ["codex-acp"], + acp_model: "gpt-5.5/medium", + mcp_config: { + publicDocs: { + url: "https://docs.example.com/mcp", + transport: "http", + }, + }, + }; + const settings = makeSettings(agentSettings); + + const payload = buildStartConversationRequest({ + settings, + encryptedAgentSettings: agentSettings, + encryptedConversationSettings: settings.conversation_settings!, + secretsEncrypted: true, + }); + + expect(payload.agent_settings!.agent_kind).toBe("acp"); + expect(payload.secrets_encrypted).toBeUndefined(); + }); + + it("ships only allow-listed catalog skills to a OpenHands conversation context", () => { + const settings = makeSettings({ + agent_kind: "openhands", + llm: { + model: "litellm_proxy/openai/gpt-5.5", + api_key: "sk-test", + }, + agent_context: { + skills: [ + { name: "disabled-custom", content: "disabled" }, + { name: "enabled-custom", content: "enabled" }, + ], + }, + }); + settings.disabled_skills = ["agent-memory", "disabled-custom"]; + + const payload = buildStartConversationRequest({ settings }); + const skillNames = getAgentContextSkillNames(payload); + + // `agent-memory` is default-enabled, so this asserts the deny-list still + // wins over the allow-list — the case that matters before the one-shot + // migration has run. + expect(skillNames).not.toContain("agent-memory"); + expect(skillNames).not.toContain("disabled-custom"); + // Skills already on the agent context are user-authored and stay opt-out. + expect(skillNames).toContain("enabled-custom"); + // No `enabled_skills` on the settings means the curated default applies: + // `add-skill` is flagged `defaultEnabled` in the catalog, `add-javadoc` is + // not, and shipping every catalog skill is what OpenHands#16302 reported. + expect(skillNames).toContain("add-skill"); + expect(skillNames).not.toContain("add-javadoc"); + + expect(payload.agent_settings?.agent_context?.disabled_skills).toEqual([ + "agent-memory", + "disabled-custom", + ]); + }); + + it("ships only allow-listed catalog skills to a ACP conversation context", () => { + const settings = makeSettings({ + agent_kind: "acp", + acp_server: "codex", + acp_command: ["codex-acp"], + acp_model: "gpt-5.5/medium", + agent_context: { + skills: [ + { name: "disabled-custom", content: "disabled" }, + { name: "enabled-custom", content: "enabled" }, + ], + }, + }); + settings.disabled_skills = ["agent-memory", "disabled-custom"]; + + const payload = buildStartConversationRequest({ settings }); + const skillNames = getAgentContextSkillNames(payload); + + // `agent-memory` is default-enabled, so this asserts the deny-list still + // wins over the allow-list — the case that matters before the one-shot + // migration has run. + expect(skillNames).not.toContain("agent-memory"); + expect(skillNames).not.toContain("disabled-custom"); + // Skills already on the agent context are user-authored and stay opt-out. + expect(skillNames).toContain("enabled-custom"); + // No `enabled_skills` on the settings means the curated default applies: + // `add-skill` is flagged `defaultEnabled` in the catalog, `add-javadoc` is + // not, and shipping every catalog skill is what OpenHands#16302 reported. + expect(skillNames).toContain("add-skill"); + expect(skillNames).not.toContain("add-javadoc"); + + expect(payload.agent_settings?.agent_context?.disabled_skills).toEqual([ + "agent-memory", + "disabled-custom", + ]); + }); + + it("loads a catalog skill the opening slash command invokes", () => { + // An automation card fills the chat input with the skill's own command + // (`findAutomationCommand`), and 18 of the catalog's 24 slash commands + // belong to skills that are off by default — without this the card would + // silently do nothing. + const settings = makeSettings({ + agent_kind: "openhands", + llm: { model: "litellm_proxy/openai/gpt-5.5", api_key: "sk-test" }, + }); + + const payload = buildStartConversationRequest({ + settings, + query: "/standup-digest:setup", + }); + + expect(getAgentContextSkillNames(payload)).toContain( + "slack-standup-digest", + ); + }); + + it("does not admit a skill named mid-sentence rather than invoked", () => { + const settings = makeSettings({ + agent_kind: "openhands", + llm: { model: "litellm_proxy/openai/gpt-5.5", api_key: "sk-test" }, + }); + + const payload = buildStartConversationRequest({ + settings, + query: "tell me what /standup-digest:setup would do", + }); + + expect(getAgentContextSkillNames(payload)).not.toContain( + "slack-standup-digest", + ); + }); + + it("lets an invoked skill override a stored deny entry for that conversation", () => { + const settings = makeSettings({ + agent_kind: "openhands", + llm: { model: "litellm_proxy/openai/gpt-5.5", api_key: "sk-test" }, + }); + settings.disabled_skills = ["slack-standup-digest"]; + + const payload = buildStartConversationRequest({ + settings, + query: "/standup-digest:setup", + }); + + expect(getAgentContextSkillNames(payload)).toContain( + "slack-standup-digest", + ); + }); +}); + +describe("buildStartPlanningConversationRequestWithEncryptedSettings", () => { + const globallyActiveSettings = { + agentSettings: { + agent_kind: "openhands", + llm: { + model: "openhands/globally-active-model", + api_key: "gAAAAAglobal-key", + base_url: "https://global.example.com", + }, + }, + conversationSettings: {}, + secretsEncrypted: true, + skillEnablement: { disabledSkills: [] }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(SettingsService.getSettingsForConversation).mockResolvedValue( + globallyActiveSettings, + ); + vi.mocked(AgentProfilesService.listProfiles).mockResolvedValue({ + profiles: [ + { + id: "profile-parent", + name: "parent-profile", + agent_kind: "openhands", + revision: 1, + llm_profile_ref: "parent-llm", + mcp_server_refs: null, + }, + ], + active_agent_profile_id: "profile-other", + }); + vi.mocked(ProfilesService.getProfile).mockResolvedValue({ + name: "parent-llm", + api_key_set: true, + config: { + model: "openhands/parent-profile-model", + api_key: "gAAAAAparent-key", + base_url: "https://parent.example.com", + }, + }); + }); + + it("pins the planner to the parent's launched profile, not the globally active one", async () => { + const payload = + await buildStartPlanningConversationRequestWithEncryptedSettings({ + workingDir: "/workspace/project", + parentConversationId: "parent-1", + parentAgentProfileId: "profile-parent", + }); + + expect(ProfilesService.getProfile).toHaveBeenCalledWith( + "parent-llm", + "encrypted", + ); + expect(payload.agent.llm).toMatchObject({ + model: "openhands/parent-profile-model", + api_key: "gAAAAAparent-key", + base_url: "https://parent.example.com", + }); + }); + + it("falls back to global settings when the parent was not launched from a profile", async () => { + const payload = + await buildStartPlanningConversationRequestWithEncryptedSettings({ + workingDir: "/workspace/project", + parentConversationId: "parent-1", + parentAgentProfileId: null, + }); + + expect(AgentProfilesService.listProfiles).not.toHaveBeenCalled(); + expect(payload.agent.llm).toMatchObject({ + model: "openhands/globally-active-model", + }); + }); + + it("prefers the parent conversation's current active_profile over its launched agent profile", async () => { + // The conversation launched under "parent-profile" (-> "parent-llm"), but + // was later switched to a different LLM profile via /model or the + // agent's own SwitchLLMTool — active_profile tracks that switch. + vi.mocked(ProfilesService.getProfile).mockImplementation(async (name) => + name === "switched-llm" + ? { + name: "switched-llm", + api_key_set: true, + config: { + model: "openhands/switched-model", + api_key: "gAAAAAswitched-key", + base_url: "https://switched.example.com", + }, + } + : { + name: "parent-llm", + api_key_set: true, + config: { + model: "openhands/parent-profile-model", + api_key: "gAAAAAparent-key", + base_url: "https://parent.example.com", + }, + }, + ); + + const payload = + await buildStartPlanningConversationRequestWithEncryptedSettings({ + workingDir: "/workspace/project", + parentConversationId: "parent-1", + parentActiveProfileName: "switched-llm", + parentAgentProfileId: "profile-parent", + }); + + expect(ProfilesService.getProfile).toHaveBeenCalledWith( + "switched-llm", + "encrypted", + ); + // The launched agent profile must not even be consulted once + // active_profile resolves successfully. + expect(AgentProfilesService.listProfiles).not.toHaveBeenCalled(); + expect(payload.agent.llm).toMatchObject({ + model: "openhands/switched-model", + api_key: "gAAAAAswitched-key", + base_url: "https://switched.example.com", + }); + }); + + it("falls back to the launched agent profile when active_profile can't be resolved", async () => { + vi.mocked(ProfilesService.getProfile).mockImplementation(async (name) => { + if (name === "dangling-llm") throw new Error("not found"); + return { + name: "parent-llm", + api_key_set: true, + config: { + model: "openhands/parent-profile-model", + api_key: "gAAAAAparent-key", + base_url: "https://parent.example.com", + }, + }; + }); + + const payload = + await buildStartPlanningConversationRequestWithEncryptedSettings({ + workingDir: "/workspace/project", + parentConversationId: "parent-1", + parentActiveProfileName: "dangling-llm", + parentAgentProfileId: "profile-parent", + }); + + expect(payload.agent.llm).toMatchObject({ + model: "openhands/parent-profile-model", + }); + }); + + it("falls back to global settings when the parent's profile reference dangles", async () => { + vi.mocked(AgentProfilesService.listProfiles).mockResolvedValue({ + profiles: [], + active_agent_profile_id: null, + }); + + const payload = + await buildStartPlanningConversationRequestWithEncryptedSettings({ + workingDir: "/workspace/project", + parentConversationId: "parent-1", + parentAgentProfileId: "profile-parent", + }); + + expect(ProfilesService.getProfile).not.toHaveBeenCalled(); + expect(payload.agent.llm).toMatchObject({ + model: "openhands/globally-active-model", + }); + }); + + it("mirrors the parent's configured max_iterations instead of hardcoding a lower cap", async () => { + vi.mocked(SettingsService.getSettingsForConversation).mockResolvedValue({ + ...globallyActiveSettings, + conversationSettings: { max_iterations: 1000 }, + }); + + const payload = + await buildStartPlanningConversationRequestWithEncryptedSettings({ + workingDir: "/workspace/project", + parentConversationId: "parent-1", + }); + + expect(payload.max_iterations).toBe(1000); + }); + + it("falls back to 500 when the parent has no configured max_iterations", async () => { + const payload = + await buildStartPlanningConversationRequestWithEncryptedSettings({ + workingDir: "/workspace/project", + parentConversationId: "parent-1", + }); + + expect(payload.max_iterations).toBe(500); + }); + + it("applies the full skill enablement to the planner's agent context, mirroring the code agent", async () => { + // Both lists, because they cover different populations (#16302): the + // allow-list gates the bundled catalog, the deny-list still wins over it. + // `add-javadoc` is a catalog skill outside the recommended defaults, so + // it only loads because the allow-list names it. + vi.mocked(SettingsService.getSettingsForConversation).mockResolvedValue({ + ...globallyActiveSettings, + skillEnablement: { + enabledSkills: ["add-javadoc", "agent-memory"], + disabledSkills: ["agent-memory"], + }, + }); + + const payload = + await buildStartPlanningConversationRequestWithEncryptedSettings({ + workingDir: "/workspace/project", + parentConversationId: "parent-1", + }); + + const skillNames = getPlannerAgentContextSkillNames(payload); + expect(skillNames).not.toContain("agent-memory"); + expect(skillNames).toContain("add-javadoc"); + }); +}); + +describe("buildStartConversationRequest — agentProfileId path", () => { + it("sends agent_profile_id and omits agent_settings (mutually exclusive)", () => { + const settings = makeSettings({ + agent_kind: "openhands", + llm: { model: "litellm_proxy/openai/gpt-5.5", api_key: "sk-test" }, + }); + + const payload = buildStartConversationRequest({ + settings, + agentProfileId: "profile-xyz", + agentProfileKind: "openhands", + }); + + expect(payload.agent_profile_id).toBe("profile-xyz"); + expect(payload.agent_settings).toBeUndefined(); + expect(payload.client_tools.map((tool) => tool.name)).toEqual([ + CANVAS_UI_CLIENT_TOOL_NAME, + LAUNCH_CHILD_CONVERSATION_TOOL_NAME, + ]); + }); + + it("suppresses the ACP server tag when launching from a profile", () => { + const agentSettings = { + agent_kind: "acp", + acp_server: "codex", + acp_command: ["codex-acp"], + acp_model: "gpt-5.5/medium", + }; + + // Without a profile the ACP server tag is stamped from settings... + expect( + buildStartConversationRequest({ settings: makeSettings(agentSettings) }) + .tags, + ).toBeDefined(); + + // ...but a profile launch resolves the server server-side, so the tag + // (which may not match the launched profile) is omitted while the client + // source telemetry tag is still stamped. + const payload = buildStartConversationRequest({ + settings: makeSettings(agentSettings), + agentProfileId: "profile-xyz", + }); + expect(payload.tags).toEqual({ + [CLIENT_SOURCE_TAG_KEY]: AGENT_CANVAS_SOURCE, + }); + }); + + it("suppresses secrets_encrypted when launching from a profile", () => { + const agentSettings = { + agent_kind: "openhands", + llm: { + model: "litellm_proxy/openai/gpt-5.5", + api_key: "gAAAAAencrypted-llm-api-key", + }, + mcp_config: { + mcpServers: { + linear: { + url: "https://mcp.linear.app/mcp", + transport: "http", + headers: { Authorization: encryptedValue }, + }, + }, + }, + }; + const settings = makeSettings(agentSettings); + + // Same inputs without a profile would set secrets_encrypted (covered + // above); the profile path defers secret resolution to the server. + const payload = buildStartConversationRequest({ + settings, + encryptedAgentSettings: agentSettings, + encryptedConversationSettings: settings.conversation_settings!, + secretsEncrypted: true, + agentProfileId: "profile-xyz", + }); + + expect(payload.secrets_encrypted).toBeUndefined(); + }); +}); diff --git a/src/api/agent-server-adapter.ts b/src/api/agent-server-adapter.ts new file mode 100644 index 0000000000000000000000000000000000000000..54558aa52e2ccc9c2c3866bf2d5a9f11cd64daec --- /dev/null +++ b/src/api/agent-server-adapter.ts @@ -0,0 +1,1638 @@ +import { ACP_SETTINGS_KEYS } from "@openhands/typescript-client"; +import type { HookConfig } from "@openhands/typescript-client"; +import { ServerClient } from "@openhands/typescript-client/clients"; +import { SKILLS_CATALOG } from "@openhands/extensions/skills"; +import { DEFAULT_SETTINGS } from "#/services/settings"; +import { ExecutionStatus } from "#/types/agent-server/core"; +import { AgentKind, Settings, SettingsValue } from "#/types/settings"; +import { + getAcpPreferredDefaultModel, + getAcpProvider, + resolveEffectiveAcpModel, +} from "#/constants/acp-providers"; +import { getAgentServerClientOptions } from "./agent-server-client-options"; +import { + getCachedAgentServerInfo, + isAgentServerToolAvailable, +} from "./agent-server-compatibility"; +import { getAgentServerWorkingDir } from "./agent-server-config"; +import { getEffectiveLocalBackend } from "./backend-registry/active-store"; +import { buildAuthHeaders } from "./backend-registry/auth"; +import { + GetHooksResponse, + PluginSpec, + AppConversation, + AppConversationPage, + RuntimeConversationStats, + SandboxStatus, +} from "./conversation-service/agent-server-conversation-service.types"; +import { combineUsageMetrics } from "#/utils/conversation-metrics"; +import { + buildSkillEnablementFilter, + findInvokedCatalogSkill, + toSkillEnablement, + type SkillEnablement, +} from "#/utils/skill-enablement"; +import SettingsService from "./settings-service/settings-service.api"; +import { getStoredConversationMetadata } from "./conversation-metadata-store"; +import LLMSubscriptionService from "./llm-subscription-service"; +import { + LLM_AUTH_TYPE_SUBSCRIPTION, + OPENAI_SUBSCRIPTION_VENDOR, + isSubscriptionLlmConfig, +} from "#/constants/llm-subscription"; +import { + CANVAS_UI_CLIENT_TOOL, + CANVAS_UI_CLIENT_TOOL_NAME, + LEGACY_CANVAS_UI_TOOL_NAME, + type ClientToolSpec, +} from "./canvas-ui-client-tool"; +import { + LAUNCH_CHILD_CONVERSATION_CLIENT_TOOL, + LAUNCH_CHILD_CONVERSATION_TOOL_NAME, +} from "./launch-child-conversation-client-tool"; +import { + buildPlanPath, + LOCAL_PLANNER_PARENT_TAG_KEY, + PLAN_STRUCTURE_TEXT, + PLANNING_AGENT_INSTRUCTION, + PLANNING_FILE_EDITOR_TOOL_NAME, + PLANNING_SYSTEM_PROMPT_FILENAME, +} from "#/utils/plan-file"; + +export interface DirectConversationInfo { + id: string; + title?: string | null; + created_at: string; + updated_at: string; + execution_status?: string | null; + /** Cloud-only sandbox lifecycle state. Omitted / null for local agent-server conversations. */ + sandbox_status?: string | null; + metrics?: { + accumulated_cost?: number | null; + max_budget_per_task?: number | null; + accumulated_token_usage?: { + prompt_tokens?: number; + completion_tokens?: number; + cache_read_tokens?: number; + cache_write_tokens?: number; + context_window?: number; + per_turn_token?: number; + } | null; + } | null; + /** + * Raw per-usage-id LLM stats from the agent-server. Search/list responses + * often carry real usage here even when `metrics` above comes back unset; + * {@link toAppConversation} combines this as a fallback in that case. + */ + stats?: RuntimeConversationStats | null; + agent?: { + /** + * Pydantic discriminator from the SDK union: ``"ACPAgent"`` for ACP CLI + * subprocesses (model lives on the subprocess via ``acp_model``), + * ``"Agent"`` for direct litellm. Read by {@link toAppConversation}. + */ + kind?: string | null; + acp_model?: string | null; + /** + * ACP CLI identity (``claude-code`` / ``codex`` / ``gemini-cli``) from the + * SDK's ``ACPAgent.acp_server`` (#3692). Preferred fallback when the + * ``acpserver`` tag is absent — e.g. a profile launch doesn't stamp the tag + * client-side and the server may not repopulate it. Read by {@link toAppConversation}. + */ + acp_server?: string | null; + llm?: { + model?: string | null; + } | null; + } | null; + current_model_id?: string | null; + current_model_name?: string | null; + workspace?: { + working_dir?: string | null; + } | null; + /** + * Arbitrary string-keyed conversation tags surfaced by the agent-server + * (see ``ConversationInfo.tags``). Canvas only consumes one key today — + * ``ACP_SERVER_TAG_KEY`` ("acpserver") — but the field is typed as a + * generic record so future readers don't need another wire-shape change. + * Keys are constrained to ``^[a-z0-9]+$`` by the agent-server validator; + * values are opaque strings. + */ + tags?: Record | null; + launched_agent_profile?: { + agent_profile_id: string; + revision: number; + } | null; + /** + * Server-owned, derived from the catalog's ``parent_conversation_id`` link + * (agent-server >= 1.37.1, SDK #4188) — a conversation started with a + * parent is discoverable here on any browser, which is what makes the + * local planner's relationship server state rather than a browser-local + * hint. Absent on older agent-servers and on the cloud wire shape. + */ + sub_conversation_ids?: string[] | null; +} + +const DEFAULT_TOOL_NAMES = ["terminal", "file_editor", "task_tracker"]; +const BROWSER_TOOL_SET_NAME = "browser_tool_set"; +const TASK_TOOL_SET_NAME = "task_tool_set"; +// Falls back to the same default the code agent uses when the user has not +// configured `conversation_settings.max_iterations` (see buildConfiguredConversationSettings). +const DEFAULT_MAX_ITERATIONS = 500; + +function resolveMaxIterations(value: unknown): number { + return typeof value === "number" ? value : DEFAULT_MAX_ITERATIONS; +} + +function browserToolsEnabled() { + return import.meta.env.VITE_ENABLE_BROWSER_TOOLS !== "false"; +} + +/** + * Shape of the runtime services info served by Agent Canvas backends in + * `/server_info.runtime_services`. All URLs are written from the agent's point of + * view, not the browser's. The block is rendered into the agent's system prompt + * via `AgentContext.system_message_suffix` so the agent knows what's reachable + * from inside its sandbox without having to probe. + */ +export interface RuntimeServicesInfo { + mode?: string; + agent_host_alias?: string; + services?: { + agent_server?: { description?: string; url_from_agent?: string }; + ingress?: { description?: string; url_from_agent?: string }; + frontend?: { + kind?: "vite" | "static"; + description?: string; + url_from_agent?: string; + }; + // `vite` is the legacy key name for the frontend entry, accepted for + // one release while older dev-stack launchers may still emit it. + vite?: { description?: string; url_from_agent?: string }; + automation?: { + description?: string; + url_from_agent?: string; + api_prefix?: string; + docs_url?: string; + openapi_url?: string; + auth_env_var?: string; + }; + }; +} + +export function parseRuntimeServicesInfo( + value: unknown, +): RuntimeServicesInfo | null { + if (typeof value === "string") { + const raw = value.trim(); + if (!raw) return null; + try { + return parseRuntimeServicesInfo(JSON.parse(raw)); + } catch { + return null; + } + } + + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + + const parsed = value as RuntimeServicesInfo; + if (!parsed.services || typeof parsed.services !== "object") return null; + return parsed; +} + +export async function fetchBackendRuntimeServicesInfo(): Promise { + let clientOptions: ReturnType; + try { + clientOptions = getAgentServerClientOptions({ timeout: 3000 }); + } catch { + return null; + } + + const cached = parseRuntimeServicesInfo( + getCachedAgentServerInfo({ host: clientOptions.host })?.runtime_services, + ); + if (cached) return cached; + + try { + const serverInfo = await new ServerClient(clientOptions).getServerInfo(); + return parseRuntimeServicesInfo( + (serverInfo as { runtime_services?: unknown }).runtime_services, + ); + } catch { + return null; + } +} + +/** + * Return the deployment mode from the runtime services info, e.g. "docker", + * "dev:automation", etc. Returns `null` when no runtime info is supplied. + */ +export function getDeploymentMode( + runtimeServicesInfo?: RuntimeServicesInfo | null, +): string | null { + return runtimeServicesInfo?.mode ?? null; +} + +/** + * Render the runtime services info into a markdown block suitable for + * appending to the system prompt via `AgentContext.system_message_suffix`. + * + * Returns `undefined` when no runtime info is available, so callers can safely + * omit the field when the selected backend does not advertise runtime services. + */ +export function buildRuntimeServicesSystemSuffix( + runtimeServicesInfo?: RuntimeServicesInfo | null, +): string | undefined { + const info = parseRuntimeServicesInfo(runtimeServicesInfo); + if (!info?.services) return undefined; + + const lines: string[] = []; + lines.push(""); + if (info.mode) { + lines.push( + `You are running inside an agent-canvas dev stack started in '${info.mode}' mode.`, + ); + } else { + lines.push("You are running inside an agent-canvas dev stack."); + } + lines.push( + "The following services are reachable from your sandbox. URLs are written", + "from your point of view (i.e., as you should curl/fetch them).", + "", + ); + + const { agent_server, ingress, automation } = info.services; + const { frontend } = info.services; + + if (agent_server?.url_from_agent) { + lines.push( + `* Agent Server (you): ${agent_server.url_from_agent}`, + ` ${agent_server.description ?? "The agent-server hosting your tool calls."}`, + ); + } + if (ingress?.url_from_agent) { + lines.push( + `* Ingress: ${ingress.url_from_agent}`, + ` ${ingress.description ?? "Unified entry point for browser-facing traffic."}`, + ); + } + if (frontend?.url_from_agent) { + lines.push( + `* Frontend: ${frontend.url_from_agent}`, + ` ${frontend.description ?? "Frontend dev server."}`, + ); + } + if (automation?.url_from_agent) { + lines.push( + `* Automation backend: ${automation.url_from_agent}`, + ` ${automation.description ?? "OpenHands Automations service."}`, + ); + if (automation.docs_url) { + lines.push(` Docs: ${automation.docs_url}`); + } + if (automation.openapi_url) { + lines.push(` OpenAPI: ${automation.openapi_url}`); + } + if (automation.auth_env_var) { + // X-Session-API-Key is the local convention shared by the agent-server + // and automation backend (see openhands-automation auth.py). + lines.push( + ` Auth: header 'X-Session-API-Key: $${automation.auth_env_var}'`, + ); + } + } else { + lines.push( + "* Automation backend: not running in this dev mode (skip /api/automation calls).", + ); + } + + // Anchor the "don't guess" warning to the actual agent-server URL for + // this stack instead of a hardcoded port. The agent-server listens on + // different ports across dev modes, and baking the wrong port into the + // system prompt is exactly the kind of confusion this block is meant to + // prevent. + const agentServerUrl = agent_server?.url_from_agent; + lines.push( + "", + "Trust this block over guessing: do not assume any other URLs are running.", + ); + if (agentServerUrl) { + lines.push( + `In particular, ${agentServerUrl} inside your sandbox is the Agent Server`, + "you are running inside of — NOT the automation backend.", + ); + } + lines.push(""); + + return lines.join("\n"); +} + +export function toConversationUrl(conversationId: string): string { + // Local-format conversation URL — points at whichever local agent-server + // is actually serving the conversation (the bundled one when the active + // selection is cloud). + const { host } = getAgentServerClientOptions(); + return `${host}/api/conversations/${conversationId}`; +} + +// TODO(i18n): extract "Conversation" once we add CONVERSATION$DEFAULT_TITLE +// with `{{shortId}}` interpolation. Kept as a literal for now to keep the +// fallback inside this pure adapter rather than fanning out to display sites. +export function getDefaultConversationTitle(conversationId: string): string { + return `Conversation ${conversationId.slice(0, 5)}`; +} + +export function toAppConversation( + info: DirectConversationInfo, +): AppConversation { + const metadata = getStoredConversationMetadata(info.id); + // ACPAgent conversations carry a sentinel ``llm`` on older SDKs. Prefer the + // runtime model fields when available, then the configured ``acp_model`` that + // Canvas saves for built-in providers. ``agent_kind`` still gates model + // switching, so surfacing this string is display-only. + const isAcp = info.agent?.kind === "ACPAgent"; + // Only surface ``acp_server`` for ACP conversations even if the wire + // payload accidentally carries an ``acpserver`` tag on an OpenHands + // conversation — the chip is identity info for the ACP CLI subprocess, + // and showing it on a non-ACP conversation would be a lie. Fall back to the + // agent's own ``acp_server`` (#3692) when the tag is missing — a profile + // launch doesn't stamp the tag, so tag-only reads would drop the ACP model + // picker and degrade the chip to a generic "ACP" (#1571). + const acpServer = isAcp + ? (info.tags?.[ACP_SERVER_TAG_KEY] ?? info.agent?.acp_server ?? null) + : null; + return { + id: info.id, + created_by_user_id: null, + selected_repository: metadata?.selected_repository ?? null, + selected_branch: metadata?.selected_branch ?? null, + git_provider: metadata?.git_provider ?? null, + selected_workspace: metadata?.selected_workspace ?? null, + active_profile: metadata?.active_profile ?? null, + title: info.title?.trim() + ? info.title + : getDefaultConversationTitle(info.id), + trigger: null, + pr_number: [], + agent_kind: isAcp ? "acp" : "openhands", + acp_server: acpServer, + tags: info.tags ?? null, + launched_agent_profile: info.launched_agent_profile ?? null, + // Chip path: omit ``providerDefault`` so that when no concrete model + // resolves, the chip falls back to the provider display name in + // ConversationCardFooter rather than a registry default the session may + // not actually be running. + llm_model: isAcp + ? resolveEffectiveAcpModel({ + runtimeName: info.current_model_name, + runtimeId: info.current_model_id, + configured: info.agent?.acp_model, + sdkLlm: info.agent?.llm?.model, + }) + : (info.agent?.llm?.model ?? DEFAULT_SETTINGS.llm_model), + metrics: info.metrics + ? { + accumulated_cost: info.metrics.accumulated_cost ?? null, + max_budget_per_task: info.metrics.max_budget_per_task ?? null, + accumulated_token_usage: info.metrics.accumulated_token_usage + ? { + prompt_tokens: + info.metrics.accumulated_token_usage.prompt_tokens ?? 0, + completion_tokens: + info.metrics.accumulated_token_usage.completion_tokens ?? 0, + cache_read_tokens: + info.metrics.accumulated_token_usage.cache_read_tokens ?? 0, + cache_write_tokens: + info.metrics.accumulated_token_usage.cache_write_tokens ?? 0, + context_window: + info.metrics.accumulated_token_usage.context_window ?? 0, + per_turn_token: + info.metrics.accumulated_token_usage.per_turn_token ?? 0, + } + : null, + } + : combineUsageMetrics(info.stats), + created_at: info.created_at, + updated_at: info.updated_at, + execution_status: + (info.execution_status as AppConversation["execution_status"]) ?? + ExecutionStatus.IDLE, + sandbox_status: (info.sandbox_status as SandboxStatus | null) ?? null, + conversation_url: toConversationUrl(info.id), + session_api_key: getAgentServerClientOptions().apiKey ?? null, + sandbox_id: null, + workspace: { + working_dir: info.workspace?.working_dir ?? getAgentServerWorkingDir(), + }, + public: false, + sub_conversation_ids: info.sub_conversation_ids ?? [], + }; +} + +export function toConversationPage(data: { + items: DirectConversationInfo[]; + next_page_id?: string | null; +}): AppConversationPage { + return { + items: data.items + .filter((item) => !item.tags?.[LOCAL_PLANNER_PARENT_TAG_KEY]) + .map(toAppConversation), + next_page_id: data.next_page_id ?? null, + }; +} + +type SettingsRecord = Record; + +interface AgentToolSpec { + name: string; + params: SettingsRecord; +} + +type AgentSettingsPayload = SettingsRecord & { + llm?: SettingsRecord; + agent_context: SettingsRecord; + tools?: AgentToolSpec[]; +}; + +interface LocalWorkspacePayload { + kind: "LocalWorkspace"; + working_dir: string; +} + +interface InitialMessagePayload { + role: "user"; + content: Array<{ type: "text"; text: string }>; + run: true; +} + +type ConversationSettingsPayload = SettingsRecord & { + workspace: LocalWorkspacePayload; + initial_message?: InitialMessagePayload; +}; + +export const ACP_SERVER_TAG_KEY = "acpserver"; +export const CLIENT_SOURCE_TAG_KEY = "clientsource"; +export const AGENT_CANVAS_SOURCE = "agentcanvas"; + +export const AUTOMATION_TRIGGER_TAG_KEY = "automationtrigger"; +export const AUTOMATION_ID_TAG_KEY = "automationid"; +export const AUTOMATION_NAME_TAG_KEY = "automationname"; +export const AUTOMATION_RUN_ID_TAG_KEY = "automationrunid"; + +/** + * Tag keys stamped on conversations created by automation runs (see the SDK's + * `RemoteWorkspace.default_conversation_tags`). The presence of any of these + * marks a conversation as automation-born. + */ +export const AUTOMATION_TAG_KEYS: readonly string[] = [ + AUTOMATION_TRIGGER_TAG_KEY, + AUTOMATION_ID_TAG_KEY, + AUTOMATION_NAME_TAG_KEY, + AUTOMATION_RUN_ID_TAG_KEY, +]; + +/** + * Conversation tag keys that must not appear as generic chips / hovercard + * rows. Each is either already surfaced by a first-class UI source or is + * internal routing data: + * - ``acpserver`` → ACP provider chip + * - ``clientsource`` → telemetry attribution + * - ``title`` → conversation card heading + * - git / repo / branch / workspace stamps → repo-branch metadata + directory + * footer / hovercard rows (``selected_repository``, ``selected_branch``, + * ``git_provider``, ``workspace.working_dir``) + * - the automation family (``automationtrigger`` / ``automationid`` / + * ``automationname`` / ``automationrunid``) → provenance the SDK stamps at + * creation; the conversation panel's automation filter is its first-class + * UI source. The tag surface is user organization data, so machine stamps + * stay out of it — and users can't edit or spoof automation classification. + * - ``localplannerparent`` → internal routing for the local planner; already + * surfaced by the hidden-from-list planner filter + */ +export const RESERVED_CONVERSATION_TAG_KEYS: ReadonlySet = new Set([ + ACP_SERVER_TAG_KEY, + CLIENT_SOURCE_TAG_KEY, + AUTOMATION_TRIGGER_TAG_KEY, + AUTOMATION_ID_TAG_KEY, + AUTOMATION_NAME_TAG_KEY, + AUTOMATION_RUN_ID_TAG_KEY, + "title", + "git_provider", + "repo_name", + "repo", + "repository", + "selected_branch", + "branch", + "archiveworkspacepath", + "workspace", + "working_dir", + LOCAL_PLANNER_PARENT_TAG_KEY, +]); + +/** + * High-signal tag keys shown first in the chip row (before A–Z). Automations + * often stamp ``origin``; remaining free-form tags sort alphabetically. + */ +export const PRIORITY_CONVERSATION_TAG_KEYS: readonly string[] = ["origin"]; + +/** + * User-facing subset of a conversation's server-side tags: everything except + * {@link RESERVED_CONVERSATION_TAG_KEYS}, as stable ``[key, value]`` entries. + * Priority keys come first (in {@link PRIORITY_CONVERSATION_TAG_KEYS} order); + * the rest sort A–Z so chip order doesn't shuffle between refetches. + */ +export function getDisplayConversationTags( + tags: Record | null | undefined, +): Array<[string, string]> { + if (!tags) { + return []; + } + // Both the reserved-key check and the priority lookup must see the same + // normalized key: a cloud backend can stamp ``Origin`` / `` origin``, and + // ranking those off the raw key would silently drop them out of first place. + const priorityRank = (key: string): number => { + const index = PRIORITY_CONVERSATION_TAG_KEYS.indexOf( + key.trim().toLowerCase(), + ); + return index === -1 ? Number.POSITIVE_INFINITY : index; + }; + + return Object.entries(tags) + .filter( + ([key, value]) => + !RESERVED_CONVERSATION_TAG_KEYS.has(key.trim().toLowerCase()) && + typeof value === "string" && + // Bare tags (empty value) are displayable — chips/tooltips render + // the key. Whitespace-only values stay dropped (raw-write junk). + (value === "" || value.trim().length > 0), + ) + .sort(([a], [b]) => { + const aRank = priorityRank(a); + const bRank = priorityRank(b); + if (aRank !== bRank) { + return aRank - bRank; + } + return a.localeCompare(b); + }); +} + +const FERNET_TOKEN_PREFIX = "gAAAAA"; + +const CONVERSATION_SETTINGS_METADATA_KEYS = new Set([ + "schema_version", + "agent_settings", + "workspace", + "conversation_id", + "initial_message", + "plugins", +]); + +function toRecord(value: unknown): SettingsRecord { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + + return structuredClone(value as SettingsRecord); +} + +function normalizeSecretString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function buildNormalizedLlmSettings(value: unknown): SettingsRecord { + const llm = toRecord(value); + + llm.model = + typeof llm.model === "string" && llm.model.trim().length > 0 + ? llm.model + : DEFAULT_SETTINGS.llm_model; + + const apiKey = normalizeSecretString(llm.api_key); + if (apiKey) { + llm.api_key = apiKey; + } else { + delete llm.api_key; + } + + const baseUrl = normalizeSecretString(llm.base_url); + if (baseUrl) { + llm.base_url = baseUrl; + } else { + delete llm.base_url; + } + + if (isSubscriptionLlmConfig(llm)) { + llm.auth_type = LLM_AUTH_TYPE_SUBSCRIPTION; + llm.subscription_vendor = OPENAI_SUBSCRIPTION_VENDOR; + delete llm.api_key; + } else { + delete llm.auth_type; + delete llm.subscription_vendor; + } + + return llm; +} + +function isPlainRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function hasEncryptedString(value: unknown): boolean { + if (typeof value === "string") { + return value.startsWith(FERNET_TOKEN_PREFIX); + } + if (Array.isArray(value)) { + return value.some(hasEncryptedString); + } + if (isPlainRecord(value)) { + return Object.values(value).some(hasEncryptedString); + } + return false; +} + +function hasEncryptedMcpSecrets(mcpConfig: unknown): boolean { + if (!isPlainRecord(mcpConfig)) { + return false; + } + + return Object.values(mcpConfig).some(hasEncryptedString); +} + +function getConversationConfirmationPolicy( + conversationSettings: SettingsRecord, +) { + if (conversationSettings.confirmation_mode !== true) { + return { kind: "NeverConfirm" }; + } + + if (conversationSettings.security_analyzer === "llm") { + return { kind: "ConfirmRisky", threshold: "HIGH", confirm_unknown: true }; + } + + return { kind: "AlwaysConfirm" }; +} + +function getConversationSecurityAnalyzer(conversationSettings: SettingsRecord) { + switch (conversationSettings.security_analyzer) { + case "llm": + return { kind: "LLMSecurityAnalyzer" }; + case "pattern": + return { kind: "PatternSecurityAnalyzer" }; + case "policy_rail": + return { kind: "PolicyRailSecurityAnalyzer" }; + default: + return undefined; + } +} + +function isToolRecord( + value: unknown, +): value is { name: string; params?: unknown } { + return ( + !!value && + typeof value === "object" && + !Array.isArray(value) && + typeof (value as { name?: unknown }).name === "string" + ); +} + +function shouldIncludeTool(name: string, agentSettings: SettingsRecord) { + if (name === BROWSER_TOOL_SET_NAME) { + return browserToolsEnabled() && isAgentServerToolAvailable(name); + } + + if (name === TASK_TOOL_SET_NAME) { + return ( + agentSettings.enable_sub_agents === true && + isAgentServerToolAvailable(name) + ); + } + + return true; +} + +function getAgentTools(agentSettings: SettingsRecord): AgentToolSpec[] { + const tools = new Map(); + + for (const name of DEFAULT_TOOL_NAMES) { + if (shouldIncludeTool(name, agentSettings)) { + tools.set(name, { name, params: {} }); + } + } + + for (const name of [BROWSER_TOOL_SET_NAME, TASK_TOOL_SET_NAME]) { + if (shouldIncludeTool(name, agentSettings)) { + tools.set(name, { name, params: {} }); + } + } + + const configuredTools = agentSettings.tools; + if ( + Array.isArray(configuredTools) && + configuredTools.every((tool) => isToolRecord(tool)) + ) { + for (const tool of configuredTools) { + if (shouldIncludeTool(tool.name, agentSettings)) { + tools.set(tool.name, { + name: tool.name, + params: toRecord(tool.params), + }); + } + } + } + + return Array.from(tools.values()); +} + +function buildInitialMessage( + query?: string, + conversationInstructions?: string, +): InitialMessagePayload | null { + const parts = [query?.trim(), conversationInstructions?.trim()].filter( + Boolean, + ); + if (parts.length === 0) { + return null; + } + + return { + role: "user", + content: [{ type: "text", text: parts.join("\n\n") }], + run: true, + }; +} + +/** + * Shape of a bundled skill entry passed to the agent-server SDK via + * `agent_context.skills`. Mirrors the SDK's `Skill` model fields that + * the server uses for trigger matching, activation, and system-prompt + * injection. + */ +interface BundledSkill { + name: string; + content: string; + trigger: { type: "keyword"; keywords: string[] } | null; + source: string; + description: string | null; + is_agentskills_format: true; + license?: string; + compatibility?: string; +} + +/** + * Convert the bundled `SKILLS_CATALOG` entries into the SDK `Skill` JSON + * shape so the agent-server can perform trigger matching, skill activation, + * and system-prompt injection without cloning the extensions repo. + * + * The SDK discriminates triggers via `{ type: "keyword", keywords: [...] }`. + * Skills with no triggers get `trigger: null` (always-active / on-demand). + */ +function buildBundledSkills(): BundledSkill[] { + return SKILLS_CATALOG.map((entry) => { + const trigger: BundledSkill["trigger"] = + entry.triggers?.length > 0 + ? { type: "keyword", keywords: entry.triggers } + : null; + + // Use the absolute path to the skill's SKILL.md so the Python + // agent-server can resolve bundled resources (scripts/, references/). + // Falls back to "public" in library builds where the path isn't known. + const source = __EXTENSIONS_SKILLS_DIR__ + ? `${__EXTENSIONS_SKILLS_DIR__}/${entry.name}/SKILL.md` + : "public"; + + return { + name: entry.name, + content: entry.content, + trigger, + source, + description: entry.description ?? null, + is_agentskills_format: true as const, + ...(entry.license ? { license: entry.license } : {}), + ...(entry.compatibility ? { compatibility: entry.compatibility } : {}), + }; + }); +} + +function buildAgentContext( + agentSettings: SettingsRecord, + runtimeServicesInfo?: RuntimeServicesInfo | null, + enablement: SkillEnablement = {}, + invokedCatalogSkill?: string, +): SettingsRecord { + const runtimeServicesSuffix = + buildRuntimeServicesSystemSuffix(runtimeServicesInfo); + const existingContext = toRecord(agentSettings.agent_context); + + // Merge bundled public skills with any skills already present in the + // agent context (e.g. user-defined skills set via the settings API). + const existingSkills = Array.isArray(existingContext.skills) + ? (existingContext.skills as SettingsRecord[]) + : []; + const disabledSkills = enablement.disabledSkills ?? []; + const disabledSkillNames = new Set(disabledSkills); + const isSkillEnabled = buildSkillEnablementFilter(enablement); + + // The bundled catalog is allow-listed, not deny-listed: it is a build-time + // snapshot of ~60 skills, so a deny-list puts every future addition into + // every system prompt (OpenHands#16302). Skills the agent context already + // carries are user-authored and stay opt-out. A skill the opening message + // invokes by name overrides both, for this conversation only. + const mergedSkills = [ + ...existingSkills.filter( + (skill) => + typeof skill.name !== "string" || !disabledSkillNames.has(skill.name), + ), + ...buildBundledSkills().filter( + (skill) => + skill.name === invokedCatalogSkill || isSkillEnabled(skill.name), + ), + ]; + + return { + ...existingContext, + // Public skills are bundled at build time from the @openhands/extensions + // npm package and passed directly in agent_context.skills. Setting + // load_public_skills to false tells the agent-server SDK to skip its own + // extensions-repo clone — the frontend is the sole source of public + // skills now. + // + // Migration: the former VITE_LOAD_PUBLIC_SKILLS env var was removed + // because bundled skills have no clone latency. Users who previously set + // VITE_LOAD_PUBLIC_SKILLS=false to avoid clone delays no longer need it. + skills: mergedSkills, + load_public_skills: false, + load_user_skills: true, + load_project_skills: true, + // The backend also auto-loads user/project skills; the deny-list must + // travel with the context so those skills are excluded from the system + // prompt too. The allow-list has no counterpart to send: the backend + // loads no catalog skills of its own (`load_public_skills` is false). + disabled_skills: disabledSkills, + ...(runtimeServicesSuffix + ? { system_message_suffix: runtimeServicesSuffix } + : {}), + }; +} + +function isAcpAgent(settings: Settings): boolean { + const agentSettings = toRecord(settings.agent_settings); + return agentSettings.agent_kind === "acp"; +} + +function getAcpServerTag(settings: Settings): string | undefined { + const agentSettings = toRecord(settings.agent_settings); + const value = agentSettings.acp_server; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function resolveAcpCommand(agentSettings: SettingsRecord): unknown { + const cmd = agentSettings.acp_command; + const isEmpty = Array.isArray(cmd) && cmd.length === 0; + const noCommand = cmd === undefined; + if (!isEmpty && !noCommand) { + return cmd; + } + + const serverKey = + typeof agentSettings.acp_server === "string" + ? agentSettings.acp_server + : undefined; + const provider = getAcpProvider(serverKey); + return provider ? [...provider.default_command] : cmd; +} + +function buildConfiguredAcpAgentSettings( + settings: Settings, + runtimeServicesInfo?: RuntimeServicesInfo | null, + query?: string, +): AgentSettingsPayload { + const agentSettings = toRecord(settings.agent_settings); + const payload: AgentSettingsPayload = { + agent_kind: "acp", + agent_context: buildAgentContext( + agentSettings, + runtimeServicesInfo, + toSkillEnablement(settings), + findInvokedCatalogSkill(query), + ), + }; + + // TODO(#1019): set ``acp_isolate_data_dir: true`` here for a containerized + // backend so concurrent same-provider conversations don't race on a shared + // HOME. The SDK supports it (software-agent-sdk#3492), but the released + // ``@openhands/typescript-client`` (1.24.3) doesn't surface it on + // ``ACPAgentSettings`` yet, so sending it risks a validation error on older + // servers. Cloud grouping isolation is separate (agent-canvas#1016). + + for (const key of ACP_SETTINGS_KEYS) { + // ``acp_model`` is resolved separately below so a saved ``null`` still + // falls back to the provider's default rather than being dropped. + if (key === "acp_model") continue; + // ``acp_env`` is deprecated — provider creds now route via ``request.secrets``. + if (key === "acp_env") continue; + const value = + key === "acp_command" + ? resolveAcpCommand(agentSettings) + : agentSettings[key]; + if (value !== undefined && value !== null) { + payload[key] = value; + } + } + + // ``mcp_config`` is a shared field (not in ACP_SETTINGS_KEYS): forward it + // so the ACP subprocess connects to the configured MCP servers at session + // creation. Only include it when it actually carries servers — an empty or + // malformed value is dropped rather than sending ``mcp_config: {}``. + const mcpConfig = toRecord(agentSettings.mcp_config); + if (Object.keys(mcpConfig).length > 0) { + payload.mcp_config = mcpConfig; + } + + // Saved settings may carry ``acp_model: null`` (existing users predating + // the default-model registry, or saved fields the agent-server stripped). + // Fall back to the *preferred* default (Vertex-safe for Gemini) so the + // conversation starts with whatever the Settings → Agent UI shows — without + // that, the form's displayed default would silently not take effect at + // runtime until the user re-saved the page. + const serverKey = + typeof agentSettings.acp_server === "string" + ? agentSettings.acp_server + : undefined; + const effectiveModel = resolveEffectiveAcpModel({ + configured: agentSettings.acp_model as string | null | undefined, + providerDefault: getAcpPreferredDefaultModel(serverKey), + }); + if (effectiveModel) { + payload.acp_model = effectiveModel; + } + + return payload; +} + +function buildConfiguredOpenHandsAgentSettings( + settings: Settings, + runtimeServicesInfo?: RuntimeServicesInfo | null, + query?: string, +): AgentSettingsPayload { + const agentSettings = toRecord(settings.agent_settings); + const llm = buildNormalizedLlmSettings(agentSettings.llm); + + // Stream assistant tokens (parity with ACP agents). The agent-server only + // emits StreamingDeltaEvents for SDK LLM agents when an LLM has stream=True. + llm.stream = true; + + const mcpConfig = toRecord(agentSettings.mcp_config); + if (Object.keys(mcpConfig).length === 0) { + delete agentSettings.mcp_config; + } + + delete agentSettings.acp_server; + for (const key of ACP_SETTINGS_KEYS) { + delete agentSettings[key]; + } + // ``acp_env`` is no longer a forwarded ACP setting (provider creds ride the + // Secrets panel), but a legacy value may linger on persisted settings — + // scrub it so it never leaks onto the OpenHands payload. + delete agentSettings.acp_env; + + return { + ...agentSettings, + llm, + agent_context: buildAgentContext( + agentSettings, + runtimeServicesInfo, + toSkillEnablement(settings), + findInvokedCatalogSkill(query), + ), + tools: getAgentTools(agentSettings), + }; +} + +function buildConfiguredAgentSettings( + settings: Settings, + runtimeServicesInfo?: RuntimeServicesInfo | null, + query?: string, +): AgentSettingsPayload { + return isAcpAgent(settings) + ? buildConfiguredAcpAgentSettings(settings, runtimeServicesInfo, query) + : buildConfiguredOpenHandsAgentSettings( + settings, + runtimeServicesInfo, + query, + ); +} + +function buildConfiguredConversationSettings(options: { + settings: Settings; + query?: string; + conversationInstructions?: string; + plugins?: PluginSpec[]; + workingDir?: string; +}): ConversationSettingsPayload { + const { settings, query, conversationInstructions, plugins, workingDir } = + options; + const conversationSettings = toRecord(settings.conversation_settings); + const initialMessage = buildInitialMessage(query, conversationInstructions); + + CONVERSATION_SETTINGS_METADATA_KEYS.forEach( + (key) => delete conversationSettings[key], + ); + + const payload: ConversationSettingsPayload = { + ...conversationSettings, + workspace: { + kind: "LocalWorkspace", + working_dir: workingDir ?? getAgentServerWorkingDir(), + }, + ...(initialMessage ? { initial_message: initialMessage } : {}), + ...(plugins?.length + ? { + plugins: plugins.map((plugin) => ({ + source: plugin.source, + ...(plugin.ref ? { ref: plugin.ref } : {}), + ...(plugin.repo_path ? { repo_path: plugin.repo_path } : {}), + })), + } + : {}), + }; + + return payload; +} + +interface LookupSecret { + kind: "LookupSecret"; + url: string; + headers?: Record; + description?: string; +} + +/** A custom secret's public identity — name + optional description, no value. */ +type CustomSecretInput = { name: string; description?: string }; + +type StartConversationPayloadBase = Record & { + workspace: LocalWorkspacePayload; + confirmation_policy: SettingsRecord; + security_analyzer?: SettingsRecord; + initial_message?: InitialMessagePayload; + max_iterations: number; + stuck_detection: true; + autotitle: boolean; + title_llm_profile?: string; + worktree: boolean; + secrets_encrypted?: true; + conversation_id?: string; + parent_conversation_id?: string; + secrets?: Record; + tags?: Record; + client_tools: ClientToolSpec[]; + tool_module_qualnames?: Record; +}; + +type AgentSettingsStartConversationPayload = StartConversationPayloadBase & { + // Omitted when launching via ``agent_profile_id`` — the two are mutually + // exclusive agent sources; the server resolves the profile server-side. + agent_settings?: AgentSettingsPayload; + agent_profile_id?: string; + agent?: never; +}; + +// The local planner bypasses agent assembly entirely and sends a raw agent +// spec, so neither of the two managed agent sources applies. +type RawAgentStartConversationPayload = StartConversationPayloadBase & { + agent: SettingsRecord; + agent_settings?: never; + agent_profile_id?: never; + parent_conversation_id?: string; +}; + +export interface StartConversationOptions { + settings: Settings; + query?: string; + conversationInstructions?: string; + plugins?: PluginSpec[]; + conversationId?: string; + // Links the new conversation to an existing one as its child. The + // agent-server requires the parent to exist and to share this + // conversation's requested `workspace.working_dir` (software-agent-sdk + // #4188, agent-server >= 1.37.1); older servers ignore the field. + parentConversationId?: string; + workingDir?: string; + worktree?: boolean; + encryptedAgentSettings?: Record; + encryptedConversationSettings?: Record; + secretsEncrypted?: boolean; + customSecrets?: CustomSecretInput[]; + // When set, the conversation launches from this AgentProfile (resolved + // server-side) instead of an inline ``agent_settings`` dump (#3727). + agentProfileId?: string; + agentProfileKind?: AgentKind; + titleLlmProfile?: string; + runtimeServicesInfo?: RuntimeServicesInfo | null; + workspaceHookConfig?: HookConfig | null; +} + +/** + * Build the `request.secrets` map shared by the standard and planning + * conversation builders. Every saved secret rides as a LookupSecret the + * agent-server resolves from its own store at spawn time — `request.secrets` is + * the sole channel, uniform for ACP and non-ACP (agent-canvas#1039). For ACP the + * resolution runs off the event loop (software-agent-sdk#3510, >=1.25.0), so the + * loopback fetch can't deadlock. Returns `undefined` when there are no custom + * secrets so callers can omit the field. + */ +function buildCustomSecrets( + customSecrets: CustomSecretInput[] | undefined, +): Record | undefined { + if (!customSecrets?.length) return undefined; + + const backend = getEffectiveLocalBackend(); + const headers = backend ? buildAuthHeaders(backend) : {}; + + const secrets: Record = {}; + for (const secret of customSecrets) { + const lookupSecret: LookupSecret = { + kind: "LookupSecret", + url: `/api/settings/secrets/${encodeURIComponent(secret.name)}`, + description: secret.description, + }; + if (Object.keys(headers).length > 0) { + lookupSecret.headers = headers; + } + secrets[secret.name] = lookupSecret; + } + return secrets; +} + +export function buildStartConversationRequest( + options: StartConversationOptions, +): AgentSettingsStartConversationPayload { + const sourceAgentSettings = options.encryptedAgentSettings + ? { ...options.settings, agent_settings: options.encryptedAgentSettings } + : options.settings; + + const acpMode = isAcpAgent(sourceAgentSettings); + const launchAgentKind = options.agentProfileId + ? options.agentProfileKind + : acpMode + ? "acp" + : "openhands"; + const agentSettings = buildConfiguredAgentSettings( + sourceAgentSettings, + options.runtimeServicesInfo, + options.query, + ); + const acpServerTag = acpMode + ? getAcpServerTag(sourceAgentSettings) + : undefined; + + const sourceConversationOptions = options.encryptedConversationSettings + ? { + ...options, + settings: { + ...options.settings, + conversation_settings: options.encryptedConversationSettings, + }, + } + : options; + + const conversationSettings = buildConfiguredConversationSettings( + sourceConversationOptions, + ); + + const payload: AgentSettingsStartConversationPayload = { + // ``agent_profile_id`` and ``agent_settings`` are mutually exclusive agent + // sources; the profile path lets the server resolve the profile (#3727). + // + // Enrichment boundary: on the profile path the server rebuilds the agent + // purely from the stored profile fields, so the client-owned enrichments + // this adapter folds into ``agent_settings`` do NOT apply. The exec toolset + // (terminal/file_editor/task_tracker) and public-skill loading are the + // server/SDK's responsibility to restore on the profile path — tracked in + // software-agent-sdk#3967 (profile resolution must attach the default + // toolset + public skills, else a profile-launched OpenHands agent has only + // Finish/Think). The dev ``RUNTIME_SERVICES`` system-message suffix remains + // agent-settings-only; the Canvas UI tool is a top-level client tool and + // therefore works on both inline-agent and profile launch paths. + // + // Persistent memory is NOT on that boundary: ``load_memory`` is a global + // user preference, so the agent-server stamps the stored + // ``agent_settings.agent_context.load_memory`` onto the profile-resolved + // agent the same way it already applies the global ``mcp_config``. The + // toggle therefore applies to both launch paths, and the client must not + // re-send it here (``agent_profile_id`` and ``agent_settings`` are + // mutually exclusive). + ...(options.agentProfileId + ? { agent_profile_id: options.agentProfileId } + : { agent_settings: agentSettings }), + workspace: conversationSettings.workspace, + // The agent-server caches each client tool's schema per tool *name* for the + // life of the process and rejects a re-registration with a different schema + // (`ClientToolSchemaConflictError`). Editing either schema below therefore + // requires restarting a long-running dev agent-server before new + // conversations can start. + client_tools: + launchAgentKind === "openhands" + ? [CANVAS_UI_CLIENT_TOOL, LAUNCH_CHILD_CONVERSATION_CLIENT_TOOL] + : [], + confirmation_policy: + getConversationConfirmationPolicy(conversationSettings), + max_iterations: resolveMaxIterations(conversationSettings.max_iterations), + stuck_detection: true, + autotitle: true, + ...(options.titleLlmProfile + ? { title_llm_profile: options.titleLlmProfile } + : {}), + worktree: options.worktree ?? true, + }; + + // Stamp the client source tag so the agent-server can attribute the + // conversation to Canvas in telemetry (conversation_source = "canvas"). + // A profile launch resolves the ACP server server-side, so don't stamp the + // tag from current settings (it may not match the launched profile). + if (!options.agentProfileId && acpServerTag) { + payload.tags = { + [ACP_SERVER_TAG_KEY]: acpServerTag, + [CLIENT_SOURCE_TAG_KEY]: AGENT_CANVAS_SOURCE, + }; + } else { + payload.tags = { [CLIENT_SOURCE_TAG_KEY]: AGENT_CANVAS_SOURCE }; + } + + // ``secrets_encrypted`` makes the agent-server decrypt request secrets at + // conversation start. Non-ACP conversations need it for encrypted LLM keys. + // ACP normally carries provider credentials as LookupSecrets, so avoid + // forcing a cipher on fresh ACP-only backends. The exception is MCP: + // encrypted settings round-trip mcp_config secrets as Fernet tokens, + // and ACP forwards mcp_config directly to the subprocess. + if ( + !options.agentProfileId && + options.secretsEncrypted && + (!acpMode || hasEncryptedMcpSecrets(agentSettings.mcp_config)) + ) { + payload.secrets_encrypted = true; + } + + if (options.conversationId) { + payload.conversation_id = options.conversationId; + } + + if (options.parentConversationId) { + payload.parent_conversation_id = options.parentConversationId; + } + + const securityAnalyzer = + getConversationSecurityAnalyzer(conversationSettings); + if (securityAnalyzer) { + payload.security_analyzer = securityAnalyzer; + } + + if (conversationSettings.initial_message) { + payload.initial_message = conversationSettings.initial_message; + } + + if (conversationSettings.plugins) { + payload.plugins = conversationSettings.plugins; + } + + if (conversationSettings.hook_config) { + payload.hook_config = conversationSettings.hook_config; + } else if (options.workspaceHookConfig) { + payload.hook_config = options.workspaceHookConfig; + } + + const toolModuleQualnames = { + ...((conversationSettings.tool_module_qualnames as + | Record + | undefined) ?? {}), + }; + delete toolModuleQualnames[LEGACY_CANVAS_UI_TOOL_NAME]; + delete toolModuleQualnames[CANVAS_UI_CLIENT_TOOL_NAME]; + delete toolModuleQualnames[LAUNCH_CHILD_CONVERSATION_TOOL_NAME]; + if (Object.keys(toolModuleQualnames).length > 0) { + payload.tool_module_qualnames = toolModuleQualnames; + } + + if (conversationSettings.agent_definitions) { + payload.agent_definitions = conversationSettings.agent_definitions; + } + + const secrets = buildCustomSecrets(options.customSecrets); + if (secrets) { + payload.secrets = secrets; + } + + return payload; +} + +export function buildStartPlanningConversationRequest(options: { + encryptedAgentSettings: Record; + workingDir: string; + parentConversationId: string; + initialMessage?: string; + secretsEncrypted?: boolean; + customSecrets?: CustomSecretInput[]; + /** Mirrors the parent's configured `conversation_settings.max_iterations` — see DEFAULT_MAX_ITERATIONS. */ + maxIterations?: number; + /** Mirrors the code agent's skill filtering — see buildConfiguredOpenHandsAgentSettings. */ + skillEnablement?: SkillEnablement; +}): RawAgentStartConversationPayload { + const agentSettings = toRecord(options.encryptedAgentSettings); + const llm = buildNormalizedLlmSettings(agentSettings.llm); + // Stream assistant tokens, matching the code agent (see + // buildConfiguredOpenHandsAgentSettings) — otherwise the agent-server never + // emits StreamingDeltaEvents for the planner and its replies would appear + // all at once instead of token-by-token. + llm.stream = true; + + const planPath = buildPlanPath(options.workingDir); + + // Put the planner's directive + boundaries in the system prompt (matching the + // OpenHands app-server's PLANNING_AGENT_INSTRUCTION), preserving any suffix + // buildAgentContext already set (e.g. the runtime-services block). + const agentContext = buildAgentContext( + agentSettings, + undefined, + options.skillEnablement, + ); + const existingSuffix = agentContext.system_message_suffix; + agentContext.system_message_suffix = + typeof existingSuffix === "string" + ? `${PLANNING_AGENT_INSTRUCTION}\n\n${existingSuffix}` + : PLANNING_AGENT_INSTRUCTION; + + // Idle planner: "Create a Plan" only switches to plan mode and provisions + // this conversation; the user sends the first message themselves, so nothing + // is injected into the chat. An explicit initialMessage is still honored. + const initialMessage = buildInitialMessage(options.initialMessage); + + const payload: RawAgentStartConversationPayload = { + agent: { + kind: "Agent", + llm, + tools: [ + { name: "glob", params: {} }, + { name: "grep", params: {} }, + { + name: PLANNING_FILE_EDITOR_TOOL_NAME, + params: { plan_path: planPath }, + }, + ], + system_prompt_filename: PLANNING_SYSTEM_PROMPT_FILENAME, + system_prompt_kwargs: { plan_structure: PLAN_STRUCTURE_TEXT }, + // agent_context (skills + project context) is intentionally included so + // the planner shares the code agent's project context. This goes beyond + // the bare SDK `get_planning_agent()` preset (which sets no context); it + // mirrors how the frontend builds every agent, so the local planner is + // not context-starved relative to the code agent. + agent_context: agentContext, + // Mirror the SDK planning preset's condenser (openhands-tools preset + // `get_planning_condenser`): a larger rolling window and more pinned + // initial context than the default, with its own usage_id so its + // summarization LLM calls are attributed separately. The planning + // conversation is sent as a raw agent spec (it bypasses the agent-server's + // default agent assembly), so without this it would never condense — + // diverging from the canonical planning agent on long sessions. + condenser: { + kind: "LLMSummarizingCondenser", + llm: { ...llm, usage_id: "planning_condenser" }, + max_size: 100, + keep_first: 6, + }, + }, + workspace: { + kind: "LocalWorkspace", + working_dir: options.workingDir, + }, + // No Canvas UI client tool: the planner's only output is PLAN.md, which the + // Planner tab surfaces on its own via PlanningFileEditorObservation. Handing + // it the panel-control tool would only let it navigate the right-side panel + // away from the plan the user is reading. + client_tools: [], + confirmation_policy: { kind: "NeverConfirm" }, + max_iterations: options.maxIterations ?? DEFAULT_MAX_ITERATIONS, + stuck_detection: true, + autotitle: false, + worktree: false, + // Two independent links to the parent, for two different jobs: + // + // - ``parent_conversation_id`` is the server-owned relationship (SDK + // #4188, agent-server >= 1.37.1). The parent's ``sub_conversation_ids`` + // is derived from it, so the planner is recoverable from the server on + // any browser and after storage loss. Older agent-servers ignore the + // field (``StartConversationRequest`` does not forbid extras), which is + // why the client-side metadata hint is still written as a fallback. + // - The tag is what hides the helper from the conversation list; it is + // also the only marker on agent-servers too old for the parent link. + parent_conversation_id: options.parentConversationId, + tags: { [LOCAL_PLANNER_PARENT_TAG_KEY]: options.parentConversationId }, + ...(initialMessage ? { initial_message: initialMessage } : {}), + }; + + if (options.secretsEncrypted) { + payload.secrets_encrypted = true; + } + + const secrets = buildCustomSecrets(options.customSecrets); + if (secrets) { + payload.secrets = secrets; + } + + return payload; +} + +/** + * Resolve the encrypted LLM config for a named LLM profile, so a planner can + * run the parent conversation's *current* model (`active_profile`, tracking + * `/model` and `SwitchLLMTool`) rather than a stale launch-time value. + * Returns `null` — caller falls back further — if unknown or lookup fails. + */ +async function resolveLlmProfileSettings( + profileName: string, +): Promise { + try { + const { default: ProfilesService } = + await import("./profiles-service/profiles-service.api"); + // ``encrypted`` matches ``secrets_encrypted`` on the payload: the + // agent-server decrypts with the same cipher it encrypted with. + const detail = await ProfilesService.getProfile(profileName, "encrypted"); + return isPlainRecord(detail.config) ? detail.config : null; + } catch (error) { + console.warn( + `Falling back: could not resolve LLM profile ${profileName}`, + error, + ); + return null; + } +} + +/** + * Resolve the encrypted LLM config the given AgentProfile launches with, as a + * fallback for `resolveLlmProfileSettings`. AgentProfiles hold a reference + * (`llm_profile_ref`), not credentials, so this is a two-hop lookup: profile + * id -> llm profile name -> encrypted LLM config. Returns `null` — caller + * falls back to global settings — for an unknown/ACP/dangling-ref profile or + * a failed lookup, none of which should block plan creation outright. + */ +async function resolveAgentProfileLlmSettings( + agentProfileId: string, +): Promise { + try { + const { default: AgentProfilesService } = + await import("./agent-profiles-service/agent-profiles-service.api"); + + const { profiles } = await AgentProfilesService.listProfiles(); + const summary = profiles.find( + (profile) => profile.id === agentProfileId && profile.llm_profile_ref, + ); + if (!summary?.llm_profile_ref) return null; + + return await resolveLlmProfileSettings(summary.llm_profile_ref); + } catch (error) { + console.warn( + `Falling back to global agent settings: could not resolve the LLM for agent profile ${agentProfileId}`, + error, + ); + return null; + } +} + +export async function buildStartPlanningConversationRequestWithEncryptedSettings(options: { + workingDir: string; + parentConversationId: string; + /** + * The parent conversation's current LLM profile (`AppConversation.active_profile`, + * tracking `/model` and `SwitchLLMTool`). Takes priority over + * `parentAgentProfileId` so a model switch on the parent carries over to a + * planner created afterward, without a *different* conversation's global + * profile activation repointing it. + */ + parentActiveProfileName?: string | null; + /** + * `launched_agent_profile.agent_profile_id` of the parent, when started + * from an AgentProfile. Fallback for when `parentActiveProfileName` can't + * be resolved (e.g. an ACP parent, whose `active_profile` is a stale + * launch-time snapshot rather than anything the ACP agent itself runs). + */ + parentAgentProfileId?: string | null; + initialMessage?: string; +}): Promise { + const { SecretsService } = await import("./secrets-service"); + + const [settingsResult, customSecrets] = await Promise.all([ + SettingsService.getSettingsForConversation(), + SecretsService.getSecrets(), + ]); + + const profileLlm = + (options.parentActiveProfileName + ? await resolveLlmProfileSettings(options.parentActiveProfileName) + : null) ?? + (options.parentAgentProfileId + ? await resolveAgentProfileLlmSettings(options.parentAgentProfileId) + : null); + const encryptedAgentSettings: Record = profileLlm + ? { ...settingsResult.agentSettings, llm: profileLlm as SettingsValue } + : settingsResult.agentSettings; + + await assertSubscriptionAuthReady(encryptedAgentSettings); + + // Mirror the code agent's configured cap (see DEFAULT_MAX_ITERATIONS) rather + // than hardcoding a different value the planner could silently stop against. + const maxIterations = resolveMaxIterations( + settingsResult.conversationSettings.max_iterations, + ); + + return buildStartPlanningConversationRequest({ + ...options, + encryptedAgentSettings, + secretsEncrypted: settingsResult.secretsEncrypted, + customSecrets, + maxIterations, + skillEnablement: settingsResult.skillEnablement, + }); +} + +export const SUBSCRIPTION_LOGIN_REQUIRED_ERROR = + "Connect your ChatGPT subscription before starting a conversation with this LLM profile."; + +/** + * Throws if a ChatGPT subscription LLM profile is not connected. + * Called before conversation creation and LLM profile switch only — not on + * subsequent message sends or conversation resume. The agent-server must handle + * mid-conversation token expiry gracefully. + */ +export async function assertSubscriptionAuthReady( + agentSettings: Record, +): Promise { + const llm = toRecord(agentSettings.llm); + if (!isSubscriptionLlmConfig(llm)) return; + + const status = await LLMSubscriptionService.getOpenAIStatus(); + if (!status.connected) { + throw new Error(SUBSCRIPTION_LOGIN_REQUIRED_ERROR); + } +} + +export async function buildStartConversationRequestWithEncryptedSettings(options: { + settings: Settings; + query?: string; + conversationInstructions?: string; + plugins?: PluginSpec[]; + conversationId?: string; + parentConversationId?: string; + workingDir?: string; + /** Workspace root for the hooks lookup, not the per-conversation `workingDir` (#16907). */ + hooksProjectDir?: string; + worktree?: boolean; + agentProfileId?: string; + agentProfileKind?: AgentKind; + titleLlmProfile?: string; +}): Promise> { + const [{ SecretsService }, { default: HooksService }] = await Promise.all([ + import("./secrets-service"), + import("./hooks-service"), + ]); + + const [ + settingsResult, + customSecrets, + runtimeServicesInfo, + workspaceHookConfig, + ] = await Promise.all([ + SettingsService.getSettingsForConversation(), + SecretsService.getSecrets(), + fetchBackendRuntimeServicesInfo(), + HooksService.loadWorkspaceHooks(options.hooksProjectDir), + ]); + + const { agentSettings, conversationSettings, secretsEncrypted } = + settingsResult; + + // A profile launch resolves the LLM server-side, so the current-settings + // subscription check doesn't apply (and can't see the profile's LLM). + if (!options.agentProfileId) { + await assertSubscriptionAuthReady(agentSettings); + } + + return buildStartConversationRequest({ + ...options, + encryptedAgentSettings: agentSettings, + encryptedConversationSettings: conversationSettings, + secretsEncrypted, + customSecrets, + runtimeServicesInfo, + workspaceHookConfig, + }); +} + +export function emptyHooksResponse(): GetHooksResponse { + return { hooks: [] }; +} diff --git a/src/api/agent-server-client-options.ts b/src/api/agent-server-client-options.ts new file mode 100644 index 0000000000000000000000000000000000000000..6926bdf1bc4b6cd5f4582c2dcf55e0d802bc0439 --- /dev/null +++ b/src/api/agent-server-client-options.ts @@ -0,0 +1,80 @@ +import { buildHttpBaseUrl } from "#/utils/websocket-url"; +import { getAgentServerWorkingDir } from "./agent-server-config"; +import { getEffectiveLocalBackend } from "./backend-registry/active-store"; +import type { Backend } from "./backend-registry/types"; + +export interface AgentServerClientOverrides { + host?: string; + apiKey?: string | null; + sessionApiKey?: string | null; + workingDir?: string; + conversationUrl?: string | null; + timeout?: number; +} + +export interface AgentServerClientOptions { + host: string; + apiKey?: string; + workingDir: string; + timeout?: number; +} + +export class NoBackendAvailableError extends Error { + constructor() { + super("No backend is configured."); + this.name = "NoBackendAvailableError"; + } +} + +export const isNoBackendAvailableError = ( + error: unknown, +): error is NoBackendAvailableError => + error instanceof NoBackendAvailableError || + (typeof error === "object" && + error !== null && + "name" in error && + error.name === "NoBackendAvailableError"); + +function normalizeHost(host: string): string { + return host.replace(/\/+$/, ""); +} + +function resolveHost( + overrides: AgentServerClientOverrides, + backend: Backend | null, +): string { + if (overrides.host) return normalizeHost(overrides.host); + if (overrides.conversationUrl) + return normalizeHost(buildHttpBaseUrl(overrides.conversationUrl)); + return normalizeHost(backend?.host ?? ""); +} + +export function getAgentServerClientOptions( + overrides: AgentServerClientOverrides = {}, +): AgentServerClientOptions { + const backend = getEffectiveLocalBackend(); + if (!backend && !overrides.host && !overrides.conversationUrl) { + throw new NoBackendAvailableError(); + } + + const apiKey = + overrides.sessionApiKey ?? overrides.apiKey ?? backend?.apiKey ?? undefined; + + return { + host: resolveHost(overrides, backend), + ...(apiKey ? { apiKey } : {}), + workingDir: overrides.workingDir ?? getAgentServerWorkingDir(), + ...(overrides.timeout !== undefined ? { timeout: overrides.timeout } : {}), + }; +} + +export function getAgentServerHttpClientOptions( + overrides?: AgentServerClientOverrides, +) { + const { host, apiKey, timeout } = getAgentServerClientOptions(overrides); + return { + baseUrl: host, + ...(apiKey ? { apiKey } : {}), + timeout: timeout ?? 60000, + }; +} diff --git a/src/api/agent-server-compatibility.test.ts b/src/api/agent-server-compatibility.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..bde7fa0e6b09d3186160acb57cad1f8a9c4a9f5c --- /dev/null +++ b/src/api/agent-server-compatibility.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { + AgentServerUnknownVersionError, + AgentServerUnsupportedVersionError, + assertAgentServerVersionIsSupported, + getDisplayAgentServerSdkVersion, + getDisplayAgentServerVersion, + type AgentServerInfo, +} from "./agent-server-compatibility"; + +const serverInfo = (version?: string, sdkVersion?: string): AgentServerInfo => + ({ version, sdk_version: sdkVersion }) as AgentServerInfo; + +describe("agent-server version compatibility", () => { + it("classifies missing and unknown versions separately from old versions", () => { + for (const version of [undefined, "", "unknown", " UNKNOWN "]) { + expect(() => + assertAgentServerVersionIsSupported(serverInfo(version)), + ).toThrow(AgentServerUnknownVersionError); + } + }); + + it("classifies malformed versions separately from old versions", () => { + expect(() => + assertAgentServerVersionIsSupported(serverInfo("dev-build")), + ).toThrow(AgentServerUnknownVersionError); + }); + + it("keeps valid but too-old versions on the unsupported-version path", () => { + expect(() => + assertAgentServerVersionIsSupported(serverInfo("0.0.1")), + ).toThrow(AgentServerUnsupportedVersionError); + }); + + it("does not render unknown or malformed versions as backend badges", () => { + expect(getDisplayAgentServerVersion(serverInfo("unknown"))).toBeNull(); + expect(getDisplayAgentServerVersion(serverInfo("dev-build"))).toBeNull(); + expect(getDisplayAgentServerVersion(serverInfo("1.28.0"))).toBe("1.28.0"); + }); + + it("uses sdk_version as the agent-server version fallback", () => { + expect(getDisplayAgentServerVersion(serverInfo(undefined, "1.36.1"))).toBe( + "1.36.1", + ); + expect(() => + assertAgentServerVersionIsSupported(serverInfo(undefined, "1.36.1")), + ).not.toThrow(); + }); + + it("reports the dedicated agent-server SDK version when present", () => { + expect( + getDisplayAgentServerSdkVersion(serverInfo("1.36.0", "1.36.1")), + ).toBe("1.36.1"); + expect(getDisplayAgentServerSdkVersion(serverInfo("1.36.0"))).toBe( + "1.36.0", + ); + }); +}); diff --git a/src/api/agent-server-compatibility.ts b/src/api/agent-server-compatibility.ts new file mode 100644 index 0000000000000000000000000000000000000000..a2834b6423f70210274f63c5f070206f76a634ee --- /dev/null +++ b/src/api/agent-server-compatibility.ts @@ -0,0 +1,444 @@ +import { + ServerClient, + SettingsClient, +} from "@openhands/typescript-client/clients"; +import type { ServerInfo as BaseServerInfo } from "@openhands/typescript-client"; +import { getAgentServerClientOptions } from "#/api/agent-server-client-options"; +import { isAuthRequired } from "#/api/agent-server-config"; +import { + getActiveBackend, + getEffectiveLocalBackend, + isNoBackend, +} from "#/api/backend-registry/active-store"; +import type { Backend } from "#/api/backend-registry/types"; +import defaults from "../../config/defaults.json"; + +const AGENT_SERVER_INFO_TIMEOUT_MS = 5000; +const UNKNOWN_AGENT_SERVER_VERSION = "unknown"; + +export const MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION = + defaults.compatibility.minimumAgentServer; +export const AGENT_SERVER_UNSUPPORTED_VERSION_ERROR_CODE = + "AGENT_SERVER_UNSUPPORTED_VERSION"; +export const AGENT_SERVER_UNKNOWN_VERSION_ERROR_CODE = + "AGENT_SERVER_UNKNOWN_VERSION"; + +/** + * Sentinel string thrown (as an Error message) when a local backend + * rejects the configured session API key with HTTP 401. + * Shared between the backend health probe ({@link validateLocalBackend}) + * and any consumer that needs to detect this specific failure. + */ +export const INVALID_BACKEND_API_KEY_ERROR = "Invalid API key"; + +export interface AgentServerInfo extends BaseServerInfo { + sdk_version?: string; + usable_tools?: string[] | null; + runtime_services?: unknown; +} + +let cachedAgentServerInfo: AgentServerInfo | null = null; +let cachedAgentServerInfoHost: string | null = null; + +const getAdvertisedTools = (serverInfo: AgentServerInfo | null) => { + if (Array.isArray(serverInfo?.usable_tools)) { + return serverInfo.usable_tools; + } + return null; +}; + +export class AgentServerUnavailableError extends Error { + readonly details: string | null; + readonly noBackendConfigured: boolean; + + constructor( + details?: string | null, + options?: { noBackendConfigured?: boolean }, + ) { + const noBackendConfigured = options?.noBackendConfigured ?? false; + super( + noBackendConfigured + ? "No agent server backend is configured yet. Add a backend to get started." + : "Could not connect to the configured agent server. Make sure it is running and reachable, then reload the page.", + ); + this.name = "AgentServerUnavailableError"; + this.details = details ?? null; + this.noBackendConfigured = noBackendConfigured; + } +} + +export const isAgentServerUnavailableError = ( + error: unknown, +): error is AgentServerUnavailableError => + error instanceof AgentServerUnavailableError || + (typeof error === "object" && + error !== null && + "name" in error && + (error.name === "AgentServerUnavailableError" || + error.name === "AgentServerUnsupportedVersionError" || + error.name === "AgentServerUnknownVersionError")); + +export class AgentServerUnsupportedVersionError extends AgentServerUnavailableError { + readonly code = AGENT_SERVER_UNSUPPORTED_VERSION_ERROR_CODE; + readonly actualVersion: string; + readonly requiredVersion = MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION; + + constructor(actualVersion: string) { + 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.`; + super(message); + this.name = "AgentServerUnsupportedVersionError"; + this.message = message; + this.actualVersion = actualVersion; + } +} + +export class AgentServerUnknownVersionError extends AgentServerUnavailableError { + readonly code = AGENT_SERVER_UNKNOWN_VERSION_ERROR_CODE; + readonly actualVersion: string | null; + readonly requiredVersion = MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION; + + constructor(actualVersion: string | null) { + const reported = actualVersion ? ` It reported "${actualVersion}".` : ""; + const message = + `Could not determine this backend's agent-server version.${reported} ` + + `Agent Canvas requires agent-server ${MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION} ` + + "or newer, but this backend did not return a valid version from " + + "/server_info. Restart or rebuild the agent-server backend, then try again."; + super(message); + this.name = "AgentServerUnknownVersionError"; + this.message = message; + this.actualVersion = actualVersion; + } +} + +export const isAgentServerUnsupportedVersionError = ( + error: unknown, +): error is AgentServerUnsupportedVersionError => + error instanceof AgentServerUnsupportedVersionError || + (typeof error === "object" && + error !== null && + "code" in error && + error.code === AGENT_SERVER_UNSUPPORTED_VERSION_ERROR_CODE); + +export const isAgentServerUnknownVersionError = ( + error: unknown, +): error is AgentServerUnknownVersionError => + error instanceof AgentServerUnknownVersionError || + (typeof error === "object" && + error !== null && + "code" in error && + error.code === AGENT_SERVER_UNKNOWN_VERSION_ERROR_CODE); + +/** + * Returns true when the agent-server probe failed with HTTP 401. + * In public mode this means the stored key is stale (server restarted + * with a different `LOCAL_BACKEND_API_KEY`). Only meaningful when + * auth is required — a 401 in local mode is a misconfiguration, not a + * key-rotation event. Uses {@link isAuthRequired} so both the build-time + * `VITE_AUTH_REQUIRED` flag and the runtime `window.__AGENT_CANVAS_AUTH_REQUIRED__` + * injection (used by pre-built static binaries) are honoured. + */ +export const isAgentServerAuthError = (error: unknown): boolean => + isAuthRequired() && isSdkHttpStatusError(error, 401); + +export function clearCachedAgentServerInfo() { + cachedAgentServerInfo = null; + cachedAgentServerInfoHost = null; +} + +export function getCachedAgentServerInfo(options?: { + host?: string | null; +}): AgentServerInfo | null { + if (options?.host && options.host !== cachedAgentServerInfoHost) { + return null; + } + return cachedAgentServerInfo; +} + +export function isAgentServerToolAvailable(toolName: string) { + const availableTools = getAdvertisedTools(cachedAgentServerInfo); + if (!Array.isArray(availableTools)) { + return true; + } + return availableTools.includes(toolName); +} + +export function isSdkHttpError(error: unknown) { + return ( + error instanceof Error && + error.name === "HttpError" && + "status" in error && + typeof error.status === "number" + ); +} + +/** + * Narrows an SDK HTTP error to a specific status code. + * Use instead of manually casting `(err as { status: number }).status`. + */ +export function isSdkHttpStatusError(error: unknown, status: number): boolean { + return ( + isSdkHttpError(error) && (error as { status: number }).status === status + ); +} + +function normalizeAgentServerInfoVersion(version: unknown): string | null { + if (typeof version !== "string") return null; + const trimmed = version.trim(); + return trimmed || null; +} + +function getRawAgentServerSdkVersion( + serverInfo: AgentServerInfo, +): string | null { + return normalizeAgentServerInfoVersion(serverInfo.sdk_version); +} + +function getRawAgentServerVersion(serverInfo: AgentServerInfo): string | null { + return ( + normalizeAgentServerInfoVersion(serverInfo.version) ?? + getRawAgentServerSdkVersion(serverInfo) + ); +} + +function getComparableAgentServerVersion( + serverInfo: AgentServerInfo, +): string | null { + const version = getRawAgentServerVersion(serverInfo); + if (!version || version.toLowerCase() === UNKNOWN_AGENT_SERVER_VERSION) { + return null; + } + return version; +} + +export function getDisplayAgentServerVersion( + serverInfo: AgentServerInfo, +): string | null { + const version = getComparableAgentServerVersion(serverInfo); + if (!version || !parseAgentServerVersion(version)) { + return null; + } + return version; +} + +export function getDisplayAgentServerSdkVersion( + serverInfo: AgentServerInfo, +): string | null { + const version = + getRawAgentServerSdkVersion(serverInfo) ?? + getComparableAgentServerVersion(serverInfo); + if (!version || version.toLowerCase() === UNKNOWN_AGENT_SERVER_VERSION) { + return null; + } + return version; +} + +export function getCachedAgentServerSdkVersion( + host?: string | null, +): string | null { + if (!cachedAgentServerInfo) return null; + if (host && getEffectiveLocalBackend()?.host !== host) return null; + return getDisplayAgentServerSdkVersion(cachedAgentServerInfo); +} + +export function getCachedAgentServerVersion( + host?: string | null, +): string | null { + if (!cachedAgentServerInfo) return null; + if (host && getEffectiveLocalBackend()?.host !== host) return null; + return getDisplayAgentServerVersion(cachedAgentServerInfo); +} + +export function compareAgentServerVersions(actual: string, required: string) { + const parsedActual = parseAgentServerVersion(actual); + const parsedRequired = parseAgentServerVersion(required); + + if (!parsedActual || !parsedRequired) { + return null; + } + + for (const key of ["major", "minor", "patch"] as const) { + if (parsedActual[key] > parsedRequired[key]) { + return 1; + } + if (parsedActual[key] < parsedRequired[key]) { + return -1; + } + } + + if (parsedActual.prerelease && !parsedRequired.prerelease) { + return -1; + } + if (!parsedActual.prerelease && parsedRequired.prerelease) { + return 1; + } + if (parsedActual.prerelease && parsedRequired.prerelease) { + return parsedActual.prerelease.localeCompare(parsedRequired.prerelease); + } + + return 0; +} + +function parseAgentServerVersion(version: string) { + const trimmed = version.trim().replace(/^v/, ""); + const [withoutBuild] = trimmed.split("+"); + const [core, prerelease] = withoutBuild.split("-", 2); + const parts = core.split("."); + + if (parts.length !== 3) { + return null; + } + + const [major, minor, patch] = parts.map((part) => Number(part)); + if ( + ![major, minor, patch].every((part) => Number.isInteger(part) && part >= 0) + ) { + return null; + } + + return { major, minor, patch, prerelease }; +} + +export function assertAgentServerVersionIsSupported( + serverInfo: AgentServerInfo, +) { + const actualVersion = getComparableAgentServerVersion(serverInfo); + if (!actualVersion) { + clearCachedAgentServerInfo(); + throw new AgentServerUnknownVersionError( + getRawAgentServerVersion(serverInfo), + ); + } + + const comparison = compareAgentServerVersions( + actualVersion, + MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION, + ); + + if (comparison === null) { + clearCachedAgentServerInfo(); + throw new AgentServerUnknownVersionError(actualVersion); + } + + if (comparison < 0) { + clearCachedAgentServerInfo(); + throw new AgentServerUnsupportedVersionError(actualVersion); + } +} + +/** + * Validates a local agent-server backend with a two-step probe: + * 1. GET /api/settings — authenticates the configured session API key; + * a 401 throws an Error with message {@link INVALID_BACKEND_API_KEY_ERROR}. + * 2. GET /server_info — asserts the server meets the minimum version floor. + * + * Returns the display version string reported by the server, or `null` when + * the server does not report a parseable version. Throws on any failure. + * + * Used by both the backend health poller and the backend-form connection test + * so that auth-check semantics (status codes, error messages) stay in one place. + */ +export async function validateLocalBackend( + backend: Pick, + timeout: number, +): Promise { + const clientOptions = getAgentServerClientOptions({ + host: backend.host, + sessionApiKey: backend.apiKey || null, + timeout, + }); + + try { + await new SettingsClient(clientOptions).getSettings(); + const serverInfo = await new ServerClient(clientOptions).getServerInfo(); + assertAgentServerVersionIsSupported(serverInfo); + return getDisplayAgentServerVersion(serverInfo); + } catch (error) { + if (isSdkHttpStatusError(error, 401)) { + throw new Error(INVALID_BACKEND_API_KEY_ERROR); + } + throw error; + } +} + +export async function loadAgentServerInfo() { + // The probe is a *local* agent-server concern — it verifies the runtime + // hosting the GUI is reachable. It must NEVER run against the active + // backend when that backend is cloud, because cloud hosts don't + // expose /api/server_info and would fail with a CORS error besides. + const local = getEffectiveLocalBackend(); + if (!local) { + clearCachedAgentServerInfo(); + + // Empty registry (NO_BACKEND sentinel) — the user has no backend + // configured at all. Throw so root.tsx shows the manage-backends + // modal instead of silently rendering a broken home page. + if (isNoBackend(getActiveBackend().backend)) { + throw new AgentServerUnavailableError("No backend configured", { + noBackendConfigured: true, + }); + } + + // Active backend is cloud — no local probe needed. + return null; + } + + const clientOptions = getAgentServerClientOptions({ + host: local.host, + sessionApiKey: local.apiKey || null, + timeout: AGENT_SERVER_INFO_TIMEOUT_MS, + }); + let serverInfo: AgentServerInfo; + + try { + serverInfo = (await new ServerClient( + clientOptions, + ).getServerInfo()) as AgentServerInfo; + } catch (error) { + clearCachedAgentServerInfo(); + // Preserve 401 so root.tsx can show the auth screen (public mode). + // All other HTTP errors (502, 503, etc.) mean the server is unreachable + // or misconfigured — treat them as unavailable. + if (isSdkHttpStatusError(error, 401)) { + throw error; + } + + const details = error instanceof Error ? error.message : null; + throw new AgentServerUnavailableError(details); + } + + assertAgentServerVersionIsSupported(serverInfo); + + // /server_info is unprotected, so a stale session key still gets 200. + // In public mode, validate the key against a protected endpoint so a + // server restart with a new LOCAL_BACKEND_API_KEY surfaces immediately + // instead of letting the app load and fail on every subsequent call. + if (isAuthRequired()) { + try { + await new SettingsClient(clientOptions).getSettings(); + } catch (error) { + // Only rethrow 401 — that means the stored key is invalid / + // rotated. Other HTTP errors (403, 5xx) and non-HTTP errors + // (network, timeout) are swallowed: the server *is* up (we just + // reached /server_info), so let the app proceed with an + // unvalidated key rather than blocking the UI. + // NOTE: If the connection drops between the /server_info and + // getSettings() probes, the app loads with an unvalidated key and + // subsequent 401s won't trigger the auth screen (they come from + // React Query hooks, not this bootstrap path). Acceptable for now + // since the window is narrow and a page refresh recovers. + if (isSdkHttpStatusError(error, 401)) { + throw error; + } + + console.warn( + "[agent-server] getSettings() probe failed (non-401):", + error, + ); + } + } + + cachedAgentServerInfo = serverInfo; + cachedAgentServerInfoHost = clientOptions.host; + return serverInfo; +} diff --git a/src/api/agent-server-config.test.ts b/src/api/agent-server-config.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..739dbeb454616818dec25961165cefde7d000ba5 --- /dev/null +++ b/src/api/agent-server-config.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + DEFAULT_WORKING_DIR, + buildConversationWorkingDir, + buildConversationWorkingDirForBackend, + buildRelativeConversationWorkingDir, + isServedOriginHost, +} from "./agent-server-config"; + +describe("buildRelativeConversationWorkingDir", () => { + afterEach(() => vi.unstubAllEnvs()); + + it("ignores a baked absolute VITE_WORKING_DIR and stays relative", () => { + vi.stubEnv("VITE_WORKING_DIR", "/Users/someone/.openhands/workspaces"); + expect(buildRelativeConversationWorkingDir("abc-123")).toBe( + `${DEFAULT_WORKING_DIR}/abc123`, + ); + }); + + it("strips dashes from the conversation id", () => { + expect(buildRelativeConversationWorkingDir("a-b-c")).toBe( + `${DEFAULT_WORKING_DIR}/abc`, + ); + }); +}); + +describe("buildConversationWorkingDir (baked default)", () => { + afterEach(() => vi.unstubAllEnvs()); + + it("uses a baked absolute dir as the base", () => { + vi.stubEnv("VITE_WORKING_DIR", "/Users/someone/workspaces"); + expect(buildConversationWorkingDir("abc-123")).toBe( + "/Users/someone/workspaces/abc123", + ); + }); + + it("falls back to the relative default when nothing is baked", () => { + vi.stubEnv("VITE_WORKING_DIR", ""); + expect(buildConversationWorkingDir("abc-123")).toBe( + `${DEFAULT_WORKING_DIR}/abc123`, + ); + }); +}); + +describe("isServedOriginHost", () => { + afterEach(() => vi.unstubAllEnvs()); + + it("matches the host that served this frontend", () => { + vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000"); + expect(isServedOriginHost("http://127.0.0.1:8000")).toBe(true); + }); + + it("normalizes a bare host against the served origin", () => { + vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000"); + // getAgentServerBaseUrl returns the already-normalized configured URL; + // a bare candidate host is normalized to http:// off-window. + expect(isServedOriginHost("127.0.0.1:8000")).toBe(true); + }); + + it("does not match a different host", () => { + vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000"); + expect(isServedOriginHost("https://cabin.liberty-labs.org")).toBe(false); + }); + + it("does not match when the backend host is empty", () => { + vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000"); + expect(isServedOriginHost("")).toBe(false); + expect(isServedOriginHost(null)).toBe(false); + }); +}); + +describe("buildConversationWorkingDirForBackend", () => { + afterEach(() => vi.unstubAllEnvs()); + + it("uses the baked absolute default for the served-origin backend", () => { + vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000"); + vi.stubEnv( + "VITE_WORKING_DIR", + "/Users/me/.openhands/agent-canvas/workspaces", + ); + expect( + buildConversationWorkingDirForBackend("abc-123", "http://127.0.0.1:8000"), + ).toBe("/Users/me/.openhands/agent-canvas/workspaces/abc123"); + }); + + // Regression for the reviewed bug: the seeded `default-local` backend is + // mutable. Editing its host to a remote VM (while its id stays + // `default-local`) must NOT send the baked macOS path to that VM — this is + // the exact case that failed on our remote Linux backend (Cabin) with + // `mkdir: cannot create directory '/Users': Permission denied`. + it("uses the relative default for an edited default-local pointing at a remote host", () => { + vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000"); + vi.stubEnv( + "VITE_WORKING_DIR", + "/Users/me/.openhands/agent-canvas/workspaces", + ); + // The user edited the seeded `default-local` entry to a remote Linux VM + // (home /home/daytona); its host no longer matches the served origin. + expect( + buildConversationWorkingDirForBackend( + "abc-123", + "https://cabin.liberty-labs.org", + ), + ).toBe(`${DEFAULT_WORKING_DIR}/abc123`); + }); + + it("uses the relative default for any separately-registered remote backend", () => { + vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000"); + vi.stubEnv("VITE_WORKING_DIR", "/Users/me/workspaces"); + expect( + buildConversationWorkingDirForBackend( + "abc-123", + "https://sandbox.example.dev", + ), + ).toBe(`${DEFAULT_WORKING_DIR}/abc123`); + }); +}); diff --git a/src/api/agent-server-config.ts b/src/api/agent-server-config.ts new file mode 100644 index 0000000000000000000000000000000000000000..7e5175a2acaee98e7437b7a6e4d086e0b62ef948 --- /dev/null +++ b/src/api/agent-server-config.ts @@ -0,0 +1,290 @@ +export const DEFAULT_WORKING_DIR = "workspace/project"; + +export type LockedCloudAuthMode = "api-key" | "cookie"; + +export interface AgentServerFormDefaults { + baseUrl: string; + sessionApiKey: string; +} + +// Window-global key the static server injects `--lock-to-cloud` into; kept +// module-private because only `getLockedCloudHost()` reads it. The static +// server (`scripts/static-server.mjs`) and its tests reference the literal +// string directly, not this constant. +const LOCK_TO_CLOUD_WINDOW_KEY = "__AGENT_CANVAS_LOCK_TO_CLOUD__"; +const LEGACY_CLOUD_DOMAIN = "all-hands.dev"; +const CURRENT_CLOUD_DOMAIN = "openhands.dev"; +const LEGACY_PRODUCTION_APP_HOST = `app.${LEGACY_CLOUD_DOMAIN}`; +const CURRENT_PRODUCTION_APP_HOST = CURRENT_CLOUD_DOMAIN; +const PRODUCTION_APP_HOST_ALIAS = `app.${CURRENT_CLOUD_DOMAIN}`; + +function trimToNull(value?: string | null): string | null { + return value?.trim() || null; +} + +function normalizeBaseUrl(value?: string | null): string | null { + if (!value) return null; + + const trimmed = value.trim().replace(/\/$/, ""); + if (!trimmed) return null; + + if (/^https?:\/\//i.test(trimmed)) { + return trimmed; + } + + if (typeof window !== "undefined") { + return `${window.location.protocol}//${trimmed}`; + } + + return `http://${trimmed}`; +} + +function normalizeCloudHost(value?: string | null): string | null { + if (!value) return null; + + const trimmed = value.trim().replace(/\/+$/, ""); + if (!trimmed) return null; + + if (/^https?:\/\//i.test(trimmed)) { + return trimmed; + } + + return `https://${trimmed}`; +} + +function canonicalizeCloudHostname(hostname: string): string { + const lower = hostname.toLowerCase(); + if ( + lower === LEGACY_PRODUCTION_APP_HOST || + lower === PRODUCTION_APP_HOST_ALIAS + ) { + return CURRENT_PRODUCTION_APP_HOST; + } + + if (lower === LEGACY_CLOUD_DOMAIN) return CURRENT_CLOUD_DOMAIN; + if (lower.endsWith(`.${LEGACY_CLOUD_DOMAIN}`)) { + return `${lower.slice(0, -LEGACY_CLOUD_DOMAIN.length)}${CURRENT_CLOUD_DOMAIN}`; + } + + return lower; +} + +function getCloudHostComparisonKey(value?: string | null): string | null { + const normalized = normalizeCloudHost(value); + if (!normalized) return null; + + try { + const url = new URL(normalized); + const port = url.port ? `:${url.port}` : ""; + return `${url.protocol}//${canonicalizeCloudHostname(url.hostname)}${port}`; + } catch { + return normalized.toLowerCase(); + } +} + +export function getCookieAuthCloudHost(): string | null { + const lockedHost = getLockedCloudHost(); + if ( + !lockedHost || + typeof window === "undefined" || + !isSameCloudHost(window.location.origin, lockedHost) + ) { + return null; + } + + return window.location.origin; +} + +function getConfiguredBaseUrl(): string | null { + return normalizeBaseUrl(import.meta.env.VITE_BACKEND_BASE_URL); +} + +/** + * Return the session API key supplied by the deployment host. + * + * Two sources are consulted, in order: + * 1. `VITE_SESSION_API_KEY` — baked into the bundle at build time (used by + * `npm run dev` so the dev server has the key without a round-trip). + * 2. `window.__AGENT_CANVAS_SESSION_API_KEY__` — injected into `index.html` + * at serve time by `scripts/static-server.mjs --session-api-key `. + * This is the path used by the published `agent-canvas` binary, where + * `VITE_SESSION_API_KEY` is empty in the prebuilt bundle and the + * runtime key is generated when the user launches the CLI. + * + * Without the window-global fallback, the published binary cannot construct a + * default local backend (`makeDefaultLocalBackend()` returns null), the + * registry is left empty, and the user sees the Manage Backends modal + * instead of the onboarding flow. + */ +export function getBakedSessionApiKey(): string | null { + const envKey = trimToNull(import.meta.env.VITE_SESSION_API_KEY); + if (envKey) return envKey; + + if (typeof window !== "undefined") { + const injected = (window as unknown as Record) + .__AGENT_CANVAS_SESSION_API_KEY__; + if (typeof injected === "string") { + return trimToNull(injected); + } + } + + return null; +} + +export function getAgentServerFormDefaults(): AgentServerFormDefaults { + return { + baseUrl: getAgentServerBaseUrl() ?? "", + sessionApiKey: getAgentServerSessionApiKey() ?? "", + }; +} + +export function getLockedCloudHost(): string | null { + const envHost = normalizeCloudHost(import.meta.env.VITE_LOCK_TO_CLOUD); + if (envHost) return envHost; + + if (typeof window !== "undefined") { + const injected = (window as unknown as Record)[ + LOCK_TO_CLOUD_WINDOW_KEY + ]; + if (typeof injected === "string") { + return normalizeCloudHost(injected); + } + } + + return null; +} + +/** + * Compare a backend host against the locked Cloud host, normalizing + * trailing slashes, protocol, and case so that e.g. + * `https://app.all-hands.dev/` matches `https://app.all-hands.dev`. + * + * Used by the locked-to-Cloud gates (`root.tsx`, + * `onboarding-modal.tsx`) to decide whether the active backend is the + * configured locked Cloud host — a Cloud backend on a *different* host + * (or a stale Local backend) must not be treated as the locked backend. + */ +export function isSameCloudHost( + host: string | null | undefined, + lockedHost: string | null | undefined, +): boolean { + const a = getCloudHostComparisonKey(host); + const b = getCloudHostComparisonKey(lockedHost); + if (!a || !b) return false; + return a === b; +} + +export function getLockedCloudAuthMode(): LockedCloudAuthMode { + return getCookieAuthCloudHost() ? "cookie" : "api-key"; +} + +export function getAgentServerBaseUrl(): string | null { + const configuredUrl = getConfiguredBaseUrl(); + if (configuredUrl) return configuredUrl; + + if (typeof window !== "undefined") { + return window.location.origin; + } + + return null; +} + +export function getAgentServerSessionApiKey(): string | null { + return getBakedSessionApiKey(); +} + +export function getAgentServerWorkingDir(): string { + const envDir = import.meta.env.VITE_WORKING_DIR?.trim(); + if (envDir) return envDir; + + return DEFAULT_WORKING_DIR; +} + +function buildWorkingDir(base: string, conversationId: string): string { + const trimmed = base.replace(/\/+$/, ""); + const hex = conversationId.replace(/-/g, ""); + return `${trimmed}/${hex}`; +} + +export function buildConversationWorkingDir(conversationId: string): string { + return buildWorkingDir(getAgentServerWorkingDir(), conversationId); +} + +/** + * Conversation working dir under the backend-relative default + * (`workspace/project/`), deliberately ignoring any baked absolute + * `VITE_WORKING_DIR`. `resolveAbsoluteAgentServerPath()` anchors this to the + * active backend's own home via `GET /api/file/home`, so it resolves to a + * writable path on whichever backend actually runs the conversation. + */ +export function buildRelativeConversationWorkingDir( + conversationId: string, +): string { + return buildWorkingDir(DEFAULT_WORKING_DIR, conversationId); +} + +/** + * Whether `backendHost` is the same host that served this frontend. + * + * A launcher-baked `VITE_WORKING_DIR` is an absolute path on the serving + * host's filesystem, so it is only valid on that exact backend. The seeded + * `default-local` entry starts life pointing at the served origin, but its + * `host` is mutable — the user can edit it to a remote backend while its id + * stays `default-local`. Matching on the host (not the stable id) keeps the + * real invariant: the baked path is only safe for the host it was baked for. + */ +export function isServedOriginHost( + backendHost: string | null | undefined, +): boolean { + const served = normalizeBaseUrl(getAgentServerBaseUrl()); + const candidate = normalizeBaseUrl(backendHost); + if (!served || !candidate) return false; + return served === candidate; +} + +/** + * Base working dir for a new conversation on the backend at `backendHost`. + * The baked (possibly absolute) default is used only for the served-origin + * backend; every other backend gets the relative default, anchored to its + * own home by `resolveAbsoluteAgentServerPath()`. + */ +export function buildConversationWorkingDirForBackend( + conversationId: string, + backendHost: string | null | undefined, +): string { + return isServedOriginHost(backendHost) + ? buildConversationWorkingDir(conversationId) + : buildRelativeConversationWorkingDir(conversationId); +} + +/** + * Workspace root for `backendHost` — the dir each conversation's `/` + * working dir is created under. Same per-backend rule as + * `buildConversationWorkingDirForBackend()`. + */ +export function getWorkspaceRootForBackend( + backendHost: string | null | undefined, +): string { + return isServedOriginHost(backendHost) + ? getAgentServerWorkingDir() + : DEFAULT_WORKING_DIR; +} + +export function getAgentServerHeaders(): Record { + const sessionApiKey = getAgentServerSessionApiKey(); + return sessionApiKey ? { "X-Session-API-Key": sessionApiKey } : {}; +} + +export function isAuthRequired(): boolean { + return ( + import.meta.env.VITE_AUTH_REQUIRED === "true" || + (typeof window !== "undefined" && + (window as unknown as Record) + .__AGENT_CANVAS_AUTH_REQUIRED__ === true) + ); +} + +export function isAuthRequiredAndMissing(): boolean { + if (!isAuthRequired()) return false; + return !getAgentServerSessionApiKey(); +} diff --git a/src/api/agent-server-home.ts b/src/api/agent-server-home.ts new file mode 100644 index 0000000000000000000000000000000000000000..5372cdc4e30951910a6f1c2e5379d5f8a46c7821 --- /dev/null +++ b/src/api/agent-server-home.ts @@ -0,0 +1,114 @@ +// @spec WUP-001 — Resolve relative working dirs against /api/file/home +import { FileClient } from "@openhands/typescript-client/clients"; +import { + getAgentServerClientOptions, + type AgentServerClientOverrides, +} from "./agent-server-client-options"; + +/** + * Cache the agent-server's home directory per host so we only round-trip + * `/api/file/home` once per backend. The home dir is effectively static for + * the lifetime of a running agent-server (it's `Path.home()` on the host), + * so caching is safe and avoids hammering the endpoint on every upload. + * + * The cache holds `Promise` rather than `string` so concurrent + * callers share a single in-flight request. + */ +const homeDirCache = new Map>(); + +function isAbsolutePath(path: string): boolean { + // Treat POSIX-style and Windows-style absolute paths as absolute. + // The agent-server itself is POSIX on Linux/macOS and uses `\\` style + // on Windows; we just need to know whether `Path(path).is_absolute()` + // would return true on the server. + // + // Patterns covered: + // POSIX absolute: /foo/bar + // Windows drive: C:\foo or C:/foo + // Windows UNC: \\server\share (starts with `\`, matches `[/\\]`) + return /^([/\\]|[a-zA-Z]:[/\\])/.test(path); +} + +/** + * Join a parent directory and a relative child segment with a forward slash, + * collapsing any duplicate separators that result from the join. + */ +function joinPath(parent: string, child: string): string { + const left = parent.replace(/[/\\]+$/, ""); + const right = child.replace(/^[/\\]+/, ""); + return `${left}/${right}`; +} + +/** + * Fetch and cache the agent-server's home directory via `GET /api/file/home`. + * + * The result is the absolute path returned by `Path.home()` on the + * agent-server host (e.g. `/Users/foo`, `/root`, or `C:\\Users\\Foo`). This + * is the most reliable absolute, writable anchor the agent-server API + * currently exposes — `/server_info` doesn't include the process CWD. + * + * @param overrides Same shape as `getAgentServerClientOptions` — lets cloud + * sandboxes pass a `conversationUrl` + `sessionApiKey` so the lookup goes + * to the per-conversation runtime rather than the bundled local backend. + */ +export async function getAgentServerHomeDir( + overrides: AgentServerClientOverrides = {}, +): Promise { + const options = getAgentServerClientOptions(overrides); + const cacheKey = options.host; + const cached = homeDirCache.get(cacheKey); + if (cached) return cached; + + const lookup = (async () => { + const { home } = await new FileClient(options).getHome(); + if (!home || typeof home !== "string") { + throw new Error("Agent server returned an empty home directory"); + } + return home.replace(/[/\\]+$/, ""); + })(); + + homeDirCache.set(cacheKey, lookup); + try { + return await lookup; + } catch (error) { + // Don't cache failures — let the next call retry. + homeDirCache.delete(cacheKey); + throw error; + } +} + +/** Test-only helper. */ +export function clearAgentServerHomeDirCache(): void { + homeDirCache.clear(); +} + +/** + * Resolve a (possibly relative) working dir to an absolute path the + * agent-server's file APIs will accept. + * + * - If `workingDir` is already absolute, returns it unchanged. + * - Otherwise prepends the agent-server's home dir (looked up via + * `/api/file/home` and cached). This matches how the published binary + * and Docker entrypoint expect to anchor relative working dirs: under + * `~/workspace/project` rather than the filesystem root. + * + * Why this matters: the agent-server's `/api/file/upload` endpoint requires + * an absolute path and `mkdir -p`s the parent. Naively prepending `/` to a + * relative dir like `workspace/project/` produces `/workspace/...`, + * which on macOS lives under the SIP-protected read-only root and fails + * with `Errno 30: Read-only file system: '/workspace'`. Resolving against + * `Path.home()` instead puts the path somewhere reliably writable. + */ +export async function resolveAbsoluteAgentServerPath( + workingDir: string, + overrides: AgentServerClientOverrides = {}, +): Promise { + const trimmed = workingDir.replace(/[/\\]+$/, ""); + if (!trimmed) { + return getAgentServerHomeDir(overrides); + } + if (isAbsolutePath(trimmed)) return trimmed; + + const home = await getAgentServerHomeDir(overrides); + return joinPath(home, trimmed); +} diff --git a/src/api/canvas-extensions-service.test.ts b/src/api/canvas-extensions-service.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b61b1d754653927a7da173acd34458121fc33f9 --- /dev/null +++ b/src/api/canvas-extensions-service.test.ts @@ -0,0 +1,208 @@ +import { AgentServerClient } from "@openhands/typescript-client/clients"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import CanvasExtensionsService, { + CanvasExtensionsUnsupportedError, +} from "#/api/canvas-extensions-service"; +import { + __resetActiveStoreForTests, + setActiveSelection, + setRegisteredBackends, +} from "#/api/backend-registry/active-store"; +import type { Backend } from "#/api/backend-registry/types"; +import type { InstalledCanvasExtensionInfo } from "#/types/canvas-extension"; + +vi.mock("@openhands/typescript-client/clients", () => ({ + AgentServerClient: vi.fn(), +})); + +const get = vi.fn(); +const post = vi.fn(); +const patch = vi.fn(); +const remove = vi.fn(); +const request = vi.fn(); + +const localBackend: Backend = { + id: "local", + name: "Local", + host: "http://127.0.0.1:8000", + apiKey: "session-key", + kind: "local", +}; + +const extension: InstalledCanvasExtensionInfo = { + name: "demo-extension", + version: "0.1.0", + description: "Demo", + enabled: false, + source: "github:example/extensions", + resolved_ref: "abc123", + repo_path: "extensions/demo", + installed_at: "2026-08-01T00:00:00Z", + install_path: "/tmp/extensions/demo-extension", + manifest: { + schema_version: 1, + name: "demo-extension", + version: "0.1.0", + entrypoint: "dist/extension.js", + contributes: { + pages: [{ id: "home", title: "Home", path: "home" }], + }, + }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + __resetActiveStoreForTests(); + setRegisteredBackends([localBackend]); + setActiveSelection({ backendId: localBackend.id }); + vi.mocked(AgentServerClient).mockImplementation( + function MockAgentServerClient() { + return { + get, + post, + patch, + delete: remove, + request, + close: vi.fn(), + } as unknown as AgentServerClient; + } as unknown as typeof AgentServerClient, + ); +}); + +afterEach(() => { + setActiveSelection(null); + setRegisteredBackends([]); + __resetActiveStoreForTests(); +}); + +describe("CanvasExtensionsService", () => { + it("lists extensions installed on the active Agent Server", async () => { + get.mockResolvedValue({ canvas_extensions: [extension] }); + + await expect(CanvasExtensionsService.listInstalled()).resolves.toEqual([ + extension, + ]); + expect(get).toHaveBeenCalledWith("/api/canvas-extensions/installed"); + }); + + it("preserves install coordinates without allowing an enabled flag", async () => { + post.mockResolvedValue(extension); + + await CanvasExtensionsService.install({ + source: "github:example/extensions", + ref: "main", + repo_path: "extensions/demo", + }); + + expect(post).toHaveBeenCalledWith("/api/canvas-extensions/install", { + source: "github:example/extensions", + ref: "main", + repo_path: "extensions/demo", + }); + }); + + it.each([ + { + source: "/repository-name/", + repoPath: "/demo-extension", + expectedSource: "/repository-name/demo-extension", + }, + { + source: "/repository-name", + repoPath: "demo-extension", + expectedSource: "/repository-name/demo-extension", + }, + { + source: "/repository-name/demo-extension", + repoPath: null, + expectedSource: "/repository-name/demo-extension", + }, + ])( + "resolves a local extension from source $source and path $repoPath", + async ({ source, repoPath, expectedSource }) => { + post.mockResolvedValue(extension); + + await CanvasExtensionsService.install({ + source, + ref: null, + repo_path: repoPath, + }); + + expect(post).toHaveBeenCalledWith("/api/canvas-extensions/install", { + source: expectedSource, + ref: null, + repo_path: null, + }); + }, + ); + + it("maps a missing router to an explicit unsupported-backend error", async () => { + get.mockRejectedValue( + Object.assign(new Error("Not Found"), { + name: "HttpError", + status: 404, + }), + ); + + await expect(CanvasExtensionsService.listInstalled()).rejects.toEqual( + expect.objectContaining>({ + name: "CanvasExtensionsUnsupportedError", + reason: "missing-api", + }), + ); + }); + + it("does not call the Agent Server protocol for a Cloud backend", async () => { + const cloud: Backend = { + ...localBackend, + id: "cloud", + name: "Cloud", + kind: "cloud", + }; + setRegisteredBackends([cloud]); + setActiveSelection({ backendId: cloud.id }); + + await expect(CanvasExtensionsService.listInstalled()).rejects.toEqual( + expect.objectContaining({ reason: "cloud-backend" }), + ); + expect(AgentServerClient).not.toHaveBeenCalled(); + }); + + it("fetches the bundle as authenticated text from the captured backend", async () => { + get.mockResolvedValue("export const activate = () => {};"); + + await expect( + CanvasExtensionsService.fetchBundle(extension.name, localBackend), + ).resolves.toContain("activate"); + + expect(AgentServerClient).toHaveBeenCalledWith({ + host: localBackend.host, + apiKey: localBackend.apiKey, + timeout: 60000, + }); + expect(get).toHaveBeenCalledWith( + "/api/canvas-extensions/installed/demo-extension/bundle", + { responseType: "text" }, + ); + }); + + it("encodes extension names in mutation paths", async () => { + patch.mockResolvedValue({ name: "a/b", enabled: true }); + + await CanvasExtensionsService.setEnabled("a/b", true); + + expect(patch).toHaveBeenCalledWith( + "/api/canvas-extensions/installed/a%2Fb", + { enabled: true }, + ); + }); + + it("does not send the backend session key to an absolute request URL", async () => { + await expect( + CanvasExtensionsService.requestAgentServer({ + path: "https://example.com/collect", + }), + ).rejects.toThrow("root-relative path"); + expect(AgentServerClient).not.toHaveBeenCalled(); + }); +}); diff --git a/src/api/canvas-extensions-service.ts b/src/api/canvas-extensions-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..39b454598157cc4c1624d973501e567bec224219 --- /dev/null +++ b/src/api/canvas-extensions-service.ts @@ -0,0 +1,192 @@ +import { AgentServerClient } from "@openhands/typescript-client/clients"; +import { getAgentServerClientOptions } from "#/api/agent-server-client-options"; +import { isSdkHttpStatusError } from "#/api/agent-server-compatibility"; +import { + getActiveBackend, + isNoBackend, +} from "#/api/backend-registry/active-store"; +import type { Backend } from "#/api/backend-registry/types"; +import type { + CanvasExtensionAgentServerRequest, + InstallCanvasExtensionRequest, + InstalledCanvasExtensionInfo, +} from "#/types/canvas-extension"; + +const CANVAS_EXTENSIONS_BASE_PATH = "/api/canvas-extensions"; +const REMOTE_EXTENSION_SOURCE_PATTERN = + /^(?:github:|https?:\/\/|git:\/\/|file:\/\/|[\w.-]+@[\w.-]+:)/i; + +export type CanvasExtensionsUnsupportedReason = + | "no-backend" + | "cloud-backend" + | "missing-api"; + +export class CanvasExtensionsUnsupportedError extends Error { + readonly reason: CanvasExtensionsUnsupportedReason; + + constructor(reason: CanvasExtensionsUnsupportedReason) { + const message = + reason === "no-backend" + ? "Add an Agent Server backend to use Apps." + : reason === "cloud-backend" + ? "Apps are not available on Cloud backends yet." + : "This Agent Server does not support Apps yet. Upgrade the backend and try again."; + super(message); + this.name = "CanvasExtensionsUnsupportedError"; + this.reason = reason; + } +} + +export function isCanvasExtensionsUnsupportedError( + error: unknown, +): error is CanvasExtensionsUnsupportedError { + return ( + error instanceof CanvasExtensionsUnsupportedError || + (typeof error === "object" && + error !== null && + "name" in error && + error.name === "CanvasExtensionsUnsupportedError") + ); +} + +function requireSupportedBackend(): void { + const { backend } = getActiveBackend(); + if (isNoBackend(backend)) { + throw new CanvasExtensionsUnsupportedError("no-backend"); + } + if (backend.kind === "cloud") { + throw new CanvasExtensionsUnsupportedError("cloud-backend"); + } +} + +function getClient(): AgentServerClient { + requireSupportedBackend(); + return new AgentServerClient(getAgentServerClientOptions()); +} + +function getClientForBackend(backend: Backend): AgentServerClient { + if (isNoBackend(backend)) { + throw new CanvasExtensionsUnsupportedError("no-backend"); + } + if (backend.kind === "cloud") { + throw new CanvasExtensionsUnsupportedError("cloud-backend"); + } + return new AgentServerClient({ + host: backend.host, + ...(backend.apiKey ? { apiKey: backend.apiKey } : {}), + timeout: 60000, + }); +} + +async function mapUnsupported(operation: () => Promise): Promise { + try { + return await operation(); + } catch (error) { + if (isSdkHttpStatusError(error, 404)) { + throw new CanvasExtensionsUnsupportedError("missing-api"); + } + throw error; + } +} + +function installedExtensionPath(name: string): string { + return `${CANVAS_EXTENSIONS_BASE_PATH}/installed/${encodeURIComponent(name)}`; +} + +function joinLocalExtensionPath(source: string, repoPath: string): string { + const separator = source.includes("\\") && !source.includes("/") ? "\\" : "/"; + const basePath = source.replace(/[\\/]+$/, ""); + const childPath = repoPath.replace(/^[\\/]+/, ""); + + if (!childPath) return source; + if (!basePath) return `${separator}${childPath}`; + return `${basePath}${separator}${childPath}`; +} + +function normalizeInstallRequest( + request: InstallCanvasExtensionRequest, +): InstallCanvasExtensionRequest { + const repoPath = request.repo_path?.trim(); + if (!repoPath || REMOTE_EXTENSION_SOURCE_PATTERN.test(request.source)) { + return request; + } + + return { + ...request, + source: joinLocalExtensionPath(request.source, repoPath), + repo_path: null, + }; +} + +class CanvasExtensionsService { + static async listInstalled(): Promise { + const client = getClient(); + const response = await mapUnsupported(() => + client.get<{ canvas_extensions: InstalledCanvasExtensionInfo[] }>( + `${CANVAS_EXTENSIONS_BASE_PATH}/installed`, + ), + ); + return response.canvas_extensions ?? []; + } + + static async install( + request: InstallCanvasExtensionRequest, + ): Promise { + const client = getClient(); + return mapUnsupported(() => + client.post( + `${CANVAS_EXTENSIONS_BASE_PATH}/install`, + normalizeInstallRequest(request), + ), + ); + } + + static async setEnabled( + name: string, + enabled: boolean, + ): Promise<{ name: string; enabled: boolean }> { + const client = getClient(); + return mapUnsupported(() => + client.patch<{ name: string; enabled: boolean }>( + installedExtensionPath(name), + { enabled }, + ), + ); + } + + static async uninstall(name: string): Promise<{ message: string }> { + const client = getClient(); + return mapUnsupported(() => + client.delete<{ message: string }>(installedExtensionPath(name)), + ); + } + + static async fetchBundle(name: string, backend?: Backend): Promise { + const client = backend ? getClientForBackend(backend) : getClient(); + return mapUnsupported(() => + client.get(`${installedExtensionPath(name)}/bundle`, { + responseType: "text", + }), + ); + } + + static async requestAgentServer( + request: CanvasExtensionAgentServerRequest, + backend?: Backend, + ): Promise { + if (!request.path.startsWith("/") || request.path.startsWith("//")) { + throw new Error( + "Canvas Extension Agent Server requests must use a root-relative path.", + ); + } + const client = backend ? getClientForBackend(backend) : getClient(); + return client.request({ + method: request.method ?? "GET", + path: request.path, + body: request.body, + headers: request.headers, + }); + } +} + +export default CanvasExtensionsService; diff --git a/src/api/canvas-ui-client-tool.ts b/src/api/canvas-ui-client-tool.ts new file mode 100644 index 0000000000000000000000000000000000000000..168c83eab4f1e8125a0959fd0816c9134b66fa03 --- /dev/null +++ b/src/api/canvas-ui-client-tool.ts @@ -0,0 +1,91 @@ +import { CANVAS_UI_CLIENT_TOOL_NAME } from "#/constants/canvas-ui"; + +export { + CANVAS_UI_CLIENT_ACTION_KIND, + CANVAS_UI_CLIENT_TOOL_NAME, + LEGACY_CANVAS_UI_TOOL_NAME, +} from "#/constants/canvas-ui"; + +export interface ClientToolSpec { + name: string; + description: string; + parameters: Record; + annotations?: { + title?: string | null; + readOnlyHint: boolean; + destructiveHint: boolean; + idempotentHint: boolean; + openWorldHint: boolean; + }; +} + +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. + +They will NOT see the files you wrote, the terminal output, or the browser +unless you call this tool to switch the right-side panel to the relevant +tab. Call this every time you finish work that produces something the user +should look at — don't rely on them noticing on their own. + +When to call (pick the most specific option that matches your last action): + +* You wrote or modified a single file (ANY language, ANY size — including + small scripts like a hello-world bash file) → + command="navigate_to_file", path= + +* You generated an HTML page, image, SVG, PDF, markdown report, or other + previewable artifact → + command="show_preview", path= + +* You finished editing multiple files in one logical step → + command="open_tab", tab="files" + (The Files tab automatically renders a diff view when the workspace has + uncommitted git changes, which covers the "highlight changes" case.) + +* You ran a long-running terminal command, or one whose output the user + should inspect → + command="open_tab", tab="terminal" + +* You browsed to a URL the user should see → + First call browser_get_state(include_screenshot=true) after your final + browser interaction so Agent Canvas has a screenshot to display, then call + command="open_tab", tab="browser" + (browser_navigate alone only updates the URL; without browser_get_state, + the Browser tab will open without a screenshot.) + +Call this BEFORE writing your chat-message summary of the change, so the +artifact is visible while the user reads what you did. One canvas_ui_control +call per logical step is enough — don't repeat it for the same file or tab in +the same turn.`; + +export const CANVAS_UI_CLIENT_TOOL: ClientToolSpec = { + name: CANVAS_UI_CLIENT_TOOL_NAME, + description: CANVAS_UI_DESCRIPTION, + parameters: { + type: "object", + additionalProperties: false, + properties: { + command: { + type: "string", + enum: ["navigate_to_file", "open_tab", "show_preview"], + description: "UI command to dispatch.", + }, + path: { + type: "string", + description: + "Workspace-relative file path. Required for navigate_to_file and show_preview; ignored otherwise.", + }, + tab: { + type: "string", + enum: ["files", "browser", "vscode", "terminal", "planner", "tasklist"], + description: "Tab to open. Required for open_tab; ignored otherwise.", + }, + }, + required: ["command"], + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, +}; diff --git a/src/api/client-source.ts b/src/api/client-source.ts new file mode 100644 index 0000000000000000000000000000000000000000..48443ef8bea6c70ccdee59afad29b06bd1190d86 --- /dev/null +++ b/src/api/client-source.ts @@ -0,0 +1,20 @@ +import packageJson from "../../package.json"; + +export const AGENT_CANVAS_CLIENT_SOURCE = "agent_canvas"; +export const AGENT_CANVAS_CLIENT_VERSION = packageJson.version; + +/** + * Coarse, non-user-identifying request metadata for Cloud observability. + * + * Cloud ingress can retain these headers as facets without logging request + * bodies, API keys, device codes, or conversation content. + */ +export const OPENHANDS_CLIENT_HEADER = "X-OpenHands-Client"; +export const OPENHANDS_CLIENT_VERSION_HEADER = "X-OpenHands-Client-Version"; +export const OPENHANDS_TELEMETRY_DISTINCT_ID_HEADER = + "X-OpenHands-Telemetry-Distinct-Id"; + +export const AGENT_CANVAS_CLIENT_HEADERS: Readonly> = { + [OPENHANDS_CLIENT_HEADER]: AGENT_CANVAS_CLIENT_SOURCE, + [OPENHANDS_CLIENT_VERSION_HEADER]: AGENT_CANVAS_CLIENT_VERSION, +}; diff --git a/src/api/conversation-file-upload.api.ts b/src/api/conversation-file-upload.api.ts new file mode 100644 index 0000000000000000000000000000000000000000..d07e54db9edb4b62188d6839692a136ac9485c6b --- /dev/null +++ b/src/api/conversation-file-upload.api.ts @@ -0,0 +1,172 @@ +import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace"; +import { getAgentServerClientOptions } from "#/api/agent-server-client-options"; +import { getActiveBackend } from "#/api/backend-registry/active-store"; +import { batchGetCloudConversations } from "#/api/cloud/conversation-service.api"; +import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types"; +import type { FileUploadSuccessResponse } from "#/api/open-hands.types"; +import { + buildWorkspaceUploadPath, + getSafeUploadFileName, + resolveConversationUploadWorkingDir, +} from "#/api/workspace-upload-path"; + +const FILE_UPLOAD_CONCURRENCY = 5; + +export interface ConversationRuntimeContext { + conversationUrl: string | null; + sessionApiKey: string | null; +} + +/** + * Resolve the sandbox runtime URL + session key needed for file upload and + * send-event calls. Cloud conversations only exist on the provisioned runtime, + * not on the bundled local agent-server. + */ +export async function resolveConversationRuntime( + conversationId: string, + currentConversation?: AppConversation | null, +): Promise { + if ( + currentConversation?.id === conversationId && + currentConversation.conversation_url?.trim() && + currentConversation.session_api_key?.trim() + ) { + return { + conversationUrl: currentConversation.conversation_url.trim(), + sessionApiKey: currentConversation.session_api_key.trim(), + }; + } + + if (getActiveBackend().backend.kind === "cloud") { + const [conversation] = await batchGetCloudConversations([conversationId]); + return { + conversationUrl: conversation?.conversation_url?.trim() ?? null, + sessionApiKey: conversation?.session_api_key?.trim() ?? null, + }; + } + + return { conversationUrl: null, sessionApiKey: null }; +} + +function requireCloudRuntime( + runtime: ConversationRuntimeContext, +): ConversationRuntimeContext & { + conversationUrl: string; + sessionApiKey: string; +} { + if (!runtime.conversationUrl || !runtime.sessionApiKey) { + throw new Error( + "Conversation sandbox is still starting. Wait for it to finish, then try again.", + ); + } + return { + conversationUrl: runtime.conversationUrl, + sessionApiKey: runtime.sessionApiKey, + }; +} + +/** + * Upload attachments into the conversation workspace. Local conversations use + * the bundled agent-server; cloud conversations target the provisioned runtime. + */ +export async function uploadFilesToConversation( + conversationId: string, + files: File[], + currentConversation?: AppConversation | null, +): Promise { + const workingDir = await resolveConversationUploadWorkingDir( + conversationId, + currentConversation, + ); + const runtime = await resolveConversationRuntime( + conversationId, + currentConversation, + ); + const isCloud = getActiveBackend().backend.kind === "cloud"; + + const sessionApiKey = + currentConversation?.id === conversationId + ? (currentConversation.session_api_key ?? runtime.sessionApiKey) + : runtime.sessionApiKey; + const conversationUrl = + currentConversation?.id === conversationId + ? (currentConversation.conversation_url ?? runtime.conversationUrl) + : runtime.conversationUrl; + + if (isCloud) { + const cloudRuntime = requireCloudRuntime({ + conversationUrl, + sessionApiKey, + }); + return uploadFilesToRuntime({ + files, + workingDir, + conversationUrl: cloudRuntime.conversationUrl, + sessionApiKey: cloudRuntime.sessionApiKey, + }); + } + + return uploadFilesToRuntime({ + files, + workingDir, + conversationUrl, + sessionApiKey, + }); +} + +async function uploadFilesToRuntime(options: { + files: File[]; + workingDir: string; + conversationUrl: string | null; + sessionApiKey: string | null; +}): Promise { + const { files, workingDir, conversationUrl, sessionApiKey } = options; + const workspace = new RemoteWorkspace( + getAgentServerClientOptions({ + conversationUrl, + sessionApiKey, + workingDir, + }), + ); + + const uploadFile = async (file: File) => { + try { + const safeName = getSafeUploadFileName(file.name); + // @spec WUP-001 — Build an absolute upload path that's anchored against + // the agent-server's home dir (when `workingDir` is relative) instead + // of the filesystem root. Without this, default conversations whose + // working_dir is `workspace/project/` (relative) land at + // `/workspace/project//...` on the agent-server, which on macOS + // and fresh containers is a read-only mount. + const uploadPath = await buildWorkspaceUploadPath(file.name, workingDir, { + conversationUrl, + sessionApiKey, + }); + await workspace.fileUpload(file, uploadPath); + return { uploadedFile: safeName, skippedFile: null }; + } catch (error) { + return { + uploadedFile: null, + skippedFile: { + name: file.name, + reason: error instanceof Error ? error.message : "Upload failed", + }, + }; + } + }; + + const results: Awaited>[] = []; + for (let index = 0; index < files.length; index += FILE_UPLOAD_CONCURRENCY) { + const batch = files.slice(index, index + FILE_UPLOAD_CONCURRENCY); + results.push(...(await Promise.all(batch.map(uploadFile)))); + } + + return { + uploaded_files: results.flatMap((result) => + result.uploadedFile ? [result.uploadedFile] : [], + ), + skipped_files: results.flatMap((result) => + result.skippedFile ? [result.skippedFile] : [], + ), + }; +} diff --git a/src/api/conversation-metadata-store.ts b/src/api/conversation-metadata-store.ts new file mode 100644 index 0000000000000000000000000000000000000000..45f63c5a44415d67872f76c7b56daf6e674251a2 --- /dev/null +++ b/src/api/conversation-metadata-store.ts @@ -0,0 +1,128 @@ +import { Provider } from "#/types/settings"; +import type { PluginSpec } from "#/api/conversation-service/agent-server-conversation-service.types"; + +const STORAGE_KEY = "openhands-agent-server-conversation-metadata"; + +export type WorkspaceMode = "local_repo" | "new_worktree"; + +export interface ConversationMetadata { + selected_repository: string | null; + selected_branch: string | null; + git_provider: Provider | null; + /** + * The local workspace path the user explicitly attached at conversation + * creation time. Distinct from `selected_repository` (which is set by + * the repo picker on the home page). Used by the Files tab to decide + * whether to default to diff view: if the user attached *anything* + * (repo or local workspace), we lean diff-first because there's a real + * git baseline to compare against. + */ + selected_workspace?: string | null; + /** + * How the conversation should use `selected_workspace`. + * + * `local_repo` means the runtime should operate directly in the selected + * folder, even when it is not a git checkout. `new_worktree` preserves the + * historical agent-server behavior for conversations that should start in a + * generated per-conversation worktree. + */ + workspace_mode?: WorkspaceMode | null; + /** + * The LLM profile the conversation was created with (or last switched to). + * Client-side only. Lets the chat-header switcher show the exact profile + * name even when several profiles share the same underlying model — the + * agent-server only round-trips the model string, so matching on it alone + * is ambiguous (issue #1082). + */ + active_profile?: string | null; + /** + * When `active_profile` was stamped (ISO string). Written by the live + * WebSocket handler (the observation's server timestamp) and the user + * `/model` mutation (client clock). The history seed compares the latest + * SwitchLLM observation's timestamp against this so a reload can repair a + * missed agent switch without rolling back a newer manual switch — manual + * `/model` switches go through REST and leave no observation in history. + * Absent on stamps written before this field existed; treated as older + * than any observation so the seed can still repair them. + */ + stamped_at?: string | null; + /** Store plugin coordinates only; parameters may contain secrets. */ + plugins?: PluginSpec[] | null; + /** + * Local-only client-managed planning conversation id. Cloud backends expose + * real sub-conversations through `sub_conversation_ids`; local agent-server + * backends do not, so Canvas stores the relationship here. + */ + local_planning_conversation_id?: string | null; +} + +export const toPluginCoordinates = (plugin: PluginSpec): PluginSpec => ({ + source: plugin.source, + ref: plugin.ref ?? null, + repo_path: plugin.repo_path ?? null, +}); + +type StoredMetadata = Record; + +const readAll = (): StoredMetadata => { + if (typeof window === "undefined") return {}; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object") return {}; + return parsed as StoredMetadata; + } catch { + return {}; + } +}; + +const writeAll = (next: StoredMetadata): void => { + if (typeof window === "undefined") return; + if (Object.keys(next).length === 0) { + window.localStorage.removeItem(STORAGE_KEY); + return; + } + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); +}; + +export const getStoredConversationMetadata = ( + conversationId: string, +): ConversationMetadata | null => readAll()[conversationId] ?? null; + +export const setStoredConversationMetadata = ( + conversationId: string, + metadata: ConversationMetadata, +): void => { + const all = readAll(); + all[conversationId] = metadata; + writeAll(all); +}; + +/** + * Merge `patch` into a conversation's stored metadata, preserving every + * existing field — `setStoredConversationMetadata` does a full-object + * replace, which would otherwise silently drop fields the caller doesn't + * enumerate (e.g. `plugins`). + */ +export const mergeStoredConversationMetadata = ( + conversationId: string, + patch: Partial, +): void => { + setStoredConversationMetadata(conversationId, { + selected_repository: null, + selected_branch: null, + git_provider: null, + ...(getStoredConversationMetadata(conversationId) ?? {}), + ...patch, + }); +}; + +export const removeStoredConversationMetadata = ( + conversationId: string, +): void => { + const all = readAll(); + if (!(conversationId in all)) return; + delete all[conversationId]; + writeAll(all); +}; diff --git a/src/api/device-flow-client.ts b/src/api/device-flow-client.ts new file mode 100644 index 0000000000000000000000000000000000000000..63dbd5344c100a16d95e45bd2305882873bfe755 --- /dev/null +++ b/src/api/device-flow-client.ts @@ -0,0 +1,38 @@ +import { + pollForToken as sdkPollForToken, + startDeviceFlow as sdkStartDeviceFlow, +} from "@openhands/typescript-client/client/device-flow-client"; +import type { + DeviceAuthorizationResponse, + DeviceTokenResponse, + PollDeviceTokenOptions, +} from "@openhands/typescript-client/client/device-flow-client"; +import { AGENT_CANVAS_CLIENT_HEADERS } from "./client-source"; + +export { + DeviceFlowError, + isOpenHandsCloudHost, +} from "@openhands/typescript-client/client/device-flow-client"; + +export function startDeviceFlow( + host: string, +): Promise { + return sdkStartDeviceFlow(host, { headers: AGENT_CANVAS_CLIENT_HEADERS }); +} + +export function pollForToken( + host: string, + deviceCode: string, + options: PollDeviceTokenOptions, +): Promise { + return sdkPollForToken(host, deviceCode, { + ...options, + headers: AGENT_CANVAS_CLIENT_HEADERS, + }); +} + +export type { + DeviceAuthorizationResponse, + DeviceTokenResponse, + PollDeviceTokenOptions as PollOptions, +} from "@openhands/typescript-client/client/device-flow-client"; diff --git a/src/api/git-provider-items-service.ts b/src/api/git-provider-items-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b98af6778dae528f19cf6961bd51e69f1f51bd1 --- /dev/null +++ b/src/api/git-provider-items-service.ts @@ -0,0 +1,290 @@ +import { SettingsClient } from "@openhands/typescript-client/clients"; +import { getAgentServerClientOptions } from "#/api/agent-server-client-options"; +import { SecretsService } from "#/api/secrets-service"; +import { getActiveBackend } from "#/api/backend-registry/active-store"; +import type { Provider } from "#/types/settings"; +import { constructPullRequestUrl, getGitProviderBaseUrl } from "#/utils/utils"; + +export interface GitProviderItem { + id: number; + number: number; + title: string; + url: string; + authorLogin: string | null; + updatedAt: string | null; +} + +const PROVIDER_TOKEN_SECRET_CANDIDATES: Partial> = { + github: ["GITHUB_TOKEN", "GH_TOKEN", "github"], + gitlab: ["GITLAB_TOKEN", "GL_TOKEN", "gitlab"], + bitbucket: ["BITBUCKET_TOKEN", "bitbucket"], + forgejo: ["FORGEJO_TOKEN", "forgejo"], +}; + +const LIST_LIMIT = 30; + +async function resolveProviderToken( + provider: Provider, +): Promise { + // Cloud backends keep provider tokens server-side; browser-side secret + // lookup is only meaningful for the local agent-server secrets store. + if (getActiveBackend().backend.kind !== "local") { + return null; + } + + const candidates = PROVIDER_TOKEN_SECRET_CANDIDATES[provider] ?? []; + if (candidates.length === 0) { + return null; + } + + const secrets = await SecretsService.getSecrets(); + const available = new Set(secrets.map((secret) => secret.name)); + const match = candidates.find((name) => available.has(name)); + if (!match) { + return null; + } + + try { + return await new SettingsClient(getAgentServerClientOptions()).getSecret( + match, + ); + } catch { + return null; + } +} + +function constructIssueUrl( + issueNumber: number, + provider: Provider, + repositoryName: string, +): string { + const baseUrl = getGitProviderBaseUrl(provider); + switch (provider) { + case "gitlab": + return `${baseUrl}/${repositoryName}/-/issues/${issueNumber}`; + case "bitbucket": + return `${baseUrl}/${repositoryName}/issues/${issueNumber}`; + case "forgejo": + return `${baseUrl}/${repositoryName}/issues/${issueNumber}`; + case "github": + default: + return `${baseUrl}/${repositoryName}/issues/${issueNumber}`; + } +} + +function constructIssuesListUrl( + provider: Provider, + repositoryName: string, +): string { + const baseUrl = getGitProviderBaseUrl(provider); + switch (provider) { + case "gitlab": + return `${baseUrl}/${repositoryName}/-/issues`; + case "bitbucket": + return `${baseUrl}/${repositoryName}/issues`; + case "forgejo": + return `${baseUrl}/${repositoryName}/issues`; + case "github": + default: + return `${baseUrl}/${repositoryName}/issues`; + } +} + +function constructPullRequestsListUrl( + provider: Provider, + repositoryName: string, +): string { + const baseUrl = getGitProviderBaseUrl(provider); + switch (provider) { + case "gitlab": + return `${baseUrl}/${repositoryName}/-/merge_requests`; + case "bitbucket": + return `${baseUrl}/${repositoryName}/pull-requests`; + case "forgejo": + return `${baseUrl}/${repositoryName}/pulls`; + case "github": + default: + return `${baseUrl}/${repositoryName}/pulls`; + } +} + +async function fetchGithubJson( + path: string, + token: string | null, +): Promise { + const headers: Record = { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetch(`https://api.github.com${path}`, { headers }); + if (!response.ok) { + throw new Error(`GitHub API ${response.status}`); + } + return response.json() as Promise; +} + +async function fetchGitlabJson( + path: string, + token: string | null, +): Promise { + const headers: Record = { + Accept: "application/json", + }; + if (token) { + headers["PRIVATE-TOKEN"] = token; + } + + // gitlab.com's public REST API — an external host, not the agent-server + // `/api` surface the rule guards (its `/api/v4` prefix just trips the + // substring heuristic; the companion no-direct-agent-server-calls test + // matches only relative `/api/...` URLs and is not affected). + // eslint-disable-next-line local/no-direct-agent-server-fetch + const response = await fetch(`https://gitlab.com/api/v4${path}`, { headers }); + if (!response.ok) { + throw new Error(`GitLab API ${response.status}`); + } + return response.json() as Promise; +} + +type GithubIssueOrPr = { + id: number; + number: number; + title: string; + html_url: string; + user?: { login?: string } | null; + updated_at?: string | null; + pull_request?: unknown; +}; + +type GitlabMergeRequest = { + id: number; + iid: number; + title: string; + web_url: string; + author?: { username?: string } | null; + updated_at?: string | null; +}; + +type GitlabIssue = { + id: number; + iid: number; + title: string; + web_url: string; + author?: { username?: string } | null; + updated_at?: string | null; +}; + +/** + * Lists open pull requests / merge requests and issues for a connected + * repository by calling the provider's public REST API from the browser. + * When a matching token secret exists locally (e.g. `GITHUB_TOKEN`), it is + * used for auth so private repos work; otherwise public-repo unauthenticated + * requests are attempted. + */ +export class GitProviderItemsService { + static constructIssuesListUrl = constructIssuesListUrl; + + static constructPullRequestsListUrl = constructPullRequestsListUrl; + + static async listPullRequests( + repository: string, + provider: Provider, + ): Promise { + const token = await resolveProviderToken(provider); + + if (provider === "gitlab") { + const encoded = encodeURIComponent(repository); + const items = await fetchGitlabJson( + `/projects/${encoded}/merge_requests?state=opened&per_page=${LIST_LIMIT}`, + token, + ); + return items.map((item) => ({ + id: item.id, + number: item.iid, + title: item.title, + url: item.web_url, + authorLogin: item.author?.username ?? null, + updatedAt: item.updated_at ?? null, + })); + } + + if (provider !== "github" && provider !== "forgejo") { + return []; + } + + const [owner, repo] = repository.split("/"); + if (!owner || !repo) { + return []; + } + + const items = await fetchGithubJson( + `/repos/${owner}/${repo}/pulls?state=open&per_page=${LIST_LIMIT}`, + token, + ); + + return items.map((item) => ({ + id: item.id, + number: item.number, + title: item.title, + url: + item.html_url || + constructPullRequestUrl(item.number, provider, repository), + authorLogin: item.user?.login ?? null, + updatedAt: item.updated_at ?? null, + })); + } + + static async listIssues( + repository: string, + provider: Provider, + ): Promise { + const token = await resolveProviderToken(provider); + + if (provider === "gitlab") { + const encoded = encodeURIComponent(repository); + const items = await fetchGitlabJson( + `/projects/${encoded}/issues?state=opened&per_page=${LIST_LIMIT}`, + token, + ); + return items.map((item) => ({ + id: item.id, + number: item.iid, + title: item.title, + url: item.web_url, + authorLogin: item.author?.username ?? null, + updatedAt: item.updated_at ?? null, + })); + } + + if (provider !== "github" && provider !== "forgejo") { + return []; + } + + const [owner, repo] = repository.split("/"); + if (!owner || !repo) { + return []; + } + + const items = await fetchGithubJson( + `/repos/${owner}/${repo}/issues?state=open&per_page=${LIST_LIMIT}`, + token, + ); + + // GitHub's issues endpoint also returns pull requests. + return items + .filter((item) => !item.pull_request) + .map((item) => ({ + id: item.id, + number: item.number, + title: item.title, + url: + item.html_url || constructIssueUrl(item.number, provider, repository), + authorLogin: item.user?.login ?? null, + updatedAt: item.updated_at ?? null, + })); + } +} diff --git a/src/api/hooks-service.ts b/src/api/hooks-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..828a4fbe3d7849dd579e4b20d9b2466c0d1b94ff --- /dev/null +++ b/src/api/hooks-service.ts @@ -0,0 +1,49 @@ +import { HooksClient } from "@openhands/typescript-client/clients"; +import type { HookConfig } from "@openhands/typescript-client"; +import { getAgentServerWorkingDir } from "./agent-server-config"; +import { getEffectiveLocalBackend } from "./backend-registry/active-store"; +import { getAgentServerClientOptions } from "./agent-server-client-options"; + +// `loadHooks()` spends this twice (version probe, then the call) on the +// conversation-start critical path; the SDK's 60s default would stall a launch. +const HOOKS_LOAD_TIMEOUT_MS = 5000; + +class HooksService { + /** + * Load workspace hooks from the agent-server by calling POST /api/hooks. + * Returns the HookConfig if the workspace has hooks, or null. + * Gracefully returns null when no usable local backend is available + * (cloud backend, unseeded registry, or older agent-server). + * `projectDir` must be the workspace root: the agent-server checks + * `/.openhands/hooks.json` literally and never walks parents. + */ + static async loadWorkspaceHooks( + projectDir?: string, + ): Promise { + // getEffectiveLocalBackend() returns null for cloud backends AND for the + // NO_BACKEND sentinel (unseeded/empty registry), avoiding the silent + // NoBackendAvailableError that getActiveBackend() would cause. + if (!getEffectiveLocalBackend()) { + return null; + } + + try { + const response = await new HooksClient( + getAgentServerClientOptions({ timeout: HOOKS_LOAD_TIMEOUT_MS }), + ).loadHooks({ + project_dir: projectDir ?? getAgentServerWorkingDir(), + }); + return response?.hook_config ?? null; + } catch (error) { + // Agent-server may not support the hooks endpoint or may be + // unreachable; gracefully fall back to null. + console.warn( + "Failed to load workspace hooks, continuing without:", + error, + ); + return null; + } + } +} + +export default HooksService; diff --git a/src/api/launch-child-conversation-client-tool.ts b/src/api/launch-child-conversation-client-tool.ts new file mode 100644 index 0000000000000000000000000000000000000000..17ca1801115dc39575a32196bce3e93cd4950cf0 --- /dev/null +++ b/src/api/launch-child-conversation-client-tool.ts @@ -0,0 +1,112 @@ +import { + CHILD_CONVERSATION_ISOLATIONS, + CHILD_CONVERSATION_RESULT_PREFIX, + CHILD_CONVERSATION_TARGETS, + LAUNCH_CHILD_CONVERSATION_TOOL_NAME, +} from "#/constants/child-conversation"; +import type { ClientToolSpec } from "./canvas-ui-client-tool"; + +export { + LAUNCH_CHILD_CONVERSATION_ACTION_KIND, + LAUNCH_CHILD_CONVERSATION_TOOL_NAME, +} from "#/constants/child-conversation"; + +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. + +The child runs on its own. It does not block you, you do not see its output, +and it cannot see this conversation's history — everything it needs must be in +the task brief. + +Choosing target: + +* target="local" — runs on the same machine, in this conversation's workspace. + Fast, no repository clone, no sandbox provisioning. Use this by default when + the work is on code that is already checked out here. + +* target="cloud" — runs on OpenHands Cloud in its own isolated sandbox, from a + git repository. Use this when the work should not touch the user's machine or + when it needs a repository that is not checked out locally. Requires the user + to have an OpenHands Cloud backend connected in Agent Canvas; if none is + connected you will be told so and should fall back to target="local". + +Writing the task brief: + +* Put everything the child needs in "task": the goal, the relevant file paths, + the constraints, the expected deliverable, and how it should report back. +* Keep each child's scope independent of its siblings so parallel children do + not fight over the same files. +* One call per delegated task. Do NOT call this tool twice for the same task. + +Parameter rules (a call that breaks one of these launches nothing and comes +back with corrective guidance): + +* "repository" and "branch" apply to target="cloud" only. A local child always + inherits this conversation's workspace, so do not pass them with + target="local". +* "isolation" applies to target="local" only. Cloud sandboxes are always + isolated, so do not pass it with target="cloud". +* isolation="worktree" (the default) gives the child its own git worktree and + branch, cut from the repository's default branch — it will NOT see this + conversation's uncommitted or committed work. isolation="shared" puts the + child in this conversation's exact directory; only choose it when the child + genuinely must see work in progress, and expect the two agents to conflict. + +What comes back: this tool is acknowledged immediately, before the conversation +exists. The real outcome arrives moments later as a follow-up message starting +with "${CHILD_CONVERSATION_RESULT_PREFIX.trim()}" that carries the child's +conversation id, URL, target and initial status — or an error with guidance. +Wait for it, then tell the user what you launched and give them the URL. If it +reports an error, fix the parameters and call this tool again.`; + +export const LAUNCH_CHILD_CONVERSATION_CLIENT_TOOL: ClientToolSpec = { + name: LAUNCH_CHILD_CONVERSATION_TOOL_NAME, + description: LAUNCH_CHILD_CONVERSATION_DESCRIPTION, + parameters: { + type: "object", + additionalProperties: false, + properties: { + target: { + type: "string", + enum: [...CHILD_CONVERSATION_TARGETS], + description: + "Where the child runs. 'local' reuses this machine and this conversation's workspace; 'cloud' runs in an isolated OpenHands Cloud sandbox.", + }, + task: { + type: "string", + description: + "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.", + }, + title: { + type: "string", + description: + "Optional short title for the child conversation. Omit to let it title itself from the task.", + }, + repository: { + type: "string", + description: + "target='cloud' only. Repository to clone into the sandbox, as 'owner/repo'. Defaults to this conversation's repository when it has one.", + }, + branch: { + type: "string", + description: + "target='cloud' only, and only together with 'repository'. Defaults to the repository's default branch.", + }, + isolation: { + type: "string", + enum: [...CHILD_CONVERSATION_ISOLATIONS], + description: + "target='local' only. 'worktree' (default) gives the child its own git worktree and branch; 'shared' runs it in this conversation's exact directory.", + }, + }, + required: ["target", "task"], + }, + annotations: { + // Launching a conversation writes real state and is not repeatable, so the + // agent is asked to predict a security risk before every call. + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + // The cloud target reaches OpenHands Cloud. + openWorldHint: true, + }, +}; diff --git a/src/api/llm-balance-service.test.ts b/src/api/llm-balance-service.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..eb5963d9b574553c325ab01bf7bd5b41d134e817 --- /dev/null +++ b/src/api/llm-balance-service.test.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + setActiveSelection, + setRegisteredBackends, +} from "#/api/backend-registry/active-store"; +import type { Backend } from "#/api/backend-registry/types"; +import { + LLM_BALANCE_PATH, + LLM_BALANCE_TIMEOUT_MS, +} from "#/constants/llm-balance"; +import LLMBalanceService from "./llm-balance-service"; +import { AgentServerClient } from "@openhands/typescript-client/clients"; + +const getMock = vi.hoisted(() => vi.fn()); + +vi.mock("@openhands/typescript-client/clients", () => ({ + AgentServerClient: vi.fn(function AgentServerClientMock() { + return { get: getMock, close: vi.fn() }; + }), +})); + +const localBackend: Backend = { + id: "local-test", + name: "Local test backend", + host: "http://localhost:3000", + apiKey: "test-session-key", + kind: "local", +}; + +const balancePayload = { + provider: "openrouter", + limit: 20, + limit_remaining: 14.96, + usage: 5.04, + usage_daily: 1.2, + usage_weekly: 3.4, + usage_monthly: 5.04, + is_free_tier: false, +}; + +function httpError(status: number): Error & { status: number } { + return Object.assign(new Error(`HTTP ${status}`), { status }); +} + +function mockedClient(): AgentServerClient { + return vi.mocked(AgentServerClient).mock.results[0] + ?.value as AgentServerClient; +} + +describe("LLMBalanceService.getBalance", () => { + beforeEach(() => { + getMock.mockReset(); + vi.mocked(AgentServerClient).mockClear(); + setRegisteredBackends([localBackend]); + setActiveSelection({ backendId: localBackend.id }); + }); + + afterEach(() => { + setActiveSelection(null); + setRegisteredBackends([]); + }); + + it("normalizes a successful balance response via the typed client", async () => { + getMock.mockResolvedValue(balancePayload); + + const balance = await LLMBalanceService.getBalance(); + + expect(balance).toEqual({ + provider: "openrouter", + limit: 20, + limitRemaining: 14.96, + usage: 5.04, + usageDaily: 1.2, + usageWeekly: 3.4, + usageMonthly: 5.04, + isFreeTier: false, + }); + + expect(vi.mocked(AgentServerClient)).toHaveBeenCalledWith({ + host: "http://localhost:3000", + apiKey: "test-session-key", + timeout: LLM_BALANCE_TIMEOUT_MS, + }); + expect(getMock).toHaveBeenCalledWith(LLM_BALANCE_PATH, { + acceptableStatusCodes: new Set([200]), + responseType: "json", + timeoutSeconds: Math.floor(LLM_BALANCE_TIMEOUT_MS / 1000), + }); + expect(mockedClient().close).toHaveBeenCalled(); + }); + + it("treats null caps as an uncapped key", async () => { + getMock.mockResolvedValue({ + ...balancePayload, + limit: null, + limit_remaining: null, + }); + + const balance = await LLMBalanceService.getBalance(); + + expect(balance?.limit).toBeNull(); + expect(balance?.limitRemaining).toBeNull(); + expect(balance?.usage).toBe(5.04); + }); + + it("returns null when the endpoint is missing (404)", async () => { + getMock.mockRejectedValue(httpError(404)); + + await expect(LLMBalanceService.getBalance()).resolves.toBeNull(); + }); + + it("returns null for a malformed response body", async () => { + getMock.mockResolvedValue({ unexpected: true }); + + await expect(LLMBalanceService.getBalance()).resolves.toBeNull(); + }); + + it("throws on non-404 HTTP failures", async () => { + getMock.mockRejectedValue(httpError(500)); + + await expect(LLMBalanceService.getBalance()).rejects.toThrow( + "Balance request failed with 500", + ); + }); + + it("throws a named error when the request times out", async () => { + getMock.mockRejectedValue( + new DOMException("The operation was aborted.", "TimeoutError"), + ); + + await expect(LLMBalanceService.getBalance()).rejects.toThrow( + "Balance request timed out", + ); + }); +}); diff --git a/src/api/llm-balance-service.ts b/src/api/llm-balance-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..de1635705ca70b2571e704f3096820af871e17df --- /dev/null +++ b/src/api/llm-balance-service.ts @@ -0,0 +1,113 @@ +import { AgentServerClient } from "@openhands/typescript-client/clients"; +import { getAgentServerClientOptions } from "./agent-server-client-options"; +import { + LLM_BALANCE_PATH, + LLM_BALANCE_TIMEOUT_MS, +} from "#/constants/llm-balance"; + +/** + * Credit balance reported by the agent server for the active LLM provider + * (currently only OpenRouter). Amounts are in USD credits. + */ +export interface LLMBalance { + provider: string; + /** Credit cap configured for the key, or null when uncapped. */ + limit: number | null; + /** Credits remaining under `limit`, or null when uncapped. */ + limitRemaining: number | null; + /** Total credits consumed by the key. */ + usage: number; + usageDaily: number | null; + usageWeekly: number | null; + usageMonthly: number | null; + isFreeTier: boolean; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function numberOrNull(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +/** `AbortSignal.timeout` rejects with a `TimeoutError`; a manual abort uses `AbortError`. */ +function isAbortLike(error: unknown): boolean { + return ( + isRecord(error) && + (error.name === "TimeoutError" || error.name === "AbortError") + ); +} + +function normalizeBalance(raw: unknown): LLMBalance | null { + if (!isRecord(raw) || typeof raw.provider !== "string") return null; + + return { + provider: raw.provider, + limit: numberOrNull(raw.limit), + limitRemaining: numberOrNull(raw.limit_remaining), + usage: numberOrNull(raw.usage) ?? 0, + usageDaily: numberOrNull(raw.usage_daily), + usageWeekly: numberOrNull(raw.usage_weekly), + usageMonthly: numberOrNull(raw.usage_monthly), + isFreeTier: raw.is_free_tier === true, + }; +} + +class LLMBalanceService { + /** + * Fetch the provider balance from the agent server. + * + * Returns `null` when the server answers 404 (older servers without the + * endpoint, or a provider that doesn't support balance reporting) or when + * the response doesn't match the expected shape — callers hide the balance + * UI in that case. Other HTTP failures throw, as does a request that outlives + * `LLM_BALANCE_TIMEOUT_MS`. A timeout stays an error rather than collapsing + * into `null`: `null` means "this server has no balance to report" and is + * cached under `staleTime: Infinity`, so treating a transient stall as an + * absent endpoint would hide the card for the rest of the conversation. + */ + static async getBalance(): Promise { + const { host, apiKey } = getAgentServerClientOptions(); + const client = new AgentServerClient({ + host, + apiKey, + timeout: LLM_BALANCE_TIMEOUT_MS, + }); + try { + const data = await client.get(LLM_BALANCE_PATH, { + acceptableStatusCodes: new Set([200]), + responseType: "json", + timeoutSeconds: Math.floor(LLM_BALANCE_TIMEOUT_MS / 1000), + }); + return normalizeBalance(data); + } catch (error) { + // Older servers without balance support answer 404 — hide the UI. + if ( + error instanceof Error && + "status" in error && + (error as { status: number }).status === 404 + ) { + return null; + } + if (isAbortLike(error)) { + throw new Error( + `Balance request timed out after ${LLM_BALANCE_TIMEOUT_MS}ms`, + ); + } + const status = + error instanceof Error && + "status" in error && + (error as { status: number }).status; + throw new Error( + status + ? `Balance request failed with ${status}` + : `Balance request failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } finally { + client.close(); + } + } +} + +export default LLMBalanceService; diff --git a/src/api/llm-subscription-service.ts b/src/api/llm-subscription-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..dea3dfe9b657a8ab01f75ed014e1c3f148d4ac48 --- /dev/null +++ b/src/api/llm-subscription-service.ts @@ -0,0 +1,202 @@ +import { AgentServerClient } from "@openhands/typescript-client/clients"; +import { getAgentServerClientOptions } from "./agent-server-client-options"; +import { + OPENAI_SUBSCRIPTION_DEVICE_POLL_PATH, + OPENAI_SUBSCRIPTION_DEVICE_START_PATH, + OPENAI_SUBSCRIPTION_LOGOUT_PATH, + OPENAI_SUBSCRIPTION_MODELS_PATH, + OPENAI_SUBSCRIPTION_STATUS_PATH, + OPENAI_SUBSCRIPTION_VENDOR, +} from "#/constants/llm-subscription"; + +type RawSubscriptionStatus = Record; +type RawDeviceStart = Record; + +export interface LLMSubscriptionStatus { + vendor: typeof OPENAI_SUBSCRIPTION_VENDOR; + connected: boolean; + accountEmail: string | null; + expiresAt: string | number | null; +} + +export interface LLMSubscriptionDeviceChallenge { + deviceCode: string; + userCode: string; + verificationUri: string; + verificationUriComplete: string | null; + expiresAt: string | number | null; + intervalSeconds: number | null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +const readString = ( + value: Record, + keys: string[], +): string | null => { + for (const key of keys) { + const candidate = value[key]; + if (typeof candidate === "string" && candidate.trim().length > 0) { + return candidate.trim(); + } + } + return null; +}; + +const readNumber = ( + value: Record, + keys: string[], +): number | null => { + for (const key of keys) { + const candidate = value[key]; + if (typeof candidate === "number" && Number.isFinite(candidate)) { + return candidate; + } + } + return null; +}; + +const readBoolean = ( + value: Record, + keys: string[], +): boolean => { + for (const key of keys) { + const candidate = value[key]; + if (typeof candidate === "boolean") { + return candidate; + } + } + return false; +}; + +interface SubscriptionRequestOptions { + method?: "GET" | "POST"; + jsonBody?: unknown; +} + +async function requestSubscriptionEndpoint( + path: string, + options: SubscriptionRequestOptions = {}, +): Promise { + const { host, apiKey } = getAgentServerClientOptions(); + const client = new AgentServerClient({ host, apiKey }); + try { + return await client.request({ + method: options.method ?? "GET", + path, + body: options.jsonBody, + responseType: "json", + }); + } finally { + client.close(); + } +} + +function normalizeModels(raw: unknown): string[] { + if (Array.isArray(raw)) { + return raw.filter((item): item is string => typeof item === "string"); + } + if (isRecord(raw) && Array.isArray(raw.models)) { + return raw.models.filter( + (item): item is string => typeof item === "string", + ); + } + return []; +} + +function normalizeStatus(raw: RawSubscriptionStatus): LLMSubscriptionStatus { + return { + vendor: OPENAI_SUBSCRIPTION_VENDOR, + connected: readBoolean(raw, ["connected", "authenticated", "is_connected"]), + accountEmail: readString(raw, ["account_email", "email", "account"]), + expiresAt: + readString(raw, ["expires_at", "expiresAt"]) ?? + readNumber(raw, ["expires_at", "expiresAt"]), + }; +} + +function normalizeDeviceChallenge( + raw: RawDeviceStart, +): LLMSubscriptionDeviceChallenge { + const deviceCode = readString(raw, ["device_code", "deviceCode"]); + const userCode = readString(raw, ["user_code", "userCode"]); + const verificationUri = readString(raw, [ + "verification_uri", + "verificationUri", + "verification_url", + "verificationUrl", + ]); + + if (!deviceCode || !userCode || !verificationUri) { + throw new Error("Subscription device login response is incomplete"); + } + + return { + deviceCode, + userCode, + verificationUri, + verificationUriComplete: readString(raw, [ + "verification_uri_complete", + "verificationUriComplete", + "verification_url_complete", + "verificationUrlComplete", + ]), + expiresAt: + readString(raw, ["expires_at", "expiresAt"]) ?? + readNumber(raw, ["expires_at", "expiresAt", "expires_in", "expiresIn"]), + intervalSeconds: readNumber(raw, [ + "interval", + "interval_seconds", + "intervalSeconds", + ]), + }; +} + +class LLMSubscriptionService { + static async getOpenAIModels(): Promise { + const response = await requestSubscriptionEndpoint( + OPENAI_SUBSCRIPTION_MODELS_PATH, + ); + return normalizeModels(response); + } + + static async getOpenAIStatus(): Promise { + const response = await requestSubscriptionEndpoint( + OPENAI_SUBSCRIPTION_STATUS_PATH, + ); + return normalizeStatus(response as RawSubscriptionStatus); + } + + static async startOpenAIDeviceLogin(): Promise { + const response = await requestSubscriptionEndpoint( + OPENAI_SUBSCRIPTION_DEVICE_START_PATH, + { method: "POST" }, + ); + return normalizeDeviceChallenge(response as RawDeviceStart); + } + + static async pollOpenAIDeviceLogin( + deviceCode: string, + ): Promise { + const response = await requestSubscriptionEndpoint( + OPENAI_SUBSCRIPTION_DEVICE_POLL_PATH, + { + method: "POST", + jsonBody: { device_code: deviceCode }, + }, + ); + return normalizeStatus(response as RawSubscriptionStatus); + } + + static async logoutOpenAI(): Promise { + const response = await requestSubscriptionEndpoint( + OPENAI_SUBSCRIPTION_LOGOUT_PATH, + { method: "POST" }, + ); + return normalizeStatus(response as RawSubscriptionStatus); + } +} + +export default LLMSubscriptionService; diff --git a/src/api/main-app-auth.ts b/src/api/main-app-auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..dcbb3de9f3307a86609799982cbc8203229dafef --- /dev/null +++ b/src/api/main-app-auth.ts @@ -0,0 +1,76 @@ +import axios from "axios"; +import { AGENT_CANVAS_CLIENT_VERSION } from "./client-source"; +import { getLockedCloudAuthMode } from "./agent-server-config"; + +export const MAIN_APP_AUTHENTICATE_PATH = "/api/authenticate"; +export const MAIN_APP_ANALYTICS_EVENTS_PATH = "/api/analytics/events"; +export const MAIN_APP_LOGIN_PATH = "/login"; +export const MAIN_APP_LOGIN_REDIRECT_PARAM = "returnTo"; + +const LOCAL_BROWSER_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]); + +function getCurrentPathForRedirect(): string { + if (typeof window === "undefined") return "/"; + return `${window.location.pathname}${window.location.search}${window.location.hash}`; +} + +export function isLocalBrowserHost(): boolean { + if (typeof window === "undefined") return false; + return LOCAL_BROWSER_HOSTNAMES.has(window.location.hostname); +} + +export function shouldUseMainAppCookieAuth(): boolean { + return getLockedCloudAuthMode() === "cookie" && !isLocalBrowserHost(); +} + +let canvasAuthenticatedReported = false; + +export function _resetCanvasAuthReported(): void { + canvasAuthenticatedReported = false; +} + +export async function reportCanvasAuthenticated(): Promise { + if (canvasAuthenticatedReported) return; + canvasAuthenticatedReported = true; + try { + await axios.post( + MAIN_APP_ANALYTICS_EVENTS_PATH, + { + event_type: "canvas_authenticated", + client_version: AGENT_CANVAS_CLIENT_VERSION, + }, + { withCredentials: true }, + ); + } catch { + // Analytics must never affect authentication or application startup. + } +} + +export async function authenticateWithMainAppCookie(): Promise { + try { + await axios.post(MAIN_APP_AUTHENTICATE_PATH, null, { + withCredentials: true, + }); + void reportCanvasAuthenticated(); + return true; + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 401) { + return false; + } + throw error; + } +} + +export function buildMainAppLoginRedirect( + redirectPath = getCurrentPathForRedirect(), +): string { + const params = new URLSearchParams({ + [MAIN_APP_LOGIN_REDIRECT_PARAM]: redirectPath, + }); + return `${MAIN_APP_LOGIN_PATH}?${params.toString()}`; +} + +export function redirectToMainAppLogin(): void { + if (typeof window === "undefined") return; + window.location.assign(buildMainAppLoginRedirect()); +} diff --git a/src/api/no-direct-agent-server-calls.test.ts b/src/api/no-direct-agent-server-calls.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..fcf6b3255db3e64af3340682db7e742ec0018124 --- /dev/null +++ b/src/api/no-direct-agent-server-calls.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; + +const SRC_ROOT = join(process.cwd(), "src"); +const EXCLUDED_SEGMENTS = new Set(["mocks", "routeTree.gen.ts"]); +const ALLOWED_AD_HOC_HTTP_FILES = new Set([ + "api/automation-service/automation-service.api.ts", + "api/cloud/proxy.ts", + "api/main-app-auth.ts", +]); + +function collectSourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const fullPath = join(dir, entry.name); + // Normalize to forward slashes so the path matches ALLOWED_AD_HOC_HTTP_FILES + // entries on Windows where path.relative() returns backslash-separated paths. + const relPath = relative(SRC_ROOT, fullPath).replace(/\\/g, "/"); + + if (entry.isDirectory()) { + if (EXCLUDED_SEGMENTS.has(entry.name)) return []; + return collectSourceFiles(fullPath); + } + + if (EXCLUDED_SEGMENTS.has(entry.name)) return []; + if (!/\.(ts|tsx)$/.test(entry.name)) return []; + if (/\.(test|spec)\.(ts|tsx)$/.test(entry.name)) return []; + return [relPath]; + }); +} + +describe("agent-server API access", () => { + it("uses typed @openhands/typescript-client access instead of ad-hoc HTTP", () => { + const violations = collectSourceFiles(SRC_ROOT).flatMap((relPath) => { + const source = readFileSync(join(SRC_ROOT, relPath), "utf8"); + const fileViolations: string[] = []; + + if (/openHands\s*\./.test(source)) { + fileViolations.push("uses the shared axios instance directly"); + } + + if (/\bcreateHttpClient\s*\(/.test(source)) { + fileViolations.push("uses createHttpClient directly"); + } + + if ( + /from\s+["']@openhands\/typescript-client\/client\/http-client["']/.test( + source, + ) + ) { + fileViolations.push("imports the low-level SDK HttpClient directly"); + } + + if (/\bnew\s+HttpClient\s*\(/.test(source)) { + fileViolations.push("constructs HttpClient directly"); + } + + if ( + (/\baxios\s*\(/.test(source) || + /\baxios\s*\.\s*(?:create|get|post|put|patch|delete|request)\s*\(/.test( + source, + )) && + !ALLOWED_AD_HOC_HTTP_FILES.has(relPath) + ) { + fileViolations.push("uses axios directly for HTTP calls"); + } + + if ( + /\bfetch\s*\([\s\S]{0,200}['"`]\/api\//.test(source) && + !ALLOWED_AD_HOC_HTTP_FILES.has(relPath) + ) { + fileViolations.push("calls an /api path with fetch directly"); + } + + return fileViolations.map((violation) => `${relPath}: ${violation}`); + }); + + expect(violations).toEqual([]); + }); +}); diff --git a/src/api/open-hands.types.ts b/src/api/open-hands.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..e9796d6162f07cafa2ff957cb5fe3524cb536465 --- /dev/null +++ b/src/api/open-hands.types.ts @@ -0,0 +1,140 @@ +import { ConversationStatus } from "#/types/conversation-status"; +import { RuntimeStatus } from "#/types/runtime-status"; +import { Provider } from "#/types/settings"; + +export interface ErrorResponse { + error: string; +} + +export interface SaveFileSuccessResponse { + message: string; +} + +export interface FileUploadSuccessResponse { + uploaded_files: string[]; + skipped_files: { name: string; reason: string }[]; +} + +export interface FeedbackBodyResponse { + message: string; + feedback_id: string; + password: string; +} + +export interface FeedbackResponse { + statusCode: number; + body: FeedbackBodyResponse; +} + +export interface AuthenticationResponse { + message: string; + login?: string; // Only present when allow list is enabled +} + +export interface Feedback { + version: string; + email: string; + token: string; + polarity: "positive" | "negative"; + permissions: "public" | "private"; + trajectory: unknown[]; +} + +export interface GetVSCodeUrlResponse { + vscode_url: string | null; + error?: string; +} + +export interface GetTrajectoryResponse { + trajectory: unknown[] | null; + error?: string; +} + +export interface RepositorySelection { + selected_repository: string | null; + selected_branch: string | null; + git_provider: Provider | null; +} + +export type ConversationTrigger = + | "resolver" + | "gui" + | "suggested_task" + | "microagent_management" + | "automation"; + +export interface Conversation { + conversation_id: string; + title: string; + selected_repository: string | null; + selected_branch: string | null; + git_provider: Provider | null; + last_updated_at: string; + created_at: string; + status: ConversationStatus; + runtime_status: RuntimeStatus | null; + trigger?: ConversationTrigger; + url: string | null; + session_api_key: string | null; + pr_number?: number[] | null; + conversation_version?: "V0" | "V1"; + sub_conversation_ids?: string[]; + public?: boolean; + llm_model?: string | null; +} + +export interface ResultSet { + results: T[]; + next_page_id: string | null; +} + +/** + * @deprecated Use AgentServerGitChangeStatus for new code. This type is maintained for backward compatibility with V0 API. + */ +export type GitChangeStatus = "M" | "A" | "D" | "R" | "U"; + +export type AgentServerGitChangeStatus = + | "MOVED" + | "ADDED" + | "DELETED" + | "UPDATED"; + +export interface GitChange { + status: GitChangeStatus; + path: string; +} + +export interface GitChangeDiff { + modified: string; + original: string; +} + +export interface GitCommit { + sha: string; + shortSha: string; + subject: string; + author: string; + // ISO 8601 author date with UTC offset (git log %aI). + timestamp: string; +} + +export interface GitCommitsPage { + commits: GitCommit[]; + hasMore: boolean; +} + +export interface InputMetadata { + name: string; + description: string; +} + +export interface IOption { + label: string; + value: T; +} + +export type GetFilesResponse = string[]; + +export interface GetFileResponse { + code: string; +} diff --git a/src/api/plugins-management-service.test.ts b/src/api/plugins-management-service.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..924abec5c4f71be1a98216a4fb4989559631bf71 --- /dev/null +++ b/src/api/plugins-management-service.test.ts @@ -0,0 +1,137 @@ +import { PluginsClient } from "@openhands/typescript-client/clients"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + __resetActiveStoreForTests, + setActiveSelection, + setRegisteredBackends, +} from "#/api/backend-registry/active-store"; +import type { Backend } from "#/api/backend-registry/types"; +import PluginsManagementService from "#/api/plugins-management-service"; + +vi.mock("@openhands/typescript-client/clients", () => ({ + PluginsClient: vi.fn(), +})); + +const listInstalledPlugins = vi.fn(); +const installPlugin = vi.fn(); +const setPluginEnabled = vi.fn(); +const uninstallPlugin = vi.fn(); +const refreshPlugin = vi.fn(); +const close = vi.fn(); + +function useBackend(kind: "local" | "cloud"): void { + const backend: Backend = { + id: kind, + name: kind, + host: "http://127.0.0.1:8001", + apiKey: "session-key", + kind, + }; + setRegisteredBackends([backend]); + setActiveSelection({ backendId: kind, orgId: null }); +} + +beforeEach(() => { + vi.clearAllMocks(); + __resetActiveStoreForTests(); + vi.mocked(PluginsClient).mockImplementation(function MockPluginsClient() { + return { + listInstalledPlugins, + installPlugin, + setPluginEnabled, + uninstallPlugin, + refreshPlugin, + close, + } as unknown as PluginsClient; + } as unknown as typeof PluginsClient); +}); + +afterEach(() => { + __resetActiveStoreForTests(); +}); + +const installedPlugin = { + name: "demo-plugin", + version: "1.0.0", + description: "A demo plugin", + enabled: true, + source: "github:OpenHands/extensions", + resolved_ref: null, + repo_path: "plugins/demo-plugin", + installed_at: "2026-06-01T00:00:00Z", + install_path: "/home/.openhands/plugins/installed/demo-plugin", +}; + +describe("PluginsManagementService", () => { + it("lists installed plugins from the local agent-server", async () => { + useBackend("local"); + listInstalledPlugins.mockResolvedValue({ plugins: [installedPlugin] }); + + const result = await PluginsManagementService.listInstalledPlugins(); + + expect(result).toEqual([installedPlugin]); + expect(listInstalledPlugins).toHaveBeenCalledTimes(1); + }); + + it("returns an empty installed list on a cloud backend without calling the client", async () => { + useBackend("cloud"); + + const result = await PluginsManagementService.listInstalledPlugins(); + + expect(result).toEqual([]); + expect(PluginsClient).not.toHaveBeenCalled(); + }); + + it("returns an empty installed list when the local request fails", async () => { + useBackend("local"); + listInstalledPlugins.mockRejectedValue(new Error("unreachable")); + + const result = await PluginsManagementService.listInstalledPlugins(); + + expect(result).toEqual([]); + }); + + it("forwards source, ref, and repo_path when installing a plugin", async () => { + useBackend("local"); + installPlugin.mockResolvedValue(installedPlugin); + + await PluginsManagementService.installPlugin({ + source: "github:OpenHands/extensions", + ref: "main", + repo_path: "plugins/demo-plugin", + }); + + expect(installPlugin).toHaveBeenCalledWith({ + source: "github:OpenHands/extensions", + ref: "main", + repo_path: "plugins/demo-plugin", + }); + }); + + it("forwards the name and enabled flag when toggling a plugin", async () => { + useBackend("local"); + setPluginEnabled.mockResolvedValue({ name: "demo-plugin", enabled: false }); + + await PluginsManagementService.setPluginEnabled("demo-plugin", false); + + expect(setPluginEnabled).toHaveBeenCalledWith("demo-plugin", false); + }); + + it("forwards the plugin name when uninstalling", async () => { + useBackend("local"); + uninstallPlugin.mockResolvedValue({ message: "ok" }); + + await PluginsManagementService.uninstallPlugin("demo-plugin"); + + expect(uninstallPlugin).toHaveBeenCalledWith("demo-plugin"); + }); + + it("forwards the plugin name when refreshing", async () => { + useBackend("local"); + refreshPlugin.mockResolvedValue({ message: "ok", plugin: installedPlugin }); + + await PluginsManagementService.refreshPlugin("demo-plugin"); + + expect(refreshPlugin).toHaveBeenCalledWith("demo-plugin"); + }); +}); diff --git a/src/api/plugins-management-service.ts b/src/api/plugins-management-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..68ffadf17b6d4b38f0b446a82778b99c59529fab --- /dev/null +++ b/src/api/plugins-management-service.ts @@ -0,0 +1,136 @@ +import { PluginsClient } from "@openhands/typescript-client/clients"; +import { getActiveBackend } from "./backend-registry/active-store"; +import { getAgentServerClientOptions } from "./agent-server-client-options"; +import type { PluginBundledSkill } from "./plugins-service"; + +/** + * An installed plugin, as returned by the agent-server management router + * (`GET /api/plugins/installed`). Matches the typescript-client + * `InstalledPluginInfo`. The contents fields (`skills`, `files`, relative to + * `install_path`) are absent on older agent-servers and null when the + * installed plugin directory failed to load. + */ +export interface InstalledPluginInfo { + name: string; + version: string; + description: string | null; + enabled: boolean; + source: string; + resolved_ref?: string | null; + repo_path?: string | null; + installed_at: string; + install_path: string; + skills?: PluginBundledSkill[] | null; + files?: string[] | null; +} + +/** Coordinates for installing a plugin from a git source or local path. */ +export interface InstallPluginRequest { + source: string; + ref?: string | null; + repo_path?: string | null; + force?: boolean; +} + +/** + * The slice of the typescript-client `PluginsClient` this service drives. The + * installed `@openhands/typescript-client` package does not yet export these + * management methods (they ship in typescript-client PRs #222/#223); narrowing + * to this local interface keeps `vitest` (types stripped) and `eslint` green + * until the client is republished, at which point the cast below can be dropped. + */ +interface PluginsManagementClient { + listInstalledPlugins(): Promise<{ plugins: InstalledPluginInfo[] }>; + installPlugin(request: InstallPluginRequest): Promise; + setPluginEnabled( + name: string, + enabled: boolean, + ): Promise<{ name: string; enabled: boolean }>; + uninstallPlugin(name: string): Promise<{ message: string }>; + refreshPlugin( + name: string, + ): Promise<{ message: string; plugin: InstalledPluginInfo }>; +} + +function isCloudBackend(): boolean { + return getActiveBackend().backend.kind === "cloud"; +} + +function getManagementClient(): PluginsManagementClient { + return new PluginsClient( + getAgentServerClientOptions(), + ) as unknown as PluginsManagementClient; +} + +/** + * Front-end management layer for installed plugins: list / install / enable / + * disable / uninstall / refresh. Kept separate from the read-only catalog + * service (`plugins-service.ts`), exactly as skills separate the marketplace + * catalog from install actions. + * + * Local backend only for now (per Appendix C Q5): installed plugins live on the + * local agent-server's `~/.openhands/plugins/installed/`. A cloud backend has no + * per-user installed store yet, so reads return an empty list and mutating + * actions throw (the UI also disables them on cloud). + */ +class PluginsManagementService { + static async listInstalledPlugins(): Promise { + if (isCloudBackend()) { + return []; + } + + try { + const response = await getManagementClient().listInstalledPlugins(); + return response.plugins ?? []; + } catch { + // Agent-server may predate the plugins router or be unreachable; surface + // an empty list rather than throwing (mirrors the catalog service). + return []; + } + } + + static async installPlugin( + request: InstallPluginRequest, + ): Promise { + if (isCloudBackend()) { + throw new Error( + "Installing plugins is only available on a local backend.", + ); + } + return getManagementClient().installPlugin(request); + } + + static async setPluginEnabled( + name: string, + enabled: boolean, + ): Promise<{ name: string; enabled: boolean }> { + if (isCloudBackend()) { + throw new Error( + "Enabling and disabling plugins is only available on a local backend.", + ); + } + return getManagementClient().setPluginEnabled(name, enabled); + } + + static async uninstallPlugin(name: string): Promise<{ message: string }> { + if (isCloudBackend()) { + throw new Error( + "Uninstalling plugins is only available on a local backend.", + ); + } + return getManagementClient().uninstallPlugin(name); + } + + static async refreshPlugin( + name: string, + ): Promise<{ message: string; plugin: InstalledPluginInfo }> { + if (isCloudBackend()) { + throw new Error( + "Refreshing plugins is only available on a local backend.", + ); + } + return getManagementClient().refreshPlugin(name); + } +} + +export default PluginsManagementService; diff --git a/src/api/plugins-service.test.ts b/src/api/plugins-service.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..78d4dbac11d8afe6b275128e1e017e493bf93aa4 --- /dev/null +++ b/src/api/plugins-service.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + FileClient, + PluginsClient, +} from "@openhands/typescript-client/clients"; +import { + setActiveSelection, + setRegisteredBackends, +} from "./backend-registry/active-store"; +import PluginsService from "./plugins-service"; + +vi.mock("@openhands/typescript-client/clients", () => ({ + PluginsClient: vi.fn(), + FileClient: vi.fn(), +})); + +const getPluginsMarketplace = vi.fn(); +const getPlugins = vi.fn(); +const downloadFile = vi.fn(); +const close = vi.fn(); + +function useBackend(kind: "local" | "cloud"): void { + setRegisteredBackends([ + { + id: kind, + name: kind, + host: "http://127.0.0.1:8001", + apiKey: "session-key", + kind, + }, + ]); + setActiveSelection({ backendId: kind, orgId: null }); +} + +describe("PluginsService.getPluginsMarketplace", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(PluginsClient).mockImplementation(function MockPluginsClient() { + return { getPluginsMarketplace, close } as unknown as PluginsClient; + } as unknown as typeof PluginsClient); + }); + + it("returns the catalog from the local agent-server", async () => { + useBackend("local"); + const plugin = { + name: "city-weather", + description: "Weather plugin", + source: "github:OpenHands/extensions", + ref: null, + repo_path: "plugins/city-weather", + installed: false, + }; + getPluginsMarketplace.mockResolvedValue({ plugins: [plugin] }); + + const result = await PluginsService.getPluginsMarketplace(); + + expect(result).toEqual([plugin]); + expect(getPluginsMarketplace).toHaveBeenCalledTimes(1); + }); + + it("returns an empty catalog on a cloud backend without calling the client", async () => { + useBackend("cloud"); + + const result = await PluginsService.getPluginsMarketplace(); + + expect(result).toEqual([]); + expect(PluginsClient).not.toHaveBeenCalled(); + }); + + it("returns an empty catalog when the local request fails", async () => { + useBackend("local"); + getPluginsMarketplace.mockRejectedValue(new Error("unreachable")); + + const result = await PluginsService.getPluginsMarketplace(); + + expect(result).toEqual([]); + }); +}); + +describe("PluginsService.getLocalPlugins", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(PluginsClient).mockImplementation(function MockPluginsClient() { + return { getPlugins, close } as unknown as PluginsClient; + } as unknown as typeof PluginsClient); + }); + + it("requests user-level local plugins from the local agent-server", async () => { + useBackend("local"); + const plugin = { + name: "hello-local", + version: "1.0.0", + description: "A local plugin", + }; + getPlugins.mockResolvedValue({ plugins: [plugin] }); + + const result = await PluginsService.getLocalPlugins(); + + expect(result).toEqual([plugin]); + expect(getPlugins).toHaveBeenCalledWith({ + load_user: true, + load_project: false, + }); + }); + + it("returns an empty list on a cloud backend without calling the client", async () => { + useBackend("cloud"); + + const result = await PluginsService.getLocalPlugins(); + + expect(result).toEqual([]); + expect(PluginsClient).not.toHaveBeenCalled(); + }); +}); + +describe("PluginsService.getPluginFileContent", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(FileClient).mockImplementation(function MockFileClient() { + return { downloadFile, close } as unknown as FileClient; + } as unknown as typeof FileClient); + }); + + it("downloads the file under the plugin directory and decodes it as text", async () => { + useBackend("local"); + downloadFile.mockResolvedValue(new TextEncoder().encode("# Hello").buffer); + + const result = await PluginsService.getPluginFileContent( + "/plugins/demo", + "docs/README.md", + ); + + expect(result).toEqual({ kind: "text", text: "# Hello" }); + expect(downloadFile).toHaveBeenCalledWith("/plugins/demo/docs/README.md"); + }); + + it("flags content containing NUL bytes as binary", async () => { + useBackend("local"); + downloadFile.mockResolvedValue( + new Uint8Array([0x89, 0x50, 0x00, 0x47]).buffer, + ); + + const result = await PluginsService.getPluginFileContent( + "/plugins/demo", + "logo.png", + ); + + expect(result).toEqual({ kind: "binary", text: null }); + }); + + it("rejects on a cloud backend without calling the client", async () => { + useBackend("cloud"); + + await expect( + PluginsService.getPluginFileContent("/plugins/demo", "README.md"), + ).rejects.toThrow(); + expect(FileClient).not.toHaveBeenCalled(); + }); +}); diff --git a/src/api/plugins-service.ts b/src/api/plugins-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..38e95116e44db8d922aafd95ea105fc905741683 --- /dev/null +++ b/src/api/plugins-service.ts @@ -0,0 +1,150 @@ +import { + FileClient, + PluginsClient, +} from "@openhands/typescript-client/clients"; +import { getActiveBackend } from "./backend-registry/active-store"; +import { getAgentServerClientOptions } from "./agent-server-client-options"; + +/** Summary of a skill bundled in a plugin (agent-server `PluginSkillSummary`). */ +export interface PluginBundledSkill { + name: string; + description?: string | null; +} + +/** + * A plugin in the dynamic marketplace catalog, with attachable coordinates and + * install state. Matches the agent-server `MarketplacePluginInfo` / the + * typescript-client `MarketplacePlugin`. The contents fields (`path`, `skills`, + * `files`) are populated when the entry resolves to a directory in the + * server's local marketplace clone, and are absent on older agent-servers. + */ +export interface MarketplacePlugin { + name: string; + description: string | null; + source: string; + ref?: string | null; + repo_path?: string | null; + installed: boolean; + path?: string | null; + skills?: PluginBundledSkill[] | null; + files?: string[] | null; +} + +/** + * A locally-discovered ("ambient") plugin reported by the agent-server — one + * found in the user's local plugin directories (e.g. `~/.agents/plugins`). + * These auto-load into conversations and are not managed via install/uninstall, + * so the Plugins page renders them as a read-only "Local" group. Matches the + * typescript-client `PluginInfo`; the contents fields are absent on older + * agent-servers. + */ +export interface LocalPlugin { + name: string; + version: string; + description: string; + path?: string; + skills?: PluginBundledSkill[]; + files?: string[]; +} + +/** Content of a single plugin file fetched for the detail-modal viewer. */ +export interface PluginFileContent { + kind: "text" | "binary"; + text: string | null; +} + +function isLikelyBinary(buffer: ArrayBuffer): boolean { + // Same heuristic git uses: presence of a NUL byte in the first ~8KB. Small + // private copy of `isLikelyBinary` in `use-workspace-file-content.ts` — that + // module is conversation-workspace-specific and heavy to import from here. + const view = new Uint8Array(buffer, 0, Math.min(buffer.byteLength, 8000)); + for (let i = 0; i < view.length; i += 1) { + if (view[i] === 0) return true; + } + return false; +} + +class PluginsService { + /** + * Fetch the dynamic plugins marketplace catalog. + * + * Local backend only for now: the catalog is fetched at run time from the + * agent-server via the typed client (no bundled catalog, so the list stays + * dynamic). On a cloud backend an empty catalog is returned — there is no + * cloud plugins-marketplace endpoint yet (tracked as a follow-up ticket). + */ + static async getPluginsMarketplace(): Promise { + if (getActiveBackend().backend.kind === "cloud") { + return []; + } + + try { + const response = await new PluginsClient( + getAgentServerClientOptions(), + ).getPluginsMarketplace(); + return (response.plugins ?? []) as MarketplacePlugin[]; + } catch { + // Agent-server may not support the plugins endpoint or be unreachable; + // surface an empty catalog rather than throwing. + return []; + } + } + + /** + * Fetch the locally-discovered ("ambient") plugins from the agent-server. + * + * Only user-level plugins are requested (`~/.agents/plugins`, + * `~/.openhands/plugins`, plus enabled installed plugins): the Plugins page is + * global, so there is no project workspace to scope project plugins to. + * + * Local backend only — a cloud backend has no local plugin directories, so an + * empty list is returned. Errors surface as an empty list (mirrors the + * catalog) rather than throwing. + */ + static async getLocalPlugins(): Promise { + if (getActiveBackend().backend.kind === "cloud") { + return []; + } + + try { + const response = await new PluginsClient( + getAgentServerClientOptions(), + ).getPlugins({ load_user: true, load_project: false }); + return (response.plugins ?? []) as LocalPlugin[]; + } catch { + return []; + } + } + + /** + * Fetch one plugin file's content for the detail-modal viewer. `basePath` is + * the plugin directory reported by the agent-server (`path`/`install_path`) + * and `relativePath` a POSIX path from the plugin's `files` listing. + * + * Local backend only — plugin files live on the local agent-server's disk. + * Errors propagate so the caller can render a load-error state. + */ + static async getPluginFileContent( + basePath: string, + relativePath: string, + ): Promise { + if (getActiveBackend().backend.kind === "cloud") { + throw new Error( + "Reading plugin files is only available on a local backend.", + ); + } + + const buffer = await new FileClient( + getAgentServerClientOptions(), + ).downloadFile(`${basePath}/${relativePath}`); + if (isLikelyBinary(buffer)) { + return { kind: "binary", text: null }; + } + return { + kind: "text", + text: new TextDecoder("utf-8", { fatal: false }).decode(buffer), + }; + } +} + +export default PluginsService; diff --git a/src/api/secrets-service.ts b/src/api/secrets-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..ed31385918afb7635c869ee80cf756904d368fee --- /dev/null +++ b/src/api/secrets-service.ts @@ -0,0 +1,156 @@ +import { SettingsClient } from "@openhands/typescript-client/clients"; +import { isSdkHttpStatusError } from "./agent-server-compatibility"; +import { getActiveBackend } from "./backend-registry/active-store"; +import { + deleteCloudSecret, + fetchCloudSecrets, + saveCloudSecret, +} from "./cloud/secrets-service.api"; +import { getAgentServerClientOptions } from "./agent-server-client-options"; +import { CustomSecretWithoutValue } from "./secrets-service.types"; +import { withRetry } from "./with-retry"; + +async function fetchSecrets(): Promise { + if (getActiveBackend().backend.kind === "cloud") { + return withRetry(() => fetchCloudSecrets()); + } + const response = await withRetry(() => + new SettingsClient(getAgentServerClientOptions()).listSecrets(), + ); + return response.secrets.map((s) => ({ + name: s.name, + description: s.description, + })); +} + +export class SecretsService { + /** + * List all custom secrets (names and descriptions only, no values). + * Uses the agent-server API endpoint: GET /api/settings/secrets + * + * Note: The agent-server API doesn't support pagination or search filtering. + * All secrets are returned in a single response. + */ + static async getSecrets(): Promise { + try { + return await fetchSecrets(); + } catch (error) { + console.error("Failed to fetch secrets after retries:", error); + return []; + } + } + + /** + * List all custom secrets, surfacing failures to callers that must + * distinguish an unavailable list from an empty one. + */ + static async getSecretsOrThrow(): Promise { + return fetchSecrets(); + } + + /** + * Create or update a custom secret (upsert by name). + * Uses the agent-server API endpoint: PUT /api/settings/secrets + * + * @param name - Secret name (must start with letter, contain only letters/numbers/underscores, 1-64 chars) + * @param value - Secret value + * @param description - Optional description + * @throws Error if the API call fails after retries + */ + static async createSecret( + name: string, + value: string, + description?: string, + ): Promise { + if (getActiveBackend().backend.kind === "cloud") { + await saveCloudSecret({ name, value, description }); + return; + } + await withRetry(() => + new SettingsClient(getAgentServerClientOptions()).upsertSecret({ + name, + value, + description, + }), + ); + } + + /** + * Update a secret's name and/or description, and optionally overwrite its + * value. When no value is given the existing one is preserved: the + * agent-server only exposes an upsert endpoint, so we fetch the existing + * value and re-upsert it under the updated name/description. + * + * @param secretToEdit - Existing secret name + * @param name - New (or same) secret name + * @param description - Optional new description + * @param value - Optional new value; when omitted the stored value is kept + * @throws Error if the API call fails after retries + */ + static async updateSecret( + secretToEdit: string, + name: string, + description?: string, + value?: string, + ): Promise { + if (getActiveBackend().backend.kind === "cloud") { + await saveCloudSecret({ + name, + value, + description, + previousName: secretToEdit, + }); + return; + } + + const client = new SettingsClient(getAgentServerClientOptions()); + const nextValue = + value ?? (await withRetry(() => client.getSecret(secretToEdit))); + + await withRetry(() => + client.upsertSecret({ + name, + value: nextValue, + description, + }), + ); + + if (name !== secretToEdit) { + await this.deleteSecret(secretToEdit); + } + } + + /** + * Delete a custom secret by name. + * Uses the agent-server API endpoint: DELETE /api/settings/secrets/{name} + * + * @param name - Secret name to delete + * @throws Error if the API call fails (except 404, which is treated as success) + */ + static async deleteSecret(name: string): Promise { + try { + if (getActiveBackend().backend.kind === "cloud") { + await withRetry(() => deleteCloudSecret(name)); + return; + } + await withRetry(() => + new SettingsClient(getAgentServerClientOptions()).deleteSecret(name), + ); + } catch (error) { + // 404 means secret doesn't exist - treat as successful deletion. + // Both the SDK's HttpError (status on the error itself) and + // axios-style errors (status under `response`) count. + if ( + isSdkHttpStatusError(error, 404) || + (error && + typeof error === "object" && + "response" in error && + (error as { response?: { status?: number } }).response?.status === + 404) + ) { + return; + } + throw error; + } + } +} diff --git a/src/api/secrets-service.types.ts b/src/api/secrets-service.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..01118269eecef35bea68c7f783a383fada08fa15 --- /dev/null +++ b/src/api/secrets-service.types.ts @@ -0,0 +1,15 @@ +/** + * Custom secret with name, value, and optional description. + * Used for creating/updating secrets via PUT /api/settings/secrets. + */ +export type CustomSecret = { + name: string; + value: string; + description?: string; +}; + +/** + * Custom secret metadata without the secret value. + * Used for listing secrets via GET /api/settings/secrets. + */ +export type CustomSecretWithoutValue = Omit; diff --git a/src/api/skills-service.ts b/src/api/skills-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..496ac4d19acc795bdcf4e40751aa152335fb9a31 --- /dev/null +++ b/src/api/skills-service.ts @@ -0,0 +1,80 @@ +import { SkillsClient } from "@openhands/typescript-client/clients"; +import { + SKILLS_CATALOG, + type SkillCatalogEntry, +} from "@openhands/extensions/skills"; +import { SkillInfo } from "#/types/settings"; +import { getAgentServerWorkingDir } from "./agent-server-config"; +import { getActiveBackend } from "./backend-registry/active-store"; +import { + fetchCloudConversationSkills, + fetchCloudSkills, +} from "./cloud/skills-service.api"; +import { getAgentServerClientOptions } from "./agent-server-client-options"; + +function catalogEntryToSkillInfo(entry: SkillCatalogEntry): SkillInfo { + return { + name: entry.name, + type: "knowledge", + source: "public", + description: entry.description, + triggers: entry.triggers, + category: entry.category, + content: entry.content, + license: entry.license ?? null, + compatibility: entry.compatibility ?? null, + }; +} + +/** + * Public skills loaded from the `@openhands/extensions` npm package. + * + * This is an **immutable build-time snapshot**: the catalog is baked into the + * bundle at `npm run build` / `vite build` time and does not change at + * runtime. Updating the catalog requires bumping the `@openhands/extensions` + * dependency and rebuilding. + */ +const PUBLIC_SKILLS: SkillInfo[] = SKILLS_CATALOG.map(catalogEntryToSkillInfo); + +class SkillsService { + static async getSkills(projectDir?: string): Promise { + if (getActiveBackend().backend.kind === "cloud") { + return fetchCloudSkills(); + } + + // Public skills come from the bundled @openhands/extensions npm package — + // no agent-server round-trip or GitHub fetch needed. Only ask the agent- + // server for user and project skills so local .agents/skills/ content is + // still picked up. + let localSkills: SkillInfo[] = []; + try { + const response = await new SkillsClient( + getAgentServerClientOptions(), + ).getSkills({ + load_public: false, + load_user: true, + load_project: true, + load_org: false, + project_dir: projectDir ?? getAgentServerWorkingDir(), + }); + localSkills = (response.skills ?? []) as SkillInfo[]; + } catch { + // Agent-server may not support the skills endpoint or may be + // unreachable; fall back to the bundled public catalog alone. + } + + return [...localSkills, ...PUBLIC_SKILLS]; + } + + /** + * Skills loaded into a running cloud conversation (see + * `fetchCloudConversationSkills`). Cloud-only: local conversations keep + * using `getSkills(projectDir)`, whose agent-server call already scopes to + * the conversation's workspace. + */ + static getConversationSkills(conversationId: string): Promise { + return fetchCloudConversationSkills(conversationId); + } +} + +export default SkillsService; diff --git a/src/api/with-retry.ts b/src/api/with-retry.ts new file mode 100644 index 0000000000000000000000000000000000000000..d22f1046646b18084a5379b263c3268288133263 --- /dev/null +++ b/src/api/with-retry.ts @@ -0,0 +1,26 @@ +/** + * Retry helper for API calls with exponential backoff. + */ +export async function withRetry( + fn: () => Promise, + maxRetries: number = 3, + baseDelayMs: number = 500, +): Promise { + for (let attempt = 0; attempt < maxRetries; attempt += 1) { + try { + return await fn(); + } catch (error) { + if (attempt >= maxRetries - 1) { + throw error; + } + + const delay = baseDelayMs * 2 ** attempt; + + await new Promise((resolve) => { + setTimeout(resolve, delay); + }); + } + } + + throw new Error("Retry attempts exhausted"); +} diff --git a/src/api/workspace-path-resolution.test.ts b/src/api/workspace-path-resolution.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..36082aa6bbc9f43268c85521edb136d3733981c8 --- /dev/null +++ b/src/api/workspace-path-resolution.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it, vi } from "vitest"; + +const { + fileClientConstructorMock, + getAgentServerClientOptionsMock, + getHomeMock, +} = vi.hoisted(() => ({ + fileClientConstructorMock: vi.fn(), + getAgentServerClientOptionsMock: vi.fn(), + getHomeMock: vi.fn(), +})); + +vi.mock("@openhands/typescript-client/clients", () => ({ + FileClient: class { + constructor(options: unknown) { + fileClientConstructorMock(options); + } + + getHome() { + return getHomeMock(); + } + }, +})); + +vi.mock("./agent-server-client-options", () => ({ + getAgentServerClientOptions: getAgentServerClientOptionsMock, +})); + +import { + clearAgentServerHomeDirCache, + getAgentServerHomeDir, + resolveAbsoluteAgentServerPath, +} from "./agent-server-home"; + +const defaultClientOptions = { + host: "https://agent.example.com", + apiKey: "agent-key", + workingDir: "workspace/project", +}; + +function arrangeHomeLookup(): void { + clearAgentServerHomeDirCache(); + vi.resetAllMocks(); + getAgentServerClientOptionsMock.mockReturnValue(defaultClientOptions); +} + +describe("agent-server workspace path resolution", () => { + it("looks up the server home with the resolved client options and normalizes its separator", async () => { + arrangeHomeLookup(); + getHomeMock.mockResolvedValue({ home: "/Users/alice///" }); + const overrides = { + conversationUrl: "wss://runtime.example.com/conversations/1", + sessionApiKey: "session-key", + }; + + await expect(getAgentServerHomeDir(overrides)).resolves.toBe( + "/Users/alice", + ); + + expect(getAgentServerClientOptionsMock).toHaveBeenCalledWith(overrides); + expect(fileClientConstructorMock).toHaveBeenCalledWith( + defaultClientOptions, + ); + expect(getHomeMock).toHaveBeenCalledOnce(); + }); + + it("shares one in-flight home lookup between callers for the same host", async () => { + arrangeHomeLookup(); + getHomeMock.mockResolvedValue({ home: "/home/agent" }); + + await expect( + Promise.all([getAgentServerHomeDir(), getAgentServerHomeDir()]), + ).resolves.toEqual(["/home/agent", "/home/agent"]); + + expect(getHomeMock).toHaveBeenCalledOnce(); + }); + + it("caches home directories independently for each agent-server host", async () => { + arrangeHomeLookup(); + getAgentServerClientOptionsMock.mockImplementation( + (overrides: { host?: string } = {}) => ({ + ...defaultClientOptions, + host: overrides.host ?? defaultClientOptions.host, + }), + ); + getHomeMock + .mockResolvedValueOnce({ home: "/home/first" }) + .mockResolvedValueOnce({ home: "/home/second" }); + + await expect( + Promise.all([ + getAgentServerHomeDir({ host: "https://first.example.com" }), + getAgentServerHomeDir({ host: "https://second.example.com" }), + ]), + ).resolves.toEqual(["/home/first", "/home/second"]); + await expect( + getAgentServerHomeDir({ host: "https://first.example.com" }), + ).resolves.toBe("/home/first"); + + expect(getHomeMock).toHaveBeenCalledTimes(2); + }); + + it("retries a home lookup after a transient failure", async () => { + arrangeHomeLookup(); + getHomeMock + .mockRejectedValueOnce(new Error("agent server unavailable")) + .mockResolvedValueOnce({ home: "/home/recovered" }); + + await expect(getAgentServerHomeDir()).rejects.toThrow( + "agent server unavailable", + ); + await expect(getAgentServerHomeDir()).resolves.toBe("/home/recovered"); + + expect(getHomeMock).toHaveBeenCalledTimes(2); + }); + + it.each([undefined, null, "", 0, 42])( + "rejects an invalid home directory response (%s)", + async (home) => { + arrangeHomeLookup(); + getHomeMock.mockResolvedValue({ home }); + + await expect(getAgentServerHomeDir()).rejects.toThrow( + "Agent server returned an empty home directory", + ); + }, + ); + + it("clears a successful cached lookup on request", async () => { + arrangeHomeLookup(); + getHomeMock + .mockResolvedValueOnce({ home: "/home/first" }) + .mockResolvedValueOnce({ home: "/home/refreshed" }); + + await expect(getAgentServerHomeDir()).resolves.toBe("/home/first"); + clearAgentServerHomeDirCache(); + await expect(getAgentServerHomeDir()).resolves.toBe("/home/refreshed"); + + expect(getHomeMock).toHaveBeenCalledTimes(2); + }); + + it.each([ + ["/workspace/project///", "/workspace/project"], + ["C:\\workspace\\project\\", "C:\\workspace\\project"], + ["D:/workspace/project///", "D:/workspace/project"], + ["\\\\server\\share\\", "\\\\server\\share"], + ])( + "keeps the absolute path %s without a home lookup", + async (path, expected) => { + arrangeHomeLookup(); + + await expect(resolveAbsoluteAgentServerPath(path)).resolves.toBe( + expected, + ); + + expect(getAgentServerClientOptionsMock).not.toHaveBeenCalled(); + expect(getHomeMock).not.toHaveBeenCalled(); + }, + ); + + it.each(["", "/", "\\", "///", "\\\\"])( + "resolves an empty path (%s) to the server home", + async (path) => { + arrangeHomeLookup(); + getHomeMock.mockResolvedValue({ home: "/home/agent/" }); + + await expect(resolveAbsoluteAgentServerPath(path)).resolves.toBe( + "/home/agent", + ); + + expect(getHomeMock).toHaveBeenCalledOnce(); + }, + ); + + it("anchors a relative workspace below the server home", async () => { + arrangeHomeLookup(); + getHomeMock.mockResolvedValue({ home: "/Users/alice///" }); + const overrides = { + host: "https://runtime.example.com/", + sessionApiKey: "session-key", + }; + + await expect( + resolveAbsoluteAgentServerPath("workspace/project///", overrides), + ).resolves.toBe("/Users/alice/workspace/project"); + + expect(getAgentServerClientOptionsMock).toHaveBeenCalledWith(overrides); + }); +}); diff --git a/src/api/workspace-upload-path.ts b/src/api/workspace-upload-path.ts new file mode 100644 index 0000000000000000000000000000000000000000..7708af21a1973085f0463d66631ef09cb3f181d2 --- /dev/null +++ b/src/api/workspace-upload-path.ts @@ -0,0 +1,99 @@ +// @spec WUP-001 — Resolve relative working dirs against /api/file/home +import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; +import { getAgentServerWorkingDir } from "#/api/agent-server-config"; +import { + type AgentServerClientOverrides, + getAgentServerClientOptions, +} from "#/api/agent-server-client-options"; +import { resolveAbsoluteAgentServerPath } from "#/api/agent-server-home"; +import { getStoredConversationMetadata } from "#/api/conversation-metadata-store"; +import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types"; + +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export function getSafeUploadFileName(fileName: string): string { + const parts = fileName.split(/[\\/]+/).filter(Boolean); + const safeName = parts[parts.length - 1]; + + if (!safeName || safeName === "." || safeName === "..") { + throw new Error("Invalid file name"); + } + + return safeName; +} + +/** + * Resolve `workingDir` to an absolute path the agent-server's file APIs + * accept. Relative paths are joined against `/api/file/home` (cached per + * backend); absolute paths pass through. + */ +export async function resolveAbsoluteWorkspacePath( + workingDir: string, + overrides: AgentServerClientOverrides = {}, +): Promise { + return resolveAbsoluteAgentServerPath(workingDir, overrides); +} + +/** + * Build the absolute destination path for a file upload, resolving the + * working-dir leg via {@link resolveAbsoluteWorkspacePath}. + */ +export async function buildWorkspaceUploadPath( + fileName: string, + workingDir: string, + overrides: AgentServerClientOverrides = {}, +): Promise { + const safeName = getSafeUploadFileName(fileName); + const absoluteDir = await resolveAbsoluteWorkspacePath(workingDir, overrides); + return `${absoluteDir.replace(/[/\\]+$/, "")}/${safeName}`; +} + +/** + * Resolve the working directory for a file upload into a conversation's + * workspace. + * + * **Returns the raw working dir string** — which may be relative (e.g. + * `workspace/project/`) when the default `DEFAULT_WORKING_DIR` is in + * use. Callers that need an actual filesystem-absolute path (e.g. to pass to + * the agent-server's `/api/file/upload` endpoint) **must** funnel this result + * through {@link buildWorkspaceUploadPath}, which calls + * {@link resolveAbsoluteWorkspacePath} to anchor any relative segment against + * the agent-server's home directory. + * + * Why not resolve here? Because this function is also called by cloud-runtime + * upload paths where the overrides (conversationUrl, sessionApiKey) aren't + * available until {@link uploadFilesToConversation} assembles them. Keeping + * the resolution step in {@link buildWorkspaceUploadPath} means both the + * local and cloud legs share a single resolution point with the correct + * override context. + */ +export async function resolveConversationUploadWorkingDir( + conversationId: string, + currentConversation?: AppConversation | null, +): Promise { + if ( + currentConversation?.id === conversationId && + currentConversation.workspace?.working_dir?.trim() + ) { + return currentConversation.workspace.working_dir.trim(); + } + + const stored = getStoredConversationMetadata(conversationId); + if (stored?.selected_workspace?.trim()) { + return stored.selected_workspace.trim(); + } + + if (UUID_PATTERN.test(conversationId)) { + return AgentServerConversationService.resolveConversationWorkingDir( + conversationId, + ); + } + + return getAgentServerWorkingDir(); +} + +// Re-export so callers can construct overrides matching what +// {@link buildWorkspaceUploadPath} expects without importing two modules. +export type { AgentServerClientOverrides }; +export { getAgentServerClientOptions }; diff --git a/src/dev/seed-pending-chat-preview.ts b/src/dev/seed-pending-chat-preview.ts new file mode 100644 index 0000000000000000000000000000000000000000..90268a23da2a7f982556a78a5f1a541f9ce640d6 --- /dev/null +++ b/src/dev/seed-pending-chat-preview.ts @@ -0,0 +1,69 @@ +import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; + +const PREVIEW_SENDING_TEXT = "can you review the readme"; + +const PREVIEW_SENDING_MULTILINE_TEXT = + "can you review the readme and then update the contributing guide " + + "to match, making sure the setup steps and scripts are consistent " + + "across both files"; + +const PREVIEW_ERROR_TEXT = + "here's a long message. here's a long message. here's a long message. " + + "here's a long message. here's a long message. here's a long message."; + +export function seedPendingChatPreview(conversationId: string): void { + const store = useOptimisticUserMessageStore.getState(); + store.clearPendingMessages(); + + store.enqueuePendingMessage({ + conversationId, + text: PREVIEW_SENDING_TEXT, + }); + + store.enqueuePendingMessage({ + conversationId, + text: PREVIEW_SENDING_MULTILINE_TEXT, + }); + + const errorId = store.enqueuePendingMessage({ + conversationId, + text: PREVIEW_ERROR_TEXT, + }); + store.markPendingMessageError(errorId, "Failed to send message"); +} + +export function installPendingChatPreview(): void { + ( + window as Window & + typeof globalThis & { + __OH_SEED_PENDING_PREVIEW__?: typeof seedPendingChatPreview; + __OH_CLEAR_PENDING_PREVIEW__?: () => void; + } + ).__OH_SEED_PENDING_PREVIEW__ = seedPendingChatPreview; + ( + window as Window & + typeof globalThis & { + __OH_CLEAR_PENDING_PREVIEW__?: () => void; + } + ).__OH_CLEAR_PENDING_PREVIEW__ = () => { + useOptimisticUserMessageStore.getState().clearPendingMessages(); + }; + + const params = new URLSearchParams(window.location.search); + const preview = params.get("previewPendingChat"); + if (!preview) { + return; + } + + const conversationId = preview === "true" ? "1" : preview; + const targetPath = `/conversations/${conversationId}`; + + if (!window.location.pathname.startsWith(targetPath)) { + window.location.assign( + `${targetPath}?previewPendingChat=${encodeURIComponent(conversationId)}`, + ); + return; + } + + seedPendingChatPreview(conversationId); +} diff --git a/src/fixtures/canvas-demo-conversation.ts b/src/fixtures/canvas-demo-conversation.ts new file mode 100644 index 0000000000000000000000000000000000000000..59f0f7feadf856bf1248e3a26ebe5f923d9f143c --- /dev/null +++ b/src/fixtures/canvas-demo-conversation.ts @@ -0,0 +1,131 @@ +/** + * Generated-canvas demo fixture — models an agent creating a Markdown artifact + * so mock mode can demonstrate chat-to-drawer file navigation without an LLM. + */ +import type { + ActionEvent, + MessageEvent, + ObservationEvent, + OpenHandsEvent, +} from "#/types/agent-server/core"; +import { SecurityRisk } from "#/types/agent-server/core"; +import type { FileEditorAction } from "#/types/agent-server/core/base/action"; +import type { FileEditorObservation } from "#/types/agent-server/core/base/observation"; + +export const CANVAS_DEMO_CONVERSATION_ID = "canvas-demo"; +export const CANVAS_DEMO_FILE_PATH = "canvas.md"; + +export const CANVAS_DEMO_MARKDOWN = [ + "# Agent Canvas Demo", + "", + "This report was generated by the agent and opened from the file chip in chat.", + "", + "## Release snapshot", + "", + "| Area | Status | Next step |", + "| --- | --- | --- |", + "| Conversation UX | Ready | Validate the drawer flow |", + "| Markdown preview | Ready | Review rich rendering |", + "| Accessibility | Ready | Confirm keyboard navigation |", + "", + "## Highlights", + "", + "- The artifact stays in the conversation workspace.", + "- The chat chip is the compact entry point.", + "- The Files drawer provides the full rich Markdown view.", +].join("\n"); + +const BASE_TIME = Date.UTC(2026, 6, 29, 12, 0, 0); +const timestamp = (offsetSeconds: number) => + new Date(BASE_TIME + offsetSeconds * 1000).toISOString(); + +/** + * Creates a minimal message event with the same SDK shape used by history. + */ +function createMessage( + id: string, + role: "user" | "assistant", + text: string, + offsetSeconds: number, +): MessageEvent { + return { + id, + timestamp: timestamp(offsetSeconds), + source: role === "user" ? "user" : "agent", + llm_message: { + role, + content: [{ type: "text", text }], + }, + activated_skills: [], + extended_content: [], + }; +} + +const createCanvasAction: ActionEvent = { + id: "canvas-demo-action", + timestamp: timestamp(1), + source: "agent", + thought: [], + thinking_blocks: [], + action: { + kind: "FileEditorAction", + command: "create", + path: CANVAS_DEMO_FILE_PATH, + file_text: CANVAS_DEMO_MARKDOWN, + old_str: null, + new_str: null, + insert_line: null, + view_range: null, + }, + tool_name: "file_editor", + tool_call_id: "canvas-demo-tool-call", + tool_call: { + id: "canvas-demo-tool-call", + type: "function", + function: { + name: "file_editor", + arguments: JSON.stringify({ + command: "create", + path: CANVAS_DEMO_FILE_PATH, + }), + }, + }, + llm_response_id: "canvas-demo-response", + security_risk: SecurityRisk.LOW, +}; + +const createCanvasObservation: ObservationEvent = { + id: "canvas-demo-observation", + timestamp: timestamp(2), + source: "environment", + tool_name: "file_editor", + tool_call_id: "canvas-demo-tool-call", + action_id: createCanvasAction.id, + observation: { + kind: "FileEditorObservation", + command: "create", + output: `Created ${CANVAS_DEMO_FILE_PATH}`, + path: CANVAS_DEMO_FILE_PATH, + prev_exist: false, + old_content: null, + new_content: CANVAS_DEMO_MARKDOWN, + error: null, + }, +}; + +export const CANVAS_DEMO_EVENTS: OpenHandsEvent[] = [ + createMessage( + "canvas-demo-user", + "user", + "Create a concise project canvas as a Markdown report.", + 0, + ), + createCanvasAction, + createCanvasObservation, + createMessage( + "canvas-demo-agent", + "assistant", + "I generated the project canvas. Open it from the file chip above.", + 3, + ), +]; diff --git a/src/fixtures/home-automations-demo.ts b/src/fixtures/home-automations-demo.ts new file mode 100644 index 0000000000000000000000000000000000000000..9ad98cb36edc45a29e693a1a07cdad029cc71e1c --- /dev/null +++ b/src/fixtures/home-automations-demo.ts @@ -0,0 +1,478 @@ +import { + AUTOMATION_RUN_ACTIVITY_LIMIT, + type LatestAutomationRunState, +} from "#/hooks/query/use-latest-automation-runs"; +import { + AutomationRunStatus, + type Automation, + type AutomationRun, +} from "#/types/automation"; + +/** Must stay equal to `MAX_HOME_AUTOMATION_CHIPS` in use-home-automations. */ +const DEMO_LIST_LENGTH = 20; + +const DEMO_HISTORY_PATTERN: AutomationRunStatus[] = [ + AutomationRunStatus.COMPLETED, + AutomationRunStatus.COMPLETED, + AutomationRunStatus.FAILED, + AutomationRunStatus.COMPLETED, + AutomationRunStatus.CANCELLED, + AutomationRunStatus.COMPLETED, + AutomationRunStatus.COMPLETED, + AutomationRunStatus.FAILED, + AutomationRunStatus.COMPLETED, + AutomationRunStatus.SKIPPED, + AutomationRunStatus.COMPLETED, +]; + +/** + * Local preview of every pinned-card + activity-list state. + * + * Enable with either: + * - `HOME_AUTOMATIONS_FORCE_DEMO = true` below + * - `VITE_HOME_AUTOMATIONS_DEMO=true` + */ +export const HOME_AUTOMATIONS_FORCE_DEMO = false; + +export const HOME_AUTOMATIONS_DEMO_KEY = "oh:home-automations-demo"; + +export function isHomeAutomationsDemoEnabled(): boolean { + if (HOME_AUTOMATIONS_FORCE_DEMO) return true; + return import.meta.env.VITE_HOME_AUTOMATIONS_DEMO === "true"; +} + +const DEMO_CONVERSATION_TITLES: Record = { + "demo-conv-titled": "Fix flaky CI on main", + "demo-conv-untitled": "Summarize open pull requests", +}; + +/** Returns a demo conversation title, or `undefined` if not a demo id. */ +export function getDemoConversationTitle( + conversationId: string, +): string | null | undefined { + if (!(conversationId in DEMO_CONVERSATION_TITLES)) return undefined; + return DEMO_CONVERSATION_TITLES[conversationId]; +} + +function hoursAgo(hours: number): string { + return new Date(Date.now() - hours * 3_600_000).toISOString(); +} + +function makeAutomation( + id: string, + name: string, + trigger: Automation["trigger"], +): Automation { + return { + id, + name, + trigger, + enabled: true, + created_at: hoursAgo(720), + updated_at: hoursAgo(24), + prompt: "demo", + }; +} + +function makeRun( + id: string, + status: AutomationRunStatus, + options: { + conversationId?: string | null; + errorDetail?: string | null; + startedHoursAgo?: number; + completedHoursAgo?: number | null; + } = {}, +): AutomationRun { + const startedHoursAgo = options.startedHoursAgo ?? 2; + const completedHoursAgo = + options.completedHoursAgo === undefined ? 1 : options.completedHoursAgo; + return { + id, + status, + conversation_id: + options.conversationId === undefined ? null : options.conversationId, + bash_command_id: null, + error_detail: + options.errorDetail === undefined ? null : options.errorDetail, + started_at: hoursAgo(startedHoursAgo), + completed_at: + completedHoursAgo === null ? null : hoursAgo(completedHoursAgo), + }; +} + +type DemoRunStateInput = Omit & { + recentRuns?: AutomationRun[]; +}; + +type DemoSpec = { + automation: Automation; + runState: DemoRunStateInput; + /** When true, this automation is pinned in the dashboard. */ + pin?: boolean; +}; + +function buildDemoRecentRuns(latestRun: AutomationRun | null): AutomationRun[] { + if (!latestRun) return []; + // Varied durations (minutes) so sparkline bar heights differ in demo mode. + const durationMinutes = [2, 8, 45, 3, 15, 1, 25, 90, 5, 12, 6]; + const older = DEMO_HISTORY_PATTERN.slice( + 0, + AUTOMATION_RUN_ACTIVITY_LIMIT - 1, + ).map((status, index) => { + const startedHoursAgo = 24 + index * 6; + const durationHours = (durationMinutes[index] ?? 5) / 60; + return makeRun(`${latestRun.id}-hist-${index}`, status, { + startedHoursAgo, + completedHoursAgo: startedHoursAgo - durationHours, + conversationId: null, + errorDetail: + status === AutomationRunStatus.FAILED ? "Prior failure" : null, + }); + }); + return [latestRun, ...older]; +} + +function toDemoRunState(state: DemoRunStateInput): LatestAutomationRunState { + return { + ...state, + recentRuns: state.recentRuns ?? buildDemoRecentRuns(state.latestRun), + }; +} + +/** + * Explicit card states first (pinned), then fillers to hit + * DEMO_LIST_LENGTH for list-length stress. + */ +const DEMO_SPECS: DemoSpec[] = [ + { + pin: true, + automation: makeAutomation("demo-pin-loading", "Card: loading skeleton", { + type: "cron", + schedule: "0 * * * *", + schedule_human: "Every hour", + }), + runState: { latestRun: null, isLoading: true, isError: false }, + }, + { + pin: true, + automation: makeAutomation("demo-pin-error", "Card: status unavailable", { + type: "cron", + schedule: "0 9 * * *", + schedule_human: "Daily at 9am", + }), + runState: { latestRun: null, isLoading: false, isError: true }, + }, + { + pin: true, + automation: makeAutomation("demo-pin-no-runs", "Card: no runs yet", { + type: "event", + source: "github", + on: "pull_request.opened", + }), + runState: { latestRun: null, isLoading: false, isError: false }, + }, + { + pin: true, + automation: makeAutomation( + "demo-pin-failed-detail", + "Card: failed with error detail", + { + type: "cron", + schedule: "*/15 * * * *", + schedule_human: "Every 15 min", + }, + ), + runState: { + latestRun: makeRun("run-failed-detail", AutomationRunStatus.FAILED, { + errorDetail: + "Sandbox provisioning timed out after 120s while waiting for the runtime to become ready.", + startedHoursAgo: 3, + completedHoursAgo: 2.5, + }), + isLoading: false, + isError: false, + }, + }, + { + pin: true, + automation: makeAutomation( + "demo-pin-completed-title", + "Card: completed + conversation title", + { type: "event", source: "github", on: "push" }, + ), + runState: { + latestRun: makeRun("run-completed-title", AutomationRunStatus.COMPLETED, { + conversationId: "demo-conv-titled", + startedHoursAgo: 5, + completedHoursAgo: 4, + }), + isLoading: false, + isError: false, + }, + }, + { + pin: true, + automation: makeAutomation( + "demo-pin-completed-untitled", + "Card: completed + conversation title", + { + type: "cron", + schedule: "0 0 * * 1", + schedule_human: "Mondays at midnight", + }, + ), + runState: { + latestRun: makeRun( + "run-completed-untitled", + AutomationRunStatus.COMPLETED, + { + conversationId: "demo-conv-untitled", + startedHoursAgo: 8, + completedHoursAgo: 7, + }, + ), + isLoading: false, + isError: false, + }, + }, + { + pin: true, + automation: makeAutomation( + "demo-pin-completed-no-conv", + "Card: completed, no conversation", + { type: "cron", schedule: "0 12 * * *", schedule_human: "Daily at noon" }, + ), + runState: { + latestRun: makeRun( + "run-completed-no-conv", + AutomationRunStatus.COMPLETED, + { + conversationId: null, + startedHoursAgo: 10, + completedHoursAgo: 9, + }, + ), + isLoading: false, + isError: false, + }, + }, + { + pin: true, + automation: makeAutomation( + "demo-pin-failed-no-conv", + "Card: failed, no conversation", + { type: "event", source: "slack", on: "message" }, + ), + runState: { + latestRun: makeRun("run-failed-no-conv", AutomationRunStatus.FAILED, { + conversationId: null, + errorDetail: null, + startedHoursAgo: 12, + completedHoursAgo: 11, + }), + isLoading: false, + isError: false, + }, + }, + { + pin: true, + automation: makeAutomation( + "demo-pin-pending", + "Card: pending (empty result panel)", + { + type: "cron", + schedule: "0 */6 * * *", + schedule_human: "Every 6 hours", + }, + ), + runState: { + latestRun: makeRun("run-pending", AutomationRunStatus.PENDING, { + conversationId: null, + startedHoursAgo: 0.1, + completedHoursAgo: null, + }), + isLoading: false, + isError: false, + }, + }, + { + pin: true, + automation: makeAutomation( + "demo-pin-running", + "Card: running (empty result panel)", + { type: "event", source: "github", on: "issues.opened" }, + ), + runState: { + latestRun: makeRun("run-running", AutomationRunStatus.RUNNING, { + // No conversation yet — status strip shows the "Running" label. + conversationId: null, + startedHoursAgo: 0.25, + completedHoursAgo: null, + }), + isLoading: false, + isError: false, + }, + }, + // Remaining rows fill the list to MAX_HOME_AUTOMATION_CHIPS. + { + automation: makeAutomation("demo-list-cancelled", "List: cancelled run", { + type: "cron", + schedule: "30 2 * * *", + schedule_human: "Daily at 2:30am", + }), + runState: { + latestRun: makeRun("run-cancelled", AutomationRunStatus.CANCELLED, { + startedHoursAgo: 14, + completedHoursAgo: 13, + }), + isLoading: false, + isError: false, + }, + }, + { + automation: makeAutomation("demo-list-skipped", "List: skipped run", { + type: "event", + source: "github", + on: "pull_request.synchronize", + }), + runState: { + latestRun: makeRun("run-skipped", AutomationRunStatus.SKIPPED, { + startedHoursAgo: 16, + completedHoursAgo: 16, + }), + isLoading: false, + isError: false, + }, + }, + { + automation: makeAutomation( + "demo-list-running-2", + "List: second in-flight run", + { type: "cron", schedule: "*/5 * * * *", schedule_human: "Every 5 min" }, + ), + runState: { + latestRun: makeRun("run-running-2", AutomationRunStatus.RUNNING, { + conversationId: null, + startedHoursAgo: 0.5, + completedHoursAgo: null, + }), + isLoading: false, + isError: false, + }, + }, + { + automation: makeAutomation("demo-list-success-old", "List: older success", { + type: "cron", + schedule: "0 8 * * 1-5", + schedule_human: "Weekdays at 8am", + }), + runState: { + latestRun: makeRun("run-success-old", AutomationRunStatus.COMPLETED, { + conversationId: "demo-conv-titled", + startedHoursAgo: 48, + completedHoursAgo: 47, + }), + isLoading: false, + isError: false, + }, + }, + { + automation: makeAutomation("demo-list-failed-old", "List: older failure", { + type: "event", + source: "github", + on: "release.published", + }), + runState: { + latestRun: makeRun("run-failed-old", AutomationRunStatus.FAILED, { + errorDetail: "Model returned an empty completion.", + startedHoursAgo: 72, + completedHoursAgo: 71, + }), + isLoading: false, + isError: false, + }, + }, + { + automation: makeAutomation("demo-list-no-runs-2", "List: never ran", { + type: "cron", + schedule: "0 0 1 * *", + schedule_human: "Monthly on the 1st", + }), + runState: { latestRun: null, isLoading: false, isError: false }, + }, + { + automation: makeAutomation("demo-list-loading-2", "List: loading status", { + type: "event", + source: "linear", + on: "Issue.create", + }), + runState: { latestRun: null, isLoading: true, isError: false }, + }, + { + automation: makeAutomation("demo-list-error-2", "List: fetch error", { + type: "cron", + schedule: "15 * * * *", + schedule_human: "Hourly at :15", + }), + runState: { latestRun: null, isLoading: false, isError: true }, + }, + { + automation: makeAutomation( + "demo-list-pending-2", + "List: pending, long name that should truncate in the activity row when the viewport is narrow", + { type: "cron", schedule: "0 3 * * 0", schedule_human: "Sundays at 3am" }, + ), + runState: { + latestRun: makeRun("run-pending-2", AutomationRunStatus.PENDING, { + startedHoursAgo: 0.05, + completedHoursAgo: null, + }), + isLoading: false, + isError: false, + }, + }, + { + automation: makeAutomation( + "demo-list-completed-fallback", + "List: completed with conversation", + { type: "event", source: "github", on: "workflow_run.completed" }, + ), + runState: { + latestRun: makeRun( + "run-completed-fallback", + AutomationRunStatus.COMPLETED, + { + conversationId: "demo-conv-untitled", + startedHoursAgo: 20, + completedHoursAgo: 19, + }, + ), + isLoading: false, + isError: false, + }, + }, +]; + +if (DEMO_SPECS.length !== DEMO_LIST_LENGTH) { + throw new Error( + `home-automations-demo: expected ${DEMO_LIST_LENGTH} specs, got ${DEMO_SPECS.length}`, + ); +} + +export const HOME_AUTOMATIONS_DEMO_PINNED_IDS: string[] = DEMO_SPECS.filter( + (spec) => spec.pin, +).map((spec) => spec.automation.id); + +export const HOME_AUTOMATIONS_DEMO_AUTOMATIONS: Automation[] = DEMO_SPECS.map( + (spec) => spec.automation, +); + +export const HOME_AUTOMATIONS_DEMO_RUN_STATES: Map< + string, + LatestAutomationRunState +> = new Map( + DEMO_SPECS.map( + (spec) => [spec.automation.id, toDemoRunState(spec.runState)] as const, + ), +); diff --git a/src/fixtures/table-demo-conversation.ts b/src/fixtures/table-demo-conversation.ts new file mode 100644 index 0000000000000000000000000000000000000000..f2066eb7b82f7df2e2c0b6432b005f5cdb6be99c --- /dev/null +++ b/src/fixtures/table-demo-conversation.ts @@ -0,0 +1,58 @@ +import type { MessageEvent, OpenHandsEvent } from "#/types/agent-server/core"; + +export const TABLE_DEMO_CONVERSATION_ID = "table-demo"; + +const TABLE_DEMO_USER_MESSAGE = + "Can you compare several coding agents in a wide table?"; + +/** Wide GFM table used to exercise horizontal scrolling in the chat feed. */ +export const TABLE_DEMO_AGENT_MARKDOWN = [ + "Here is a wide comparison table. Scroll horizontally to read every column:", + "", + "| Feature | OpenHands | Claude Code | Codex | Gemini CLI | Cursor | Windsurf | Cline | Aider | Devin | Continue |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", + "| CLI | Yes | Yes | Yes | Yes | IDE | IDE | IDE | Terminal | Cloud | IDE |", + "| MCP support | Yes | Yes | Partial | Partial | Yes | Yes | Yes | No | Yes | Yes |", + "| Local models | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | No | Yes |", + "| Browser tools | Yes | Yes | Yes | Limited | Yes | Yes | Yes | No | Yes | Yes |", + "| Git integration | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |", + "| Best for | General coding | Anthropic stack | OpenAI stack | Google stack | IDE users | IDE users | VS Code | Terminal | Autonomous | IDE users |", +].join("\n"); + +const TABLE_DEMO_BASE_TIME = Date.UTC(2026, 5, 23, 12, 0, 0); + +function createTableDemoMessageEvent( + id: string, + role: "user" | "assistant", + text: string, + offsetMinutes: number, +): MessageEvent { + return { + id, + timestamp: new Date( + TABLE_DEMO_BASE_TIME + offsetMinutes * 60_000, + ).toISOString(), + source: role === "user" ? "user" : "agent", + llm_message: { + role, + content: [{ type: "text", text }], + }, + activated_skills: [], + extended_content: [], + }; +} + +export const TABLE_DEMO_EVENTS: OpenHandsEvent[] = [ + createTableDemoMessageEvent( + "table-demo-user-1", + "user", + TABLE_DEMO_USER_MESSAGE, + 0, + ), + createTableDemoMessageEvent( + "table-demo-agent-1", + "assistant", + TABLE_DEMO_AGENT_MARKDOWN, + 1, + ), +]; diff --git a/src/hooks/use-acp-model-context.ts b/src/hooks/use-acp-model-context.ts new file mode 100644 index 0000000000000000000000000000000000000000..f50285c403db780ee0e80aac8e0cef7fe2588b0a --- /dev/null +++ b/src/hooks/use-acp-model-context.ts @@ -0,0 +1,68 @@ +import { useTranslation } from "react-i18next"; +import { useActiveBackend } from "#/contexts/active-backend-context"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { useSettings } from "#/hooks/query/use-settings"; +import { useActiveAgentKind } from "#/hooks/use-active-agent-profile"; +import { I18nKey } from "#/i18n/declaration"; + +const AGENT_SETTINGS_PATH = "/settings/agents"; +const LLM_SETTINGS_PATH = "/settings/llm"; + +export interface AcpModelContext { + /** The active conversation runs an ACP agent. */ + isActiveAcpConversation: boolean; + /** + * No active conversation (home page) but the saved agent settings already + * select an ACP agent — the next conversation created here inherits it, so + * the model UI should reflect that now. + */ + isHomeAcp: boolean; + /** Either of the above: the model affordance should defer to the ACP picker. */ + isAcpContext: boolean; + /** Where the model/settings link should navigate. */ + destinationPath: typeof AGENT_SETTINGS_PATH | typeof LLM_SETTINGS_PATH; + /** Translated label for that link. */ + destinationLabel: string; +} + +/** + * Shared source of truth for "is this an ACP model context, and where does + * the model affordance link?". The chat model affordance consumes this through + * ``useChatInputModelState`` so inline and overflow surfaces can't drift on the + * home-page-ACP rule or on the destination path/label. + */ +export function useAcpModelContext(): AcpModelContext { + const { t } = useTranslation("openhands"); + const { backend } = useActiveBackend(); + const { data: conversation } = useActiveConversation(); + const { data: settings } = useSettings(); + const activeAgentKind = useActiveAgentKind(); + + const isActiveAcpConversation = conversation?.agent_kind === "acp"; + // On the home page the "current agent" is the active AgentProfile, not the + // global agent settings (activate never writes them). Fall back to settings + // only while the profile list is loading. + const isHomeAcp = + !conversation && + (activeAgentKind ?? settings?.agent_settings?.agent_kind) === "acp"; + const isAcpContext = isActiveAcpConversation || isHomeAcp; + + const destinationPath = isAcpContext + ? AGENT_SETTINGS_PATH + : LLM_SETTINGS_PATH; + const destinationLabel = t( + isAcpContext + ? I18nKey.SETTINGS$NAV_AGENT + : backend.kind === "cloud" + ? I18nKey.SETTINGS$LLM_SETTINGS + : I18nKey.SETTINGS$LLM_PROFILES, + ); + + return { + isActiveAcpConversation, + isHomeAcp, + isAcpContext, + destinationPath, + destinationLabel, + }; +} diff --git a/src/hooks/use-agent-notification.ts b/src/hooks/use-agent-notification.ts new file mode 100644 index 0000000000000000000000000000000000000000..8cc8566e74a884356d6907a6082dee46421f7ff3 --- /dev/null +++ b/src/hooks/use-agent-notification.ts @@ -0,0 +1,49 @@ +import { useEffect, useRef } from "react"; +import { AgentState } from "#/types/agent-state"; +import { useSettings } from "#/hooks/query/use-settings"; +import notificationSound from "#/assets/notification.mp3"; + +const NOTIFICATION_STATES: AgentState[] = [ + AgentState.AWAITING_USER_INPUT, + AgentState.FINISHED, + AgentState.AWAITING_USER_CONFIRMATION, +]; + +/** + * Hook that plays a notification sound when the agent transitions into a + * state that requires user attention. The browser tab title itself is + * managed by `useAppTitle`, which prefixes the title with an emoji that + * reflects the current agent state. + */ +export function useAgentNotification(curAgentState: AgentState) { + const { data: settings } = useSettings(); + const audioRef = useRef(undefined); + const prevStateRef = useRef(undefined); + + // Initialize audio only in browser environment, inside useEffect to + // avoid side effects during render (React 18 strict mode, SSR safety). + useEffect(() => { + if (typeof window !== "undefined" && !audioRef.current) { + audioRef.current = new Audio(notificationSound); + audioRef.current.volume = 0.5; + } + }, []); + + const isSoundEnabled = settings?.enable_sound_notifications ?? false; + + // Trigger notification only on actual state transitions into a + // notification-worthy state — not when unrelated deps (e.g. settings) change. + useEffect(() => { + if (prevStateRef.current === curAgentState) return; + prevStateRef.current = curAgentState; + + if (!NOTIFICATION_STATES.includes(curAgentState)) return; + + if (isSoundEnabled && audioRef.current) { + audioRef.current.currentTime = 0; + audioRef.current.play().catch(() => { + // Ignore autoplay errors (browsers may block autoplay) + }); + } + }, [curAgentState, isSoundEnabled]); +} diff --git a/src/hooks/use-app-title.test.tsx b/src/hooks/use-app-title.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..63b95d25bdeea802553be8e6a58502368ba95acb --- /dev/null +++ b/src/hooks/use-app-title.test.tsx @@ -0,0 +1,125 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useParams } from "react-router"; +import { useUserConversation } from "./query/use-user-conversation"; +import { useAppTitle } from "./use-app-title"; +import { useConversationStateStore } from "#/stores/conversation-state-store"; +import { ExecutionStatus } from "#/types/agent-server/core/base/common"; + +const renderAppTitleHook = () => + renderHook(() => useAppTitle(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + +vi.mock("./query/use-user-conversation"); +vi.mock("react-router", async () => { + const actual = await vi.importActual("react-router"); + return { + ...actual, + useParams: vi.fn(), + }; +}); + +describe("useAppTitle", () => { + const mockUseUserConversation = vi.mocked(useUserConversation); + const mockUseParams = vi.mocked(useParams); + + beforeEach(() => { + // @ts-expect-error - only returning partial config for test + mockUseUserConversation.mockReturnValue({ data: null }); + mockUseParams.mockReturnValue({}); + useConversationStateStore.getState().reset(); + }); + + it("returns the OSS app title outside conversations", async () => { + const { result } = renderAppTitleHook(); + + await waitFor(() => expect(result.current).toBe("OpenHands")); + }); + + it("returns the conversation title with the OSS app name", async () => { + mockUseParams.mockReturnValue({ conversationId: "123" }); + mockUseUserConversation.mockReturnValue({ + // @ts-expect-error - only returning partial config for test + data: { title: "My Conversation" }, + }); + + const { result } = renderAppTitleHook(); + + await waitFor(() => + expect(result.current).toBe("My Conversation | OpenHands"), + ); + }); + + it("returns the app name while conversation data is loading", async () => { + mockUseParams.mockReturnValue({ conversationId: "123" }); + // @ts-expect-error - only returning partial config for test + mockUseUserConversation.mockReturnValue({ data: undefined }); + + const { result } = renderAppTitleHook(); + + await waitFor(() => expect(result.current).toBe("OpenHands")); + }); + + it.each([ + [ExecutionStatus.RUNNING, "🟢"], + [ExecutionStatus.FINISHED, "✅"], + [ExecutionStatus.IDLE, "✅"], + [ExecutionStatus.WAITING_FOR_CONFIRMATION, "✅"], + [ExecutionStatus.PAUSED, "⚪"], + [ExecutionStatus.ERROR, "🔴"], + [ExecutionStatus.STUCK, "🔴"], + ])( + "prefixes the title with %s emoji for execution status %s", + async (status, emoji) => { + mockUseParams.mockReturnValue({ conversationId: "123" }); + mockUseUserConversation.mockReturnValue({ + // @ts-expect-error - only returning partial config for test + data: { title: "My Conversation" }, + }); + useConversationStateStore.getState().setExecutionStatus("123", status); + + const { result } = renderAppTitleHook(); + + await waitFor(() => + expect(result.current).toBe(`${emoji} My Conversation | OpenHands`), + ); + }, + ); + + it("falls back to the conversation's execution_status when the live store is empty", async () => { + mockUseParams.mockReturnValue({ conversationId: "123" }); + mockUseUserConversation.mockReturnValue({ + // @ts-expect-error - only returning partial config for test + data: { + title: "My Conversation", + execution_status: ExecutionStatus.RUNNING, + }, + }); + + const { result } = renderAppTitleHook(); + + await waitFor(() => + expect(result.current).toBe("🟢 My Conversation | OpenHands"), + ); + }); + + it("does not add an emoji when in a conversation but execution status is unknown", async () => { + mockUseParams.mockReturnValue({ conversationId: "123" }); + mockUseUserConversation.mockReturnValue({ + // @ts-expect-error - only returning partial config for test + data: { title: "My Conversation" }, + }); + + const { result } = renderAppTitleHook(); + + await waitFor(() => + expect(result.current).toBe("My Conversation | OpenHands"), + ); + }); +}); diff --git a/src/hooks/use-app-title.ts b/src/hooks/use-app-title.ts new file mode 100644 index 0000000000000000000000000000000000000000..187721c8e6af5f2c589fe1d68717d2faee5b5c12 --- /dev/null +++ b/src/hooks/use-app-title.ts @@ -0,0 +1,32 @@ +import { useParams } from "react-router"; +import { useUserConversation } from "#/hooks/query/use-user-conversation"; +import { useConversationStateStore } from "#/stores/conversation-state-store"; +import { getAgentStateEmoji } from "#/utils/agent-state-emoji"; + +const APP_TITLE = "OpenHands"; + +export const useAppTitle = () => { + const { conversationId } = useParams<{ conversationId: string }>(); + const { data: conversation } = useUserConversation(conversationId ?? null); + const liveExecutionStatus = useConversationStateStore((state) => + conversationId + ? (state.executionStatusByConversation[conversationId] ?? null) + : null, + ); + + const conversationTitle = conversation?.title; + const baseTitle = + conversationId && conversationTitle + ? `${conversationTitle} | ${APP_TITLE}` + : APP_TITLE; + + if (!conversationId) { + return baseTitle; + } + + const executionStatus = + liveExecutionStatus ?? conversation?.execution_status ?? null; + const emoji = getAgentStateEmoji(executionStatus); + + return emoji ? `${emoji} ${baseTitle}` : baseTitle; +}; diff --git a/src/hooks/use-auto-resize.ts b/src/hooks/use-auto-resize.ts new file mode 100644 index 0000000000000000000000000000000000000000..7eec544a343b5a4e69200f2e9b06a782588af59e --- /dev/null +++ b/src/hooks/use-auto-resize.ts @@ -0,0 +1,379 @@ +import { useCallback, useEffect, RefObject, useRef } from "react"; +import { IMessageToSend } from "#/stores/conversation-store"; +import { EPS } from "#/utils/constants"; +import { getStyleHeightPx, setStyleHeightPx } from "#/utils/utils"; +import { focusContentEditableAtEnd } from "#/components/features/chat/utils/chat-input.utils"; +import { useDragResize } from "./use-drag-resize"; + +// Constants +const DEFAULT_MIN_HEIGHT = 20; +const DEFAULT_MAX_HEIGHT = 120; +const HEIGHT_INCREMENT = 20; +const MANUAL_OVERSIZE_THRESHOLD = 50; + +// Manual height tracking utilities +const useManualHeight = () => { + const hasUserResizedRef = useRef(false); + const manualHeightRef = useRef(null); + return { hasUserResizedRef, manualHeightRef }; +}; + +interface UseAutoResizeOptions { + minHeight?: number; + maxHeight?: number; + enableManualResize?: boolean; + onGripDragStart?: () => void; + onGripDragEnd?: () => void; + onHeightChange?: (height: number) => void; // New callback for height changes + value?: IMessageToSend; + /** Called once `value` has been applied to the element (one-shot consume). */ + onValueApplied?: () => void; +} + +interface UseAutoResizeReturn { + smartResize: () => void; + handleGripMouseDown: (e: React.MouseEvent) => void; + handleGripTouchStart: (e: React.TouchEvent) => void; + increaseHeightForEmptyContent: () => void; + resetManualResize: () => void; +} + +// Height management utilities +interface HeightConstraints { + minHeight: number; + maxHeight: number; +} + +interface HeightMeasurements { + currentHeight: number; + currentStyleHeight: number; + contentHeight: number; +} + +interface ResizeStrategy { + finalHeight: number; + overflowY: "hidden" | "auto"; +} + +const applyHeightToElement = ( + element: HTMLElement, + height: number, + constraints: HeightConstraints, +): number => { + const { minHeight, maxHeight } = constraints; + const finalHeight = Math.max(minHeight, Math.min(height, maxHeight)); + + element.style.setProperty("height", `${finalHeight}px`); + element.style.setProperty( + "overflow-y", + finalHeight >= maxHeight ? "auto" : "hidden", + ); + + return finalHeight; +}; + +const isManuallyOversized = ( + currentHeight: number, + contentHeight: number, + threshold = MANUAL_OVERSIZE_THRESHOLD, +): boolean => currentHeight > contentHeight + threshold; + +const measureElementHeights = ( + element: HTMLElement, + minHeight: number, +): HeightMeasurements => { + // Use the previous explicit style height as the "current" for restore, not offsetHeight + const currentStyleHeight = getStyleHeightPx(element, minHeight); + const currentHeight = currentStyleHeight; + + // Temporarily reset to measure content + element.style.setProperty("height", "auto"); + const contentHeight = element.scrollHeight; + + // Restore height + setStyleHeightPx(element, currentStyleHeight); + + return { + currentHeight, + currentStyleHeight, + contentHeight, + }; +}; + +const applyResizeStrategy = ( + element: HTMLElement, + strategy: ResizeStrategy, +): void => { + const { finalHeight, overflowY } = strategy; + const elementRef = element; + elementRef.style.height = `${finalHeight}px`; + elementRef.style.overflowY = overflowY; +}; + +const executeHeightCallback = ( + height: number, + onHeightChange?: (height: number) => void, +): void => { + if (onHeightChange) { + onHeightChange(height); + } +}; + +// DOM manipulation utilities +export const useAutoResize = ( + elementRef: RefObject, + options: UseAutoResizeOptions = {}, +): UseAutoResizeReturn => { + const pendingSmartRef = useRef(null); + + const { + minHeight = DEFAULT_MIN_HEIGHT, + maxHeight = DEFAULT_MAX_HEIGHT, + enableManualResize = false, + value, + onValueApplied, + onGripDragStart, + onGripDragEnd, + onHeightChange, + } = options; + + const constraints: HeightConstraints = { minHeight, maxHeight }; + const { hasUserResizedRef, manualHeightRef } = useManualHeight(); + + const resetManualResize = () => { + hasUserResizedRef.current = false; + manualHeightRef.current = null; + }; + + // Wrap onHeightChange to track manual height during drag + const handleExternalHeightChange = useCallback( + (elementHeight: number) => { + onHeightChange?.(elementHeight); + if (hasUserResizedRef.current) { + manualHeightRef.current = elementHeight; + } + }, + [onHeightChange], + ); + + // Handle drag start - set manual mode flag + const handleDragStart = useCallback(() => { + hasUserResizedRef.current = true; + onGripDragStart?.(); + }, [onGripDragStart]); + + // Handle drag end - clear manual mode if at minimum height + const handleDragEnd = useCallback(() => { + const textareaElement = elementRef.current; + if (textareaElement) { + const currentHeight = getStyleHeightPx(textareaElement, minHeight); + if (Math.abs(currentHeight - minHeight) <= EPS) { + hasUserResizedRef.current = false; + manualHeightRef.current = null; + } + } + onGripDragEnd?.(); + }, [minHeight, onGripDragEnd]); + + // Use the drag resize hook for manual resizing functionality + const { handleGripMouseDown, handleGripTouchStart } = useDragResize({ + elementRef, + minHeight, + maxHeight, + onGripDragStart: enableManualResize ? handleDragStart : undefined, + onGripDragEnd: enableManualResize ? handleDragEnd : undefined, + onHeightChange: handleExternalHeightChange, + }); + + // Handle content that fits within current height + const handleContentFitsInCurrentHeight = useCallback( + ( + element: HTMLElement, + currentHeight: number, + contentHeight: number, + ): void => { + // If user manually resized and we're above min height, preserve their chosen height + if (hasUserResizedRef.current && currentHeight > minHeight) { + applyResizeStrategy(element, { + finalHeight: currentHeight, + overflowY: "hidden", + }); + executeHeightCallback(currentHeight, onHeightChange); + return; + } + + // Otherwise allow shrinking towards content (respect minHeight) + const finalHeight = Math.max(contentHeight, minHeight); + applyResizeStrategy(element, { + finalHeight, + overflowY: "hidden", + }); + executeHeightCallback(finalHeight, onHeightChange); + }, + [minHeight, onHeightChange], + ); + + // Handle content that exceeds current height but within max height + const handleContentExceedsCurrentHeight = useCallback( + ( + element: HTMLElement, + currentHeight: number, + contentHeight: number, + ): void => { + // Grow unless the element is manually oversized beyond content significantly + if (!isManuallyOversized(currentHeight, contentHeight)) { + const finalHeight = Math.max(contentHeight, minHeight); + applyResizeStrategy(element, { + finalHeight, + overflowY: "hidden", + }); + executeHeightCallback(finalHeight, onHeightChange); + return; + } + + // Keep manual height and allow scrolling as needed + applyResizeStrategy(element, { + finalHeight: currentHeight, + overflowY: "auto", + }); + executeHeightCallback(currentHeight, onHeightChange); + }, + [minHeight, onHeightChange], + ); + + // Handle content that exceeds max height + const handleContentExceedsMaxHeight = useCallback( + (element: HTMLElement) => { + applyResizeStrategy(element, { + finalHeight: maxHeight, + overflowY: "auto", + }); + executeHeightCallback(maxHeight, onHeightChange); + }, + [maxHeight, onHeightChange], + ); + + // Debounced smartResize body + const smartResizeBody = useCallback(() => { + const element = elementRef.current; + if (!element) return; + + const textIsEmpty = (element.textContent ?? "").trim().length === 0; + + // If empty content and we have a manual height above min, preserve it + if ( + textIsEmpty && + hasUserResizedRef.current && + manualHeightRef.current && + manualHeightRef.current > minHeight + EPS + ) { + setStyleHeightPx(element, manualHeightRef.current); + element.style.overflowY = "hidden"; + executeHeightCallback(manualHeightRef.current, onHeightChange); + return; + } + + // Measure element heights + const measurements = measureElementHeights(element, minHeight); + const { currentHeight, contentHeight } = measurements; + + // If content fits within current height + if (contentHeight <= currentHeight) { + handleContentFitsInCurrentHeight(element, currentHeight, contentHeight); + return; + } + + // If content exceeds current height but within max + if (contentHeight <= maxHeight) { + handleContentExceedsCurrentHeight(element, currentHeight, contentHeight); + return; + } + + // Content exceeds max height + handleContentExceedsMaxHeight(element); + }, [ + elementRef, + minHeight, + maxHeight, + onHeightChange, + handleContentFitsInCurrentHeight, + handleContentExceedsCurrentHeight, + handleContentExceedsMaxHeight, + ]); + + // rAF-debounced smartResize wrapper to collapse bursts + const smartResize = useCallback(() => { + if (pendingSmartRef.current) cancelAnimationFrame(pendingSmartRef.current); + pendingSmartRef.current = requestAnimationFrame(() => { + pendingSmartRef.current = null; + smartResizeBody(); + }); + }, [smartResizeBody]); + + // Function to increase height when content is empty + const increaseHeightForEmptyContent = () => { + const element = elementRef.current; + if (!element) return; + + const currentHeight = getStyleHeightPx(element, minHeight); + const newHeight = Math.min(currentHeight + HEIGHT_INCREMENT, maxHeight); + + if (newHeight > currentHeight) { + const finalHeight = applyHeightToElement(element, newHeight, constraints); + + // Execute height change callback + executeHeightCallback(finalHeight, onHeightChange); + + // Set manual mode for Shift+Enter height increases + hasUserResizedRef.current = true; + manualHeightRef.current = finalHeight; + } + }; + + // Update content and resize when value prop changes + useEffect(() => { + if (value === undefined) { + return undefined; + } + + let rafId = 0; + + const applyValue = () => { + const element = elementRef.current; + if (!element) { + return false; + } + + element.textContent = value.text; + smartResize(); + focusContentEditableAtEnd(element); + onValueApplied?.(); // one-shot: let the caller clear the value + return true; + }; + + if (!applyValue()) { + rafId = requestAnimationFrame(() => { + applyValue(); + }); + } + + return () => { + if (rafId) { + cancelAnimationFrame(rafId); + } + }; + }, [value, smartResize, onValueApplied]); + + // Initialize auto-resize on mount + useEffect(() => { + smartResize(); + }, [smartResize]); + + return { + smartResize, + handleGripMouseDown, + handleGripTouchStart, + increaseHeightForEmptyContent, + resetManualResize, + }; +}; diff --git a/src/hooks/use-automation-permissions.ts b/src/hooks/use-automation-permissions.ts new file mode 100644 index 0000000000000000000000000000000000000000..d189ced0a9493d1a09765fc931878d91494df234 --- /dev/null +++ b/src/hooks/use-automation-permissions.ts @@ -0,0 +1,102 @@ +import { useQuery } from "@tanstack/react-query"; +import { getCloudOrganizationMe } from "#/api/cloud/organization-service.api"; +import { useActiveBackend } from "#/contexts/active-backend-context"; +import { useCloudCurrentUserId } from "./query/use-cloud-current-user-id"; +import type { Automation } from "#/types/automation"; + +/** + * Server-defined permission strings (mirror the automation service's + * `view_automations` / `manage_automations` and the enterprise + * `authorization.Permission` enum). The `/me` endpoint returns whichever + * subset the caller's role grants. + */ +export const VIEW_AUTOMATIONS = "view_automations"; +export const MANAGE_AUTOMATIONS = "manage_automations"; + +export interface AutomationPermissionsResult { + /** Read-only access (list, get, list runs, capabilities, git-sync status). */ + canView: boolean; + /** Full write access (create, update, delete, dispatch, git-sync config). */ + canManage: boolean; + /** + * `true` when permissions are still being resolved on cloud. Local always + * resolves synchronously. Mutating controls should stay hidden while this is + * `true` so a member never sees a flash of write controls before the + * server-defined permission set arrives. + */ + isLoading: boolean; +} + +/** + * Resolve the caller's automation-level permissions on the active backend. + * + * - Local agent-server (OSS): always `{ canView: true, canManage: true }`. + * - Cloud: reads the server-defined `permissions` array from + * `GET /api/organizations/{orgId}/me` (same query `useCloudCurrentUserId` + * and `useCanManageOrgProfiles` use, so no extra request is issued) and + * falls back to a role check (`owner`/`admin` ⇒ both, `member` ⇒ view + * only) when an older app-server doesn't return `permissions`. + */ +export function useAutomationPermissions(): AutomationPermissionsResult { + const { backend, orgId } = useActiveBackend(); + const isCloud = backend.kind === "cloud"; + + // eslint-disable-next-line @tanstack/query/exhaustive-deps + const { data, isLoading } = useQuery({ + queryKey: [ + "cloud-current-user", + backend.id, + orgId, + backend.connectionRevision ?? 0, + ], + queryFn: () => getCloudOrganizationMe(orgId!, backend), + enabled: isCloud && !!orgId, + staleTime: 1000 * 60 * 5, + retry: false, + meta: { disableToast: true }, + }); + + if (!isCloud) { + return { canView: true, canManage: true, isLoading: false }; + } + + if (isLoading || !data) { + return { canView: false, canManage: false, isLoading }; + } + + if (data.permissions) { + const canView = data.permissions.includes(VIEW_AUTOMATIONS); + const canManage = data.permissions.includes(MANAGE_AUTOMATIONS); + return { canView, canManage, isLoading: false }; + } + + // Fall back to role check for older app-servers without `permissions`. + const isOwnerOrAdmin = data.role === "owner" || data.role === "admin"; + return { + canView: isOwnerOrAdmin || data.role === "member", + canManage: isOwnerOrAdmin, + isLoading: false, + }; +} + +/** + * Resolve whether the current user is the creator of the given automation. + * + * - Local: always `true` (the local user owns every automation; and since + * local always has `manage_automations`, the distinction is moot). + * - Cloud: compares `automation.user_id` with the caller's `user_id` from + * `GET /api/organizations/{orgId}/me`. Returns `false` while the id is + * still loading so the creator escape hatch never flashes open prematurely. + */ +export function useIsAutomationOwner(automation: Automation): boolean { + const { backend } = useActiveBackend(); + // Always call the hook so rules-of-hooks is satisfied; it's a no-op for + // local backends since the early return below short-circuits before use. + const userIds = useCloudCurrentUserId(); + + if (backend.kind !== "cloud") return true; + + const entry = userIds[backend.id]; + if (!entry || entry.isLoading || !entry.userId) return false; + return automation.user_id === entry.userId; +} diff --git a/src/hooks/use-await-context-compaction.ts b/src/hooks/use-await-context-compaction.ts new file mode 100644 index 0000000000000000000000000000000000000000..3a2d5a1d08e433c07e26135c1d2f0bd04e596396 --- /dev/null +++ b/src/hooks/use-await-context-compaction.ts @@ -0,0 +1,164 @@ +import { useEffect, useRef } from "react"; +import useMetricsStore from "#/stores/metrics-store"; +import { useEventStore } from "#/stores/use-event-store"; +import { isCondensationEvent } from "#/types/agent-server/type-guards"; + +/** How long to wait after a Condensation event for a lower per_turn_token. */ +const METRICS_SETTLE_MS = 2_500; +/** Give up waiting for condensation / metrics and surface whatever we have. */ +const DEFAULT_TIMEOUT_MS = 90_000; + +export type ContextCompactionOutcome = "compacted" | "no_change" | "timeout"; + +export interface ContextCompactionResult { + beforeToken: number; + afterToken: number; + savedToken: number; + /** + * "compacted" — a Condensation event landed and per_turn_token dropped. + * "no_change" — a Condensation event landed but no token drop was measured. + * "timeout" — no Condensation event arrived in time; compaction did not + * happen (or was never processed), so this is a failure, not a no-op. + */ + outcome: ContextCompactionOutcome; +} + +export function buildContextCompactionResult( + beforeToken: number, + afterToken: number, + outcome: ContextCompactionOutcome, +): ContextCompactionResult { + return { + beforeToken, + afterToken, + savedToken: Math.max(0, beforeToken - afterToken), + outcome, + }; +} + +interface UseAwaitContextCompactionOptions { + /** + * Snapshot of `per_turn_token` taken when compaction was requested. + * Pass `null` when not awaiting a result. + */ + beforeToken: number | null; + /** + * Event ids that predate the compaction *request*. The condense POST can + * return only after the server already emitted its Condensation event, so + * snapshotting "known" ids when this effect starts would miss fast + * condensations; the baseline must be captured before the request fires. + * When omitted, falls back to the ids present when the effect starts. + */ + baselineEventIds?: Set | null; + onComplete: (result: ContextCompactionResult) => void; + timeoutMs?: number; +} + +/** + * Waits for a post-request Condensation event (and ideally a lower + * `per_turn_token` in the live metrics store), then reports how many tokens + * were freed. The HTTP `/condense` ack only means work *started*. + */ +export function useAwaitContextCompaction({ + beforeToken, + baselineEventIds, + onComplete, + timeoutMs = DEFAULT_TIMEOUT_MS, +}: UseAwaitContextCompactionOptions) { + const onCompleteRef = useRef(onComplete); + onCompleteRef.current = onComplete; + + useEffect(() => { + if (beforeToken === null) { + return undefined; + } + + let completed = false; + let sawCondensation = false; + let settleTimer: ReturnType | undefined; + const knownEventIds = baselineEventIds + ? new Set(baselineEventIds) + : new Set(useEventStore.getState().eventIds); + + const readAfterToken = () => + useMetricsStore.getState().usage?.per_turn_token ?? beforeToken; + + const finish = (afterToken: number, outcome: ContextCompactionOutcome) => { + if (completed) { + return; + } + completed = true; + onCompleteRef.current( + buildContextCompactionResult(beforeToken, afterToken, outcome), + ); + }; + + const finishFromMetricsIfReady = () => { + const afterToken = readAfterToken(); + if (sawCondensation && afterToken < beforeToken) { + finish(afterToken, "compacted"); + return true; + } + return false; + }; + + const scheduleSettleFinish = () => { + clearTimeout(settleTimer); + settleTimer = setTimeout(() => { + finish(readAfterToken(), "no_change"); + }, METRICS_SETTLE_MS); + }; + + const scanForNewCondensation = () => { + for (const event of useEventStore.getState().events) { + const eventId = "id" in event ? event.id : undefined; + if (eventId !== undefined) { + if (knownEventIds.has(eventId)) { + continue; + } + knownEventIds.add(eventId); + } + if (!isCondensationEvent(event)) { + continue; + } + sawCondensation = true; + if (!finishFromMetricsIfReady()) { + scheduleSettleFinish(); + } + return; + } + }; + + scanForNewCondensation(); + + const unsubscribeEvents = useEventStore.subscribe(() => { + if (completed) { + return; + } + scanForNewCondensation(); + }); + + const unsubscribeMetrics = useMetricsStore.subscribe(() => { + if (completed) { + return; + } + if (finishFromMetricsIfReady()) { + clearTimeout(settleTimer); + } + }); + + const timeoutTimer = setTimeout(() => { + // No Condensation event in time means the compaction never landed — + // a failure, not a "nothing to compact" no-op. + finish(readAfterToken(), sawCondensation ? "no_change" : "timeout"); + }, timeoutMs); + + return () => { + completed = true; + unsubscribeEvents(); + unsubscribeMetrics(); + clearTimeout(settleTimer); + clearTimeout(timeoutTimer); + }; + }, [beforeToken, baselineEventIds, timeoutMs]); +} diff --git a/src/hooks/use-backend-scoped-path.ts b/src/hooks/use-backend-scoped-path.ts new file mode 100644 index 0000000000000000000000000000000000000000..c0368b9cc904842a198808363ff694b77a862fa5 --- /dev/null +++ b/src/hooks/use-backend-scoped-path.ts @@ -0,0 +1,27 @@ +import React from "react"; +import { isNoBackend } from "#/api/backend-registry/active-store"; +import { withBackendSelectionParams } from "#/api/backend-registry/url-selection"; +import { useActiveBackend } from "#/contexts/active-backend-context"; + +/** + * Build in-app paths that carry the active backend identity. + * + * Use it for links a user can plausibly open in a *new* browsing context + * (cmd/ctrl-click, middle click, "Open link in new tab"). Such a tab does not + * reliably inherit the opener's `sessionStorage`, so without the pinned + * identity it can boot on whichever backend `localStorage` last recorded and + * fail to resolve the linked conversation. + */ +export function useBackendScopedPath(): (path: string) => string { + const active = useActiveBackend(); + + return React.useCallback( + (path: string) => { + // The sentinel isn't a real backend and must never be pinned onto a + // link — a new tab would only ignore it and fall back to storage. + if (isNoBackend(active.backend)) return path; + return withBackendSelectionParams(path, active); + }, + [active], + ); +} diff --git a/src/hooks/use-bash-command-runner.ts b/src/hooks/use-bash-command-runner.ts new file mode 100644 index 0000000000000000000000000000000000000000..f4df6b50939d6eb3ba2c98da81a39ec36e6135c1 --- /dev/null +++ b/src/hooks/use-bash-command-runner.ts @@ -0,0 +1,206 @@ +import { useCallback, useEffect, useRef } from "react"; +import type { + BashCommand, + BashError, + BashEvent, + BashOutput, +} from "@openhands/typescript-client"; +import type { CommandResult } from "#/api/runtime-service/agent-server-runtime-service"; +import { sendWebSocketAuth } from "#/utils/websocket-auth"; +import { startHandshakeWatchdog } from "#/utils/websocket-handshake"; +import { buildBashWebSocketUrl } from "#/utils/websocket-url"; + +interface WaitingCommand { + command: string; + cwd: string; + timeout: number; + resolve: (result: CommandResult) => void; + reject: (error: Error) => void; +} + +interface PendingCommand { + resolve: (result: CommandResult) => void; + reject: (error: Error) => void; +} + +interface ActiveCommand extends PendingCommand { + stdout: string[]; + stderr: string[]; +} + +export type BashCommandRunner = ( + command: string, + cwd: string, + timeout: number, +) => Promise; + +function isBashCommand(event: BashEvent): event is BashCommand { + return event.kind === "BashCommand"; +} + +function isBashOutput(event: BashEvent): event is BashOutput { + return event.kind === "BashOutput"; +} + +function isBashError(event: BashEvent): event is BashError { + return event.kind === "BashError"; +} + +/** + * Maintains a persistent WebSocket connection to the agent-server's + * `/sockets/bash-events` endpoint and exposes a `runCommand` function that + * executes a bash command and returns a Promise that resolves when the + * final `BashOutput` (non-null `exit_code`) arrives. + * + * Commands are correlated using a FIFO queue: each `BashCommand` echo + * received from the server is paired with the oldest outstanding request in + * the queue, and subsequent `BashOutput` events are matched by `command_id`. + * + * Commands are buffered until the socket's open handler sends authentication. + */ +export function useBashCommandRunner( + conversationUrl: string | null | undefined, + sessionApiKey: string | null | undefined, + enabled: boolean, +): BashCommandRunner { + const wsRef = useRef(null); + const readyWsRef = useRef(null); + const waitingQueueRef = useRef([]); + // Commands whose request was sent; waiting for the BashCommand echo to get command_id + const pendingQueueRef = useRef([]); + // Commands whose command_id is known; waiting for BashOutput with non-null exit_code + const activeCommandsRef = useRef>(new Map()); + + useEffect(() => { + if (!enabled) return; + + const wsUrl = buildBashWebSocketUrl(conversationUrl); + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + readyWsRef.current = null; + + // Abort a handshake stuck in CONNECTING — a hung bash-events handshake + // would also block the conversation's events socket (the one the chat + // needs) until it settles. + const cancelHandshakeWatchdog = startHandshakeWatchdog(ws); + + ws.onopen = () => { + cancelHandshakeWatchdog(); + sendWebSocketAuth(ws, sessionApiKey); + readyWsRef.current = ws; + for (const { + command, + cwd, + timeout, + resolve, + reject, + } of waitingQueueRef.current) { + pendingQueueRef.current.push({ resolve, reject }); + ws.send(JSON.stringify({ command, cwd, timeout })); + } + waitingQueueRef.current = []; + }; + + ws.onmessage = (event: MessageEvent) => { + let data: BashEvent; + try { + data = JSON.parse(event.data as string) as BashEvent; + } catch { + return; // ignore malformed frames + } + + if (isBashCommand(data)) { + // Associate the next pending request with the server-assigned command_id + const pending = pendingQueueRef.current.shift(); + if (pending) { + activeCommandsRef.current.set(data.id, { + ...pending, + stdout: [], + stderr: [], + }); + } + } else if (isBashOutput(data) && data.command_id) { + const active = activeCommandsRef.current.get(data.command_id); + if (active) { + if (data.stdout) active.stdout.push(data.stdout); + if (data.stderr) active.stderr.push(data.stderr); + if (data.exit_code != null) { + activeCommandsRef.current.delete(data.command_id); + active.resolve({ + exit_code: data.exit_code, + stdout: active.stdout.join(""), + stderr: active.stderr.join(""), + }); + } + } + } else if (isBashError(data)) { + rejectAll(`Bash error: ${data.code}: ${data.detail}`); + } + }; + + function rejectAll(reason: string): void { + const err = new Error(reason); + for (const { reject: rej } of waitingQueueRef.current) rej(err); + waitingQueueRef.current = []; + for (const p of pendingQueueRef.current) p.reject(err); + pendingQueueRef.current = []; + for (const a of activeCommandsRef.current.values()) a.reject(err); + activeCommandsRef.current.clear(); + } + + ws.onclose = () => { + cancelHandshakeWatchdog(); + wsRef.current = null; + readyWsRef.current = null; + rejectAll("Bash WebSocket closed"); + }; + + ws.onerror = () => { + wsRef.current = null; + readyWsRef.current = null; + rejectAll("Bash WebSocket error"); + }; + + return () => { + // The close handler is nulled below, so cancel the watchdog here. + cancelHandshakeWatchdog(); + // Prevent the close/error handlers from double-rejecting after unmount + ws.onclose = null; + ws.onerror = null; + ws.close(); + wsRef.current = null; + readyWsRef.current = null; + rejectAll("Bash WebSocket unmounted"); + }; + }, [enabled, conversationUrl, sessionApiKey]); + + const runCommand: BashCommandRunner = useCallback( + (command: string, cwd: string, timeout: number) => + new Promise((resolve, reject) => { + const ws = wsRef.current; + if ( + !ws || + ws.readyState === WebSocket.CLOSED || + ws.readyState === WebSocket.CLOSING + ) { + reject(new Error("Bash WebSocket not available")); + return; + } + if (ws.readyState !== WebSocket.OPEN || readyWsRef.current !== ws) { + waitingQueueRef.current.push({ + command, + cwd, + timeout, + resolve, + reject, + }); + } else { + pendingQueueRef.current.push({ resolve, reject }); + ws.send(JSON.stringify({ command, cwd, timeout })); + } + }), + [], + ); + + return runCommand; +} diff --git a/src/hooks/use-breakpoint.ts b/src/hooks/use-breakpoint.ts new file mode 100644 index 0000000000000000000000000000000000000000..49b24b4a581afa5b4a12d34b11710abaf51a04ca --- /dev/null +++ b/src/hooks/use-breakpoint.ts @@ -0,0 +1,40 @@ +import { useState, useEffect, useRef } from "react"; + +const MOBILE_BREAKPOINT = 1024; + +/** + * Inclusive max width where the primary sidebar rail is hidden (`max-md:hidden`) + * and the slide-out drawer is used. Matches typical Tailwind `md` (48rem): rail + * shows from 768px upward, so we treat width <= 767 as drawer-only. + */ +export const SIDEBAR_RAIL_COLLAPSE_MAX_WIDTH = 767; + +/** + * Returns true when window width is at or below the breakpoint. + * Only triggers a re-render when the boolean value changes (i.e., when the + * width crosses the breakpoint), NOT on every pixel of resize. + * + * This replaces useWindowSize() for breakpoint detection, avoiding + * unnecessary re-renders during drag resize. + */ +export function useBreakpoint(breakpoint: number = MOBILE_BREAKPOINT): boolean { + const [isMobile, setIsMobile] = useState( + () => window.innerWidth <= breakpoint, + ); + const isMobileRef = useRef(isMobile); + + useEffect(() => { + function handleResize() { + const newIsMobile = window.innerWidth <= breakpoint; + if (newIsMobile !== isMobileRef.current) { + isMobileRef.current = newIsMobile; + setIsMobile(newIsMobile); + } + } + + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [breakpoint]); + + return isMobile; +} diff --git a/src/hooks/use-can-manage-org-profiles.ts b/src/hooks/use-can-manage-org-profiles.ts new file mode 100644 index 0000000000000000000000000000000000000000..b9cca2d9905e8c00808b789a571f6b00a2403ae2 --- /dev/null +++ b/src/hooks/use-can-manage-org-profiles.ts @@ -0,0 +1,58 @@ +import { useQuery } from "@tanstack/react-query"; +import { getCloudOrganizationMe } from "#/api/cloud/organization-service.api"; +import { useActiveBackend } from "#/contexts/active-backend-context"; + +/** + * Server-defined permission (mirrors the app-server's + * `authorization.Permission.EDIT_ORG_SETTINGS`) that gates org-scoped profile + * mutations (both LLM profiles and agent profiles). + */ +const EDIT_ORG_SETTINGS = "edit_org_settings"; + +/** + * Whether the current user may MUTATE org-scoped profiles — LLM profiles and + * agent profiles alike — on the active backend: create, edit, rename, delete, + * duplicate, or activate/switch. + * + * - Local agent-server (OSS): always `true`; the user owns their own profiles. + * - Cloud: profiles are org-scoped. The app-server grants every mutating + * profile action (save/delete/rename/activate) only to the `owner`/`admin` + * roles via the `edit_org_settings` permission; a `member` is view-only. We + * read the caller's server-defined permissions from + * `GET /api/organizations/{orgId}/me` — the same call `useCloudCurrentUserId` + * makes, reusing its query key so no extra request is issued — and fall back + * to a role check when an older app-server doesn't return `permissions`. + * + * Returns `false` while the role/permissions are still loading or unknown on + * cloud, so mutating controls never flash for a member before they resolve. + */ +export function useCanManageOrgProfiles(): boolean { + const { backend, orgId } = useActiveBackend(); + const isCloud = backend.kind === "cloud"; + + // Keep the key byte-identical to useCloudCurrentUserId so React Query shares + // the cached /me result, including its connection-credential revision. + // eslint-disable-next-line @tanstack/query/exhaustive-deps + const { data } = useQuery({ + queryKey: [ + "cloud-current-user", + backend.id, + orgId, + backend.connectionRevision ?? 0, + ], + queryFn: () => getCloudOrganizationMe(orgId!, backend), + enabled: isCloud && !!orgId, + staleTime: 1000 * 60 * 5, + retry: false, + meta: { disableToast: true }, + }); + + if (!isCloud) return true; + // Prefer the server-defined permission set; fall back to a role check for + // older app-servers whose /me doesn't return `permissions` yet, so this + // keeps working against either backend version. + if (data?.permissions) { + return data.permissions.includes(EDIT_ORG_SETTINGS); + } + return data?.role === "owner" || data?.role === "admin"; +} diff --git a/src/hooks/use-chat-input-llm-profile-state.ts b/src/hooks/use-chat-input-llm-profile-state.ts new file mode 100644 index 0000000000000000000000000000000000000000..e986e95987da0fa58d49461151f9a6a19aafacf9 --- /dev/null +++ b/src/hooks/use-chat-input-llm-profile-state.ts @@ -0,0 +1,110 @@ +import { useCallback } from "react"; +import { useIsMutating } from "@tanstack/react-query"; +import type { ProfileInfo } from "@openhands/typescript-client"; +import { useOptionalConversationId } from "#/hooks/use-conversation-id"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { useLlmProfiles } from "#/hooks/query/use-llm-profiles"; +import { useCanManageOrgProfiles } from "#/hooks/use-can-manage-org-profiles"; +import { useSwitchLlmProfileAndLog } from "#/hooks/mutation/use-switch-llm-profile-and-log"; +import { SWITCH_LLM_PROFILE_MUTATION_KEY } from "#/hooks/mutation/use-switch-llm-profile"; +import { useModelStore } from "#/stores/model-store"; + +export interface ChatInputLlmProfileState { + profiles: ProfileInfo[]; + /** The LLM profile the running conversation is currently using. */ + currentProfileName: string | null; + /** That profile's model id (for the tooltip / subtitle). */ + currentProfileModel: string | null; + isLoading: boolean; + isSwitching: boolean; + /** + * Whether picking a profile here would actually land. False on a cloud + * start-task route and for a cloud member on the home page; the surfaces + * still name the active profile, they just drop the selectable rows. + */ + canSwitchProfile: boolean; + /** + * Switch the LLM profile: live via `/switch_profile` when inside a + * conversation, or activate it globally when on the home page (no + * conversation) — see {@link useSwitchLlmProfile}. + */ + selectProfile: (profileName: string) => void; +} + +/** + * Backs the OpenHands LLM-profile switcher pill, both on the home page (where + * a pick activates the profile globally so the next conversation launches + * with it) and inside a conversation (live swap). The ACP analog is + * {@link useChatInputModelState}. Mirrors the former SwitchProfileButton's + * resolution priority so a switch is reflected instantly. + */ +export function useChatInputLlmProfileState(): ChatInputLlmProfileState { + const { conversationId } = useOptionalConversationId(); + const { data: conversation } = useActiveConversation(); + const { data, isLoading } = useLlmProfiles(); + const canManageOrgProfiles = useCanManageOrgProfiles(); + const { switchAndLog } = useSwitchLlmProfileAndLog(); + // Read the switch's pending state globally by mutation key: the pill button + // and the menu that fires the switch are separate hook instances, so a + // per-observer `isPending` would never light up on the button (#1571). + const isSwitching = + useIsMutating({ mutationKey: SWITCH_LLM_PROFILE_MUTATION_KEY }) > 0; + // Written by useSwitchLlmProfile's onSuccess -> recordModelSwitchMessage -> + // recordSwitch on a successful switch, so the label/check update before the + // conversation refetch lands the new llm_model. + const optimisticActiveProfile = useModelStore((s) => + conversationId ? s.activeProfileByConversation[conversationId] : undefined, + ); + + const profiles = data?.profiles ?? []; + const conversationModel = conversation?.llm_model ?? null; + + // Resolution priority (mirrors the former SwitchProfileButton): + // 1. Optimistic (just-clicked) — instant feedback before the refetch. + // 2. Profile stamped on the conversation, validated against the live list + // so a since-deleted/renamed profile falls through. + // 3. Profile whose model matches the running llm_model. + // 4. User-level active_profile (fallback before the first message). + const stampedProfile = conversation?.active_profile ?? null; + const conversationProfile = + stampedProfile && profiles.some((p) => p.name === stampedProfile) + ? stampedProfile + : null; + const currentProfileName = + optimisticActiveProfile ?? + conversationProfile ?? + (conversationModel + ? (profiles.find((p) => p.model === conversationModel)?.name ?? null) + : (data?.active_profile ?? null)); + const currentProfileModel = + profiles.find((p) => p.name === currentProfileName)?.model ?? + conversationModel ?? + null; + + // A home pick activates the profile account-wide — on cloud that's the + // org-gated activate endpoint a member can only 403 on. A cloud `task-…` + // route has no real conversation id yet, so the per-conversation + // /switch_profile would 404. Either way the pill still names the active + // profile; only the selectable rows drop out. + const isTaskRoute = conversationId?.startsWith("task-") ?? false; + const canSwitchProfile = + !isTaskRoute && (!!conversationId || canManageOrgProfiles); + + const selectProfile = useCallback( + (profileName: string) => { + if (!canSwitchProfile || profileName === currentProfileName) return; + switchAndLog(conversationId, profileName); + }, + [canSwitchProfile, conversationId, currentProfileName, switchAndLog], + ); + + return { + profiles, + currentProfileName, + currentProfileModel, + isLoading, + isSwitching, + canSwitchProfile, + selectProfile, + }; +} diff --git a/src/hooks/use-chat-input-profile-state.ts b/src/hooks/use-chat-input-profile-state.ts new file mode 100644 index 0000000000000000000000000000000000000000..18ed5275a07be1987e1c638019e3eed3f0292689 --- /dev/null +++ b/src/hooks/use-chat-input-profile-state.ts @@ -0,0 +1,122 @@ +import { useCallback } from "react"; +import { useIsMutating } from "@tanstack/react-query"; +import type { AgentProfileSummary } from "#/api/agent-profiles-service/agent-profiles-service.api"; +import { getStoredConversationMetadata } from "#/api/conversation-metadata-store"; +import { useNavigation } from "#/context/navigation-context"; +import { useAgentProfiles } from "#/hooks/query/use-agent-profiles"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { + CREATE_CONVERSATION_MUTATION_KEY, + useCreateConversation, + type CreateConversationVariables, +} from "#/hooks/mutation/use-create-conversation"; +import { + useActivateAgentProfile, + ACTIVATE_AGENT_PROFILE_MUTATION_KEY, +} from "#/hooks/mutation/use-activate-agent-profile"; + +export interface ChatInputProfileState { + profiles: AgentProfileSummary[]; + currentProfileId: string | null; + currentProfileName: string | null; + isInConversation: boolean; + isLoading: boolean; + isSwitching: boolean; + selectProfile: (profile: AgentProfileSummary) => void; +} + +export function useChatInputProfileState(): ChatInputProfileState { + const { conversationId, navigate } = useNavigation(); + const { data: conversation, isLoading: isLoadingConversation } = + useActiveConversation(); + const { data: agentProfiles, isLoading: isLoadingProfiles } = + useAgentProfiles(); + const createConversation = useCreateConversation(); + const activateProfile = useActivateAgentProfile(); + const activatingProfiles = useIsMutating({ + mutationKey: ACTIVATE_AGENT_PROFILE_MUTATION_KEY, + }); + const creatingConversations = useIsMutating({ + mutationKey: CREATE_CONVERSATION_MUTATION_KEY, + }); + const isTaskRoute = conversationId?.startsWith("task-") ?? false; + const isSwitching = + isTaskRoute || activatingProfiles > 0 || creatingConversations > 0; + + const profiles = agentProfiles?.profiles ?? []; + const isInConversation = Boolean(conversationId); + const currentProfileId = isInConversation + ? (conversation?.launched_agent_profile?.agent_profile_id ?? + agentProfiles?.active_agent_profile_id ?? + null) + : (agentProfiles?.active_agent_profile_id ?? null); + const currentProfileName = + profiles.find((p) => p.id != null && p.id === currentProfileId)?.name ?? + null; + + const selectProfile = useCallback( + (profile: AgentProfileSummary) => { + if (isTaskRoute || !profile.id || profile.id === currentProfileId) return; + if (!isInConversation) { + activateProfile.mutate(profile.id); + return; + } + + const metadata = conversationId + ? getStoredConversationMetadata(conversationId) + : null; + const variables: CreateConversationVariables = { + agentProfileId: profile.id, + entryPoint: "blank_conversation_profile_picker", + }; + if (conversation?.selected_repository) { + variables.repository = { + name: conversation.selected_repository, + gitProvider: conversation.git_provider ?? "github", + branch: conversation.selected_branch ?? undefined, + }; + } + if (conversation?.selected_workspace) { + variables.workingDir = conversation.selected_workspace; + variables.workspaceMode = metadata?.workspace_mode ?? "local_repo"; + } + if (metadata?.plugins?.length) { + variables.plugins = metadata.plugins; + } + + // mutateAsync, not mutate-scoped callbacks: this hook lives inside the + // tools-menu content, which unmounts as soon as a profile is picked, and + // React Query drops mutate-scoped callbacks on unmount — the replacement + // conversation would be created without ever navigating to it. The + // promise (and the navigate closure) survive the unmount. Errors are + // already surfaced by the global mutation toast; swallow the rejection. + createConversation + .mutateAsync(variables) + .then((data) => navigate(`/conversations/${data.conversation_id}`)) + .catch(() => {}); + }, + [ + activateProfile, + conversation, + conversationId, + createConversation, + currentProfileId, + isInConversation, + isTaskRoute, + navigate, + ], + ); + + return { + profiles, + currentProfileId, + currentProfileName, + isInConversation, + isLoading: + isLoadingProfiles || + isTaskRoute || + (isInConversation && isLoadingConversation), + isSwitching, + selectProfile, + }; +} diff --git a/src/hooks/use-click-outside-element.ts b/src/hooks/use-click-outside-element.ts new file mode 100644 index 0000000000000000000000000000000000000000..5bb69be83f505e5aaf7612015f9617fa492f0cce --- /dev/null +++ b/src/hooks/use-click-outside-element.ts @@ -0,0 +1,34 @@ +import React from "react"; + +/** + * Hook to call a callback function when an element is clicked outside + * @param callback The callback function to call when the element is clicked outside + */ +export const useClickOutsideElement = ( + callback: () => void, + ignoreOutsideClickRef?: React.RefObject, +) => { + const ref = React.useRef(null); + // Hold the latest callback in a ref so the listener effect doesn't + // re-register every render when callers pass an inline arrow function. + const callbackRef = React.useRef(callback); + React.useEffect(() => { + callbackRef.current = callback; + }, [callback]); + + React.useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node; + if (!ref.current) return; + if (ref.current.contains(target)) return; + if (ignoreOutsideClickRef?.current?.contains(target)) return; + callbackRef.current(); + }; + + document.addEventListener("click", handleClickOutside); + + return () => document.removeEventListener("click", handleClickOutside); + }, [ignoreOutsideClickRef]); + + return ref; +}; diff --git a/src/hooks/use-compact-context-action.test.tsx b/src/hooks/use-compact-context-action.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e0605a613ce79797a903c8344bd2826c3cccd7b9 --- /dev/null +++ b/src/hooks/use-compact-context-action.test.tsx @@ -0,0 +1,117 @@ +/* Integration: useCompactContextAction end-to-end through real stores. */ +import { act, renderHook } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import useMetricsStore from "#/stores/metrics-store"; +import { useEventStore } from "#/stores/use-event-store"; +import type { OpenHandsEvent } from "#/types/agent-server/core"; + +const mutateMock = vi.fn(); +vi.mock("#/hooks/mutation/use-condense-conversation", () => ({ + useCondenseConversation: () => ({ mutate: mutateMock, isPending: false }), +})); +vi.mock("#/hooks/query/use-active-conversation", () => ({ + useActiveConversation: () => ({ + data: { id: "c1", conversation_url: null, session_api_key: null }, + }), +})); +vi.mock("#/hooks/use-agent-state", () => ({ + useAgentState: () => ({ curAgentState: "FINISHED" }), +})); +const successToast = vi.fn(); +const errorToast = vi.fn(); +vi.mock("#/utils/custom-toast-handlers", () => ({ + displaySuccessToast: (...args: unknown[]) => successToast(...args), + displayErrorToast: (...args: unknown[]) => errorToast(...args), +})); + +import { useCompactContextAction } from "#/hooks/use-compact-context-action"; +import { I18nKey } from "#/i18n/declaration"; + +function condensationEvent(id: string): OpenHandsEvent { + return { + id, + timestamp: new Date().toISOString(), + source: "environment", + kind: "Condensation", + forgotten_event_ids: ["a"], + } as OpenHandsEvent; +} + +const usage = (perTurn: number) => ({ + prompt_tokens: perTurn, + completion_tokens: 0, + cache_read_tokens: 0, + cache_write_tokens: 0, + context_window: 262144, + per_turn_token: perTurn, +}); + +function wrapper({ children }: { children: React.ReactNode }) { + return React.createElement( + QueryClientProvider, + { client: new QueryClient() }, + children, + ); +} + +describe("useCompactContextAction (integration)", () => { + beforeEach(() => { + vi.useFakeTimers(); + useEventStore.getState().clearEvents(); + useMetricsStore.getState().setMetrics({ + cost: null, + max_budget_per_task: null, + usage: usage(50_000), + }); + mutateMock.mockImplementation((_vars, opts) => opts.onSuccess()); + successToast.mockClear(); + errorToast.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + useEventStore.getState().clearEvents(); + }); + + it("toasts counts when condensation lands and metrics drop", () => { + const { result } = renderHook(() => useCompactContextAction(50_000), { + wrapper, + }); + + act(() => result.current.handleCompact()); + expect(successToast).toHaveBeenCalledWith( + I18nKey.CONVERSATION$COMPACT_CONTEXT_STARTED, + ); + + act(() => { + useEventStore.getState().addEvent(condensationEvent("cond-x")); + useMetricsStore.getState().setMetrics({ + cost: null, + max_budget_per_task: null, + usage: usage(20_000), + }); + }); + + expect(successToast).toHaveBeenCalledWith( + I18nKey.CONVERSATION$COMPACT_CONTEXT_COMPLETE, + ); + expect(errorToast).not.toHaveBeenCalled(); + }); + + it("toasts error when no condensation arrives in time", () => { + const { result } = renderHook(() => useCompactContextAction(50_000), { + wrapper, + }); + + act(() => result.current.handleCompact()); + act(() => { + vi.advanceTimersByTime(90_000); + }); + + expect(errorToast).toHaveBeenCalledWith( + I18nKey.CONVERSATION$COMPACT_CONTEXT_FAILED, + ); + }); +}); diff --git a/src/hooks/use-compact-context-action.ts b/src/hooks/use-compact-context-action.ts new file mode 100644 index 0000000000000000000000000000000000000000..f07e89d72364c191d58d19e1aa2455b78e4f854f --- /dev/null +++ b/src/hooks/use-compact-context-action.ts @@ -0,0 +1,119 @@ +import React from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { useAgentState } from "#/hooks/use-agent-state"; +import { useCondenseConversation } from "#/hooks/mutation/use-condense-conversation"; +import { + type ContextCompactionResult, + useAwaitContextCompaction, +} from "#/hooks/use-await-context-compaction"; +import { AgentState } from "#/types/agent-state"; +import useMetricsStore from "#/stores/metrics-store"; +import { useEventStore } from "#/stores/use-event-store"; +import { + displayErrorToast, + displaySuccessToast, +} from "#/utils/custom-toast-handlers"; +import { formatCompactTokenCount } from "#/utils/format-token-count"; +import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message"; + +/** + * Shared compact/condense action used by the Usage panel CTA and the + * composer context-window popover. + */ +export function useCompactContextAction(perTurnToken: number = 0) { + const { t } = useTranslation("openhands"); + const queryClient = useQueryClient(); + const { data: conversation } = useActiveConversation(); + const { curAgentState } = useAgentState(); + const { mutate: condense, isPending } = useCondenseConversation(); + const [beforeToken, setBeforeToken] = React.useState(null); + const baselineEventIdsRef = React.useRef | null>(null); + + const isAgentBusy = + curAgentState === AgentState.RUNNING || + curAgentState === AgentState.LOADING; + const isCompacting = isPending || beforeToken !== null; + const isDisabled = !conversation?.id || isAgentBusy || isCompacting; + const description = t(I18nKey.CONVERSATION$COMPACT_CONTEXT_DESCRIPTION); + + const handleCompactionComplete = React.useEffectEvent( + (result: ContextCompactionResult) => { + setBeforeToken(null); + baselineEventIdsRef.current = null; + if (conversation?.id) { + queryClient.invalidateQueries({ + queryKey: ["conversation-metrics", conversation.id], + }); + } + + if (result.outcome === "timeout") { + displayErrorToast(t(I18nKey.CONVERSATION$COMPACT_CONTEXT_FAILED)); + return; + } + + if (result.savedToken > 0) { + displaySuccessToast( + t(I18nKey.CONVERSATION$COMPACT_CONTEXT_COMPLETE, { + saved: formatCompactTokenCount(result.savedToken), + before: formatCompactTokenCount(result.beforeToken), + after: formatCompactTokenCount(result.afterToken), + }), + ); + return; + } + + displaySuccessToast( + t(I18nKey.CONVERSATION$COMPACT_CONTEXT_COMPLETE_NO_CHANGE), + ); + }, + ); + + useAwaitContextCompaction({ + beforeToken, + baselineEventIds: beforeToken !== null ? baselineEventIdsRef.current : null, + onComplete: handleCompactionComplete, + }); + + const handleCompact = () => { + if (!conversation?.id || isCompacting) return; + + const snapshot = + useMetricsStore.getState().usage?.per_turn_token ?? perTurnToken; + // The condense POST can return only after the server already emitted its + // Condensation event, so the await baseline must be captured *before* the + // request fires, not when the post-ack effect starts. + baselineEventIdsRef.current = new Set(useEventStore.getState().eventIds); + + condense( + { + conversationId: conversation.id, + conversationUrl: conversation.conversation_url, + sessionApiKey: conversation.session_api_key, + }, + { + onSuccess: () => { + setBeforeToken(snapshot); + displaySuccessToast(t(I18nKey.CONVERSATION$COMPACT_CONTEXT_STARTED)); + }, + onError: (error) => { + setBeforeToken(null); + baselineEventIdsRef.current = null; + displayErrorToast( + retrieveAxiosErrorMessage(error) || + t(I18nKey.CONVERSATION$COMPACT_CONTEXT_FAILED), + ); + }, + }, + ); + }; + + return { + handleCompact, + isCompacting, + isDisabled, + description, + }; +} diff --git a/src/hooks/use-context-window-usage.ts b/src/hooks/use-context-window-usage.ts new file mode 100644 index 0000000000000000000000000000000000000000..0efab2462c70481cefd4529bf3f68648af733c07 --- /dev/null +++ b/src/hooks/use-context-window-usage.ts @@ -0,0 +1,52 @@ +import { useMemo } from "react"; +import useMetricsStore from "#/stores/metrics-store"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { useConversationMetrics } from "#/hooks/query/use-conversation-metrics"; + +export interface ContextWindowUsage { + perTurnToken: number; + contextWindow: number; +} + +function toContextWindowUsage( + usage: + | { + per_turn_token?: number | null; + context_window?: number | null; + } + | null + | undefined, +): ContextWindowUsage | null { + const contextWindow = usage?.context_window ?? 0; + if (contextWindow <= 0) { + return null; + } + + return { + perTurnToken: usage?.per_turn_token ?? 0, + contextWindow, + }; +} + +export function useContextWindowUsage(): ContextWindowUsage | null { + const storeUsage = useMetricsStore((state) => state.usage); + const { data: conversation } = useActiveConversation(); + + const { data: conversationMetrics } = useConversationMetrics( + conversation?.id, + conversation?.conversation_url, + conversation?.session_api_key, + Boolean(conversation?.id), + ); + + return useMemo(() => { + const fromStore = toContextWindowUsage(storeUsage); + if (fromStore) { + return fromStore; + } + + return toContextWindowUsage( + conversationMetrics?.accumulated_token_usage ?? null, + ); + }, [storeUsage, conversationMetrics]); +} diff --git a/src/hooks/use-conversation-id.ts b/src/hooks/use-conversation-id.ts new file mode 100644 index 0000000000000000000000000000000000000000..f8f1af9a5bb688f92add32db32644323c0359dbe --- /dev/null +++ b/src/hooks/use-conversation-id.ts @@ -0,0 +1,19 @@ +import { useNavigation } from "#/context/navigation-context"; + +export function useOptionalConversationId() { + const { conversationId } = useNavigation(); + + return { conversationId }; +} + +export function useConversationId() { + const { conversationId } = useOptionalConversationId(); + + if (!conversationId) { + throw new Error( + "useConversationId must be used within a route that has a conversationId parameter", + ); + } + + return { conversationId }; +} diff --git a/src/hooks/use-conversation-name-context-menu.ts b/src/hooks/use-conversation-name-context-menu.ts new file mode 100644 index 0000000000000000000000000000000000000000..f7715d142c02e46673469c0e71b3b06300403bd6 --- /dev/null +++ b/src/hooks/use-conversation-name-context-menu.ts @@ -0,0 +1,226 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigation } from "#/context/navigation-context"; +import { useActiveBackend } from "#/contexts/active-backend-context"; +import { useDeleteConversation } from "./mutation/use-delete-conversation"; +import { useUnifiedPauseConversation } from "./mutation/use-unified-stop-conversation"; +import { useUpdateConversationPublicFlag } from "./mutation/use-update-conversation-public-flag"; +import { useActiveConversation } from "./query/use-active-conversation"; +import { useEventStore } from "#/stores/use-event-store"; +import { displaySuccessToast } from "#/utils/custom-toast-handlers"; +import { I18nKey } from "#/i18n/declaration"; +import { getStoredConversationMetadata } from "#/api/conversation-metadata-store"; + +import { useDownloadConversation } from "./use-download-conversation"; +import { + adaptSystemMessage, + SystemMessageForModal, +} from "#/utils/system-message-adapter"; +import { ExecutionStatus } from "#/types/agent-server/core/base/common"; +import { isExecutionActive } from "#/utils/status"; +import { useSelectConversationTab } from "./use-select-conversation-tab"; + +interface UseConversationNameContextMenuProps { + conversationId?: string; + executionStatus?: ExecutionStatus | null; + showOptions?: boolean; + onContextMenuToggle?: (isOpen: boolean) => void; +} + +export function useConversationNameContextMenu({ + conversationId, + executionStatus, + showOptions = false, + onContextMenuToggle, +}: UseConversationNameContextMenuProps) { + const { t } = useTranslation(); + const { conversationId: currentConversationId, navigate } = useNavigation(); + const { backend } = useActiveBackend(); + const events = useEventStore((state) => state.events); + const { mutate: deleteConversation } = useDeleteConversation(); + const { mutate: stopConversation } = useUnifiedPauseConversation(); + const { mutate: updatePublicFlag } = useUpdateConversationPublicFlag(); + const { data: conversation } = useActiveConversation(); + const [systemModalVisible, setSystemModalVisible] = React.useState(false); + const [skillsModalVisible, setSkillsModalVisible] = React.useState(false); + const [pluginsModalVisible, setPluginsModalVisible] = React.useState(false); + const [hooksModalVisible, setHooksModalVisible] = React.useState(false); + const [confirmDeleteModalVisible, setConfirmDeleteModalVisible] = + React.useState(false); + const [confirmStopModalVisible, setConfirmStopModalVisible] = + React.useState(false); + const { mutateAsync: downloadConversation } = useDownloadConversation(); + const { navigateToTab } = useSelectConversationTab(); + + const systemMessage: SystemMessageForModal | null = + adaptSystemMessage(events); + + const handleDelete = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setConfirmDeleteModalVisible(true); + onContextMenuToggle?.(false); + }; + + const handleStop = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setConfirmStopModalVisible(true); + onContextMenuToggle?.(false); + }; + + const handleConfirmDelete = () => { + if (conversationId) { + deleteConversation( + { conversationId }, + { + onSuccess: () => { + if (conversationId === currentConversationId) { + navigate("/conversations"); + } + }, + }, + ); + } + setConfirmDeleteModalVisible(false); + }; + + const handleConfirmStop = () => { + if (conversationId) { + stopConversation({ conversationId }); + } + setConfirmStopModalVisible(false); + }; + + const handleEdit = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + // This will be handled by the parent component to switch to edit mode + onContextMenuToggle?.(false); + }; + + const handleDownloadConversation = async ( + event: React.MouseEvent, + ) => { + event.preventDefault(); + event.stopPropagation(); + if (conversationId) { + await downloadConversation(conversationId); + } + onContextMenuToggle?.(false); + }; + + const handleDisplayCost = (event: React.MouseEvent) => { + event.stopPropagation(); + navigateToTab("usage"); + onContextMenuToggle?.(false); + }; + + const handleShowAgentTools = (event: React.MouseEvent) => { + event.stopPropagation(); + setSystemModalVisible(true); + onContextMenuToggle?.(false); + }; + + const handleShowSkills = (event: React.MouseEvent) => { + event.stopPropagation(); + setSkillsModalVisible(true); + onContextMenuToggle?.(false); + }; + + const handleShowPlugins = (event: React.MouseEvent) => { + event.stopPropagation(); + setPluginsModalVisible(true); + onContextMenuToggle?.(false); + }; + + const handleShowHooks = (event: React.MouseEvent) => { + event.stopPropagation(); + setHooksModalVisible(true); + onContextMenuToggle?.(false); + }; + + const handleTogglePublic = (nextIsPublic?: boolean) => { + if (conversationId && conversation) { + updatePublicFlag({ + conversationId, + isPublic: nextIsPublic ?? !conversation.public, + }); + } + // Intentionally don't close the menu — let the user see the toggle flip. + }; + + const shareUrl = React.useMemo(() => { + if (!conversationId) return ""; + // On cloud backends, the shareable URL must point at the cloud + // environment's host (e.g. the cloud app domain) rather than the local + // dev origin so the link works for anyone the user shares it with. + const origin = + backend.kind === "cloud" + ? backend.host.replace(/\/+$/, "") + : window.location.origin; + return `${origin}/shared/conversations/${conversationId}`; + }, [conversationId, backend.kind, backend.host]); + + const handleCopyShareLink = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + if (!shareUrl) { + onContextMenuToggle?.(false); + return; + } + + navigator.clipboard.writeText(shareUrl); + displaySuccessToast(t(I18nKey.CONVERSATION$LINK_COPIED)); + }; + + return { + // Handlers + handleDelete, + handleStop, + handleEdit, + handleDownloadConversation, + handleDisplayCost, + handleShowAgentTools, + handleShowSkills, + handleShowPlugins, + handleShowHooks, + handleTogglePublic, + handleCopyShareLink, + shareUrl, + handleConfirmDelete, + handleConfirmStop, + + // Modal states + systemModalVisible, + setSystemModalVisible, + skillsModalVisible, + setSkillsModalVisible, + pluginsModalVisible, + setPluginsModalVisible, + hooksModalVisible, + setHooksModalVisible, + confirmDeleteModalVisible, + setConfirmDeleteModalVisible, + confirmStopModalVisible, + setConfirmStopModalVisible, + + // Data + systemMessage, + + shouldShowStop: isExecutionActive(executionStatus), + shouldShowDownloadConversation: Boolean(conversationId && showOptions), + shouldShowDisplayCost: showOptions, + shouldShowAgentTools: Boolean(showOptions && systemMessage), + shouldShowSkills: Boolean(showOptions && conversationId), + shouldShowPlugins: Boolean( + showOptions && + conversationId && + (getStoredConversationMetadata(conversationId)?.plugins?.length ?? 0) > 0, + ), + shouldShowHooks: Boolean( + showOptions && conversationId && isExecutionActive(executionStatus), + ), + }; +} diff --git a/src/hooks/use-conversation-overview-add-intent.ts b/src/hooks/use-conversation-overview-add-intent.ts new file mode 100644 index 0000000000000000000000000000000000000000..11bd8d48a679871d35a116208112c14438ad4f0b --- /dev/null +++ b/src/hooks/use-conversation-overview-add-intent.ts @@ -0,0 +1,28 @@ +import { useEffect, useRef } from "react"; +import { useSearchParams } from "react-router"; +import { + CONVERSATION_OVERVIEW_ADD_QUERY_PARAM, + hasConversationOverviewAddIntent, +} from "#/components/features/conversation/conversation-overview-panel.constants"; + +export function useConversationOverviewAddIntent(onOpen: () => void): void { + const [searchParams, setSearchParams] = useSearchParams(); + const onOpenRef = useRef(onOpen); + + useEffect(() => { + onOpenRef.current = onOpen; + }, [onOpen]); + + useEffect(() => { + const search = searchParams.toString(); + if (!hasConversationOverviewAddIntent(search)) { + return; + } + + onOpenRef.current(); + + const nextParams = new URLSearchParams(searchParams); + nextParams.delete(CONVERSATION_OVERVIEW_ADD_QUERY_PARAM); + setSearchParams(nextParams, { replace: true }); + }, [searchParams, setSearchParams]); +} diff --git a/src/hooks/use-conversation-overview-column-space.ts b/src/hooks/use-conversation-overview-column-space.ts new file mode 100644 index 0000000000000000000000000000000000000000..ee83e1f34819d1842842a3181a0b8324c6e7c25f --- /dev/null +++ b/src/hooks/use-conversation-overview-column-space.ts @@ -0,0 +1,40 @@ +import { useEffect, useLayoutEffect, useState, type RefObject } from "react"; +import { hasEnoughOverviewLayoutSpace } from "#/components/features/conversation/conversation-overview-panel.constants"; + +const useIsomorphicLayoutEffect = + typeof window !== "undefined" ? useLayoutEffect : useEffect; + +export function useConversationOverviewColumnSpace( + containerRef: RefObject, + enabled: boolean, +): boolean { + const [hasSpace, setHasSpace] = useState(false); + + useIsomorphicLayoutEffect(() => { + if (!enabled) { + setHasSpace(false); + return undefined; + } + + const container = containerRef.current; + if (!container) { + return undefined; + } + + const update = () => { + setHasSpace(hasEnoughOverviewLayoutSpace(container.clientWidth)); + }; + + update(); + + if (typeof ResizeObserver === "undefined") { + return undefined; + } + + const observer = new ResizeObserver(update); + observer.observe(container); + return () => observer.disconnect(); + }, [containerRef, enabled]); + + return hasSpace; +} diff --git a/src/hooks/use-conversation-overview-panel-peek.ts b/src/hooks/use-conversation-overview-panel-peek.ts new file mode 100644 index 0000000000000000000000000000000000000000..d9614632f8e69e80baf19a06d8a2bdbfcf008af1 --- /dev/null +++ b/src/hooks/use-conversation-overview-panel-peek.ts @@ -0,0 +1,60 @@ +import { useEffect } from "react"; +import { useConversationStore } from "#/stores/conversation-store"; + +/** Grace period so the pointer can move from the toggle onto the peek panel. */ +export const CONVERSATION_OVERVIEW_PEEK_CLOSE_DELAY_MS = 150; + +let peekCloseTimer: ReturnType | null = null; + +function clearPeekCloseTimer() { + if (peekCloseTimer !== null) { + clearTimeout(peekCloseTimer); + peekCloseTimer = null; + } +} + +export function openConversationOverviewPanelPeek() { + const { isRightPanelShown, isOverviewPanelShown, setIsOverviewPanelPeeked } = + useConversationStore.getState(); + + if (!isRightPanelShown || isOverviewPanelShown) { + return; + } + + clearPeekCloseTimer(); + setIsOverviewPanelPeeked(true); +} + +export function scheduleCloseConversationOverviewPanelPeek() { + clearPeekCloseTimer(); + peekCloseTimer = setTimeout(() => { + peekCloseTimer = null; + useConversationStore.getState().setIsOverviewPanelPeeked(false); + }, CONVERSATION_OVERVIEW_PEEK_CLOSE_DELAY_MS); +} + +export function closeConversationOverviewPanelPeek() { + clearPeekCloseTimer(); + useConversationStore.getState().setIsOverviewPanelPeeked(false); +} + +/** + * Clears a stale peek when the files drawer closes or overview is pinned open. + */ +export function useSyncConversationOverviewPanelPeek() { + const isRightPanelShown = useConversationStore( + (state) => state.isRightPanelShown, + ); + const isOverviewPanelShown = useConversationStore( + (state) => state.isOverviewPanelShown, + ); + const isOverviewPanelPeeked = useConversationStore( + (state) => state.isOverviewPanelPeeked, + ); + + useEffect(() => { + if (isOverviewPanelPeeked && (!isRightPanelShown || isOverviewPanelShown)) { + closeConversationOverviewPanelPeek(); + } + }, [isOverviewPanelPeeked, isOverviewPanelShown, isRightPanelShown]); +} diff --git a/src/hooks/use-conversation-overview-stats.ts b/src/hooks/use-conversation-overview-stats.ts new file mode 100644 index 0000000000000000000000000000000000000000..056f0a2c0f051ad61cce62fde3d5fcd75433da77 --- /dev/null +++ b/src/hooks/use-conversation-overview-stats.ts @@ -0,0 +1,28 @@ +import { getStoredConversationMetadata } from "#/api/conversation-metadata-store"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { useConversationId } from "#/hooks/use-conversation-id"; + +function workspaceBasename(path: string | null | undefined): string | null { + if (!path) { + return null; + } + return path.replace(/\/+$/, "").split("/").pop() || path; +} + +/** Workspace label for the overview panel (git stats come from other hooks). */ +export function useConversationOverviewStats() { + const { conversationId } = useConversationId(); + const { data: conversation } = useActiveConversation(); + + const storedMetadata = conversationId + ? getStoredConversationMetadata(conversationId) + : null; + const workspacePath = + storedMetadata?.selected_workspace ?? + conversation?.selected_workspace ?? + null; + + return { + workspaceName: workspaceBasename(workspacePath), + }; +} diff --git a/src/hooks/use-conversation-plugins.ts b/src/hooks/use-conversation-plugins.ts new file mode 100644 index 0000000000000000000000000000000000000000..82dfae0522607fd16128c967cf048dfe8035a0cc --- /dev/null +++ b/src/hooks/use-conversation-plugins.ts @@ -0,0 +1,19 @@ +import { useMemo } from "react"; +import { getStoredConversationMetadata } from "#/api/conversation-metadata-store"; +import type { PluginSpec } from "#/api/conversation-service/agent-server-conversation-service.types"; +import { useOptionalConversationId } from "#/hooks/use-conversation-id"; + +/** + * Plugins loaded into the active conversation, read from the client-side + * metadata snapshot taken at creation (explicitly attached plugins plus the + * enabled installed plugins the SDK auto-loads). Empty when none are loaded or + * when used outside a conversation route. The agent-server doesn't return a + * live conversation's loaded plugins, so this is the available source today. + */ +export function useConversationPlugins(): PluginSpec[] { + const { conversationId } = useOptionalConversationId(); + return useMemo(() => { + if (!conversationId) return []; + return getStoredConversationMetadata(conversationId)?.plugins ?? []; + }, [conversationId]); +} diff --git a/src/hooks/use-conversation-primary-repository.ts b/src/hooks/use-conversation-primary-repository.ts new file mode 100644 index 0000000000000000000000000000000000000000..49509d4ce0d9a054b6de7bd78947a6e5ea303a03 --- /dev/null +++ b/src/hooks/use-conversation-primary-repository.ts @@ -0,0 +1,35 @@ +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { useLocalGitInfo } from "#/hooks/query/use-local-git-info"; +import { useTaskPolling } from "#/hooks/query/use-task-polling"; +import type { Provider } from "#/types/settings"; + +/** + * Resolves the conversation's primary connected git repository using the + * same priority as the conversation overview git section: + * conversation metadata → task polling → local workspace probe. + */ +export function useConversationPrimaryRepository() { + const { data: conversation } = useActiveConversation(); + const { repositoryInfo } = useTaskPolling(); + const { data: localGitInfo } = useLocalGitInfo(); + + const conversationRepository = + conversation?.selected_repository || repositoryInfo?.selectedRepository; + const conversationProvider = (conversation?.git_provider || + repositoryInfo?.gitProvider) as Provider | undefined; + const conversationBranch = + conversation?.selected_branch || repositoryInfo?.selectedBranch; + + const repository = conversationRepository || localGitInfo?.repository || null; + const provider = (conversationProvider || + localGitInfo?.provider || + null) as Provider | null; + const branch = conversationBranch || localGitInfo?.branch || null; + + return { + repository, + provider, + branch, + isConnected: Boolean(repository && provider), + }; +} diff --git a/src/hooks/use-download-conversation.ts b/src/hooks/use-download-conversation.ts new file mode 100644 index 0000000000000000000000000000000000000000..bb4a2d1fbbaec05bf6f3ffdf417d0095db0fd231 --- /dev/null +++ b/src/hooks/use-download-conversation.ts @@ -0,0 +1,27 @@ +import { useMutation } from "@tanstack/react-query"; +import { useTracking } from "#/hooks/use-tracking"; +import { useTranslation } from "react-i18next"; +import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; +import { downloadBlob } from "#/utils/utils"; +import { displayErrorToast } from "#/utils/custom-toast-handlers"; +import { I18nKey } from "#/i18n/declaration"; + +export const useDownloadConversation = () => { + const { trackDownloadTrajectoryButtonClicked } = useTracking(); + const { t } = useTranslation("openhands"); + + return useMutation({ + mutationKey: ["conversations", "download"], + mutationFn: async (conversationId: string) => { + trackDownloadTrajectoryButtonClicked(); + const blob = + await AgentServerConversationService.downloadConversation( + conversationId, + ); + downloadBlob(blob, `conversation_${conversationId}.zip`); + }, + onError: () => { + displayErrorToast(t(I18nKey.CONVERSATION$DOWNLOAD_ERROR)); + }, + }); +}; diff --git a/src/hooks/use-drag-resize.ts b/src/hooks/use-drag-resize.ts new file mode 100644 index 0000000000000000000000000000000000000000..733ac84d6458b2ff8b943b8464eb85b96812770a --- /dev/null +++ b/src/hooks/use-drag-resize.ts @@ -0,0 +1,197 @@ +import type { + RefObject, + MouseEvent as ReactMouseEvent, + TouchEvent as ReactTouchEvent, +} from "react"; +import { EPS } from "#/utils/constants"; +import { isMobileDevice } from "#/utils/utils"; + +/** Movement required before a pointer gesture is treated as a resize drag. */ +const DRAG_COMMIT_PX = 2; + +/** + * Distance (px) from the viewport bottom within which we consider the chat + * input to be "bottom-anchored" by its parent's flexbox. When the input is + * bottom-anchored (e.g. the conversation page), dragging the top grip makes + * the box grow upward — the expected behaviour. When it is NOT + * bottom-anchored (e.g. the home page where the input sits in the centre of + * the screen), the same drag would make the box grow downward, which is + * confusing, so we disable manual resizing entirely in that case. + */ +const BOTTOM_ANCHORED_THRESHOLD = 120; + +/** + * Determine whether the chat input is anchored to the bottom of the viewport + * by its parent layout (e.g. the conversation page uses a flex column with a + * growing scroll area above the input). When true, dragging the top grip + * makes the box grow upward, which is the expected behaviour. When false, + * the drag would grow the box downward, so we disable it. + */ +export const isBottomAnchored = (): boolean => { + const grip = document.getElementById("resize-grip"); + const wrapper = grip?.parentElement ?? null; + if (!wrapper) return true; // fall back to allowing the drag + const rect = wrapper.getBoundingClientRect(); + return rect.bottom >= window.innerHeight - BOTTOM_ANCHORED_THRESHOLD; +}; + +// Drag handling hook +interface UseDragResizeOptions { + elementRef: RefObject; + minHeight: number; + maxHeight: number; + onGripDragStart?: () => void; + onGripDragEnd?: () => void; + onHeightChange?: (height: number) => void; + onReachedMinHeight?: () => void; // Notify when user drags to minimum height +} + +export const useDragResize = ({ + elementRef, + minHeight, + maxHeight, + onGripDragStart, + onGripDragEnd, + onHeightChange, + onReachedMinHeight, +}: UseDragResizeOptions) => { + const getClientY = (event: MouseEvent | TouchEvent): number => { + if ("touches" in event && event.touches.length > 0) { + return event.touches[0].clientY; + } + return (event as MouseEvent).clientY; + }; + + // Setup event listeners for mobile devices + const setupMobileEventListeners = ( + handleDragMove: (event: MouseEvent | TouchEvent) => void, + handleDragEnd: () => void, + ) => { + const resizeGrip = document.getElementById("resize-grip"); + + if (!resizeGrip) { + return; + } + + resizeGrip.addEventListener("touchmove", handleDragMove, { + passive: false, + capture: true, + }); + resizeGrip.addEventListener("touchend", handleDragEnd, { + capture: true, + }); + }; + + // Setup event listeners for desktop devices + const setupDesktopEventListeners = ( + handleDragMove: (event: MouseEvent | TouchEvent) => void, + handleDragEnd: () => void, + ) => { + document.addEventListener("mousemove", handleDragMove); + document.addEventListener("mouseup", handleDragEnd); + }; + + // Start drag operation + const startDrag = (startY: number) => { + const isMobile = isMobileDevice(); + const startHeight = elementRef.current?.offsetHeight || minHeight; + let dragCommitted = false; + + const handleDragMove = (moveEvent: MouseEvent | TouchEvent) => { + moveEvent.preventDefault(); + + if (!dragCommitted) { + const y = getClientY(moveEvent); + if (Math.abs(y - startY) < DRAG_COMMIT_PX) { + return; + } + dragCommitted = true; + onGripDragStart?.(); + } + + const deltaY = getClientY(moveEvent) - startY; + // Invert deltaY so moving up increases height and moving down decreases height + const newHeight = Math.max( + minHeight, + Math.min(maxHeight, startHeight - deltaY), + ); + + const element = elementRef.current; + + if (!element) { + return; + } + + element.style.height = `${newHeight}px`; + + // Check if content exceeds the new height to determine scrollbar visibility + const contentHeight = element.scrollHeight; + const shouldShowScrollbar = + contentHeight > newHeight || newHeight >= maxHeight; + element.style.overflowY = shouldShowScrollbar ? "auto" : "hidden"; + + // Call the height change callback if provided + if (onHeightChange) { + onHeightChange(newHeight); + } + + // Notify when dragged to minimum height to clear manual mode + if (onReachedMinHeight && Math.abs(newHeight - minHeight) <= EPS) { + onReachedMinHeight(); + } + }; + + const handleDragEnd = () => { + if (dragCommitted) { + onGripDragEnd?.(); + } + + if (isMobile) { + const resizeGrip = document.getElementById("resize-grip"); + if (!resizeGrip) { + return; + } + + // Remove both mouse and touch event listeners + resizeGrip.removeEventListener("mousemove", handleDragMove); + resizeGrip.removeEventListener("mouseup", handleDragEnd); + resizeGrip.removeEventListener("touchmove", handleDragMove); + resizeGrip.removeEventListener("touchend", handleDragEnd); + } else { + document.removeEventListener("mousemove", handleDragMove); + document.removeEventListener("mouseup", handleDragEnd); + document.removeEventListener("touchmove", handleDragMove); + document.removeEventListener("touchend", handleDragEnd); + } + }; + + // Setup event listeners based on device type + if (isMobile) { + setupMobileEventListeners(handleDragMove, handleDragEnd); + } else { + setupDesktopEventListeners(handleDragMove, handleDragEnd); + } + }; + + // Handle mouse down on grip for manual resizing + const handleGripMouseDown = (e: ReactMouseEvent) => { + e.preventDefault(); + // Only allow manual resizing when the input is bottom-anchored (e.g. the + // conversation page). On the home page the input is centred and dragging + // the top grip would grow the box downward, which is confusing. + if (!isBottomAnchored()) return; + startDrag(e.clientY); + }; + + // Handle touch start on grip for manual resizing + const handleGripTouchStart = (e: ReactTouchEvent) => { + e.preventDefault(); + if (!isBottomAnchored()) return; + startDrag(e.touches[0].clientY); + }; + + return { + handleGripMouseDown, + handleGripTouchStart, + }; +}; diff --git a/src/hooks/use-drag-scroll.ts b/src/hooks/use-drag-scroll.ts new file mode 100644 index 0000000000000000000000000000000000000000..8b11450ea8c8db2f6cd5d9b01be3c6c57250d87f --- /dev/null +++ b/src/hooks/use-drag-scroll.ts @@ -0,0 +1,94 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type RefObject, +} from "react"; + +/** Movement required before a mouse gesture is treated as a scroll drag. */ +const DRAG_COMMIT_PX = 2; + +/** + * Mouse drag-to-scroll for a horizontal `overflow-x-auto` container. Touch + * input keeps the browser's native panning; only primary-button mouse drags + * are handled. After a committed drag, the click that follows mouseup is + * suppressed so child buttons are not activated by the drag release. + */ +export function useDragScroll(scrollRef: RefObject) { + const [isDragging, setIsDragging] = useState(false); + const startXRef = useRef(0); + const startScrollLeftRef = useRef(0); + const dragCommittedRef = useRef(false); + + const handleMouseDown = useCallback( + (event: React.MouseEvent) => { + const element = scrollRef.current; + if (event.button !== 0 || !element) { + return; + } + dragCommittedRef.current = false; + startXRef.current = event.clientX; + startScrollLeftRef.current = element.scrollLeft; + setIsDragging(true); + }, + [scrollRef], + ); + + const handleClickCapture = useCallback((event: React.MouseEvent) => { + if (!dragCommittedRef.current) { + return; + } + dragCommittedRef.current = false; + event.preventDefault(); + event.stopPropagation(); + }, []); + + const handleDragStart = useCallback((event: React.DragEvent) => { + // Child elements would otherwise start a native image drag. + event.preventDefault(); + }, []); + + useEffect(() => { + if (!isDragging) { + return undefined; + } + + const handleMouseMove = (event: MouseEvent) => { + const element = scrollRef.current; + if (!element) { + return; + } + + // The mouseup can be missed when released outside the window; end the + // drag once the primary button is no longer held. + if ((event.buttons & 1) === 0) { + setIsDragging(false); + return; + } + + if (!dragCommittedRef.current) { + if (Math.abs(event.clientX - startXRef.current) < DRAG_COMMIT_PX) { + return; + } + dragCommittedRef.current = true; + } + + event.preventDefault(); + element.scrollLeft = + startScrollLeftRef.current - (event.clientX - startXRef.current); + }; + + const handleMouseUp = () => setIsDragging(false); + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [isDragging, scrollRef]); + + return { handleMouseDown, handleClickCapture, handleDragStart }; +} diff --git a/src/hooks/use-filtered-events.ts b/src/hooks/use-filtered-events.ts new file mode 100644 index 0000000000000000000000000000000000000000..3d4de33d131a2080ad72b2e7511b2591c6b2b8af --- /dev/null +++ b/src/hooks/use-filtered-events.ts @@ -0,0 +1,56 @@ +import React from "react"; +import { useEventStore } from "#/stores/use-event-store"; +import { + shouldRenderEvent as shouldRenderAgentServerEvent, + hasUserEvent as hasAgentServerUserEvent, +} from "#/components/conversation-events/chat/event-content-helpers/should-render-event"; +import { + isSystemPromptEvent, + isConversationStateUpdateEvent, +} from "#/types/agent-server/type-guards"; + +/** + * Hook that provides memoized filtered event arrays for ChatInterface. + */ +export function useFilteredEvents() { + const storeEvents = useEventStore((state) => state.events); + const uiEvents = useEventStore((state) => state.uiEvents); + + const renderableEvents = React.useMemo( + () => uiEvents.filter(shouldRenderAgentServerEvent), + [uiEvents], + ); + + const allConversationEvents = React.useMemo(() => storeEvents, [storeEvents]); + + const totalEvents = React.useMemo( + () => renderableEvents.length, + [renderableEvents], + ); + + const hasSubstantiveAgentActions = React.useMemo( + () => + allConversationEvents.some( + (event) => + event.source === "agent" && + !isSystemPromptEvent(event) && + !isConversationStateUpdateEvent(event), + ), + [allConversationEvents], + ); + + const conversationUserEventsExist = hasAgentServerUserEvent( + allConversationEvents, + ); + + return { + storeEvents, + uiEvents, + renderableEvents, + allConversationEvents, + totalEvents, + hasSubstantiveAgentActions, + conversationUserEventsExist, + userEventsExist: conversationUserEventsExist, + }; +} diff --git a/src/hooks/use-handle-build-plan-click.ts b/src/hooks/use-handle-build-plan-click.ts new file mode 100644 index 0000000000000000000000000000000000000000..f086513020ac637d62dd07b38c900d7b7984c70f --- /dev/null +++ b/src/hooks/use-handle-build-plan-click.ts @@ -0,0 +1,64 @@ +import { useCallback } from "react"; +import { useConversationStore } from "#/stores/conversation-store"; +import { useSendMessage } from "#/hooks/use-send-message"; +import { createChatMessage } from "#/services/chat-service"; +import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; +import { useOptionalConversationId } from "#/hooks/use-conversation-id"; + +/** + * Custom hook that encapsulates the logic for handling the Build button click. + * Switches to code mode and sends a prompt to execute the plan. + * + * @returns An object containing handleBuildClick function + */ +export const useHandleBuildPlanClick = () => { + const { setConversationMode } = useConversationStore(); + const { send } = useSendMessage(); + const { conversationId } = useOptionalConversationId(); + const enqueuePendingMessage = useOptimisticUserMessageStore( + (state) => state.enqueuePendingMessage, + ); + const markPendingMessageError = useOptimisticUserMessageStore( + (state) => state.markPendingMessageError, + ); + + const handleBuildPlanClick = useCallback( + (event?: React.MouseEvent | KeyboardEvent) => { + event?.preventDefault(); + event?.stopPropagation(); + + // Switch to code mode + setConversationMode("code"); + + // Create the build prompt to execute the plan + const buildPrompt = `Execute the plan based on the .agents_tmp/PLAN.md file.`; + + // Show the prompt as a pending message and send it to the code agent. + // Skip the pending bubble if we somehow don't have a conversation id — + // the message still gets sent, just without the optimistic queue entry. + const timestamp = new Date().toISOString(); + const pendingId = conversationId + ? enqueuePendingMessage({ + conversationId, + text: buildPrompt, + timestamp, + }) + : null; + send(createChatMessage(buildPrompt, [], [], timestamp)).catch((error) => { + if (!pendingId) return; + const errorMessage = + error instanceof Error ? error.message : "Failed to send message"; + markPendingMessageError(pendingId, errorMessage); + }); + }, + [ + setConversationMode, + send, + conversationId, + enqueuePendingMessage, + markPendingMessageError, + ], + ); + + return { handleBuildPlanClick }; +}; diff --git a/src/hooks/use-has-attached-source.ts b/src/hooks/use-has-attached-source.ts new file mode 100644 index 0000000000000000000000000000000000000000..0c7b002a085f8fdc388867260cc3c8a11890d5b0 --- /dev/null +++ b/src/hooks/use-has-attached-source.ts @@ -0,0 +1,32 @@ +import { getStoredConversationMetadata } from "#/api/conversation-metadata-store"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; + +/** + * Returns whether the user explicitly attached a "source" to the active + * conversation — i.e. picked a repository on the home page *or* picked a + * local workspace folder. From the Files tab's point of view those two + * cases are equivalent: both mean there's an existing working tree the + * user came in to inspect, so the diff view is the more useful default. + * + * We deliberately do *not* probe the filesystem here — the agent-server + * can initialise conversations without an explicit selection in generated git + * worktrees, so a positive `git status` does not by itself imply the user + * attached a real source. That's why we read the explicit selection signals + * (`selected_repository` on the conversation, `selected_workspace` from the + * conversation-metadata store) instead. + */ +export function useHasAttachedSource(): { + hasAttachedSource: boolean; + isLoading: boolean; +} { + const { data: conversation, isLoading } = useActiveConversation(); + const storedMetadata = conversation?.id + ? getStoredConversationMetadata(conversation.id) + : null; + return { + hasAttachedSource: + !!conversation?.selected_repository || + !!storedMetadata?.selected_workspace, + isLoading, + }; +} diff --git a/src/hooks/use-home-automation-actions.ts b/src/hooks/use-home-automation-actions.ts new file mode 100644 index 0000000000000000000000000000000000000000..f83ba1ba318cf5f09386b99d0d9c7226b28a0e3b --- /dev/null +++ b/src/hooks/use-home-automation-actions.ts @@ -0,0 +1,151 @@ +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigation } from "#/context/navigation-context"; +import { + useCancelAutomationRun, + useDispatchAutomation, + useToggleAutomation, +} from "#/hooks/query/use-automations"; +import { + useAutomationPermissions, + useIsAutomationOwner, +} from "#/hooks/use-automation-permissions"; +import { isHomeAutomationsDemoEnabled } from "#/fixtures/home-automations-demo"; +import { I18nKey } from "#/i18n/declaration"; +import { + AutomationRunStatus, + type Automation, + type AutomationRun, +} from "#/types/automation"; +import { getApiErrorMessage } from "#/utils/api-error-message"; +import { + displayErrorToast, + displaySuccessToast, +} from "#/utils/custom-toast-handlers"; + +export function isInFlightAutomationRun( + run: AutomationRun | null | undefined, +): boolean { + return ( + run?.status === AutomationRunStatus.PENDING || + run?.status === AutomationRunStatus.RUNNING + ); +} + +/** + * Shared home-surface actions for pinned cards and activity rows: + * run now, view, edit, turn off (with confirm), cancel in-flight. + */ +export function useHomeAutomationActions( + automation: Automation, + latestRun: AutomationRun | null, +) { + const { t } = useTranslation("openhands"); + const { navigate } = useNavigation(); + const { canManage: hasManagePermission } = useAutomationPermissions(); + const isOwner = useIsAutomationOwner(automation); + // Write actions on a specific automation are allowed when the user has + // manage permission OR is the automation's creator (creator escape hatch). + const canManage = hasManagePermission || isOwner; + const isDemo = isHomeAutomationsDemoEnabled(); + + const dispatchMutation = useDispatchAutomation(); + const toggleMutation = useToggleAutomation(); + const cancelMutation = useCancelAutomationRun(); + + const [editOpen, setEditOpen] = useState(false); + const [turnOffConfirmOpen, setTurnOffConfirmOpen] = useState(false); + + const isRunPending = + dispatchMutation.isPending && dispatchMutation.variables === automation.id; + const isCancelPending = + cancelMutation.isPending && + cancelMutation.variables?.runId === latestRun?.id; + const canCancel = canManage && isInFlightAutomationRun(latestRun); + + const runNow = useCallback(() => { + if (isDemo) { + displaySuccessToast(t(I18nKey.AUTOMATIONS$RUN_NOW_SUCCESS)); + return; + } + dispatchMutation.mutate(automation.id, { + onSuccess: () => { + displaySuccessToast(t(I18nKey.AUTOMATIONS$RUN_NOW_SUCCESS)); + }, + onError: (error) => { + displayErrorToast( + getApiErrorMessage(error, t(I18nKey.AUTOMATIONS$RUN_NOW_ERROR)), + ); + }, + }); + }, [automation.id, dispatchMutation, isDemo, t]); + + const viewDetails = useCallback(() => { + navigate?.(`/automations/${automation.id}`); + }, [automation.id, navigate]); + + const openEdit = useCallback(() => { + setEditOpen(true); + }, []); + + const requestTurnOff = useCallback(() => { + setTurnOffConfirmOpen(true); + }, []); + + const confirmTurnOff = useCallback(() => { + setTurnOffConfirmOpen(false); + if (isDemo) return; + toggleMutation.mutate( + { id: automation.id, enabled: false }, + { + onError: (error) => { + displayErrorToast( + getApiErrorMessage(error, t(I18nKey.AUTOMATIONS$TURN_OFF_ERROR)), + ); + }, + }, + ); + }, [automation.id, isDemo, t, toggleMutation]); + + const cancelTurnOff = useCallback(() => { + setTurnOffConfirmOpen(false); + }, []); + + const cancelRun = useCallback(() => { + if (!latestRun || !isInFlightAutomationRun(latestRun)) return; + if (isDemo) { + displaySuccessToast(t(I18nKey.AUTOMATIONS$CANCEL_RUN_SUCCESS)); + return; + } + cancelMutation.mutate( + { automationId: automation.id, runId: latestRun.id }, + { + onSuccess: () => { + displaySuccessToast(t(I18nKey.AUTOMATIONS$CANCEL_RUN_SUCCESS)); + }, + onError: (error) => { + displayErrorToast( + getApiErrorMessage(error, t(I18nKey.AUTOMATIONS$CANCEL_RUN_ERROR)), + ); + }, + }, + ); + }, [automation.id, cancelMutation, isDemo, latestRun, t]); + + return { + canManage, + isRunPending, + isCancelPending, + canCancel, + editOpen, + setEditOpen, + turnOffConfirmOpen, + runNow, + viewDetails, + openEdit, + requestTurnOff, + confirmTurnOff, + cancelTurnOff, + cancelRun, + }; +} diff --git a/src/hooks/use-home-pinned-automations.ts b/src/hooks/use-home-pinned-automations.ts new file mode 100644 index 0000000000000000000000000000000000000000..8471b7692bf8b771da7613872985df590987f34e --- /dev/null +++ b/src/hooks/use-home-pinned-automations.ts @@ -0,0 +1,209 @@ +import { useLocalStorage } from "@uidotdev/usehooks"; +import { useCallback, useMemo, useState } from "react"; +import { useActiveBackend } from "#/contexts/active-backend-context"; +import { + HOME_AUTOMATIONS_DEMO_PINNED_IDS, + isHomeAutomationsDemoEnabled, +} from "#/fixtures/home-automations-demo"; + +export const HOME_PINNED_AUTOMATIONS_KEY = "oh:home-pinned-automations"; + +/** + * Pins are stored per backend + org: automation ids only resolve against the + * backend that issued them, and `pruneMissing` compares against the active + * backend's list — a shared key would let one backend wipe another's pins. + */ +export function getHomePinnedAutomationsKey( + backendId: string, + orgId: string | null, +): string { + return `${HOME_PINNED_AUTOMATIONS_KEY}:${backendId}:${orgId ?? "-"}`; +} + +/** Soft preview cap for the home pinned dashboard before "View more". */ +export const HOME_PINNED_PREVIEW_LIMIT = 6; + +function sanitizePinnedIds(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const next: string[] = []; + for (const entry of value) { + if (typeof entry !== "string" || !entry || seen.has(entry)) { + continue; + } + seen.add(entry); + next.push(entry); + } + return next; +} + +/** Reorder `base` to match `preferred` where possible; append leftovers. */ +export function applyPinnedOrder( + base: readonly string[], + preferred: readonly string[], +): string[] { + const remaining = new Set(base); + const ordered: string[] = []; + for (const id of preferred) { + if (remaining.has(id)) { + ordered.push(id); + remaining.delete(id); + } + } + for (const id of base) { + if (remaining.has(id)) ordered.push(id); + } + return ordered; +} + +export function movePinnedId( + ids: readonly string[], + activeId: string, + targetId: string, + position: "before" | "after" = "after", +): string[] { + if (activeId === targetId) return [...ids]; + const fromIndex = ids.indexOf(activeId); + const toIndex = ids.indexOf(targetId); + if (fromIndex < 0 || toIndex < 0) return [...ids]; + + const next = [...ids]; + next.splice(fromIndex, 1); + const adjustedTarget = next.indexOf(targetId); + const insertIndex = + position === "before" ? adjustedTarget : adjustedTarget + 1; + next.splice(insertIndex, 0, activeId); + return next; +} + +/** + * Pin state for home automation activity rows. Persists pinned ids in + * localStorage so dashboard modules survive reload. Resolution against live + * automations happens in the consuming components. + */ +export function useHomePinnedAutomations() { + const demo = isHomeAutomationsDemoEnabled(); + const active = useActiveBackend(); + const [rawPinnedIds, setRawPinnedIds] = useLocalStorage( + getHomePinnedAutomationsKey(active.backend.id, active.orgId), + [], + ); + // Demo pins are fixture-backed; keep session order overrides in memory so + // drag reorder still works while testing with mock data. + const [demoOrder, setDemoOrder] = useState(null); + + const pinnedIds = useMemo(() => { + if (demo) { + return demoOrder + ? applyPinnedOrder(HOME_AUTOMATIONS_DEMO_PINNED_IDS, demoOrder) + : [...HOME_AUTOMATIONS_DEMO_PINNED_IDS]; + } + return sanitizePinnedIds(rawPinnedIds); + }, [demo, demoOrder, rawPinnedIds]); + + const isPinned = useCallback( + (id: string) => pinnedIds.includes(id), + [pinnedIds], + ); + + const pin = useCallback( + (id: string) => { + if (demo) { + setDemoOrder((current) => { + const base = current + ? applyPinnedOrder(HOME_AUTOMATIONS_DEMO_PINNED_IDS, current) + : [...HOME_AUTOMATIONS_DEMO_PINNED_IDS]; + if (base.includes(id)) return base; + return [...base, id]; + }); + return; + } + setRawPinnedIds((current) => { + const next = sanitizePinnedIds(current); + if (next.includes(id)) return next; + return [...next, id]; + }); + }, + [demo, setRawPinnedIds], + ); + + const unpin = useCallback( + (id: string) => { + if (demo) { + setDemoOrder((current) => { + const base = current + ? applyPinnedOrder(HOME_AUTOMATIONS_DEMO_PINNED_IDS, current) + : [...HOME_AUTOMATIONS_DEMO_PINNED_IDS]; + return base.filter((pinnedId) => pinnedId !== id); + }); + return; + } + setRawPinnedIds((current) => + sanitizePinnedIds(current).filter((pinnedId) => pinnedId !== id), + ); + }, + [demo, setRawPinnedIds], + ); + + const togglePin = useCallback( + (id: string) => { + if (isPinned(id)) { + unpin(id); + return; + } + pin(id); + }, + [isPinned, pin, unpin], + ); + + const reorder = useCallback( + ( + activeId: string, + targetId: string, + position: "before" | "after" = "after", + ) => { + if (demo) { + setDemoOrder((current) => { + const base = current + ? applyPinnedOrder(HOME_AUTOMATIONS_DEMO_PINNED_IDS, current) + : [...HOME_AUTOMATIONS_DEMO_PINNED_IDS]; + return movePinnedId(base, activeId, targetId, position); + }); + return; + } + setRawPinnedIds((current) => + movePinnedId(sanitizePinnedIds(current), activeId, targetId, position), + ); + }, + [demo, setRawPinnedIds], + ); + + /** Drop pin ids that no longer exist on the backend (deleted automations). */ + const pruneMissing = useCallback( + (knownIds: ReadonlySet) => { + if (demo) return; + setRawPinnedIds((current) => { + const sanitized = sanitizePinnedIds(current); + const next = sanitized.filter((id) => knownIds.has(id)); + if ( + next.length === sanitized.length && + next.every((id, index) => id === sanitized[index]) + ) { + return sanitized; + } + return next; + }); + }, + [demo, setRawPinnedIds], + ); + + return { + pinnedIds, + isPinned, + pin, + unpin, + togglePin, + reorder, + pruneMissing, + }; +} diff --git a/src/hooks/use-infinite-scroll.ts b/src/hooks/use-infinite-scroll.ts new file mode 100644 index 0000000000000000000000000000000000000000..fdc537aa4814a0b3efc328fa594da9aa077a07e3 --- /dev/null +++ b/src/hooks/use-infinite-scroll.ts @@ -0,0 +1,42 @@ +import { useEffect, useRef, useCallback } from "react"; + +interface UseInfiniteScrollOptions { + hasNextPage: boolean; + isFetchingNextPage: boolean; + fetchNextPage: () => void; + threshold?: number; +} + +export const useInfiniteScroll = ({ + hasNextPage, + isFetchingNextPage, + fetchNextPage, + threshold = 100, +}: UseInfiniteScrollOptions) => { + const containerRef = useRef(null); + + const handleScroll = useCallback(() => { + if (!containerRef.current || isFetchingNextPage || !hasNextPage) { + return; + } + + const { scrollTop, scrollHeight, clientHeight } = containerRef.current; + const isNearBottom = scrollTop + clientHeight >= scrollHeight - threshold; + + if (isNearBottom) { + fetchNextPage(); + } + }, [hasNextPage, isFetchingNextPage, fetchNextPage, threshold]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return undefined; + + container.addEventListener("scroll", handleScroll); + return () => { + container.removeEventListener("scroll", handleScroll); + }; + }, [handleScroll]); + + return containerRef; +}; diff --git a/src/hooks/use-is-archived-conversation.ts b/src/hooks/use-is-archived-conversation.ts new file mode 100644 index 0000000000000000000000000000000000000000..711bae427b2bf60bd0b13a940165561c199c9cbb --- /dev/null +++ b/src/hooks/use-is-archived-conversation.ts @@ -0,0 +1,7 @@ +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { isArchivedSandboxStatus } from "#/utils/conversation-archive-status"; + +export function useIsArchivedConversation() { + const { data: conversation } = useActiveConversation(); + return isArchivedSandboxStatus(conversation?.sandbox_status); +} diff --git a/src/hooks/use-is-creating-conversation.ts b/src/hooks/use-is-creating-conversation.ts new file mode 100644 index 0000000000000000000000000000000000000000..e491169cb97780c699ef064a6f298876266897ca --- /dev/null +++ b/src/hooks/use-is-creating-conversation.ts @@ -0,0 +1,14 @@ +import { useIsMutating } from "@tanstack/react-query"; +import { useNavigation } from "#/context/navigation-context"; + +export const useIsCreatingConversation = () => { + const navigation = useNavigation(); + const numberOfPendingMutations = useIsMutating({ + mutationKey: ["create-conversation"], + }); + + const { isNavigating } = navigation; + const hasPendingMutations = numberOfPendingMutations > 0; + + return hasPendingMutations || isNavigating; +}; diff --git a/src/hooks/use-launch-skill-in-chat.ts b/src/hooks/use-launch-skill-in-chat.ts new file mode 100644 index 0000000000000000000000000000000000000000..73deb2b579c0133c46dc0710dc611da6c7577329 --- /dev/null +++ b/src/hooks/use-launch-skill-in-chat.ts @@ -0,0 +1,21 @@ +import { useCallback } from "react"; +import { useNavigation } from "#/context/navigation-context"; +import { useConversationStore } from "#/stores/conversation-store"; + +export function useLaunchSkillInChat() { + const { navigate } = useNavigation(); + const setMessageToSend = useConversationStore( + (state) => state.setMessageToSend, + ); + + return useCallback( + (message: string, onClose?: () => void) => { + onClose?.(); + navigate("/conversations"); + window.setTimeout(() => { + setMessageToSend(message); + }, 0); + }, + [navigate, setMessageToSend], + ); +} diff --git a/src/hooks/use-live-conversation-metrics.ts b/src/hooks/use-live-conversation-metrics.ts new file mode 100644 index 0000000000000000000000000000000000000000..399c46f4108a7231f4a9c65bd80ad21419bc4c6d --- /dev/null +++ b/src/hooks/use-live-conversation-metrics.ts @@ -0,0 +1,85 @@ +import { useMemo } from "react"; +import useMetricsStore, { type MetricsState } from "#/stores/metrics-store"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { useConversationMetrics } from "#/hooks/query/use-conversation-metrics"; + +/** + * Combined cost/token metrics for the active conversation. + * + * Prefers the live WebSocket metrics store, which updates in real time as + * agent events stream in, and falls back to the REST snapshot + * (`GET /api/conversations/{id}` stats, combined across all usage ids and + * refreshed on a 30s interval by {@link useConversationMetrics}) before the + * first stats arrive. The store is reset on conversation switch (see + * `ConversationWebSocketProvider`), so its contents always belong to the + * active conversation — preferring it cannot leak a previous + * conversation's figures, and preferring REST would lag the display up to + * a poll interval behind what the user just watched happen. + * + * Pass `enabled: false` to pause the REST polling while the consumer is + * hidden (e.g. a closed modal). + */ +export function useLiveConversationMetrics( + enabled: boolean = true, +): MetricsState { + // Per-field selectors rather than a bare `useMetricsStore()`: subscribing to + // the whole store re-runs this hook on any store write, including the action + // identity, and matches how `useContextWindowUsage` reads the same store. + const storeCost = useMetricsStore((state) => state.cost); + const storeMaxBudgetPerTask = useMetricsStore( + (state) => state.max_budget_per_task, + ); + const storeUsage = useMetricsStore((state) => state.usage); + const { data: conversation } = useActiveConversation(); + + const { data: conversationMetrics } = useConversationMetrics( + conversation?.id, + conversation?.conversation_url, + conversation?.session_api_key, + enabled, + ); + + return useMemo(() => { + // Live store first: real-time and (post-reset) always the active + // conversation's data. Any non-null field counts as data. + if (storeCost !== null || storeUsage !== null) { + return { + cost: storeCost, + max_budget_per_task: storeMaxBudgetPerTask, + usage: storeUsage, + }; + } + + if (conversationMetrics) { + return { + cost: conversationMetrics.accumulated_cost, + max_budget_per_task: conversationMetrics.max_budget_per_task, + usage: conversationMetrics.accumulated_token_usage + ? { + prompt_tokens: + conversationMetrics.accumulated_token_usage.prompt_tokens ?? 0, + completion_tokens: + conversationMetrics.accumulated_token_usage.completion_tokens ?? + 0, + cache_read_tokens: + conversationMetrics.accumulated_token_usage.cache_read_tokens ?? + 0, + cache_write_tokens: + conversationMetrics.accumulated_token_usage + .cache_write_tokens ?? 0, + context_window: + conversationMetrics.accumulated_token_usage.context_window ?? 0, + per_turn_token: + conversationMetrics.accumulated_token_usage.per_turn_token ?? 0, + } + : null, + }; + } + + return { + cost: null, + max_budget_per_task: null, + usage: null, + }; + }, [conversationMetrics, storeCost, storeMaxBudgetPerTask, storeUsage]); +} diff --git a/src/hooks/use-llm-configured.ts b/src/hooks/use-llm-configured.ts new file mode 100644 index 0000000000000000000000000000000000000000..8ce01457774eb086021af6b69e42c2455e5b7d03 --- /dev/null +++ b/src/hooks/use-llm-configured.ts @@ -0,0 +1,173 @@ +import { useQuery } from "@tanstack/react-query"; +import { useSettings } from "#/hooks/query/use-settings"; +import { useConfig } from "#/hooks/query/use-config"; +import { useLlmProfiles } from "#/hooks/query/use-llm-profiles"; +import { useActiveBackend } from "#/contexts/active-backend-context"; +import { useActiveAgentProfile } from "#/hooks/use-active-agent-profile"; +import { isSettingsPageHidden } from "#/utils/settings-utils"; +import ProfilesService from "#/api/profiles-service/profiles-service.api"; +import { WELL_KNOWN_DEFAULT_AGENT_PROFILE_NAME } from "#/api/agent-profiles-service/agent-profiles-service.api"; +import { + CONFIG_CACHE_OPTIONS, + LLM_PROFILES_QUERY_KEYS, +} from "#/hooks/query/query-keys"; +import { isSubscriptionLlmConfig } from "#/constants/llm-subscription"; + +interface LlmConfiguredResult { + /** + * True when the active backend's agent has a usable LLM: + * - ACP agents own their LLM via a subprocess, so they never need a key. + * - OpenHands agents are ready only once an LLM API key has been saved. + * - When the LLM settings page is hidden by a feature flag there is no + * place to finish setup, so we treat the LLM as configured to avoid + * surfacing an actionless warning. + */ + isConfigured: boolean; + /** + * True while the configured/unconfigured state is indeterminate — either + * settings/config are still resolving, or a fetch failed and left us with no + * data to decide from. Consumers should render nothing in this state so a + * warning doesn't flash before data loads or on a transient network error. + */ + isLoading: boolean; +} + +/** + * Reports whether the active backend's agent has an LLM ready to run + * conversations. Surfaces the gap left by the onboarding "Skip for now" path, + * which persists no settings — leaving an OpenHands agent without an API key. + */ +export function useLlmConfigured(): LlmConfiguredResult { + const { + data: settings, + isLoading: settingsLoading, + isError: settingsError, + } = useSettings(); + const { + data: config, + isLoading: configLoading, + isError: configError, + } = useConfig(); + const { + data: profilesData, + isLoading: profilesLoading, + isError: profilesError, + } = useLlmProfiles(); + const { backend, orgId } = useActiveBackend(); + const isLocal = backend.kind === "local"; + + // The active AgentProfile is the current agent — an ACP profile owns its LLM + // via the subprocess and never needs an API key. Fall back to the global + // agent settings only while the profile list loads. + const { + activeProfile: activeAgentProfile, + isLoading: activeAgentProfileLoading, + } = useActiveAgentProfile(); + const activeAgentKind = activeAgentProfile?.agent_kind; + const isAcpAgent = + (activeAgentKind ?? settings?.agent_settings?.agent_kind) === "acp"; + const hasApiKey = settings?.llm_api_key_set === true; + // The LLM that will actually power the next conversation is the profile the + // active AGENT profile references (`llm_profile_ref`) — conversations launch + // from the agent profile, not the standalone "active LLM profile". + // Except the local seeded `default` profile: `useCreateConversation` launches + // it via `agent_settings` on the ACTIVE LLM, not the seed's ref, so gating on + // that ref blocks launches that would have succeeded (#16193). + const referencedLlmProfileName = + activeAgentProfile?.agent_kind === "openhands" && + !( + isLocal && + activeAgentProfile.name === WELL_KNOWN_DEFAULT_AGENT_PROFILE_NAME + ) + ? activeAgentProfile.llm_profile_ref + : undefined; + const referencedProfile = referencedLlmProfileName + ? profilesData?.profiles.find( + (profile) => profile.name === referencedLlmProfileName, + ) + : undefined; + // Fall back to the active LLM profile when the ref is absent (list loading, + // or an agent profile without a ref) OR stale (names a profile that no longer + // exists). This mirrors the launch-time fallback in `useCreateConversation`, + // which drops a stale-ref profile launch to an `agent_settings` launch on the + // active LLM. Without this fallback the two contradict each other: launch + // succeeds via the fallback, but this hook reports `isConfigured: false` and + // spuriously disables the composer + shows the banner — even inside a running + // conversation (VascoSch92 review, #1571). + const activeProfile = + referencedProfile ?? + profilesData?.profiles.find( + (profile) => profile.name === profilesData?.active_profile, + ); + const hasActiveProfileApiKey = activeProfile?.api_key_set === true; + const shouldLoadActiveProfileDetail = + isLocal && !!activeProfile && !hasActiveProfileApiKey; + const { + data: activeProfileDetail, + isLoading: activeProfileDetailLoading, + isError: activeProfileDetailError, + } = useQuery({ + queryKey: [ + ...LLM_PROFILES_QUERY_KEYS.all, + backend.id, + orgId, + "detail", + activeProfile?.name, + ], + queryFn: () => ProfilesService.getProfile(activeProfile!.name), + ...CONFIG_CACHE_OPTIONS, + enabled: shouldLoadActiveProfileDetail, + meta: { disableToast: true }, + }); + const hasActiveProfileSubscription = + shouldLoadActiveProfileDetail && + isSubscriptionLlmConfig( + activeProfileDetail?.config as Record | undefined, + ); + const llmSettingsHidden = isSettingsPageHidden( + "/settings/llm", + config?.feature_flags, + ); + + // In local mode, profiles are the source of truth: a usable LLM must be + // backed by an active profile that still exists and is authenticated. API-key + // profiles use the list endpoint's api_key_set flag; subscription profiles + // intentionally have no key, so we inspect the active profile detail config. + // The raw settings key can be a stale copy left behind by a deleted profile + // (settings are not cleared on delete), so we don't count it here. Cloud + // backends don't use profiles and keep the settings-key signal. + const hasUsableActiveProfile = + hasActiveProfileApiKey || hasActiveProfileSubscription; + const hasUsableLlm = isLocal ? hasUsableActiveProfile : hasApiKey; + + // Treat a fetch failure as indeterminate (same as loading) only when it + // leaves us with no data to decide from — otherwise a transient network + // error would surface the banner with the same urgency as a genuinely + // missing API key. A settings 404 is deliberately not covered here: + // `useSettings` maps it to DEFAULT_SETTINGS (no key, OpenHands agent) while + // keeping `isError` set, and that is exactly the new-user / "Skip for now" + // state the banner exists to catch — so we keep deciding from that data. + const settingsIndeterminate = settingsLoading || (settingsError && !settings); + const configIndeterminate = configLoading || (configError && !config); + // The active agent profile decides `isAcpAgent` and which LLM profile the + // next conversation runs; on a cold cache it's still loading, so decide + // nothing yet — otherwise an ACP agent (which needs no key) briefly reads as + // an unconfigured OpenHands agent and flashes the "LLM not set up" banner. + const agentProfileIndeterminate = activeAgentProfileLoading; + const profilesIndeterminate = + profilesLoading || (profilesError && !profilesData); + const activeProfileDetailIndeterminate = + shouldLoadActiveProfileDetail && + (activeProfileDetailLoading || + (activeProfileDetailError && !activeProfileDetail)); + + return { + isConfigured: isAcpAgent || llmSettingsHidden || hasUsableLlm, + isLoading: + settingsIndeterminate || + configIndeterminate || + profilesIndeterminate || + agentProfileIndeterminate || + activeProfileDetailIndeterminate, + }; +} diff --git a/src/hooks/use-load-older-events.ts b/src/hooks/use-load-older-events.ts new file mode 100644 index 0000000000000000000000000000000000000000..1578d1efac2bbe28215f9b602506b40eaa32434d --- /dev/null +++ b/src/hooks/use-load-older-events.ts @@ -0,0 +1,175 @@ +import React from "react"; +import EventService from "#/api/event-service/event-service.api"; +import { useUserConversation } from "#/hooks/query/use-user-conversation"; +import { useEventStore } from "#/stores/use-event-store"; +import { + INITIAL_HISTORY_PAGE_SIZE, + useConversationHistory, +} from "#/hooks/query/use-conversation-history"; +import { isTaskConversationId } from "#/utils/conversation-local-storage"; +import { seedModelSwitchesFromHistory } from "#/hooks/chat/record-model-switch-message"; +import type { OpenHandsEvent } from "#/types/agent-server/core"; + +const getEventTimestamp = (event: OpenHandsEvent): string | undefined => + "timestamp" in event ? event.timestamp : undefined; + +interface UseLoadOlderEventsResult { + /** True while a "load older" request is in flight. */ + isLoading: boolean; + /** + * Whether the server may have more events older than what we currently + * have in the store. Starts `true` and flips to `false` after the server + * returns a short page (i.e. it ran out of older events). + */ + hasMore: boolean; + /** Trigger one more older-events page. Resolves when the page is merged. */ + loadOlder: () => Promise; +} + +/** + * REST-side companion to `useConversationHistory`: paginates older events + * (`timestamp < oldest known`) into the event store on demand. Used by the + * chat scroll handler to lazily backfill history when the user scrolls up. + * + * Server dependency: cloud pagination requires the timestamp comparison + * fix from OpenHands/OpenHands#14399. The `EventService.searchEvents` + * cloud path includes a fallback that returns an empty page to stop + * pagination if the full request fails, so older-event pages will + * gracefully degrade to a no-op on unpatched backends rather than + * surfacing errors. + */ +export const useLoadOlderEvents = ( + conversationId?: string | null, +): UseLoadOlderEventsResult => { + const isTaskConversation = + !!conversationId && isTaskConversationId(conversationId); + const realConversationId = isTaskConversation ? undefined : conversationId; + + const { data: conversation } = useUserConversation(conversationId ?? null); + const { data: initialHistory, isFetched: isInitialHistoryFetched } = + useConversationHistory(realConversationId ?? undefined); + const addEvents = useEventStore((state) => state.addEvents); + + const [isLoading, setIsLoading] = React.useState(false); + const [hasMore, setHasMore] = React.useState(true); + const isLoadingRef = React.useRef(false); + const hasMoreRef = React.useRef(true); + + React.useEffect(() => { + isLoadingRef.current = false; + setIsLoading(false); + + if (isTaskConversation) { + hasMoreRef.current = false; + setHasMore(false); + return; + } + + hasMoreRef.current = true; + setHasMore(true); + }, [conversationId, isTaskConversation]); + + // Mirror the initial REST page: if the tail fetch already returned + // everything, don't auto-trigger an older-events request on short chats. + React.useEffect(() => { + if (isTaskConversation || !isInitialHistoryFetched || !initialHistory) { + return; + } + if (!initialHistory.hasMore) { + hasMoreRef.current = false; + setHasMore(false); + } + }, [ + isTaskConversation, + isInitialHistoryFetched, + initialHistory?.hasMore, + realConversationId, + ]); + + const loadOlder = React.useCallback(async () => { + if ( + !conversationId || + isTaskConversationId(conversationId) || + isLoadingRef.current || + !hasMoreRef.current + ) { + return; + } + + // Cloud/local metadata (runtime URL, session key) isn't available on + // start-task placeholder routes and may still be loading right after + // redirect from `/conversations/task-{uuid}`. + if (!conversation) { + return; + } + + const { events } = useEventStore.getState(); + const oldest = events[0]; + + // No anchor yet — defer until the initial REST load has populated the + // store (avoids fetching twice with the same `TIMESTAMP_DESC` window). + if (!oldest) return; + + const oldestTimestamp = getEventTimestamp(oldest); + if (!oldestTimestamp) { + // Nothing paginate-able — treat as exhausted rather than surfacing an + // error banner on brand-new conversations. + hasMoreRef.current = false; + setHasMore(false); + return; + } + + isLoadingRef.current = true; + setIsLoading(true); + try { + const page = await EventService.searchEvents( + conversationId, + conversation?.conversation_url ?? null, + conversation?.session_api_key ?? null, + { + limit: INITIAL_HISTORY_PAGE_SIZE, + sortOrder: "TIMESTAMP_DESC", + timestampLt: oldestTimestamp, + }, + ); + + if (!Array.isArray(page.items)) { + throw new Error( + "Invalid older-events response: expected page.items to be an array.", + ); + } + + const older = [...page.items].reverse(); + if (older.length > 0) { + addEvents(older); + // The initial preload only seeds switches from the tail page; a switch + // in an older page is hidden as a card but never seeded — silently lost. + // Reseed over the merged `uiEvents` (idempotent) so it still surfaces. + seedModelSwitchesFromHistory( + conversationId, + useEventStore.getState().uiEvents, + ); + } + // Stop once the server signals there are no more pages, OR — for + // servers that don't fill in `next_page_id` for filtered queries — + // when we get back a short page. + const exhausted = + !page.next_page_id || page.items.length < INITIAL_HISTORY_PAGE_SIZE; + if (exhausted) { + hasMoreRef.current = false; + setHasMore(false); + } + } finally { + isLoadingRef.current = false; + setIsLoading(false); + } + }, [ + conversationId, + conversation, + conversation?.conversation_url, + conversation?.session_api_key, + addEvents, + ]); + + return { isLoading, hasMore, loadOlder }; +}; diff --git a/src/hooks/use-manifest-preflight.ts b/src/hooks/use-manifest-preflight.ts new file mode 100644 index 0000000000000000000000000000000000000000..8971998f7f2c539c0decbd4300d4d444d7b09a73 --- /dev/null +++ b/src/hooks/use-manifest-preflight.ts @@ -0,0 +1,100 @@ +import { useCallback, useRef } from "react"; +import axios from "axios"; +import AutomationService from "#/api/automation-service/automation-service.api"; +import { isSdkHttpStatusError } from "#/api/agent-server-compatibility"; +import { + mapServiceErrors, + normalizeServiceErrors, + type MappedManifestErrors, +} from "#/manifests/manifest-error-map"; +import { + buildPreflightBody, + deriveErrorMap, +} from "#/manifests/automation-setup"; +import type { + SetupEntry, + SetupFormValues, + SetupRequestBody, +} from "#/manifests/types"; + +const NO_ERRORS: MappedManifestErrors = { fieldErrors: {}, formErrors: [] }; + +/** What a deployment that does not serve the validate endpoint answers with. */ +const NOT_IMPLEMENTED_STATUSES = [404, 501]; + +/** + * Whether a failed preflight means "this deployment does not implement it". + * Local calls throw an `AxiosError` and cloud calls throw the shared client's + * `HttpError`, so both shapes are read. + */ +function isPreflightUnimplemented(error: unknown): boolean { + return NOT_IMPLEMENTED_STATUSES.some( + (status) => + isSdkHttpStatusError(error, status) || + (axios.isAxiosError(error) && error.response?.status === status), + ); +} + +/** + * Stage 6 — ask the service whether a draft is valid before anything is created. + * + * The service is the authoritative validator, so what it receives is the derived + * payload rather than the raw form values: what is checked is exactly what would + * be sent. Errors come back addressed by payload path and are translated back to + * fields through the map derived from that same builder. + * + * Resolves to null when there is no verdict — the entry has no draft to check, + * the deployment does not implement preflight, a newer run has already + * superseded this one, or the request failed. A missing preflight is not a + * failure: local checks and the create response still stand between the user + * and a bad configuration. Only a deployment without the endpoint is an + * expected failure though, so any other one is reported. + */ +export function useSetupPreflight(entry: SetupEntry) { + const latestRequestRef = useRef(0); + + return useCallback( + async ( + formValues: SetupFormValues, + selectedTrigger?: string | null, + selectedAction?: string | null, + ): Promise => { + try { + // Deriving the body is inside the guard too: a bundle entry resolves + // the create endpoint here, and an interface manifest published before + // bundles declares none. That is the same "cannot be checked" as a + // validator that will not answer, and left outside it rejected a + // promise no caller handles - which reads as a Continue button that + // does nothing at all. + const body = buildPreflightBody( + entry, + formValues, + selectedTrigger, + selectedAction, + ); + if (!body) return null; + + latestRequestRef.current += 1; + const requestId = latestRequestRef.current; + + const result = await AutomationService.validateDraft(body); + if (requestId !== latestRequestRef.current) return null; + if (result?.valid) return NO_ERRORS; + + return mapServiceErrors( + normalizeServiceErrors(result, body.draft as SetupRequestBody), + deriveErrorMap(entry, selectedTrigger, selectedAction), + ); + } catch (error) { + // A broken validator and an unimplemented one degrade to the same + // advisory "no verdict", so the only thing that separates them is this + // line. Without it a service returning 500 on every draft is invisible. + if (!isPreflightUnimplemented(error)) { + console.warn("Automation setup preflight failed:", error); + } + return null; + } + }, + [entry], + ); +} diff --git a/src/hooks/use-mcp-server-health.ts b/src/hooks/use-mcp-server-health.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa117f7f376515b73747c414fde4948d345acc90 --- /dev/null +++ b/src/hooks/use-mcp-server-health.ts @@ -0,0 +1,40 @@ +import React from "react"; +import { + getMcpHealthSnapshot, + subscribeMcpHealth, +} from "#/api/mcp-health/mcp-health-store"; +import { + probeMcpServerHealth, + reauthorizeMcpServerHealth, +} from "#/api/mcp-health/probe-mcp-server-health"; +import { UNCHECKED_MCP_HEALTH, type McpServerHealth } from "#/types/mcp-health"; +import type { MCPServerConfig } from "#/types/mcp-server"; +import { getMcpServerHealthKey } from "#/utils/mcp-server-health-key"; + +/** + * Subscribe to an installed server's connection health and expose the + * probe actions bound to its current config. + */ +export function useMcpServerHealth(server: MCPServerConfig) { + const key = getMcpServerHealthKey(server); + const health: McpServerHealth = React.useSyncExternalStore( + subscribeMcpHealth, + () => getMcpHealthSnapshot()[key] ?? UNCHECKED_MCP_HEALTH, + ); + + // Keep the latest config without re-creating the callbacks every render + // (settings refetches rebuild the server objects each time). + const serverRef = React.useRef(server); + serverRef.current = server; + + const probe = React.useCallback( + () => probeMcpServerHealth(serverRef.current), + [], + ); + const reauthorize = React.useCallback( + () => reauthorizeMcpServerHealth(serverRef.current), + [], + ); + + return { health, probe, reauthorize }; +} diff --git a/src/hooks/use-resizable-panels.ts b/src/hooks/use-resizable-panels.ts new file mode 100644 index 0000000000000000000000000000000000000000..56d0f891c2adbd1cf336dfebdb5346eac1b526ac --- /dev/null +++ b/src/hooks/use-resizable-panels.ts @@ -0,0 +1,100 @@ +import { useState, useRef, useCallback, useLayoutEffect } from "react"; +import { useLocalStorage } from "@uidotdev/usehooks"; + +/** Full-screen layer above iframes so parent `document` keeps receiving drag events. */ +const PANEL_DRAG_SHIELD_Z_INDEX = 200; + +interface UseResizablePanelsOptions { + defaultLeftWidth?: number; + minLeftWidth?: number; + maxLeftWidth?: number; + storageKey?: string; +} + +export function useResizablePanels({ + defaultLeftWidth = 50, + minLeftWidth = 30, + maxLeftWidth = 80, + storageKey = "desktop-layout-panel-width", +}: UseResizablePanelsOptions = {}) { + const [persistedWidth, setPersistedWidth] = useLocalStorage( + storageKey, + defaultLeftWidth, + ); + + const clampWidth = useCallback( + (width: number) => Math.max(minLeftWidth, Math.min(maxLeftWidth, width)), + [minLeftWidth, maxLeftWidth], + ); + + // Clamp the persisted value on read so stale localStorage values from older + // min/max bounds (or other tabs) can't push the divider out of range. + const [leftWidth, setLeftWidth] = useState(() => clampWidth(persistedWidth)); + const [isDragging, setIsDragging] = useState(false); + const containerRef = useRef(null); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setIsDragging(true); + }, []); + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!isDragging || !containerRef.current) return; + + const containerRect = containerRef.current.getBoundingClientRect(); + const mouseX = e.clientX - containerRect.left; + const containerWidth = containerRect.width; + const newLeftWidth = (mouseX / containerWidth) * 100; + + const clampedWidth = clampWidth(newLeftWidth); + setLeftWidth(clampedWidth); + }, + [isDragging, clampWidth], + ); + + const handleMouseUp = useCallback(() => { + if (isDragging) { + setIsDragging(false); + setPersistedWidth(leftWidth); + } + }, [isDragging, leftWidth, setPersistedWidth]); + + useLayoutEffect(() => { + if (!isDragging) return; + + const shield = document.createElement("div"); + shield.setAttribute("aria-hidden", "true"); + shield.dataset.panelDragShield = ""; + Object.assign(shield.style, { + position: "fixed", + inset: "0", + zIndex: String(PANEL_DRAG_SHIELD_Z_INDEX), + cursor: "ew-resize", + }); + document.body.appendChild(shield); + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + document.body.style.cursor = "ew-resize"; + document.body.style.userSelect = "none"; + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + shield.remove(); + }; + }, [isDragging, handleMouseMove, handleMouseUp]); + + const rightWidth = 100 - leftWidth; + + return { + leftWidth, + rightWidth, + isDragging, + containerRef, + handleMouseDown, + }; +} diff --git a/src/hooks/use-responder-url-secret.ts b/src/hooks/use-responder-url-secret.ts new file mode 100644 index 0000000000000000000000000000000000000000..744be3353806cad80e5d8df60dc0e79729a9a974 --- /dev/null +++ b/src/hooks/use-responder-url-secret.ts @@ -0,0 +1,60 @@ +import { useCallback } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { SecretsService } from "#/api/secrets-service"; +import { useActiveBackend } from "#/contexts/active-backend-context"; +import { I18nKey } from "#/i18n/declaration"; +import i18n from "#/i18n"; +import { useCreateSecret } from "#/hooks/mutation/use-create-secret"; +import { displayErrorToast } from "#/utils/custom-toast-handlers"; +import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message"; + +const OPENHANDS_URL_SECRET_NAME = "OPENHANDS_URL"; + +/** + * Provides an operation that ensures the active backend has an OPENHANDS_URL + * secret without overwriting an existing value. + */ +export function useResponderUrlSecret() { + const activeBackend = useActiveBackend(); + const queryClient = useQueryClient(); + const { mutateAsync: createSecret } = useCreateSecret(); + + return useCallback(async (): Promise => { + let secrets; + try { + secrets = await queryClient.fetchQuery({ + queryKey: ["secrets", activeBackend.backend.id, activeBackend.orgId], + queryFn: SecretsService.getSecretsOrThrow, + staleTime: 0, + retry: false, + meta: { disableToast: true }, + }); + } catch (error) { + const message = retrieveAxiosErrorMessage(error); + displayErrorToast(message || i18n.t(I18nKey.ERROR$GENERIC)); + return false; + } + + if (secrets.some((secret) => secret.name === OPENHANDS_URL_SECRET_NAME)) { + return true; + } + + try { + await createSecret({ + name: OPENHANDS_URL_SECRET_NAME, + value: window.location.origin, + }); + } catch { + // The shared mutation cache displays the error toast. + return false; + } + + await queryClient.invalidateQueries({ queryKey: ["secrets"] }); + return true; + }, [ + activeBackend.backend.id, + activeBackend.orgId, + createSecret, + queryClient, + ]); +} diff --git a/src/hooks/use-runtime-is-ready.ts b/src/hooks/use-runtime-is-ready.ts new file mode 100644 index 0000000000000000000000000000000000000000..d6bfbde658b1c2fcf1d283037caf7eb9d5332bcb --- /dev/null +++ b/src/hooks/use-runtime-is-ready.ts @@ -0,0 +1,26 @@ +import { useAgentState } from "#/hooks/use-agent-state"; +import { + RUNTIME_INACTIVE_STATES, + RUNTIME_STARTING_STATES, +} from "#/types/agent-state"; +import { useActiveConversation } from "./query/use-active-conversation"; +import { isExecutionActive } from "#/utils/status"; + +interface UseRuntimeIsReadyOptions { + allowAgentError?: boolean; +} + +export const useRuntimeIsReady = ({ + allowAgentError = false, +}: UseRuntimeIsReadyOptions = {}): boolean => { + const { data: conversation } = useActiveConversation(); + const { curAgentState } = useAgentState(); + const inactiveStates = allowAgentError + ? RUNTIME_STARTING_STATES + : RUNTIME_INACTIVE_STATES; + + return ( + isExecutionActive(conversation?.execution_status) && + !inactiveStates.includes(curAgentState) + ); +}; diff --git a/src/hooks/use-save-acp-secrets.ts b/src/hooks/use-save-acp-secrets.ts new file mode 100644 index 0000000000000000000000000000000000000000..b35d4e5400c7ad84909d0f67523c4784a4aa9488 --- /dev/null +++ b/src/hooks/use-save-acp-secrets.ts @@ -0,0 +1,86 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { useQueryClient } from "@tanstack/react-query"; +import { AxiosError } from "axios"; +import { I18nKey } from "#/i18n/declaration"; +import { useCreateSecret } from "#/hooks/mutation/use-create-secret"; +import { type ACPProviderSecretField } from "#/constants/acp-providers"; +import { + displayErrorToast, + displaySuccessToast, + displayWarningToast, +} from "#/utils/custom-toast-handlers"; +import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message"; + +/** + * Shared save flow for the ACP credential forms (the onboarding step and the + * Settings → Agent section): persists each filled field as a global secret, + * refreshes the secret queries, and toasts the outcome — a warning instead of + * "Saved" when a file-content credential landed on a backend that can't + * materialise it to disk (cloud, pending agent-canvas#1016), so we don't claim + * success for an orphaned credential. + * + * ``saveFilled`` resolves ``true`` when every filled field saved (or nothing + * needed saving) and ``false`` on failure, so callers can gate navigation / + * form resets on it. Empty fields are never written — a blank input is a + * deliberate skip, not a request to clear an existing secret. + * + * ``consumesFileCredentials`` — whether the backend materialises ``multiline`` + * credentials to disk — comes from the caller (``useAcpCredentialForm`` + * computes it), so the save warning and the form's required-gate can't + * disagree on the capability. + */ +export function useSaveAcpSecrets( + fields: ACPProviderSecretField[], + consumesFileCredentials: boolean, +) { + const { t } = useTranslation("openhands"); + const queryClient = useQueryClient(); + const { mutateAsync: createSecret } = useCreateSecret(); + const [isSaving, setIsSaving] = React.useState(false); + + // ``silent`` suppresses the success/warning toast (not errors) so a caller + // that persists credentials alongside something else — the Settings → Agent + // single Save, which also writes the agent spec — can emit one combined + // "Saved" instead of two. The standalone onboarding caller leaves it off. + const saveFilled = async ( + values: Record, + { silent = false }: { silent?: boolean } = {}, + ) => { + const toSave = fields + .map((field) => ({ field, value: values[field.name]?.trim() })) + .filter( + (entry): entry is { field: ACPProviderSecretField; value: string } => + Boolean(entry.value), + ); + if (toSave.length === 0) return true; + + setIsSaving(true); + try { + // Sequential so a mid-list failure leaves the earlier secrets saved and + // surfaces a single, specific error rather than a race of toasts. + for (const { field, value } of toSave) { + await createSecret({ name: field.name, value }); + } + await queryClient.invalidateQueries({ queryKey: ["secrets-search"] }); + await queryClient.invalidateQueries({ queryKey: ["secrets"] }); + + const savedOrphanedFileCredential = + !consumesFileCredentials && toSave.some(({ field }) => field.multiline); + if (savedOrphanedFileCredential) { + displayWarningToast(t(I18nKey.ONBOARDING$ACP_SECRETS_ORPHANED_WARNING)); + } else if (!silent) { + displaySuccessToast(t(I18nKey.SETTINGS$SAVED)); + } + return true; + } catch (error) { + const message = retrieveAxiosErrorMessage(error as AxiosError); + displayErrorToast(message || t(I18nKey.ERROR$GENERIC)); + return false; + } finally { + setIsSaving(false); + } + }; + + return { saveFilled, isSaving }; +} diff --git a/src/hooks/use-scroll-to-bottom.ts b/src/hooks/use-scroll-to-bottom.ts new file mode 100644 index 0000000000000000000000000000000000000000..13c5cf149bd05827464848ad68e407967d9fb239 --- /dev/null +++ b/src/hooks/use-scroll-to-bottom.ts @@ -0,0 +1,73 @@ +import { RefObject, useState, useCallback, useRef } from "react"; + +export function useScrollToBottom(scrollRef: RefObject) { + // Track whether the user is currently near the bottom of the scroll area. + // Used by consumers to decide whether to scroll when new UI elements appear. + // NOT used for automatic content-following. + const [autoscroll, setAutoscroll] = useState(true); + + // Track whether the user is currently at the bottom of the scroll area + const [hitBottom, setHitBottom] = useState(true); + + // Store previous scroll position to detect scroll direction + const prevScrollTopRef = useRef(0); + + // Check if the scroll position is at the bottom + const isAtBottom = useCallback((element: HTMLElement): boolean => { + // Use a fixed 20px buffer + const bottomThreshold = 20; + const bottomPosition = element.scrollTop + element.clientHeight; + return bottomPosition >= element.scrollHeight - bottomThreshold; + }, []); + + // Handle scroll events + const onChatBodyScroll = useCallback( + (e: HTMLElement) => { + const isCurrentlyAtBottom = isAtBottom(e); + setHitBottom(isCurrentlyAtBottom); + + // Get current scroll position + const currentScrollTop = e.scrollTop; + + // Detect scroll direction + const isScrollingUp = currentScrollTop < prevScrollTopRef.current; + + // Update previous scroll position for next comparison + prevScrollTopRef.current = currentScrollTop; + + // Turn off autoscroll only when scrolling up + if (isScrollingUp) { + setAutoscroll(false); + } + + // Turn on autoscroll when scrolled to the bottom + if (isCurrentlyAtBottom) { + setAutoscroll(true); + } + }, + [isAtBottom], + ); + + // Scroll to bottom on manual click only + const scrollDomToBottom = useCallback(() => { + const dom = scrollRef.current; + if (dom) { + requestAnimationFrame(() => { + setAutoscroll(true); + setHitBottom(true); + + dom.scrollTop = dom.scrollHeight; + }); + } + }, [scrollRef]); + + return { + scrollRef, + autoScroll: autoscroll, + setAutoScroll: setAutoscroll, + scrollDomToBottom, + hitBottom, + setHitBottom, + onChatBodyScroll, + }; +} diff --git a/src/hooks/use-select-conversation-tab.ts b/src/hooks/use-select-conversation-tab.ts new file mode 100644 index 0000000000000000000000000000000000000000..161ca66c7f04e975504fd1b82c7cdad161932bf4 --- /dev/null +++ b/src/hooks/use-select-conversation-tab.ts @@ -0,0 +1,96 @@ +import { useConversationLocalStorageState } from "#/utils/conversation-local-storage"; +import { + useConversationStore, + type ConversationTab, +} from "#/stores/conversation-store"; +import { useOptionalConversationId } from "#/hooks/use-conversation-id"; + +/** + * Custom hook for selecting conversation tabs with consistent behavior. + * + * Handles panel visibility and tab toggling logic. The selected tab is + * persisted per conversation (so users land on the same tab when they + * come back), but the drawer's open/closed state is intentionally + * session-only — see `useConversationStore` for the rationale. + */ +export function useSelectConversationTab() { + const { conversationId } = useOptionalConversationId(); + const { + selectedTab, + isRightPanelShown, + setHasRightPanelToggled, + setIsOverviewPanelShown, + setIsRightPanelShown, + setSelectedTab, + setCommitsAutoExpandSection, + } = useConversationStore(); + + const { setSelectedTab: setPersistedSelectedTab, setRightPanelShown } = + useConversationLocalStorageState(conversationId ?? ""); + + const setPanelOpen = (open: boolean) => { + setHasRightPanelToggled(open); + setIsRightPanelShown(open); + setRightPanelShown?.(open); + }; + + const onTabChange = (value: ConversationTab | null) => { + setSelectedTab(value); + setPersistedSelectedTab(value); + }; + + /** + * Selects a tab with proper panel visibility handling. + * - If clicking the same active tab while panel is open, closes the panel + * - If clicking a different tab or panel is closed, opens panel and selects tab + */ + const selectTab = (tab: ConversationTab) => { + if (selectedTab === tab && isRightPanelShown) { + setPanelOpen(false); + } else { + onTabChange(tab); + if (!isRightPanelShown) { + setPanelOpen(true); + } + setIsOverviewPanelShown(false); + } + }; + + /** + * Navigates to a tab without toggle behavior. + * Always shows the panel and selects the tab, even if already selected. + * Use this for "View" or "Read More" buttons that should always navigate. + */ + const navigateToTab = (tab: ConversationTab) => { + onTabChange(tab); + setPanelOpen(true); + setIsOverviewPanelShown(false); + }; + + const navigateToChanges = () => { + setCommitsAutoExpandSection("uncommitted"); + navigateToTab("commits"); + }; + + const navigateToCommits = () => { + setCommitsAutoExpandSection(null); + navigateToTab("commits"); + }; + + /** + * Checks if a specific tab is currently active (selected and panel is visible). + */ + const isTabActive = (tab: ConversationTab) => + isRightPanelShown && selectedTab === tab; + + return { + selectTab, + navigateToTab, + navigateToChanges, + navigateToCommits, + isTabActive, + onTabChange, + selectedTab, + isRightPanelShown, + }; +} diff --git a/src/hooks/use-send-message.ts b/src/hooks/use-send-message.ts new file mode 100644 index 0000000000000000000000000000000000000000..72b8125371f3a41a435b9f5274cf24ac54b29553 --- /dev/null +++ b/src/hooks/use-send-message.ts @@ -0,0 +1,68 @@ +import { useCallback } from "react"; +import { useConversationWebSocket } from "#/contexts/conversation-websocket-context"; +import { useOptionalConversationId } from "#/hooks/use-conversation-id"; +import { MessageContent } from "#/api/conversation-service/agent-server-conversation-service.types"; + +interface SendResult { + queued: boolean; // true if message was queued for later delivery +} + +/** + * Sends user messages through the active conversation WebSocket. + */ +export function useSendMessage() { + // Optional: this hook is reachable from the home-page chat input shell. + // Outside a conversation route, `conversationContext` is null anyway so + // `send` is a no-op that returns `{ queued: false }`. + const { conversationId } = useOptionalConversationId(); + + // Get agent-server context (null outside a conversation provider) + const conversationContext = useConversationWebSocket(); + + const send = useCallback( + async (event: Record): Promise => { + if (conversationContext) { + // Convert chat input payloads to agent-server message content. + const { action, args } = event as { + action: string; + args?: { + content?: string; + image_urls?: string[]; + file_urls?: string[]; + timestamp?: string; + }; + }; + + if (action === "message" && args?.content) { + // Build agent-server message content array + const content: Array = [ + { + type: "text", + text: args.content, + }, + ]; + + // Add images if present - using SDK's ImageContent format + if (args.image_urls && args.image_urls.length > 0) { + content.push({ + type: "image", + image_urls: args.image_urls, + }); + } + + // Send via WebSocket context (uses correct host/port) + const result = await conversationContext.sendMessage({ + role: "user", + content, + }); + return result; + } + return { queued: false }; + } + return { queued: false }; + }, + [conversationContext, conversationId], + ); + + return { send }; +} diff --git a/src/hooks/use-settings-nav-items.ts b/src/hooks/use-settings-nav-items.ts new file mode 100644 index 0000000000000000000000000000000000000000..ce863ba439aeb4809aa5ee32f69e917e4e2e5e3d --- /dev/null +++ b/src/hooks/use-settings-nav-items.ts @@ -0,0 +1,50 @@ +import { useConfig } from "#/hooks/query/use-config"; +import { + LOCKED_CLOUD_SETTINGS_NAV_PATH, + OSS_NAV_ITEMS, + SettingsNavItem, +} from "#/constants/settings-nav"; +import { isSettingsPageHidden } from "#/utils/settings-utils"; +import { I18nKey } from "#/i18n/declaration"; +import { useActiveBackend } from "#/contexts/active-backend-context"; +import { getLockedCloudHost } from "#/api/agent-server-config"; + +export type SettingsNavRenderedItem = + | { + type: "item"; + item: SettingsNavItem; + } + | { type: "header"; text: I18nKey } + | { type: "divider" }; + +export function useSettingsNavItems(): SettingsNavRenderedItem[] { + const { data: config } = useConfig(); + const { backend } = useActiveBackend(); + const featureFlags = config?.feature_flags; + // Locked-to-Cloud (SaaS / self-hosted OHE) lists only the Application page; + // the OHE settings shell behind "All Cloud Settings" owns the rest (OHE-3168). + const isLockedToCloud = getLockedCloudHost() !== null; + + return OSS_NAV_ITEMS.filter( + (item) => + !isSettingsPageHidden(item.to, featureFlags) && + (!isLockedToCloud || item.to === LOCKED_CLOUD_SETTINGS_NAV_PATH), + ).map((item) => { + const renamedItem = + item.to === "/settings" + ? { + ...item, + text: + backend.kind === "local" + ? I18nKey.SETTINGS$LLM_PROFILES + : item.text, + subtitle: + backend.kind === "local" + ? I18nKey.SETTINGS$PAGE_LLM_PROFILES_SUBLINE + : item.subtitle, + } + : item; + + return { type: "item", item: renamedItem }; + }); +} diff --git a/src/hooks/use-skill-enablement.ts b/src/hooks/use-skill-enablement.ts new file mode 100644 index 0000000000000000000000000000000000000000..02734bc17e3e2c2fe9e6ce095ea5efcdd608d501 --- /dev/null +++ b/src/hooks/use-skill-enablement.ts @@ -0,0 +1,160 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { useActiveBackend } from "#/contexts/active-backend-context"; +import { useSaveSettings } from "#/hooks/mutation/use-save-settings"; +import { useSettings } from "#/hooks/query/use-settings"; +import { I18nKey } from "#/i18n/declaration"; +import type { Settings, SkillInfo } from "#/types/settings"; +import { displayErrorToast } from "#/utils/custom-toast-handlers"; +import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message"; +import { + buildSkillEnablementFilter, + CATALOG_SKILL_NAMES, + isCatalogSkill, + resolveEnabledCatalogSkills, + type SkillEnablement, +} from "#/utils/skill-enablement"; + +/** + * Settings → the two lists. Cloud creates conversations from its own + * server-side catalog and never reads `enabled_skills`, so the catalog stays + * deny-list governed there. + */ +function readSkillEnablement( + settings: Settings | undefined, + usesCatalogAllowList: boolean, +): SkillEnablement { + return { + enabledSkills: usesCatalogAllowList + ? settings?.enabled_skills + : [...CATALOG_SKILL_NAMES], + disabledSkills: settings?.disabled_skills ?? [], + }; +} + +/** Read-only view of the rule, for surfaces that list skills without toggling. */ +export function useSkillEnabledFilter(): (skill: SkillInfo) => boolean { + const { backend } = useActiveBackend(); + const { data: settings } = useSettings(); + const usesCatalogAllowList = backend.kind !== "cloud"; + + return React.useMemo(() => { + const isEnabled = buildSkillEnablementFilter( + readSkillEnablement(settings, usesCatalogAllowList), + ); + return (skill: SkillInfo) => isEnabled(skill.name); + }, [ + settings?.enabled_skills, + settings?.disabled_skills, + usesCatalogAllowList, + ]); +} + +export interface SkillEnablementController { + isEnabled: (skill: SkillInfo) => boolean; + setEnabled: (skillName: string, enabled: boolean) => void; +} + +/** Stable form of both lists, so an unchanged set can skip the save. */ +function snapshot(enablement: SkillEnablement): string { + return JSON.stringify([ + [...resolveEnabledCatalogSkills(enablement)].sort(), + [...(enablement.disabledSkills ?? [])].sort(), + ]); +} + +/** Same array reference when the membership already matches, to skip a save. */ +function withMembership( + list: string[], + name: string, + present: boolean, +): string[] { + if (list.includes(name) === present) return list; + return present ? [...list, name] : list.filter((entry) => entry !== name); +} + +/** + * Shared toggle state for every surface that switches skills on and off. + * + * Which of the two lists a skill belongs to is decided here alone, so one + * surface cannot write a preference another cannot see. + */ +export function useSkillEnablement(): SkillEnablementController { + const { t } = useTranslation("openhands"); + const { backend } = useActiveBackend(); + const usesCatalogAllowList = backend.kind !== "cloud"; + const { data: settings, isLoading: settingsLoading } = useSettings(); + const { mutate: saveSettings } = useSaveSettings(); + + const [enablement, setEnablement] = React.useState({}); + const savedRef = React.useRef(null); + + React.useEffect(() => { + if (settingsLoading || !settings) return; + const hydrated = readSkillEnablement(settings, usesCatalogAllowList); + savedRef.current = snapshot(hydrated); + setEnablement(hydrated); + }, [ + settingsLoading, + settings?.enabled_skills, + settings?.disabled_skills, + usesCatalogAllowList, + ]); + + React.useEffect(() => { + // Writing the hydrated value straight back would race the one-shot + // migration and could narrow a workspace it had just preserved. + const next = snapshot(enablement); + if (savedRef.current === null || savedRef.current === next) return; + savedRef.current = next; + + const disabledSkills = enablement.disabledSkills ?? []; + saveSettings( + usesCatalogAllowList + ? { + enabled_skills: resolveEnabledCatalogSkills(enablement), + disabled_skills: disabledSkills, + } + : { disabled_skills: disabledSkills }, + { + onError: (error) => { + displayErrorToast( + retrieveAxiosErrorMessage(error) || t(I18nKey.ERROR$GENERIC), + ); + }, + }, + ); + }, [enablement, usesCatalogAllowList, saveSettings, t]); + + const isEnabled = React.useMemo(() => { + const enabled = buildSkillEnablementFilter(enablement); + return (skill: SkillInfo) => enabled(skill.name); + }, [enablement]); + + const setEnabled = React.useCallback( + (skillName: string, enabled: boolean) => { + setEnablement((previous) => { + const allowListed = usesCatalogAllowList && isCatalogSkill(skillName); + return { + enabledSkills: allowListed + ? withMembership( + resolveEnabledCatalogSkills(previous), + skillName, + enabled, + ) + : previous.enabledSkills, + // A catalog skill switched back on also leaves the deny-list, where + // an unmigrated workspace may still hold it. + disabledSkills: withMembership( + previous.disabledSkills ?? [], + skillName, + !enabled && !allowListed, + ), + }; + }); + }, + [usesCatalogAllowList], + ); + + return { isEnabled, setEnabled }; +} diff --git a/src/hooks/use-sync-automation-telemetry-consent.ts b/src/hooks/use-sync-automation-telemetry-consent.ts new file mode 100644 index 0000000000000000000000000000000000000000..c597f28cde1e71ded8e5dc0941a54cd79cb3fd9f --- /dev/null +++ b/src/hooks/use-sync-automation-telemetry-consent.ts @@ -0,0 +1,39 @@ +import React from "react"; +import AutomationService from "#/api/automation-service/automation-service.api"; +import { useActiveBackend } from "#/contexts/active-backend-context"; +import { + getPendingLocalTelemetryRevocationId, + getTelemetryConsent, + subscribeTelemetryConsent, +} from "#/services/telemetry"; + +export function useSyncAutomationTelemetryConsent() { + const { backend } = useActiveBackend(); + const consent = React.useSyncExternalStore( + subscribeTelemetryConsent, + getTelemetryConsent, + () => "pending" as const, + ); + const lastSyncKeyRef = React.useRef(null); + + React.useEffect(() => { + const pendingRevocationId = + consent === "pending" ? getPendingLocalTelemetryRevocationId() : null; + if ( + backend.kind !== "local" || + (consent === "pending" && !pendingRevocationId) + ) { + return; + } + + const resolvedConsent = consent === "granted" ? "granted" : "denied"; + const pendingActor = consent === "pending" ? pendingRevocationId : ""; + const syncKey = `${backend.id}:${backend.host}:${backend.apiKey ?? ""}:${resolvedConsent}:${pendingActor}`; + if (lastSyncKeyRef.current === syncKey) return; + lastSyncKeyRef.current = syncKey; + + void AutomationService.syncTelemetryConsent(resolvedConsent).catch( + () => {}, + ); + }, [backend.apiKey, backend.host, backend.id, backend.kind, consent]); +} diff --git a/src/hooks/use-sync-telemetry-consent.ts b/src/hooks/use-sync-telemetry-consent.ts new file mode 100644 index 0000000000000000000000000000000000000000..f741571dfcccb74c6b16845345f977e8a06348b6 --- /dev/null +++ b/src/hooks/use-sync-telemetry-consent.ts @@ -0,0 +1,90 @@ +import React from "react"; +import { useActiveBackend } from "#/contexts/active-backend-context"; +import { useSaveSettings } from "#/hooks/mutation/use-save-settings"; +import { + clearPendingCloudTelemetryConsent, + getPendingCloudTelemetryConsent, + setTelemetryConsent, + subscribeTelemetryConsent, +} from "#/services/telemetry"; +import { useSettings } from "./query/use-settings"; + +/** + * Hook to sync PostHog opt-in/out state with the backend setting. + * + * Reconciles the browser's capture state with the backend preference. + * + * A first-run user can make an explicit choice before Cloud login. That newer + * browser decision remains pending across local backends, but does not + * automatically fill unset local backend settings; each local backend can ask + * for its own persisted preference. The pending decision is written to the + * Cloud backend after login; until Cloud confirms it, a stale/default backend + * value must not overwrite it. With no pending browser choice, backend updates + * remain authoritative so revocation in another tab or via the API is respected. + * + * Consent model: + * true → opt in (user explicitly accepted) + * false → opt out (user explicitly denied) + * null → pending (consent not yet collected; PostHog stays opted out until + * the user decides) + */ +export const useSyncTelemetryConsent = () => { + const { backend } = useActiveBackend(); + const { data: settings } = useSettings(); + const { mutate: saveSettings, isPending: isSavingSettings } = useSaveSettings( + "personal", + { retry: 2 }, + ); + const pendingBrowserConsent = React.useSyncExternalStore( + subscribeTelemetryConsent, + getPendingCloudTelemetryConsent, + () => null, + ); + const attemptedSyncRef = React.useRef(null); + + React.useEffect(() => { + if (settings === undefined) return; + + if (pendingBrowserConsent !== null) { + const backendConsent = + settings.user_consents_to_analytics === true + ? "granted" + : settings.user_consents_to_analytics === false + ? "denied" + : null; + + if (backendConsent === pendingBrowserConsent) { + attemptedSyncRef.current = null; + if (backend.kind === "cloud") { + clearPendingCloudTelemetryConsent(pendingBrowserConsent); + } + return; + } + + if (backend.kind !== "cloud") return; + + const syncKey = `${backend.id}:${pendingBrowserConsent}`; + if (isSavingSettings || attemptedSyncRef.current === syncKey) return; + + attemptedSyncRef.current = syncKey; + saveSettings({ + user_consents_to_analytics: pendingBrowserConsent === "granted", + }); + return; + } + + if (settings.user_consents_to_analytics === null) return; + + void setTelemetryConsent( + settings.user_consents_to_analytics === true ? "granted" : "denied", + { syncToCloud: false }, + ); + }, [ + backend.id, + backend.kind, + isSavingSettings, + pendingBrowserConsent, + saveSettings, + settings, + ]); +}; diff --git a/src/hooks/use-telemetry-identity.ts b/src/hooks/use-telemetry-identity.ts new file mode 100644 index 0000000000000000000000000000000000000000..340de8b53ef962029619aefa2d98406d16c2f899 --- /dev/null +++ b/src/hooks/use-telemetry-identity.ts @@ -0,0 +1,110 @@ +import React from "react"; +import { useActiveBackend } from "#/contexts/active-backend-context"; +import { useCloudCurrentUserId } from "#/hooks/query/use-cloud-current-user-id"; +import { useSettings } from "#/hooks/query/use-settings"; +import { useTracking } from "#/hooks/use-tracking"; +import { + setTelemetryCloudContext, + setTelemetryIdentity, +} from "#/services/telemetry"; + +const CLOUD_COOKIE_BACKEND_ADDED_SESSION_PREFIX = + "agent_canvas_cloud_cookie_backend_added"; +const trackedCloudCookieBackendAddedSessionKeys = new Set(); + +function getCloudCookieBackendAddedSessionKey( + backendId: string, + userId: string, +): string { + return `${CLOUD_COOKIE_BACKEND_ADDED_SESSION_PREFIX}:${backendId}:${userId}`; +} + +function hasTrackedCloudCookieBackendAddedThisSession( + backendId: string, + userId: string, +): boolean { + const key = getCloudCookieBackendAddedSessionKey(backendId, userId); + if (typeof window === "undefined") return true; + + try { + return ( + window.sessionStorage.getItem(key) === "true" || + trackedCloudCookieBackendAddedSessionKeys.has(key) + ); + } catch { + return trackedCloudCookieBackendAddedSessionKeys.has(key); + } +} + +function markCloudCookieBackendAddedTrackedThisSession( + backendId: string, + userId: string, +): void { + const key = getCloudCookieBackendAddedSessionKey(backendId, userId); + if (typeof window === "undefined") return; + + try { + window.sessionStorage.setItem(key, "true"); + } catch { + trackedCloudCookieBackendAddedSessionKeys.add(key); + } +} + +/** Keep Cloud user event context aligned with the active backend. */ +export const useTelemetryIdentity = () => { + const { backend, orgId } = useActiveBackend(); + const { data: settings } = useSettings(); + const { trackBackendAdded } = useTracking(); + const trackBackendAddedRef = React.useRef(trackBackendAdded); + const userIds = useCloudCurrentUserId(); + const identity = backend.kind === "cloud" ? userIds[backend.id] : undefined; + const isIdentityLoading = identity?.isLoading ?? true; + const userId = identity?.userId ?? null; + const email = + (settings?.email || settings?.git_user_email)?.trim() || undefined; + + React.useEffect(() => { + trackBackendAddedRef.current = trackBackendAdded; + }, [trackBackendAdded]); + + React.useEffect(() => { + if (backend.kind !== "cloud") { + setTelemetryCloudContext(null); + return; + } + + if (isIdentityLoading) return; + + if (!userId) { + setTelemetryCloudContext(null); + void setTelemetryIdentity(null); + return; + } + + setTelemetryCloudContext({ userId, email, orgId }); + void setTelemetryIdentity(userId, email ? { email } : {}); + + if ( + backend.authMode === "cookie" && + email && + !hasTrackedCloudCookieBackendAddedThisSession(backend.id, userId) + ) { + trackBackendAddedRef.current({ + backendKind: "cloud", + connectionMethod: "cloud_cookie", + hasApiKey: false, + source: "cloud_auto_connect", + }); + markCloudCookieBackendAddedTrackedThisSession(backend.id, userId); + } + }, [ + backend.authMode, + backend.connectionRevision, + backend.id, + backend.kind, + email, + isIdentityLoading, + orgId, + userId, + ]); +}; diff --git a/src/hooks/use-telemetry.ts b/src/hooks/use-telemetry.ts new file mode 100644 index 0000000000000000000000000000000000000000..0127b50fa0001abf5cf7261616b9a93f7a12a599 --- /dev/null +++ b/src/hooks/use-telemetry.ts @@ -0,0 +1,114 @@ +import { useCallback, useEffect, useRef, useSyncExternalStore } from "react"; +import { + getTelemetryConsent, + setTelemetryConsent, + trackInstall, + trackSessionStart, + trackEvent, + clearTelemetryData, + subscribeTelemetryConsent, + type TelemetryConsent, +} from "#/services/telemetry"; + +export interface UseTelemetryReturn { + /** Current consent status */ + consent: TelemetryConsent; + /** Whether telemetry is enabled (consent granted) */ + isEnabled: boolean; + /** Whether consent prompt should be shown */ + showConsentPrompt: boolean; + /** Grant consent and enable telemetry */ + grantConsent: () => Promise; + /** Deny consent and disable telemetry */ + denyConsent: () => Promise; + /** Track a custom event (only if consent granted) */ + track: (eventName: string, properties?: Record) => void; + /** Clear all telemetry data */ + clearData: () => Promise; +} + +/** + * Hook for managing telemetry consent and tracking. + * + * TRACKING BEHAVIOR: + * - Install event: Sent immediately on first mount, regardless of consent status. + * This is anonymous and allows us to track library adoption. + * - Session/custom events: Only sent after user grants consent. + * + * This hook handles: + * - Sending install event immediately on first use + * - Showing consent prompt for ongoing tracking + * - Tracking session start when consent is granted + * - Providing a simple API for tracking custom events + * + * @example + * ```tsx + * function MyComponent() { + * const { consent, showConsentPrompt, grantConsent, denyConsent, track } = useTelemetry(); + * + * useEffect(() => { + * track('component_mounted', { component: 'MyComponent' }); + * }, [track]); + * + * if (showConsentPrompt) { + * return ; + * } + * + * return
...
; + * } + * ``` + */ +export function useTelemetry(): UseTelemetryReturn { + const consent = useSyncExternalStore( + subscribeTelemetryConsent, + getTelemetryConsent, + () => "pending", + ); + + // Track install immediately on first mount (regardless of consent) + // This only fires once per installation due to localStorage deduplication + // + // PRIVACY NOTE: This sends an anonymous install event before the user grants consent. + // Data collected: browser platform, user agent, referrer, origin URL, embedded status. + // A random PostHog distinct_id is generated (not tied to user identity). + // Users can prevent this by setting VITE_DO_NOT_TRACK=1 or browser's DNT setting. + // This approach uses "legitimate interest" under GDPR Article 6(1)(f) for basic + // aggregate analytics. No cross-site tracking, no advertising, no user profiles. + const hasTrackedInstall = useRef(false); + useEffect(() => { + if (!hasTrackedInstall.current) { + hasTrackedInstall.current = true; + void trackInstall(); + } + }, []); + + // Track session start when consent is granted + useEffect(() => { + if (consent === "granted") { + void trackSessionStart(); + } + }, [consent]); + + const grantConsent = useCallback(() => setTelemetryConsent("granted"), []); + + const denyConsent = useCallback(() => setTelemetryConsent("denied"), []); + + const track = useCallback( + (eventName: string, properties?: Record) => { + void trackEvent(eventName, properties); + }, + [], + ); + + const clearData = useCallback(() => clearTelemetryData(), []); + + return { + consent, + isEnabled: consent === "granted", + showConsentPrompt: consent === "pending", + grantConsent, + denyConsent, + track, + clearData, + }; +} diff --git a/src/hooks/use-terminal.ts b/src/hooks/use-terminal.ts new file mode 100644 index 0000000000000000000000000000000000000000..5960dfc47163621838b4513a77d8f8628b235bdf --- /dev/null +++ b/src/hooks/use-terminal.ts @@ -0,0 +1,212 @@ +import { FitAddon } from "@xterm/addon-fit"; +import { Terminal } from "@xterm/xterm"; +import React from "react"; +import { Command, useCommandStore } from "#/stores/command-store"; +import { parseTerminalOutput } from "#/utils/parse-terminal-output"; + +/* + NOTE: Tests for this hook are indirectly covered by the tests for the XTermTerminal component. + The reason for this is that the hook exposes a ref that requires a DOM element to be rendered. +*/ + +const renderCommand = ( + command: Command, + terminal: Terminal, + isUserInput: boolean = false, +) => { + const { content, type } = command; + + // Skip rendering user input commands that come from the event stream + // as they've already been displayed in the terminal as the user typed + if (type === "input" && isUserInput) { + return; + } + + const trimmedContent = (content || "").replaceAll("\n", "\r\n").trim(); + // Only write if there's actual content to avoid empty newlines + if (trimmedContent) { + terminal.writeln(parseTerminalOutput(trimmedContent)); + } +}; + +/** + * Check if the terminal is ready for fit operations. + * This prevents the "Cannot read properties of undefined (reading 'dimensions')" error + * that occurs when fit() is called on a terminal that is hidden, disposed, or not fully initialized. + */ +const canFitTerminal = ( + terminalInstance: Terminal | null, + fitAddonInstance: FitAddon | null, + containerElement: HTMLDivElement | null, +): boolean => { + // Check terminal and fitAddon exist + if (!terminalInstance || !fitAddonInstance) { + return false; + } + + // Check container element exists + if (!containerElement) { + return false; + } + + // Check element is visible (not display: none) + // When display is none, offsetParent is null (except for fixed/body elements) + const computedStyle = window.getComputedStyle(containerElement); + if (computedStyle.display === "none") { + return false; + } + + // Check element has dimensions + const { clientWidth, clientHeight } = containerElement; + if (clientWidth === 0 || clientHeight === 0) { + return false; + } + + // Check terminal has been opened (element property is set after open()) + if (!terminalInstance.element) { + return false; + } + + return true; +}; + +function resolveTerminalForeground(host: HTMLElement): string { + const probe = host.ownerDocument.createElement("span"); + probe.style.color = "var(--oh-surface-foreground)"; + probe.style.position = "absolute"; + probe.style.visibility = "hidden"; + probe.style.pointerEvents = "none"; + host.appendChild(probe); + const fromVar = getComputedStyle(probe).color; + probe.remove(); + if (fromVar && fromVar !== "rgba(0, 0, 0, 0)") { + return fromVar; + } + return getComputedStyle(host).color; +} + +// Create a persistent reference that survives component unmounts +// This ensures terminal history is preserved when navigating away and back +const persistentLastCommandIndex = { current: 0 }; + +export const useTerminal = () => { + const commands = useCommandStore((state) => state.commands); + const terminal = React.useRef(null); + const fitAddon = React.useRef(null); + const ref = React.useRef(null); + const lastCommandIndex = persistentLastCommandIndex; // Use the persistent reference + const isDisposed = React.useRef(false); + + const createTerminal = (host: HTMLDivElement) => + new Terminal({ + fontFamily: "Menlo, Monaco, 'Courier New', monospace", + fontSize: 14, + scrollback: 10000, + scrollSensitivity: 1, + fastScrollSensitivity: 5, + disableStdin: true, // Make terminal read-only + // Canvas fillStyle does not resolve CSS variables; use transparency so + // the host / panel background shows through (`allowTransparency` required). + allowTransparency: true, + theme: { + background: "rgba(0, 0, 0, 0)", + foreground: resolveTerminalForeground(host), + }, + }); + + const fitTerminalSafely = React.useCallback(() => { + if (isDisposed.current) { + return; + } + if (canFitTerminal(terminal.current, fitAddon.current, ref.current)) { + fitAddon.current!.fit(); + } + }, []); + + const initializeTerminal = () => { + if (terminal.current) { + if (fitAddon.current) terminal.current.loadAddon(fitAddon.current); + if (ref.current) { + terminal.current.open(ref.current); + // Hide cursor for read-only terminal using ANSI escape sequence + terminal.current.write("\x1b[?25l"); + fitTerminalSafely(); + } + } + }; + + // Initialize terminal and handle cleanup + React.useEffect(() => { + isDisposed.current = false; + const host = ref.current; + if (!host) { + return undefined; + } + + terminal.current = createTerminal(host); + fitAddon.current = new FitAddon(); + + if (ref.current) { + initializeTerminal(); + // Render all commands in array + // This happens when we just switch to Terminal from other tabs + if (commands.length > 0) { + for (let i = 0; i < commands.length; i += 1) { + if (commands[i].type === "input") { + terminal.current.write("$ "); + } + // Don't pass isUserInput=true here because we're initializing the terminal + // and need to show all previous commands + renderCommand(commands[i], terminal.current, false); + } + lastCommandIndex.current = commands.length; + } + // Don't show prompt in read-only terminal + } + + return () => { + isDisposed.current = true; + terminal.current?.dispose(); + lastCommandIndex.current = 0; + }; + }, []); + + React.useEffect(() => { + if ( + terminal.current && + commands.length > 0 && + lastCommandIndex.current < commands.length + ) { + for (let i = lastCommandIndex.current; i < commands.length; i += 1) { + if (commands[i].type === "input") { + terminal.current.write("$ "); + } + // Pass true for isUserInput to skip rendering user input commands + // that have already been displayed as the user typed + renderCommand(commands[i], terminal.current, false); + } + lastCommandIndex.current = commands.length; + } + }, [commands]); + + React.useEffect(() => { + let resizeObserver: ResizeObserver | null = null; + + resizeObserver = new ResizeObserver(() => { + // Use requestAnimationFrame to debounce resize events and ensure DOM is ready + requestAnimationFrame(() => { + fitTerminalSafely(); + }); + }); + + if (ref.current) { + resizeObserver.observe(ref.current); + } + + return () => { + resizeObserver?.disconnect(); + }; + }, [fitTerminalSafely]); + + return ref; +}; diff --git a/src/hooks/use-unified-websocket-status.ts b/src/hooks/use-unified-websocket-status.ts new file mode 100644 index 0000000000000000000000000000000000000000..8786a8fd9b23d348cb622668a3d5213ee38afd4c --- /dev/null +++ b/src/hooks/use-unified-websocket-status.ts @@ -0,0 +1,25 @@ +import { + useConversationWebSocket, + WebSocketConnectionState, +} from "#/contexts/conversation-websocket-context"; + +/** + * Returns the current conversation WebSocket status. + */ +export function useUnifiedWebSocketStatus(): WebSocketConnectionState { + const conversationContext = useConversationWebSocket(); + return conversationContext ? conversationContext.connectionState : "CLOSED"; +} + +/** + * The main connection's own status, unmerged with the planning connection. + * Use for actions that only address the main conversation (e.g. `/code`) — + * `useUnifiedWebSocketStatus` can report non-OPEN purely from a momentary + * planning reconnect. + */ +export function useMainWebSocketStatus(): WebSocketConnectionState { + const conversationContext = useConversationWebSocket(); + return conversationContext + ? conversationContext.mainConnectionState + : "CLOSED"; +} diff --git a/src/hooks/use-websocket.ts b/src/hooks/use-websocket.ts new file mode 100644 index 0000000000000000000000000000000000000000..b483108a2e67348fe097582d990fa6e5a6b66dfb --- /dev/null +++ b/src/hooks/use-websocket.ts @@ -0,0 +1,250 @@ +import React from "react"; +import { sendWebSocketAuth } from "#/utils/websocket-auth"; +import { startHandshakeWatchdog } from "#/utils/websocket-handshake"; + +export interface WebSocketHookOptions { + queryParams?: Record; + sessionApiKey?: string | null; + onOpen?: (event: Event) => void; + onClose?: (event: CloseEvent) => void; + onMessage?: (event: MessageEvent) => void; + onError?: (event: Event) => void; + reconnect?: { + enabled?: boolean; + maxAttempts?: number; + }; +} + +// Reconnect backoff bounds: 1s, 2s, 4s, … capped at 30s. +const RECONNECT_BASE_DELAY_MS = 1_000; +const RECONNECT_MAX_DELAY_MS = 30_000; + +export const useWebSocket = (url: string, options?: WebSocketHookOptions) => { + const [isConnected, setIsConnected] = React.useState(false); + const [error, setError] = React.useState(null); + const [isReconnecting, setIsReconnecting] = React.useState(false); + const wsRef = React.useRef(null); + const attemptCountRef = React.useRef(0); + const reconnectTimeoutRef = React.useRef(null); + const shouldReconnectRef = React.useRef(true); // Only set to false by disconnect() + // Track which WebSocket instances are allowed to reconnect using a WeakSet + const allowedToReconnectRef = React.useRef>(new WeakSet()); + + // Store options in a ref to avoid reconnecting when callbacks change + const optionsRef = React.useRef(options); + React.useEffect(() => { + optionsRef.current = options; + }, [options]); + + const connectWebSocket = React.useCallback(() => { + // Build URL with query parameters if provided + let wsUrl = url; + if (optionsRef.current?.queryParams) { + const stringParams = Object.entries( + optionsRef.current.queryParams, + ).reduce( + (acc, [key, value]) => { + acc[key] = String(value); + return acc; + }, + {} as Record, + ); + const params = new URLSearchParams(stringParams); + wsUrl = `${url}?${params.toString()}`; + } + + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + // Mark this WebSocket instance as allowed to reconnect + allowedToReconnectRef.current.add(ws); + + // Abort a socket stuck in CONNECTING so it can't hold Chrome's per-host + // handshake lock indefinitely; its close flows into the reconnect path + // below. + const cancelHandshakeWatchdog = startHandshakeWatchdog(ws); + + ws.onopen = (event) => { + cancelHandshakeWatchdog(); + sendWebSocketAuth(ws, optionsRef.current?.sessionApiKey); + setIsConnected(true); + setError(null); // Clear any previous errors + setIsReconnecting(false); + attemptCountRef.current = 0; // Reset attempt count on successful connection + optionsRef.current?.onOpen?.(event); + }; + + ws.onmessage = (event) => { + // Deliberately no `lastMessage` state here: nothing reads it, and a + // React state write per frame re-renders this hook's owner on every + // streamed token. Consumers subscribe via `onMessage`. + optionsRef.current?.onMessage?.(event); + }; + + ws.onclose = (event) => { + cancelHandshakeWatchdog(); + // Check if this specific WebSocket instance is allowed to reconnect + const canReconnect = allowedToReconnectRef.current.has(ws); + setIsConnected(false); + // If the connection closes with an error code, treat it as an error + if (event.code !== 1000) { + // 1000 is normal closure + setError( + new Error( + `WebSocket closed with code ${event.code}: ${event.reason || "Connection closed unexpectedly"}`, + ), + ); + // Also call onError handler for error closures (only if allowed to reconnect) + if (canReconnect) { + optionsRef.current?.onError?.(event); + } + } + // Notify the consumer unless this socket was deliberately replaced by a + // newer one — a replaced socket's close event arrives late and must not + // clobber the replacement's OPEN state in the consumer. Final closes + // (disconnect/unmount, nothing replacing the socket) still notify. + const wasReplaced = wsRef.current !== null && wsRef.current !== ws; + if (!wasReplaced) { + optionsRef.current?.onClose?.(event); + } + + // Attempt reconnection if enabled and allowed + // IMPORTANT: Only reconnect if this specific instance is allowed to reconnect + const reconnectEnabled = optionsRef.current?.reconnect?.enabled ?? false; + const maxAttempts = + optionsRef.current?.reconnect?.maxAttempts ?? Infinity; + + if ( + reconnectEnabled && + canReconnect && + shouldReconnectRef.current && + attemptCountRef.current < maxAttempts + ) { + setIsReconnecting(true); + attemptCountRef.current += 1; + + // Exponential backoff with up to 30% random jitter so parallel + // sockets (main + planning) don't retry in lockstep and hammer an + // already-struggling server every few seconds forever. + const baseDelay = Math.min( + RECONNECT_BASE_DELAY_MS * 2 ** (attemptCountRef.current - 1), + RECONNECT_MAX_DELAY_MS, + ); + const delay = baseDelay + Math.random() * baseDelay * 0.3; + + reconnectTimeoutRef.current = setTimeout(() => { + connectWebSocket(); + }, delay); + } else { + setIsReconnecting(false); + } + }; + + ws.onerror = (event) => { + // Ignore errors from sockets we've deliberately replaced or closed — + // aborting a mid-handshake socket fires `error`, and it must not + // surface as a connection failure for the replacement socket. + if (!allowedToReconnectRef.current.has(ws)) { + return; + } + setIsConnected(false); + optionsRef.current?.onError?.(event); + }; + }, [url]); + + React.useEffect(() => { + // Reset shouldReconnect flag and attempt count when creating a new connection + shouldReconnectRef.current = true; + attemptCountRef.current = 0; + + // Only attempt connection if we have a valid URL + if (url && url.trim() !== "") { + connectWebSocket(); + } + + return () => { + // Disable reconnection on unmount to prevent reconnection attempts + // This must be set BEFORE closing the socket, so the onclose handler sees it + shouldReconnectRef.current = false; + // Clear any pending reconnection timeouts + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + // Close the WebSocket connection + if (wsRef.current) { + const { readyState } = wsRef.current; + // Remove this WebSocket from the allowed list BEFORE closing + // so its onclose handler won't try to reconnect + allowedToReconnectRef.current.delete(wsRef.current); + // Only close if not already closed/closing + if ( + readyState === WebSocket.CONNECTING || + readyState === WebSocket.OPEN + ) { + wsRef.current.close(); + } + wsRef.current = null; + } + }; + }, [url, connectWebSocket]); + + const sendMessage = React.useCallback( + (data: string | Blob | BufferSource) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(data); + } + }, + [], + ); + + const disconnect = React.useCallback(() => { + shouldReconnectRef.current = false; + setIsReconnecting(false); + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + if (wsRef.current) { + // Remove from allowed list before closing + allowedToReconnectRef.current.delete(wsRef.current); + wsRef.current.close(); + } + }, []); + + const reconnect = React.useCallback(() => { + shouldReconnectRef.current = true; + attemptCountRef.current = 0; + setIsReconnecting(true); + setError(null); + + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + + if (wsRef.current) { + const ws = wsRef.current; + allowedToReconnectRef.current.delete(ws); + wsRef.current = null; + if ( + ws.readyState === WebSocket.CONNECTING || + ws.readyState === WebSocket.OPEN + ) { + ws.close(); + } + } + + connectWebSocket(); + }, [connectWebSocket]); + + return { + isConnected, + error, + socket: wsRef.current, + sendMessage, + isReconnecting, + attemptCount: attemptCountRef.current, + disconnect, + reconnect, + }; +}; diff --git a/src/icons/activity.svg b/src/icons/activity.svg new file mode 100644 index 0000000000000000000000000000000000000000..161729d381ca8b21df730df0726e5509bd7f36e0 --- /dev/null +++ b/src/icons/activity.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/arrow-down-curve.svg b/src/icons/arrow-down-curve.svg new file mode 100644 index 0000000000000000000000000000000000000000..fa8f2e0a68bef4b66224b50050f608015c633231 --- /dev/null +++ b/src/icons/arrow-down-curve.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/arrow-send.svg b/src/icons/arrow-send.svg new file mode 100644 index 0000000000000000000000000000000000000000..461c2ca70626be6a5b353f1ce0e7f50d6faaa162 --- /dev/null +++ b/src/icons/arrow-send.svg @@ -0,0 +1,5 @@ + + + diff --git a/src/icons/arrow-up-circle-fill.svg b/src/icons/arrow-up-circle-fill.svg new file mode 100644 index 0000000000000000000000000000000000000000..53a3a020566cbccc2c2f909322774f16a406f9b4 --- /dev/null +++ b/src/icons/arrow-up-circle-fill.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/automations.svg b/src/icons/automations.svg new file mode 100644 index 0000000000000000000000000000000000000000..62c57aa7ccef09c10c9b62ed31e01714c5289b16 --- /dev/null +++ b/src/icons/automations.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/icons/calendar.svg b/src/icons/calendar.svg new file mode 100644 index 0000000000000000000000000000000000000000..dd391d7531d87e226a720637084e51e3e9d5fb38 --- /dev/null +++ b/src/icons/calendar.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/check-circle-fill.svg b/src/icons/check-circle-fill.svg new file mode 100644 index 0000000000000000000000000000000000000000..c13fa064a5833f9a43caa1fee964558c9ee9d433 --- /dev/null +++ b/src/icons/check-circle-fill.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/checkmark.svg b/src/icons/checkmark.svg new file mode 100644 index 0000000000000000000000000000000000000000..bfda5c9741b3825f1a105292108f2c26c8703464 --- /dev/null +++ b/src/icons/checkmark.svg @@ -0,0 +1,5 @@ + + + diff --git a/src/icons/chevron-double-right.svg b/src/icons/chevron-double-right.svg new file mode 100644 index 0000000000000000000000000000000000000000..afdfb71fe3753c4a37b12cb5b4e340b954355a14 --- /dev/null +++ b/src/icons/chevron-double-right.svg @@ -0,0 +1,5 @@ + + + diff --git a/src/icons/chevron-down-small.svg b/src/icons/chevron-down-small.svg new file mode 100644 index 0000000000000000000000000000000000000000..9d473af09fb3dd47abe8c8bbe5b126e36a264e00 --- /dev/null +++ b/src/icons/chevron-down-small.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/chevron-down.svg b/src/icons/chevron-down.svg new file mode 100644 index 0000000000000000000000000000000000000000..908fcc2d131d11e4eb62dc86d490e08ba6009738 --- /dev/null +++ b/src/icons/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/chevron-left-small.svg b/src/icons/chevron-left-small.svg new file mode 100644 index 0000000000000000000000000000000000000000..f1de9057389f34b537ab646037e3df689d8c18f5 --- /dev/null +++ b/src/icons/chevron-left-small.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/chevron-left.svg b/src/icons/chevron-left.svg new file mode 100644 index 0000000000000000000000000000000000000000..b885c74ad3df273bde2682143bda3bf00dede8cf --- /dev/null +++ b/src/icons/chevron-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/chevron-right-small.svg b/src/icons/chevron-right-small.svg new file mode 100644 index 0000000000000000000000000000000000000000..166d64ab29442aacb67e3c0c4d8fa27f14accafd --- /dev/null +++ b/src/icons/chevron-right-small.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/chveron-up.svg b/src/icons/chveron-up.svg new file mode 100644 index 0000000000000000000000000000000000000000..32e0f65a6f75344d6792763adc800c47ae68453e --- /dev/null +++ b/src/icons/chveron-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/circle-error.svg b/src/icons/circle-error.svg new file mode 100644 index 0000000000000000000000000000000000000000..1d5c28176ee3a9a7511be7dcf77a6096c2133c04 --- /dev/null +++ b/src/icons/circle-error.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/clip.svg b/src/icons/clip.svg new file mode 100644 index 0000000000000000000000000000000000000000..26a6acb485a96a4a6f9d8877faec3872be6f094e --- /dev/null +++ b/src/icons/clip.svg @@ -0,0 +1,5 @@ + + + diff --git a/src/icons/clock.svg b/src/icons/clock.svg new file mode 100644 index 0000000000000000000000000000000000000000..6db21960c03a6be03569c2bfd33e471c8c892ae0 --- /dev/null +++ b/src/icons/clock.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/code-pill.tsx b/src/icons/code-pill.tsx new file mode 100644 index 0000000000000000000000000000000000000000..351184ad4ff7a907a323f8f3f17abbaf86abe942 --- /dev/null +++ b/src/icons/code-pill.tsx @@ -0,0 +1,20 @@ +import type { SVGProps } from "react"; + +export function CodePillIcon(props: SVGProps) { + return ( + + ); +} diff --git a/src/icons/code-tag.svg b/src/icons/code-tag.svg new file mode 100644 index 0000000000000000000000000000000000000000..690c0ad615d7a8ecba0fa087d9f832067c8ac5bf --- /dev/null +++ b/src/icons/code-tag.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/cog.svg b/src/icons/cog.svg new file mode 100644 index 0000000000000000000000000000000000000000..0a424e353e1ec933ff837896d7828c5c6fbadd7e --- /dev/null +++ b/src/icons/cog.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/icons/credit-card.svg b/src/icons/credit-card.svg new file mode 100644 index 0000000000000000000000000000000000000000..ffb4eb6bafa360499feec1abf2fe66370928eb11 --- /dev/null +++ b/src/icons/credit-card.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/database.svg b/src/icons/database.svg new file mode 100644 index 0000000000000000000000000000000000000000..92815ccce2b556f762786e247654df5eaae82727 --- /dev/null +++ b/src/icons/database.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/debug-stackframe-dot.svg b/src/icons/debug-stackframe-dot.svg new file mode 100644 index 0000000000000000000000000000000000000000..f7480e7156ca32111e1845bf9b9e7e3f03af3ea2 --- /dev/null +++ b/src/icons/debug-stackframe-dot.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/docs.svg b/src/icons/docs.svg new file mode 100644 index 0000000000000000000000000000000000000000..d3fefe4b90c7f6117f13a52d969872862e356b8f --- /dev/null +++ b/src/icons/docs.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/icons/double-check.svg b/src/icons/double-check.svg new file mode 100644 index 0000000000000000000000000000000000000000..6205a25f890c2dbac26869c6245634dbc34f63c5 --- /dev/null +++ b/src/icons/double-check.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/icons/ellipsis.svg b/src/icons/ellipsis.svg new file mode 100644 index 0000000000000000000000000000000000000000..5a4a832957e52e2dafda80428f545b2b1b8e1c2e --- /dev/null +++ b/src/icons/ellipsis.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/export.svg b/src/icons/export.svg new file mode 100644 index 0000000000000000000000000000000000000000..2ffa65e8ac2ba4f97e5b567b82f5f249e12ab1a4 --- /dev/null +++ b/src/icons/export.svg @@ -0,0 +1 @@ + diff --git a/src/icons/external-link.svg b/src/icons/external-link.svg new file mode 100644 index 0000000000000000000000000000000000000000..b67e911517714be9b2375fd528e163400bacbadc --- /dev/null +++ b/src/icons/external-link.svg @@ -0,0 +1,7 @@ + + + + diff --git a/src/icons/file.svg b/src/icons/file.svg new file mode 100644 index 0000000000000000000000000000000000000000..a2430bf157ca6353258730629f90e1d46f59bb36 --- /dev/null +++ b/src/icons/file.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/folder.svg b/src/icons/folder.svg new file mode 100644 index 0000000000000000000000000000000000000000..4b533347d0d6c48cec36b7152fa7b519b2975cf6 --- /dev/null +++ b/src/icons/folder.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + diff --git a/src/icons/globe.svg b/src/icons/globe.svg new file mode 100644 index 0000000000000000000000000000000000000000..0284351300f98c4565ec40bab959d7e179c5a2e7 --- /dev/null +++ b/src/icons/globe.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/kebab-vertical.svg b/src/icons/kebab-vertical.svg new file mode 100644 index 0000000000000000000000000000000000000000..dc0958d997d2b88daca3a7745517fd2c46bd0c82 --- /dev/null +++ b/src/icons/kebab-vertical.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/lesson-plan.svg b/src/icons/lesson-plan.svg new file mode 100644 index 0000000000000000000000000000000000000000..bcae38c071ac3c216b87c74e5d40b8cb236d02a0 --- /dev/null +++ b/src/icons/lesson-plan.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/icons/link-external.svg b/src/icons/link-external.svg new file mode 100644 index 0000000000000000000000000000000000000000..6941629a8a85563cc54a1044857cbe05a916148d --- /dev/null +++ b/src/icons/link-external.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/icons/list-type-number.svg b/src/icons/list-type-number.svg new file mode 100644 index 0000000000000000000000000000000000000000..f01df9ff95269405a5d2bc233f8e2dfcfea6070f --- /dev/null +++ b/src/icons/list-type-number.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/list.svg b/src/icons/list.svg new file mode 100644 index 0000000000000000000000000000000000000000..57981cfbae792c5e109e0bc9269ec264ebe745ae --- /dev/null +++ b/src/icons/list.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/icons/loading-outer.svg b/src/icons/loading-outer.svg new file mode 100644 index 0000000000000000000000000000000000000000..4c2d56aff0749714e8daff66aba9736e175f168f --- /dev/null +++ b/src/icons/loading-outer.svg @@ -0,0 +1,4 @@ + + + diff --git a/src/icons/lock.svg b/src/icons/lock.svg new file mode 100644 index 0000000000000000000000000000000000000000..4e1c35b55ae47f7aef903af196c8e9ae6eafbe47 --- /dev/null +++ b/src/icons/lock.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/memory_icon.svg b/src/icons/memory_icon.svg new file mode 100644 index 0000000000000000000000000000000000000000..dd809d34cebb5dd3ed13e0db8ff9fca90eb86796 --- /dev/null +++ b/src/icons/memory_icon.svg @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/src/icons/message-square-share.svg b/src/icons/message-square-share.svg new file mode 100644 index 0000000000000000000000000000000000000000..8ff454c22407c946dbf0f0c75311c12c90757140 --- /dev/null +++ b/src/icons/message-square-share.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/icons/modal-close.svg b/src/icons/modal-close.svg new file mode 100644 index 0000000000000000000000000000000000000000..0430d644115f14a1ed6975d3a10d3413a666039b --- /dev/null +++ b/src/icons/modal-close.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/money.svg b/src/icons/money.svg new file mode 100644 index 0000000000000000000000000000000000000000..b869a795793c68340759c95b8e8ec2017f1d91e8 --- /dev/null +++ b/src/icons/money.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/new-project.svg b/src/icons/new-project.svg new file mode 100644 index 0000000000000000000000000000000000000000..30e1712bcde18ca7a0fe8597769e2d5827e69a74 --- /dev/null +++ b/src/icons/new-project.svg @@ -0,0 +1,6 @@ + + + + diff --git a/src/icons/npm.svg b/src/icons/npm.svg new file mode 100644 index 0000000000000000000000000000000000000000..0545acf4d2bf2f8aa615c8f2a5d42cc0c6f19266 --- /dev/null +++ b/src/icons/npm.svg @@ -0,0 +1,6 @@ + + + diff --git a/src/icons/pause.svg b/src/icons/pause.svg new file mode 100644 index 0000000000000000000000000000000000000000..d9facf9504f33801fa506f3d7e5183e62c34873b --- /dev/null +++ b/src/icons/pause.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/pill-fill.svg b/src/icons/pill-fill.svg new file mode 100644 index 0000000000000000000000000000000000000000..ef3ec962ee8fbfa3aee63934e68aaf901e3cceb3 --- /dev/null +++ b/src/icons/pill-fill.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/pill.svg b/src/icons/pill.svg new file mode 100644 index 0000000000000000000000000000000000000000..50dfba747bf78e31893882c004d099c952ade0b4 --- /dev/null +++ b/src/icons/pill.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/play-circle.svg b/src/icons/play-circle.svg new file mode 100644 index 0000000000000000000000000000000000000000..7150676a16ae6b62b7f88f1b4378571a9250522d --- /dev/null +++ b/src/icons/play-circle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/icons/play-solid.svg b/src/icons/play-solid.svg new file mode 100644 index 0000000000000000000000000000000000000000..9fbc31a9788835d1a64ea321bdd2afe7ca9a7e43 --- /dev/null +++ b/src/icons/play-solid.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/power.svg b/src/icons/power.svg new file mode 100644 index 0000000000000000000000000000000000000000..f8f8b8d561bcc13bd90ca9ae8d0a7273c0d261b5 --- /dev/null +++ b/src/icons/power.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/pr-status.svg b/src/icons/pr-status.svg new file mode 100644 index 0000000000000000000000000000000000000000..b9a9711a6385fbd42b01ccbea963548f63ce1f0a --- /dev/null +++ b/src/icons/pr-status.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/icons/profile.svg b/src/icons/profile.svg new file mode 100644 index 0000000000000000000000000000000000000000..a3ed9941d6850a5a2ae9086223f21b28733476ec --- /dev/null +++ b/src/icons/profile.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/icons/puzzle.svg b/src/icons/puzzle.svg new file mode 100644 index 0000000000000000000000000000000000000000..2e7b29504a992563c7817366e33c54b4f2c8dd12 --- /dev/null +++ b/src/icons/puzzle.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/question-circle.svg b/src/icons/question-circle.svg new file mode 100644 index 0000000000000000000000000000000000000000..dc4169dc4bd250d35ca9af4ed4cf2ea2cc6ff4fe --- /dev/null +++ b/src/icons/question-circle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/icons/robot.svg b/src/icons/robot.svg new file mode 100644 index 0000000000000000000000000000000000000000..19b2253013f2c28b9fb6aec491b3630a3c235ae0 --- /dev/null +++ b/src/icons/robot.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/icons/search.svg b/src/icons/search.svg new file mode 100644 index 0000000000000000000000000000000000000000..9a476e804bf18e8fc18ad5702ab61b0339f5c5c8 --- /dev/null +++ b/src/icons/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/server-process.svg b/src/icons/server-process.svg new file mode 100644 index 0000000000000000000000000000000000000000..f9b7afbe224027f0e95249da59adf1aa45e1595f --- /dev/null +++ b/src/icons/server-process.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/slack.svg b/src/icons/slack.svg new file mode 100644 index 0000000000000000000000000000000000000000..aef7dfeeaead6423fd7304865b0362e3fb2e56c6 --- /dev/null +++ b/src/icons/slack.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/sparkle.svg b/src/icons/sparkle.svg new file mode 100644 index 0000000000000000000000000000000000000000..73e65f2bda4f109c63aa3aac07caba874bc005a7 --- /dev/null +++ b/src/icons/sparkle.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/stacked.svg b/src/icons/stacked.svg new file mode 100644 index 0000000000000000000000000000000000000000..927e955d8194b0a6da111d952d781a2529c60c6f --- /dev/null +++ b/src/icons/stacked.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/icons/success.svg b/src/icons/success.svg new file mode 100644 index 0000000000000000000000000000000000000000..ff27cfcfcf614d8bb13663fb68c4bf4de93f9054 --- /dev/null +++ b/src/icons/success.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/icons/tachometer-fast.svg b/src/icons/tachometer-fast.svg new file mode 100644 index 0000000000000000000000000000000000000000..fc1422550c4c1d15a66e528b0ba60fc8134183b1 --- /dev/null +++ b/src/icons/tachometer-fast.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/target.svg b/src/icons/target.svg new file mode 100644 index 0000000000000000000000000000000000000000..6eff56672c6585eb85e747f250e3d61d46327732 --- /dev/null +++ b/src/icons/target.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/terminal.svg b/src/icons/terminal.svg new file mode 100644 index 0000000000000000000000000000000000000000..b17ba769113789e010c9bc451a1d8d61e5b2234e --- /dev/null +++ b/src/icons/terminal.svg @@ -0,0 +1,4 @@ + + + diff --git a/src/icons/tools.svg b/src/icons/tools.svg new file mode 100644 index 0000000000000000000000000000000000000000..9104c82bf6c6502b7f3d88743527815d753420a5 --- /dev/null +++ b/src/icons/tools.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/u-arrow-down.svg b/src/icons/u-arrow-down.svg new file mode 100644 index 0000000000000000000000000000000000000000..74d77aac9bb396c31df661033b987b0eb255d53d --- /dev/null +++ b/src/icons/u-arrow-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/u-arrow-up.svg b/src/icons/u-arrow-up.svg new file mode 100644 index 0000000000000000000000000000000000000000..63481958e4314d5db9b1d18dc808494c2de27b3c --- /dev/null +++ b/src/icons/u-arrow-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/u-check-circle-half.svg b/src/icons/u-check-circle-half.svg new file mode 100644 index 0000000000000000000000000000000000000000..ed80d0eb7e02dc9194fe3a0f8bfcbfeb1317b11b --- /dev/null +++ b/src/icons/u-check-circle-half.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/icons/u-circle.svg b/src/icons/u-circle.svg new file mode 100644 index 0000000000000000000000000000000000000000..c562817d9b3e7ed2dcea4764662ce6dc8f6052ab --- /dev/null +++ b/src/icons/u-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/u-clock-three.svg b/src/icons/u-clock-three.svg new file mode 100644 index 0000000000000000000000000000000000000000..a966e3b79a0bb8aea3b21dcbf4b168ceab0813cd --- /dev/null +++ b/src/icons/u-clock-three.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/u-close.svg b/src/icons/u-close.svg new file mode 100644 index 0000000000000000000000000000000000000000..f0a017c13b592775c3f14be8acfa332dbbb0edef --- /dev/null +++ b/src/icons/u-close.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/u-code-branch.svg b/src/icons/u-code-branch.svg new file mode 100644 index 0000000000000000000000000000000000000000..042277d9cfb44d40a7a32f2b731a5d55747b92d2 --- /dev/null +++ b/src/icons/u-code-branch.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/u-credit-card.svg b/src/icons/u-credit-card.svg new file mode 100644 index 0000000000000000000000000000000000000000..22c87aa86d4ec1dd73b2f85333d6f95633d665f2 --- /dev/null +++ b/src/icons/u-credit-card.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/u-delete.svg b/src/icons/u-delete.svg new file mode 100644 index 0000000000000000000000000000000000000000..a33262c573ea0cd97e9e2484dde293a4dd9e7782 --- /dev/null +++ b/src/icons/u-delete.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/u-edit.svg b/src/icons/u-edit.svg new file mode 100644 index 0000000000000000000000000000000000000000..9e2c2c9505e6573dd7cd8215b8267ac383db4680 --- /dev/null +++ b/src/icons/u-edit.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/u-plus.svg b/src/icons/u-plus.svg new file mode 100644 index 0000000000000000000000000000000000000000..5fca2644365af4441c612542e45d2d3b53981410 --- /dev/null +++ b/src/icons/u-plus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/icons/u-pr.svg b/src/icons/u-pr.svg new file mode 100644 index 0000000000000000000000000000000000000000..e1fe070782b026b874d5b9b51489f796e09ad930 --- /dev/null +++ b/src/icons/u-pr.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/icons/u-puzzle-piece.svg b/src/icons/u-puzzle-piece.svg new file mode 100644 index 0000000000000000000000000000000000000000..fb06a27303d2fa8a8b498bf54cd91c42d0da61a2 --- /dev/null +++ b/src/icons/u-puzzle-piece.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/u-refresh.svg b/src/icons/u-refresh.svg new file mode 100644 index 0000000000000000000000000000000000000000..9e3a2051d2628738b0c559acfe61b17e546a8be5 --- /dev/null +++ b/src/icons/u-refresh.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/u-robot.svg b/src/icons/u-robot.svg new file mode 100644 index 0000000000000000000000000000000000000000..6ad5fcc71abeb25378d4796c5d7e139b2a49f5a4 --- /dev/null +++ b/src/icons/u-robot.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/u-warning.svg b/src/icons/u-warning.svg new file mode 100644 index 0000000000000000000000000000000000000000..10148311412e6167ac80810ee7bb3965efee4774 --- /dev/null +++ b/src/icons/u-warning.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/icons/u-water.svg b/src/icons/u-water.svg new file mode 100644 index 0000000000000000000000000000000000000000..e2a3a9b9d65c11e972df0c7a4af3fd188c57a3fd --- /dev/null +++ b/src/icons/u-water.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/user.svg b/src/icons/user.svg new file mode 100644 index 0000000000000000000000000000000000000000..7c1fff4533300fc72647188aa06a1d7abe6e34c2 --- /dev/null +++ b/src/icons/user.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/vscode.svg b/src/icons/vscode.svg new file mode 100644 index 0000000000000000000000000000000000000000..cb5ac0ec7df67368ec4b0a85dcd076e28ad20cac --- /dev/null +++ b/src/icons/vscode.svg @@ -0,0 +1,8 @@ + + + diff --git a/src/icons/x-circle-solid.svg b/src/icons/x-circle-solid.svg new file mode 100644 index 0000000000000000000000000000000000000000..f673bbf0b1e56eb3cbe4165bf6af0c8232ccc2b6 --- /dev/null +++ b/src/icons/x-circle-solid.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/icons/x-circle.svg b/src/icons/x-circle.svg new file mode 100644 index 0000000000000000000000000000000000000000..6ddf2d1a6b4d74d7b24327cb0e878030fa639e7d --- /dev/null +++ b/src/icons/x-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/x.svg b/src/icons/x.svg new file mode 100644 index 0000000000000000000000000000000000000000..277a53a685258b9ebbcb941b4373148fd362f24c --- /dev/null +++ b/src/icons/x.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/lib/index.ts b/src/lib/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..af627ec5810ef7fed4487c7e66c30d5c37d420fb --- /dev/null +++ b/src/lib/index.ts @@ -0,0 +1,72 @@ +export * from "../components/browser"; +export * from "../components/conversation"; +export * from "../components/files"; +export * from "../components/settings"; +export * from "../components/sidebar"; +export * from "../components/terminal"; +export { + AgentServerUIProviders, + AgentServerUIRoot, + DEFAULT_AGENT_SERVER_ANALYTICS, + type AgentServerUIAnalyticsConfig, + type AgentServerUIPostHogAnalyticsConfig, + type AgentServerUIProvidersProps, + type AgentServerUIRootProps, +} from "../components/providers"; +export { + createAgentServerQueryClient, + getDefaultQueryClient, + getQueryClient, + queryClient, + setQueryClient, +} from "../query-client-config"; +export { + AvailableLanguages, + OPENHANDS_I18N_NAMESPACE, + createAgentServerI18n, + getDefaultI18n, + getI18n, + setI18n, + translationResources, + waitForI18n, +} from "../i18n"; +export { + AGENT_SERVER_UI_DEFAULT_CSS_VARIABLES, + AGENT_SERVER_UI_DEFAULT_THEME, + AGENT_SERVER_UI_SCOPE_ATTRIBUTE, + AGENT_SERVER_UI_SCOPE_SELECTOR, + type AgentServerUICssVariableName, + type AgentServerUIStyleOverrides, + type AgentServerUITheme, +} from "../styles/agent-server-ui-style-scope"; +export { + CANVAS_EXTENSION_HOST_API_VERSION, + CANVAS_EXTENSION_MANIFEST_SCHEMA_VERSION, + type CanvasExtensionAgentServerRequest, + type CanvasExtensionContributions, + type CanvasExtensionDispose, + type CanvasExtensionHost, + type CanvasExtensionManifest, + type CanvasExtensionModule, + type CanvasExtensionPageContribution, + type CanvasExtensionPageMount, + type CanvasExtensionPageMountContext, + type InstallCanvasExtensionRequest, + type InstalledCanvasExtensionInfo, +} from "../types/canvas-extension"; + +// Telemetry exports +export { useTelemetry, type UseTelemetryReturn } from "../hooks/use-telemetry"; +export { + configureTelemetry, + getTelemetryConsent, + setTelemetryConsent, + isTelemetryEnabled, + trackInstall, + trackSessionStart, + trackEvent, + clearTelemetryData, + type TelemetryConfig, + type TelemetryConfiguration, + type TelemetryConsent, +} from "../services/telemetry"; diff --git a/src/manifests/automation-insights.ts b/src/manifests/automation-insights.ts new file mode 100644 index 0000000000000000000000000000000000000000..cc61aff7ada9226d32bb5cce4dee0d87cbb5f07e --- /dev/null +++ b/src/manifests/automation-insights.ts @@ -0,0 +1,343 @@ +/** + * The dashboard's automation knowledge, and the only module beside + * `automation-setup.ts` that has any. + * + * The interface manifest's dashboard surface names metrics, filter predicates, + * and sort comparators from closed sets; the one implementation of each name + * is here, over the automation service's list and runs responses, so the rest + * of `src/manifests/` stays about composition rather than about automations. + * + * Summaries are derived from the newest page of runs (the sample the runs + * hook fetches); `total` alone is the response's lifetime count. Success rate + * and durations consider only terminal lifecycle runs; the success numerator + * uses task-aware display status so completed-but-blocked work is not counted + * as successful. + */ + +import { + AutomationRunStatus, + type Automation, + type AutomationRun, + type AutomationRunsResponse, +} from "#/types/automation"; +import { getAutomationRunDisplay } from "#/utils/automation-run-display"; +import type { + DashboardSortValue, + DashboardStatusValue, + DashboardTriggerValue, + InterfaceListInsights, + OverviewMetric, +} from "./types"; + +export type AutomationHealth = + | "healthy" + | "failing" + | "running" + | "disabled" + | "never-run" + | "unknown"; + +/** Which caption of the manifest's `insights.health` block names each state. */ +export const HEALTH_LABEL_KEYS: Record< + AutomationHealth, + keyof InterfaceListInsights["health"] +> = { + healthy: "healthy", + failing: "failing", + running: "running", + disabled: "disabled", + "never-run": "neverRun", + unknown: "checking", +}; + +export interface AutomationRunSummary { + /** Lifetime run count, from the response — not the sample's length. */ + total: number; + /** + * Lifetime COMPLETED-run count. From the response's `status_counts` when + * the service reports it; an older service leaves that out, so the sample + * stands in exactly when it holds the whole history (`total` ≤ its length) + * and the count is null — unknowable, never guessed — otherwise. + */ + completedTotal: number | null; + latestRun: AutomationRun | null; + /** Newest-first sample used by the list sparkline (same page as the summary). */ + recentRuns: AutomationRun[]; + /** COMPLETED over COMPLETED+FAILED in the sample. Null with no terminal runs. */ + recentSuccessRate: number | null; + /** Mean completed_at − started_at over the sample's terminal runs. */ + averageDurationMs: number | null; +} + +/** One automation's summary alongside its query state. */ +export interface RunSummaryState { + summary: AutomationRunSummary | null; + isLoading: boolean; + isError: boolean; +} + +type Summaries = ReadonlyMap; + +const TERMINAL_STATUSES = new Set([ + AutomationRunStatus.COMPLETED, + AutomationRunStatus.FAILED, +]); + +function isTaskSuccessful(run: AutomationRun): boolean { + const { badgeStatus } = getAutomationRunDisplay(run); + return ( + badgeStatus === AutomationRunStatus.COMPLETED || badgeStatus === "success" + ); +} + +function isTaskFailing(run: AutomationRun): boolean { + const { badgeStatus } = getAutomationRunDisplay(run); + return ( + badgeStatus === AutomationRunStatus.FAILED || + badgeStatus === "failed" || + badgeStatus === "blocked" + ); +} + +export function summarizeAutomationRuns( + response: AutomationRunsResponse, +): AutomationRunSummary { + const terminal = response.runs.filter((run) => + TERMINAL_STATUSES.has(run.status), + ); + const completed = terminal.filter(isTaskSuccessful).length; + + let completedTotal: number | null; + if (response.status_counts) { + completedTotal = response.status_counts[AutomationRunStatus.COMPLETED] ?? 0; + } else if (response.total <= response.runs.length) { + completedTotal = completed; + } else { + completedTotal = null; + } + + const durations = terminal.flatMap((run) => { + if (!run.completed_at) return []; + const ms = + new Date(run.completed_at).getTime() - new Date(run.started_at).getTime(); + return Number.isFinite(ms) && ms >= 0 ? [ms] : []; + }); + + return { + total: response.total, + completedTotal, + latestRun: response.runs[0] ?? null, + recentRuns: response.runs, + recentSuccessRate: + terminal.length === 0 ? null : completed / terminal.length, + averageDurationMs: + durations.length === 0 + ? null + : durations.reduce((sum, ms) => sum + ms, 0) / durations.length, + }; +} + +/** + * A disabled automation is disabled no matter its history; while the summary + * is unsettled nothing is claimed; after that the latest run speaks. + */ +export function deriveAutomationHealth( + automation: Automation, + state: RunSummaryState | undefined, +): AutomationHealth { + if (!automation.enabled) return "disabled"; + if (!state || state.isError || state.isLoading || !state.summary) { + return "unknown"; + } + const latest = state.summary.latestRun; + if (!latest) return "never-run"; + if (isTaskFailing(latest)) return "failing"; + if ( + latest.status === AutomationRunStatus.PENDING || + latest.status === AutomationRunStatus.RUNNING + ) { + return "running"; + } + return isTaskSuccessful(latest) ? "healthy" : "unknown"; +} + +/** "—" unknown, seconds under a minute, minutes under an hour, else "1.5h". */ +export function formatCompactDuration(ms: number | null): string { + if (ms === null || !Number.isFinite(ms)) return "—"; + if (ms < 60_000) return `${Math.max(1, Math.round(ms / 1000))}s`; + if (ms < 3_600_000) return `${Math.max(1, Math.round(ms / 60_000))}m`; + return `${(ms / 3_600_000).toFixed(1)}h`; +} + +/** The list page's search predicate: name, prompt, repository, or model. */ +export function matchesAutomationSearch( + automation: Automation, + query: string, +): boolean { + const q = query.toLowerCase(); + if (!q) return true; + return ( + automation.name.toLowerCase().includes(q) || + (automation.prompt ?? "").toLowerCase().includes(q) || + automation.repository?.toLowerCase().includes(q) === true || + automation.model?.toLowerCase().includes(q) === true + ); +} + +const STATUS_PREDICATES: Record< + Exclude, + (automation: Automation, health: AutomationHealth) => boolean +> = { + active: (automation) => automation.enabled, + failing: (_automation, health) => health === "failing", + disabled: (automation) => !automation.enabled, +}; + +const TRIGGER_PREDICATES: Record< + Exclude, + (automation: Automation) => boolean +> = { + // The backend emits several scheduled-trigger aliases, so "event" is the + // one exact kind and everything else is a schedule. + event: (automation) => automation.trigger.type === "event", + schedule: (automation) => automation.trigger.type !== "event", +}; + +function runCount(automation: Automation, byId: Summaries): number { + return byId.get(automation.id)?.summary?.total ?? 0; +} + +function lastRunTime(automation: Automation, byId: Summaries): number { + const startedAt = + byId.get(automation.id)?.summary?.latestRun?.started_at ?? + automation.last_triggered_at; + if (!startedAt) return 0; + const time = new Date(startedAt).getTime(); + return Number.isFinite(time) ? time : 0; +} + +const SORT_COMPARATORS: Record< + DashboardSortValue, + (a: Automation, b: Automation, byId: Summaries) => number +> = { + "last-run": (a, b, byId) => lastRunTime(b, byId) - lastRunTime(a, byId), + runs: (a, b, byId) => runCount(b, byId) - runCount(a, byId), + name: (a, b) => a.name.localeCompare(b.name), +}; + +export interface DashboardViewState { + search: string; + status: DashboardStatusValue; + trigger: DashboardTriggerValue; + sort: DashboardSortValue; +} + +/** The automations the dashboard shows, filtered and ordered. */ +export function applyDashboardView( + automations: readonly Automation[], + view: DashboardViewState, + byId: Summaries, +): Automation[] { + return automations + .filter((automation) => { + if (!matchesAutomationSearch(automation, view.search)) return false; + if ( + view.status !== "all" && + !STATUS_PREDICATES[view.status]( + automation, + deriveAutomationHealth(automation, byId.get(automation.id)), + ) + ) { + return false; + } + if ( + view.trigger !== "all" && + !TRIGGER_PREDICATES[view.trigger](automation) + ) { + return false; + } + return true; + }) + .sort((a, b) => SORT_COMPARATORS[view.sort](a, b, byId)); +} + +export interface OverviewTileValue { + /** The formatted value the tile displays. */ + display: string; + /** True when the metric's value is zero, which swaps in `zeroDetail`. */ + isZero: boolean; + /** Values the tile's copy may substitute via `{{name}}`. */ + placeholderValues: Record; +} + +function loadedSummaries( + automations: readonly Automation[], + byId: Summaries, +): AutomationRunSummary[] { + return automations.flatMap((automation) => { + const state = byId.get(automation.id); + return state?.summary && !state.isLoading && !state.isError + ? [state.summary] + : []; + }); +} + +const TILE_COMPUTATIONS: Record< + OverviewMetric, + (automations: readonly Automation[], byId: Summaries) => OverviewTileValue +> = { + automations: (automations) => ({ + display: automations.length.toLocaleString(), + isZero: automations.length === 0, + placeholderValues: { + active: automations.filter((automation) => automation.enabled).length, + }, + }), + "needs-attention": (automations, byId) => { + const failing = automations.filter( + (automation) => + deriveAutomationHealth(automation, byId.get(automation.id)) === + "failing", + ).length; + return { + display: failing.toLocaleString(), + isZero: failing === 0, + placeholderValues: {}, + }; + }, + "total-runs": (automations, byId) => { + const loaded = loadedSummaries(automations, byId); + if (loaded.length === 0 && automations.length > 0) { + return { display: "—", isZero: false, placeholderValues: {} }; + } + const total = loaded.reduce((sum, summary) => sum + summary.total, 0); + return { + display: total.toLocaleString(), + isZero: total === 0, + placeholderValues: {}, + }; + }, + "average-duration": (automations, byId) => { + const durations = loadedSummaries(automations, byId).flatMap((summary) => + summary.averageDurationMs === null ? [] : [summary.averageDurationMs], + ); + const mean = + durations.length === 0 + ? null + : durations.reduce((sum, ms) => sum + ms, 0) / durations.length; + return { + display: formatCompactDuration(mean), + isZero: false, + placeholderValues: {}, + }; + }, +}; + +/** The value behind one manifest-declared tile. */ +export function computeOverviewTile( + metric: OverviewMetric, + automations: readonly Automation[], + byId: Summaries, +): OverviewTileValue { + return TILE_COMPUTATIONS[metric](automations, byId); +} diff --git a/src/manifests/automation-setup.test.ts b/src/manifests/automation-setup.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..138095c85ab0e040c877905c02ec44c9f2ab049a --- /dev/null +++ b/src/manifests/automation-setup.test.ts @@ -0,0 +1,111 @@ +import { AUTOMATION_CATALOG } from "@openhands/extensions/automations"; +import customAutomationFixture from "@openhands/extensions/testing/automations/custom-automation.json"; +import { describe, expect, it } from "vitest"; +import { buildCreatePayload, buildPreflightBody } from "./automation-setup"; +import type { SetupEntry, SetupFormValues, SetupRequestBody } from "./types"; +import { validateSetupEntry } from "./manifest-validation"; + +const customAutomation = AUTOMATION_CATALOG.find( + (entry) => entry.id === "custom-automation", +) as SetupEntry | undefined; + +describe("custom automation setup actions", () => { + it("admits the custom automation action manifest", () => { + expect(customAutomation).toBeDefined(); + expect(validateSetupEntry(customAutomation).errors).toEqual([]); + }); + + it.each(customAutomationFixture.scenarios)( + "derives the $id preflight and create payloads", + (scenario) => { + expect(customAutomation).toBeDefined(); + const formValues = scenario.formValues as unknown as SetupFormValues; + const createBody = scenario.create.request + .body as unknown as SetupRequestBody; + const tarballPath = + typeof createBody.tarball_path === "string" + ? createBody.tarball_path + : undefined; + + expect( + buildPreflightBody( + customAutomation!, + formValues, + scenario.selectedTrigger, + scenario.selectedAction, + ), + ).toEqual(scenario.preflight.request.body); + expect( + buildCreatePayload( + customAutomation!, + formValues, + tarballPath, + scenario.selectedTrigger, + scenario.selectedAction, + ), + ).toEqual(createBody); + }, + ); + + it("keeps event filters rendered from non-filter form fields", () => { + const entry: SetupEntry = { + id: "mention-responder", + name: "Mention responder", + description: "Respond to matching comments.", + requires: { integrations: {} }, + setup: { + version: "1.0", + mode: "direct", + form: { + triggers: { + event: { + source: { + type: "event-source", + label: "Source", + help: "Where events come from.", + required: true, + }, + on: { + type: "event-type", + label: "Event", + help: "Which event to watch.", + required: true, + }, + mention: { + type: "text", + label: "Mention", + help: "Text that must appear in the comment.", + required: true, + }, + }, + }, + args: {}, + }, + prompt: "Handle {{form.mention}}.", + filter: "icontains(comment.body, '{{form.mention}}')", + }, + }; + + expect( + buildCreatePayload( + entry, + { + source: "github", + on: "issue_comment.created", + mention: "@openhands", + }, + undefined, + "event", + ), + ).toEqual({ + name: "Mention responder", + prompt: "Handle @openhands.", + trigger: { + type: "event", + source: "github", + on: "issue_comment.created", + filter: "icontains(comment.body, '@openhands')", + }, + }); + }); +}); diff --git a/src/manifests/automation-setup.ts b/src/manifests/automation-setup.ts new file mode 100644 index 0000000000000000000000000000000000000000..5864acedc4d13784ccd4bf480bca4e3d31722edf --- /dev/null +++ b/src/manifests/automation-setup.ts @@ -0,0 +1,705 @@ +/** + * The only module that knows a setup entry configures an *automation*. + * + * The published contract states just what varies between entries; everything + * derivable is the host's to generate. That derivation is here, and nowhere + * else, so the rest of `src/manifests/` stays about forms rather than about + * automations. + * + * The algorithm mirrors `tests/test_automation_setup.py::_render_payload` in + * `OpenHands/extensions`, which is the authoritative reference: it produces the + * request bodies published in that repository's contract fixtures, and those + * were verified against the live service. The create model is `extra="forbid"`, + * so any divergence is a hard 422 rather than a dropped field. + */ + +import i18n from "#/i18n"; +import { I18nKey } from "#/i18n/declaration"; +import { findAutomationCommand } from "#/utils/automation-catalog"; +import { getAutomationEndpoint } from "./automation-interface"; +import { + actionKinds, + collectFields, + fieldText, + fieldValues, +} from "./manifest-local-validation"; +import { interpolateText, interpolateValue } from "./manifest-template"; +import type { + SetupActionKind, + SetupBlock, + SetupBundleConfigValue, + SetupEntry, + SetupFormValues, + SetupRequestBody, + SetupTriggerKind, +} from "./types"; + +/** + * The creation endpoint a derived draft would be posted to. Resolved on call + * rather than at import, because the endpoint is the interface manifest's and + * this module loads whether or not one was admitted. + * + * A bundle entry is created through the raw endpoint, because what it sends is + * a tarball it uploaded rather than arguments to a preset. Called without an + * entry - as the import path does - it answers for a prompt. + */ +export function automationCreateEndpoint( + entry?: SetupEntry, + selectedAction?: string | null, +): string { + const kind = selectedActionKind(entry, selectedAction); + if (kind === "plugin") return getAutomationEndpoint("createPlugin"); + if (kind === "upload" || kind === "bundle") { + return requireBundleEndpoint("createBundle"); + } + return getAutomationEndpoint("createPrompt"); +} + +/** Where a bundle's tarball is uploaded, before the create call. */ +export function automationUploadEndpoint(): string { + return requireBundleEndpoint("uploads"); +} + +/** The endpoints a bundle entry cannot be created without. */ +const BUNDLE_ENDPOINTS = ["createBundle", "uploads"] as const; + +/** + * An endpoint only a bundle needs. The interface manifest may predate bundles, + * and the host holds no path of its own to fall back to, so this is where that + * runs out rather than somewhere deep in a request. + */ +function requireBundleEndpoint( + name: (typeof BUNDLE_ENDPOINTS)[number], +): string { + const path = getAutomationEndpoint(name); + if (!path) { + throw new Error( + `The published automation interface declares no '${name}' endpoint, ` + + "so this deployment cannot create an automation from a script bundle.", + ); + } + return path; +} + +/** + * The endpoints this entry needs that the published interface does not declare. + * + * Asked before the form renders rather than discovered at the moment of + * creating: a pinned package that predates bundles can never answer, and the + * dialog can say so while nothing has been filled in yet. Empty for every + * entry that is not a bundle, which needs nothing beyond what the block has + * always declared. + */ +function missingEndpointsForAction(kind: SetupActionKind | "bundle"): string[] { + return kind === "upload" || kind === "bundle" + ? BUNDLE_ENDPOINTS.filter((name) => !getAutomationEndpoint(name)) + : []; +} + +export function missingCreateEndpoints( + entry: SetupEntry, + selectedAction?: string | null, +): string[] { + if (isBundleEntry(entry)) return missingEndpointsForAction("bundle"); + if (!entry.setup.actions) return []; + + if (selectedAction && selectedAction in entry.setup.actions) { + return missingEndpointsForAction(selectedAction as SetupActionKind); + } + + const missingByAction = actionKinds(entry.setup).map( + missingEndpointsForAction, + ); + if (missingByAction.some((missing) => missing.length === 0)) return []; + return Array.from(new Set(missingByAction.flat())); +} + +/** Whether this entry ships a script tarball instead of a prompt. */ +export function isBundleEntry(entry: SetupEntry): boolean { + return entry.setup.mode === "direct" && entry.setup.bundle !== undefined; +} + +export function isUploadAction( + entry: SetupEntry, + selectedAction?: string | null, +): boolean { + return selectedActionKind(entry, selectedAction) === "upload"; +} + +export function selectedActionKind( + entry: SetupEntry | undefined, + selectedAction?: string | null, +): SetupActionKind | "bundle" | "prompt" { + if (!entry) return "prompt"; + if (isBundleEntry(entry)) return "bundle"; + const actions = entry.setup.actions ?? {}; + if (selectedAction && selectedAction in actions) { + return selectedAction as SetupActionKind; + } + const keys = actionKinds(entry.setup); + if (keys.length === 1) return keys[0]; + return "prompt"; +} + +/** + * The `tarball_path` a preflight draft carries. + * + * Preflight runs on every field blur and the upload happens once, at submit, + * so there is no real path to send yet. The service checks this field's scheme + * at preflight and its ownership only at creation, so a well-formed stand-in + * validates exactly what preflight is for - the rest of the body - without + * uploading an archive per keystroke. + */ +export const PREFLIGHT_TARBALL_PATH = + "oh-internal://uploads/00000000-0000-0000-0000-000000000000"; + +/** + * Trigger properties a form field may fill, per trigger kind. A field under a + * trigger kind whose name is listed here fills the trigger property of the same + * name; anything else there, such as a phrase to match, is an input to `filter`. + */ +const TRIGGER_PROPERTIES: Record = { + cron: ["schedule", "timezone"], + event: ["source", "on"], +}; + +const OPTIONAL_CREATE_PROPERTIES = ["model", "timeout"] as const; + +/** + * Repository properties a form field may fill, read the same way + * {@link TRIGGER_PROPERTIES} is: a field whose name is listed here fills the + * `repos[]` property of the same name. `url` and `provider` are absent because + * neither is answered by a field of its own — they come from the repo-picker's + * value and from its declared provider. + * + * These two lists are the only field names the derivation knows; everything + * else it reads it finds by field type. An entry that wants a base branch in + * its request therefore has to name that field `ref`. + */ +const REPO_PROPERTIES: readonly string[] = ["ref"]; + +const FORM_PLACEHOLDER_PATTERN = /\{\{form\.([A-Za-z0-9_.]+)\}\}/g; + +/** The repository input, which supplies `repos` and an event trigger's source. */ +function findRepoPickerField( + setup: SetupBlock, + selectedAction?: string | null, +) { + const match = Object.entries(collectFields(setup, null, selectedAction)).find( + ([, field]) => field.type === "repo-picker", + ); + return match ? { name: match[0], field: match[1] } : null; +} + +/** Every repository the form collected, whether the picker takes one or many. */ +function repositories( + setup: SetupBlock, + values: SetupFormValues, + selectedAction?: string | null, +): string[] { + const picker = findRepoPickerField(setup, selectedAction); + return picker ? fieldValues(values[picker.name]) : []; +} + +/** + * The created automation's name. + * + * One repository is worth naming; several are not, so the count stands in + * rather than a list of names that would not fit. + */ +function deriveName( + entry: SetupEntry, + values: SetupFormValues, + selectedAction?: string | null, +): string { + if ( + "name" in collectFields(entry.setup, null, selectedAction) && + "name" in values + ) { + return fieldText(values.name); + } + + const repos = repositories(entry.setup, values, selectedAction); + if (repos.length === 0) return entry.name; + if (repos.length === 1) return `${entry.name} - ${repos[0]}`; + // The count is the one word here the host writes rather than reads off the + // entry, so it is translated. There is no translator to pass in: the + // derivation runs from a memo, an upload and a test alike. + const count = i18n.t(I18nKey.SETUP$REPOSITORY_COUNT, { + total: repos.length, + }); + return `${entry.name} - ${count}`; +} + +/** The active trigger kind for a direct entry, with its fields. */ +function getTrigger(setup: SetupBlock, selectedTrigger?: string | null) { + const triggers = setup.form.triggers ?? {}; + const entries = Object.entries(triggers); + if (entries.length === 0) return null; + const match = + selectedTrigger && selectedTrigger in triggers + ? [selectedTrigger, triggers[selectedTrigger as SetupTriggerKind]] + : entries.length === 1 + ? entries[0] + : null; + if (!match) return null; + const [kind, fields] = match; + return { kind: kind as SetupTriggerKind, fields: fields ?? {} }; +} + +function hasPayloadValue( + value: SetupRequestBody[string] | undefined, +): value is SetupRequestBody[string] { + return value !== undefined && value !== null && value !== ""; +} + +function fieldPayloadValue( + fieldType: string | undefined, + value: SetupFormValues[string], +) { + if (fieldType === "number") { + const text = fieldText(value); + return text === "" ? undefined : Number(text); + } + return fieldText(value); +} + +function optionalCreateProperties( + setup: SetupBlock, + values: SetupFormValues, + selectedAction?: string | null, +): SetupRequestBody { + return Object.fromEntries( + OPTIONAL_CREATE_PROPERTIES.flatMap((name) => { + const field = collectFields(setup, null, selectedAction)[name]; + const value = fieldPayloadValue(field?.type, values[name]); + return hasPayloadValue(value) ? [[name, value]] : []; + }), + ) as SetupRequestBody; +} + +function repositoryProperty( + setup: SetupBlock, + values: SetupFormValues, + selectedAction?: string | null, +): SetupRequestBody { + const repoPicker = findRepoPickerField(setup, selectedAction); + const repos = repositories(setup, values, selectedAction); + if (repos.length === 0 || !repoPicker?.field.provider) return {}; + + const declared = REPO_PROPERTIES.filter((name) => + hasPayloadValue(fieldText(values[name])), + ); + return { + repos: repos.map((url) => ({ + url, + ...Object.fromEntries( + declared.map((name) => [name, fieldText(values[name])]), + ), + provider: repoPicker.field.provider as string, + })), + }; +} + +function parsePluginSources( + value: SetupRequestBody[string], +): SetupRequestBody[string] { + if (!hasPayloadValue(value) || typeof value !== "string") return value; + const trimmed = value.trim(); + if (!trimmed) return []; + if (trimmed.startsWith("[")) { + try { + return JSON.parse(trimmed) as SetupRequestBody[string]; + } catch { + return [{ source: trimmed }]; + } + } + return trimmed + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [source, ...parts] = line.split(/\s+/); + const plugin: Record = { source }; + parts.forEach((part) => { + const [key, ...raw] = part.split("="); + const valuePart = raw.join("="); + if ((key === "ref" || key === "repo_path") && valuePart) { + plugin[key] = valuePart; + } + }); + return plugin; + }); +} + +/** + * The create request body these form values produce. + * + * No entry declares it: `name` comes from the entry, `repos` from the + * repo-picker field and its declared provider, and `trigger` from the key and + * fields under `form.triggers`. Only `prompt` and an event `filter` are + * declared, because only they cannot be read off the form. + * + * Returns null for an assisted entry, which hands setup to a conversation + * instead of sending a body. + */ +export function buildCreatePayload( + entry: SetupEntry, + values: SetupFormValues, + /** Bundle/upload entries only: what the upload returned. */ + tarballPath: string = PREFLIGHT_TARBALL_PATH, + selectedTrigger?: string | null, + selectedActionKey?: string | null, +): SetupRequestBody | null { + const { setup } = entry; + if (setup.mode !== "direct") return null; + if (setup.bundle) { + return buildBundlePayload(entry, values, tarballPath, selectedTrigger); + } + if (setup.actions) { + return buildActionPayload( + entry, + values, + tarballPath, + selectedTrigger, + selectedActionKey, + ); + } + if (!setup.prompt) return null; + + const scope = { form: values, automation: entry }; + + const payload: SetupRequestBody = { + name: deriveName(entry, values), + prompt: interpolateText(setup.prompt, scope), + ...optionalCreateProperties(setup, values), + ...repositoryProperty(setup, values), + }; + + const trigger = buildTrigger(entry, values, selectedTrigger); + if (trigger) payload.trigger = trigger; + + const template = buildTemplate(entry, values); + if (template) payload.template = template; + + return payload; +} + +function buildActionPayload( + entry: SetupEntry, + values: SetupFormValues, + tarballPath: string, + selectedTrigger?: string | null, + selectedActionKey?: string | null, +): SetupRequestBody | null { + const kind = selectedActionKind(entry, selectedActionKey); + if (kind === "bundle") return null; + + const setup = entry.setup; + const scope = { form: values, automation: entry }; + const payload: SetupRequestBody = { + name: deriveName(entry, values, selectedActionKey), + }; + + const trigger = buildTrigger( + entry, + values, + selectedTrigger, + selectedActionKey, + ); + if (trigger) payload.trigger = trigger; + + Object.assign( + payload, + optionalCreateProperties(setup, values, selectedActionKey), + ); + + if (kind === "upload") { + const action = setup.actions?.upload; + if (!action) return null; + payload.tarball_path = + tarballPath || interpolateText(action.tarballPath, scope); + payload.entrypoint = interpolateText(action.entrypoint, scope); + if (action.setupScript) { + const setupScript = interpolateText(action.setupScript, scope); + if (hasPayloadValue(setupScript)) payload.setup_script_path = setupScript; + } + return payload; + } + + const action = + kind === "plugin" ? setup.actions?.plugin : setup.actions?.prompt; + if (!action) return null; + payload.prompt = interpolateText(action.prompt, scope); + Object.assign(payload, repositoryProperty(setup, values, selectedActionKey)); + + if (kind === "plugin") { + const pluginAction = setup.actions?.plugin; + if (!pluginAction) return null; + payload.plugins = parsePluginSources( + interpolateValue(pluginAction.plugins, scope), + ); + } + + const template = buildTemplate(entry, values); + if (template) payload.template = template; + + return payload; +} + +/** + * The `template` provenance a prompt entry sends, or undefined for one that + * publishes no version. + * + * It is what lets the service recognise a second setup of the same entry as + * the automation it already holds. The bundle path derives the same block from + * the config the bundle declares; a prompt entry declares none, so the answer + * to "what was this created from" is the form as submitted. + */ +function buildTemplate( + entry: SetupEntry, + values: SetupFormValues, +): SetupRequestBody | undefined { + if (!entry.version) return undefined; + return { + id: entry.id, + version: entry.version, + config: { ...values } as SetupRequestBody, + }; +} + +/** + * The `trigger` object, read off the key and fields under `form.triggers`. + * + * Identical for both kinds of direct entry: only the create endpoint and what + * the automation is told to do differ between a prompt and a bundle. + */ +function buildTrigger( + entry: SetupEntry, + values: SetupFormValues, + selectedTrigger?: string | null, + selectedAction?: string | null, +): SetupRequestBody | undefined { + const trigger = getTrigger(entry.setup, selectedTrigger); + if (!trigger) return undefined; + + const properties = TRIGGER_PROPERTIES[trigger.kind]; + const declared = Object.keys(trigger.fields).filter((name) => + properties.includes(name), + ); + const repoPicker = findRepoPickerField(entry.setup, selectedAction); + const derived = Object.fromEntries( + declared + .map((name) => [name, fieldText(values[name])]) + .filter(([, value]) => hasPayloadValue(value)), + ); + + const filter = entry.setup.filter + ? interpolateText(entry.setup.filter, { + form: values, + automation: entry, + }) + : undefined; + + return { + type: trigger.kind, + ...derived, + ...(trigger.kind === "event" && { + ...(!("source" in derived) && { + source: repoPicker?.field.provider ?? "", + }), + ...(hasPayloadValue(filter) && { filter }), + }), + }; +} + +/** + * The raw create body a bundle entry produces. + * + * `tarball_path` is the one value neither declared nor derived: the host packs + * and uploads the bundle first, and creates from what came back. `template` is + * the provenance that makes enabling the same entry twice return the + * automation that already exists rather than a second one. + * + * There is no `repos`: the raw endpoint has no such field, and a bundle's + * script fetches what it needs itself. + */ +function buildBundlePayload( + entry: SetupEntry, + values: SetupFormValues, + tarballPath: string, + selectedTrigger?: string | null, +): SetupRequestBody { + const bundle = entry.setup.bundle!; + const scope = { form: values, automation: entry }; + + const payload: SetupRequestBody = { + name: deriveName(entry, values), + }; + + const trigger = buildTrigger(entry, values, selectedTrigger); + if (trigger) payload.trigger = trigger; + + payload.tarball_path = tarballPath; + payload.entrypoint = bundle.entrypoint; + if (bundle.setupScript) payload.setup_script_path = bundle.setupScript; + if (bundle.timeout !== undefined) payload.timeout = bundle.timeout; + payload.template = { + id: entry.id, + version: bundle.version, + config: interpolateConfig(bundle.config, scope) as SetupRequestBody, + }; + + return payload; +} + +/** + * Placeholder substitution over the config tree. Only string leaves are + * templated; a number, a boolean or a null is written through as itself, so an + * entry can state a value the script reads as the type it expects. + */ +function interpolateConfig( + node: SetupBundleConfigValue, + scope: Parameters[1], +): SetupBundleConfigValue { + if (typeof node === "string") { + return interpolateValue(node, scope); + } + if (Array.isArray(node)) { + return node.map((item) => interpolateConfig(item, scope)); + } + if (typeof node === "object" && node !== null) { + return Object.fromEntries( + Object.entries(node).map(([key, value]) => [ + key, + interpolateConfig(value, scope), + ]), + ); + } + return node; +} + +/** + * The preflight body the host sends. The same shape for every entry, so no + * entry declares it. + */ +export function buildPreflightBody( + entry: SetupEntry, + values: SetupFormValues, + selectedTrigger?: string | null, + selectedAction?: string | null, +): SetupRequestBody | null { + const draft = buildCreatePayload( + entry, + values, + PREFLIGHT_TARBALL_PATH, + selectedTrigger, + selectedAction, + ); + if (!draft) return null; + + return { + automationId: entry.id, + endpoint: automationCreateEndpoint(entry, selectedAction), + draft, + }; +} + +/** + * What an assisted entry sends into the conversation that finishes setup. + * + * The command is not declared either: it lives once, in the owning skill's own + * `triggers` frontmatter, and the skill defaults to the entry's id. An entry + * whose skill is invoked by description rather than by command contributes + * nothing here, and the answers alone open the conversation. + */ +export function buildAssistedMessage( + entry: SetupEntry, + values: SetupFormValues, +): string { + const command = findAutomationCommand(entry); + const message = entry.setup.message + ? interpolateText(entry.setup.message, { form: values, automation: entry }) + : ""; + + return [command, message].filter(Boolean).join("\n\n"); +} + +function collectPlaceholderFields(value: string): string[] { + const names = Array.from( + value.matchAll(FORM_PLACEHOLDER_PATTERN), + (match) => match[1], + ); + return Array.from(new Set(names)); +} + +/** + * Which form fields built each payload path. + * + * Preflight and the create endpoint reject a draft by payload path, and the + * host has to turn that back into a highlighted input. Building the body with + * each field standing in for its own value recovers the mapping exactly, so an + * entry does not declare it. + */ +export function deriveErrorMap( + entry: SetupEntry, + selectedTrigger?: string | null, + selectedAction?: string | null, +): Record { + const mapping: Record = {}; + + const triggerEntries = Object.keys(entry.setup.form.triggers ?? {}); + const triggerVariants = selectedTrigger + ? [selectedTrigger] + : triggerEntries.length > 1 + ? triggerEntries + : [null]; + + const actionEntries = actionKinds(entry.setup); + const actionVariants = selectedAction + ? [selectedAction] + : actionEntries.length > 1 + ? actionEntries + : [null]; + + const walk = (node: unknown, path: string): void => { + if (Array.isArray(node)) { + node.forEach((item, index) => walk(item, `${path}[${index}]`)); + return; + } + if (typeof node === "object" && node !== null) { + Object.entries(node).forEach(([key, item]) => + walk(item, path ? `${path}.${key}` : key), + ); + return; + } + if (typeof node === "string") { + const names = collectPlaceholderFields(node); + if (names.length > 0) { + mapping[path] = Array.from( + new Set([...(mapping[path] ?? []), ...names]), + ); + } + } + }; + + actionVariants.forEach((actionVariant) => { + triggerVariants.forEach((triggerVariant) => { + const names = Object.keys( + collectFields(entry.setup, triggerVariant, actionVariant), + ); + const variantStandIns = Object.fromEntries( + names.map((name) => [name, `{{form.${name}}}`]), + ); + const template = buildCreatePayload( + entry, + variantStandIns, + actionVariant === "upload" ? "" : PREFLIGHT_TARBALL_PATH, + triggerVariant, + actionVariant, + ); + if (template) walk(template, ""); + }); + }); + return mapping; +} diff --git a/src/manifests/interface-validation.ts b/src/manifests/interface-validation.ts new file mode 100644 index 0000000000000000000000000000000000000000..86d6efb38b24c83cad8d489d3f5a96200a57f982 --- /dev/null +++ b/src/manifests/interface-validation.ts @@ -0,0 +1,757 @@ +/** + * Admission policy for the extension-published Automation interface manifest. + * + * Like a setup entry, the interface manifest is data authored in a different + * repository that instructs this host to render copy, build links, and address + * requests, so the host decides what it is *permitted* to state. Admission is + * all-or-nothing: one bad field rejects the whole manifest, and the host falls + * back to its own defaults rather than rendering a partially-trusted mix. + */ + +import type { + AutomationAttributeName, + InterfaceAttributeType, + InterfaceRoutes, + OverviewMetric, +} from "./types"; +import { + DASHBOARD_FILTER_IDS, + DASHBOARD_FILTER_VALUES, + DASHBOARD_SORT_VALUES, + INTERFACE_ICON_SLUGS, + INTERFACE_SUB_PAGE_IDS, + INTERFACE_VERSION, + OVERVIEW_METRICS, + OVERVIEW_TILE_PLACEHOLDERS, +} from "./types"; + +/** Copy must never be able to inject markup into the host. */ +const MARKUP_PATTERN = /<[A-Za-z/!]/; +const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/; +/** The one URL the manifest may state, pinned to the product documentation. */ +const DOCS_URL_PREFIX = "https://docs.openhands.dev/"; +const FILE_KIND_PATTERN = /^[a-z][a-z-]*$/; +const FILENAME_SUFFIX_PATTERN = /^\.[a-z][a-z.]*json$/; +const EVENT_SOURCE_PATTERN = /^[a-z0-9][a-z0-9.-]*$/; +/** Service-relative only: rooted, no traversal, no query, no fragment. */ +const ENDPOINT_PATTERN = /^\/[A-Za-z0-9/{}_-]*$/; +/** Exactly one `{id}` substitution and no other braces. */ +const ID_ENDPOINT_PATTERN = /^[^{}]*\{id\}[^{}]*$/; + +const GIT_PROVIDERS = ["github", "gitlab", "bitbucket"] as const; +/** + * The attribute semantics this host's edit dialog implements: the control it + * renders per property and the requiredness it enforces. Admission pins a + * declared attribute to exactly these, so an admitted manifest can never + * promise a control, a requiredness, or (below) a minimum the form would + * silently ignore. + */ +const HOST_ATTRIBUTES: Record< + AutomationAttributeName, + { type: InterfaceAttributeType; required: boolean } +> = { + name: { type: "text", required: true }, + prompt: { type: "textarea", required: false }, + model: { type: "llm-profile", required: false }, + timeout: { type: "number", required: false }, + schedule: { type: "schedule", required: false }, +}; +const ATTRIBUTE_NAMES = Object.keys(HOST_ATTRIBUTES); +const PLAIN_ENDPOINT_NAMES = [ + "list", + "health", + "capabilities", + "validate", + "createPrompt", + "createPlugin", +] as const; +// Endpoints added after the block shipped. A manifest published before them is +// still admitted - requiring them would 404 the whole surface for anyone +// pinning an older package - so they are checked only when present, and a +// bundle entry, which is the only thing that needs them, fails on its own if +// its manifest predates them. +const OPTIONAL_PLAIN_ENDPOINT_NAMES = ["createBundle", "uploads"] as const; +const ID_ENDPOINT_NAMES = ["detail", "dispatch", "runs", "tarball"] as const; + +export interface InterfaceValidationContext { + /** Ids of the published automation catalog, for the featured-list check. */ + catalogIds: ReadonlySet; + /** The routes this host has registrations for. A manifest must match them. */ + mountedRoutes: InterfaceRoutes; +} + +export interface InterfaceValidationResult { + valid: boolean; + errors: string[]; +} + +type Rec = Record; + +function isRecord(value: unknown): value is Rec { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isOneOf( + value: unknown, + values: T, +): value is T[number] { + return ( + typeof value === "string" && (values as readonly string[]).includes(value) + ); +} + +class InterfaceChecker { + readonly errors: string[] = []; + + fail(path: string, reason: string): false { + this.errors.push(`${path}: ${reason}`); + return false; + } + + /** Literal user-visible copy. Carries no markup. */ + copy(value: unknown, path: string): boolean { + if (typeof value !== "string" || value.length === 0) { + return this.fail(path, "must be a non-empty string"); + } + if (MARKUP_PATTERN.test(value)) { + return this.fail(path, "must not contain markup"); + } + return true; + } + + /** A closed object: every present key must be an expected one. */ + closed(container: Rec, allowed: readonly string[], path: string): void { + Object.keys(container) + .filter((key) => !allowed.includes(key)) + .forEach((key) => this.fail(`${path}.${key}`, "is not allowed")); + } + + record(value: unknown, path: string): value is Rec { + if (isRecord(value)) return true; + this.fail(path, "must be an object"); + return false; + } +} + +function checkRoutes( + check: InterfaceChecker, + routes: unknown, + mounted: InterfaceRoutes, +): void { + if (!check.record(routes, "routes")) return; + check.closed(routes, ["list", "setup", "detail", "templates"], "routes"); + + // The host serves what it has registrations for, so a declared route must be + // exactly the mounted shape — the manifest owns link construction, not the + // router table. + (["list", "setup", "detail"] as const).forEach((name) => { + if (routes[name] !== mounted[name]) { + check.fail(`routes.${name}`, `must be "${mounted[name]}"`); + } + }); + if ( + routes.templates !== undefined && + routes.templates !== mounted.templates + ) { + check.fail( + "routes.templates", + mounted.templates === undefined + ? "is not a route this host mounts" + : `must be "${mounted.templates}"`, + ); + } +} + +function checkSubPages(check: InterfaceChecker, subPages: unknown): void { + if (!Array.isArray(subPages) || subPages.length === 0) { + check.fail("navigation.subPages", "must be a non-empty array"); + return; + } + const seen = new Set(); + subPages.forEach((item: unknown, index) => { + const path = `navigation.subPages[${index}]`; + if (!check.record(item, path)) return; + check.closed(item, ["page", "label", "icon"], path); + + const { page, label, icon } = item; + if (!isOneOf(page, INTERFACE_SUB_PAGE_IDS)) { + check.fail(`${path}.page`, "is not a sub-page this host serves"); + } else if (seen.has(page)) { + check.fail(`${path}.page`, "repeats a page"); + } else { + seen.add(page); + } + check.copy(label, `${path}.label`); + checkIconSlug(check, icon, `${path}.icon`); + }); +} + +function checkIconSlug( + check: InterfaceChecker, + icon: unknown, + path: string, +): void { + if (!isOneOf(icon, INTERFACE_ICON_SLUGS)) { + check.fail(path, "is not an icon this host ships"); + } +} + +function checkNavigation(check: InterfaceChecker, navigation: unknown): void { + if (!check.record(navigation, "navigation")) return; + check.closed( + navigation, + ["sidebar", "commandMenu", "subPages"], + "navigation", + ); + + if (check.record(navigation.sidebar, "navigation.sidebar")) { + check.closed(navigation.sidebar, ["label"], "navigation.sidebar"); + check.copy(navigation.sidebar.label, "navigation.sidebar.label"); + } + if (check.record(navigation.commandMenu, "navigation.commandMenu")) { + const menu = navigation.commandMenu; + check.closed( + menu, + ["title", "description", "keywords"], + "navigation.commandMenu", + ); + check.copy(menu.title, "navigation.commandMenu.title"); + check.copy(menu.description, "navigation.commandMenu.description"); + check.copy(menu.keywords, "navigation.commandMenu.keywords"); + } + if (navigation.subPages !== undefined) { + checkSubPages(check, navigation.subPages); + } +} + +function checkPages(check: InterfaceChecker, pages: unknown): void { + if (!check.record(pages, "pages")) return; + check.closed(pages, ["list", "detail", "edit", "templates"], "pages"); + + if (check.record(pages.list, "pages.list")) { + check.closed( + pages.list, + ["title", "subtitle", "overview", "filters", "sort", "insights"], + "pages.list", + ); + check.copy(pages.list.title, "pages.list.title"); + check.copy(pages.list.subtitle, "pages.list.subtitle"); + if (pages.list.overview !== undefined) { + checkOverview(check, pages.list.overview); + } + if (pages.list.filters !== undefined) { + checkFilters(check, pages.list.filters); + } + if (pages.list.sort !== undefined) { + checkSort(check, pages.list.sort); + } + if (pages.list.insights !== undefined) { + checkInsights(check, pages.list.insights); + } + } + if (check.record(pages.detail, "pages.detail")) { + check.closed(pages.detail, ["backLabel"], "pages.detail"); + check.copy(pages.detail.backLabel, "pages.detail.backLabel"); + } + if (check.record(pages.edit, "pages.edit")) { + check.closed(pages.edit, ["title"], "pages.edit"); + check.copy(pages.edit.title, "pages.edit.title"); + } + if (pages.templates !== undefined) { + checkTemplatesPage(check, pages.templates); + } +} + +function checkOverview(check: InterfaceChecker, overview: unknown): void { + const path = "pages.list.overview"; + if (!check.record(overview, path)) return; + check.closed(overview, ["label", "tiles"], path); + check.copy(overview.label, `${path}.label`); + + const { tiles } = overview; + if (!Array.isArray(tiles) || tiles.length === 0) { + check.fail(`${path}.tiles`, "must be a non-empty array"); + return; + } + const seen = new Set(); + tiles.forEach((tile: unknown, index) => { + checkOverviewTile(check, tile, seen, `${path}.tiles[${index}]`); + }); +} + +function checkOverviewTile( + check: InterfaceChecker, + tile: unknown, + seen: Set, + path: string, +): void { + if (!check.record(tile, path)) return; + check.closed(tile, ["metric", "label", "detail", "zeroDetail", "icon"], path); + + const { metric, label, detail, zeroDetail, icon } = tile; + check.copy(label, `${path}.label`); + checkIconSlug(check, icon, `${path}.icon`); + if (!isOneOf(metric, OVERVIEW_METRICS)) { + check.fail(`${path}.metric`, "is not a metric this host computes"); + check.copy(detail, `${path}.detail`); + if (zeroDetail !== undefined) check.copy(zeroDetail, `${path}.zeroDetail`); + return; + } + if (seen.has(metric)) { + check.fail(`${path}.metric`, "repeats a metric"); + } else { + seen.add(metric); + } + checkTileCopy(check, detail, metric, `${path}.detail`); + if (zeroDetail !== undefined) { + checkTileCopy(check, zeroDetail, metric, `${path}.zeroDetail`); + } +} + +/** + * Tile copy may embed `{{name}}` placeholders, but only the names the metric + * exposes — plain substitution, never an expression. + */ +function checkTileCopy( + check: InterfaceChecker, + value: unknown, + metric: OverviewMetric, + path: string, +): void { + if (!check.copy(value, path)) return; + const exposed = OVERVIEW_TILE_PLACEHOLDERS[metric]; + const unknownPlaceholder = + exposed.length === 0 + ? /\{\{/ + : new RegExp(`\\{\\{(?!(?:${exposed.join("|")})\\}\\})`); + if (unknownPlaceholder.test(value as string)) { + check.fail(path, "uses a placeholder this metric does not expose"); + } +} + +function checkFilters(check: InterfaceChecker, filters: unknown): void { + const path = "pages.list.filters"; + if (!Array.isArray(filters) || filters.length === 0) { + check.fail(path, "must be a non-empty array"); + return; + } + const seen = new Set(); + filters.forEach((filter: unknown, index) => { + checkFilter(check, filter, seen, `${path}[${index}]`); + }); +} + +function checkFilter( + check: InterfaceChecker, + filter: unknown, + seen: Set, + path: string, +): void { + if (!check.record(filter, path)) return; + check.closed(filter, ["id", "label", "options"], path); + + const { id, label, options } = filter; + check.copy(label, `${path}.label`); + if (!isOneOf(id, DASHBOARD_FILTER_IDS)) { + check.fail(`${path}.id`, "is not a filter this host implements"); + return; + } + if (seen.has(id)) { + check.fail(`${path}.id`, "repeats a filter"); + } else { + seen.add(id); + } + checkFilterOptions( + check, + options, + DASHBOARD_FILTER_VALUES[id], + `${path}.options`, + ); +} + +function checkFilterOptions( + check: InterfaceChecker, + options: unknown, + values: readonly string[], + path: string, +): void { + if (!Array.isArray(options) || options.length < 2) { + check.fail(path, "must offer at least two options"); + return; + } + const seen = new Set(); + options.forEach((option: unknown, index) => { + const optionPath = `${path}[${index}]`; + if (!check.record(option, optionPath)) return; + check.closed(option, ["value", "label"], optionPath); + + const { value, label } = option; + if (!isOneOf(value, values)) { + check.fail(`${optionPath}.value`, "is not a value this host implements"); + } else if (seen.has(value)) { + check.fail(`${optionPath}.value`, "repeats a value"); + } else { + seen.add(value); + } + check.copy(label, `${optionPath}.label`); + }); + // "all" is the host's initial selection and what Clear filters resets to, + // so a filter that does not offer it could never be neutral. + if (!seen.has("all")) { + check.fail(path, 'must offer the "all" option'); + } +} + +function checkSort(check: InterfaceChecker, sort: unknown): void { + const path = "pages.list.sort"; + if (!check.record(sort, path)) return; + check.closed(sort, ["label", "options", "default"], path); + + const { label, options, default: defaultValue } = sort; + check.copy(label, `${path}.label`); + const declared = new Set(); + if (!Array.isArray(options) || options.length === 0) { + check.fail(`${path}.options`, "must be a non-empty array"); + } else { + options.forEach((option: unknown, index) => { + const optionPath = `${path}.options[${index}]`; + if (!check.record(option, optionPath)) return; + check.closed(option, ["value", "label"], optionPath); + + if (!isOneOf(option.value, DASHBOARD_SORT_VALUES)) { + check.fail(`${optionPath}.value`, "is not a sort this host implements"); + } else if (declared.has(option.value)) { + check.fail(`${optionPath}.value`, "repeats a value"); + } else { + declared.add(option.value); + } + check.copy(option.label, `${optionPath}.label`); + }); + } + if (typeof defaultValue !== "string" || !declared.has(defaultValue)) { + check.fail(`${path}.default`, "must be one of the declared option values"); + } +} + +/** Every insight state and stat the host renders must have a caption. */ +const INSIGHTS_SHAPE = { + health: ["healthy", "failing", "running", "disabled", "neverRun", "checking"], + lastRun: ["label", "never", "justNow"], + stats: ["runs", "recentSuccess", "averageDuration"], +} as const; + +function checkInsights(check: InterfaceChecker, insights: unknown): void { + const path = "pages.list.insights"; + if (!check.record(insights, path)) return; + check.closed(insights, Object.keys(INSIGHTS_SHAPE), path); + + (Object.keys(INSIGHTS_SHAPE) as (keyof typeof INSIGHTS_SHAPE)[]).forEach( + (section) => { + const sectionPath = `${path}.${section}`; + const value = insights[section]; + if (!check.record(value, sectionPath)) return; + const keys = INSIGHTS_SHAPE[section]; + check.closed(value, keys, sectionPath); + keys.forEach((key) => { + check.copy(value[key], `${sectionPath}.${key}`); + }); + }, + ); +} + +function checkTemplatesPage(check: InterfaceChecker, templates: unknown): void { + const path = "pages.templates"; + if (!check.record(templates, path)) return; + check.closed(templates, ["title", "description"], path); + check.copy(templates.title, `${path}.title`); + check.copy(templates.description, `${path}.description`); +} + +/** + * The sub-page surface is declared whole or not at all. Navigation that points + * at an unrouted page, or a dashboard with filters but no insight captions, + * would be a partially-trusted mix, and the host refuses those wholesale. + */ +function checkSubPageGroup(check: InterfaceChecker, candidate: Rec): void { + const routes = isRecord(candidate.routes) ? candidate.routes : {}; + const navigation = isRecord(candidate.navigation) ? candidate.navigation : {}; + const pages = isRecord(candidate.pages) ? candidate.pages : {}; + const list = isRecord(pages.list) ? pages.list : {}; + + const pieces: [string, unknown][] = [ + ["routes.templates", routes.templates], + ["navigation.subPages", navigation.subPages], + ["pages.templates", pages.templates], + ["pages.list.overview", list.overview], + ["pages.list.filters", list.filters], + ["pages.list.sort", list.sort], + ["pages.list.insights", list.insights], + ]; + const missing = pieces + .filter(([, value]) => value === undefined) + .map(([name]) => name); + if (missing.length === 0 || missing.length === pieces.length) return; + check.fail( + "interface", + `the sub-page surface must be declared whole; missing ${missing.join(", ")}`, + ); +} + +function checkAttribute( + check: InterfaceChecker, + host: { type: InterfaceAttributeType; required: boolean }, + attribute: unknown, + path: string, +): void { + if (!check.record(attribute, path)) return; + check.closed( + attribute, + ["type", "label", "help", "required", "constraints"], + path, + ); + + const { type, label, help, required, constraints } = attribute; + if (type !== host.type) { + check.fail( + `${path}.type`, + `must be "${host.type}", the control this host renders`, + ); + } + check.copy(label, `${path}.label`); + if (help !== undefined) check.copy(help, `${path}.help`); + if (required !== host.required) { + check.fail( + `${path}.required`, + `must be ${host.required}, what this host enforces`, + ); + } + + if (constraints !== undefined) { + if (host.type !== "number") { + check.fail( + `${path}.constraints`, + "is only allowed on a number attribute", + ); + } else if (check.record(constraints, `${path}.constraints`)) { + check.closed(constraints, ["min", "max"], `${path}.constraints`); + const { min, max } = constraints; + // The form validates a positive integer, i.e. an effective minimum of 1; + // any other declared minimum would be a promise it does not keep. + if (min !== undefined && min !== 1) { + check.fail( + `${path}.constraints.min`, + "must be the 1 this host enforces", + ); + } + if ( + max !== undefined && + (!Number.isInteger(max) || (max as number) < 1) + ) { + check.fail(`${path}.constraints.max`, "must be a positive integer"); + } + } + } +} + +function checkAttributes(check: InterfaceChecker, attributes: unknown): void { + if (!check.record(attributes, "attributes")) return; + const names = Object.keys(attributes); + if (names.length === 0) { + check.fail("attributes", "must declare at least one attribute"); + } + names.forEach((name) => { + if (!ATTRIBUTE_NAMES.includes(name)) { + check.fail(`attributes.${name}`, "is not a settable attribute"); + return; + } + checkAttribute( + check, + HOST_ATTRIBUTES[name as AutomationAttributeName], + attributes[name], + `attributes.${name}`, + ); + }); +} + +function checkImportExport( + check: InterfaceChecker, + importExport: unknown, +): void { + if (!check.record(importExport, "importExport")) return; + check.closed( + importExport, + ["fileKind", "fileVersion", "filenameSuffix", "importDefaults"], + "importExport", + ); + + const { fileKind, fileVersion, filenameSuffix, importDefaults } = + importExport; + if (typeof fileKind !== "string" || !FILE_KIND_PATTERN.test(fileKind)) { + check.fail("importExport.fileKind", "must be a lowercase slug"); + } + if (fileVersion !== 1) { + check.fail("importExport.fileVersion", "must be 1"); + } + if ( + typeof filenameSuffix !== "string" || + !FILENAME_SUFFIX_PATTERN.test(filenameSuffix) + ) { + check.fail("importExport.filenameSuffix", "must be a .json suffix"); + } + if (check.record(importDefaults, "importExport.importDefaults")) { + check.closed( + importDefaults, + ["repoProvider", "placeholderEventSource"], + "importExport.importDefaults", + ); + if ( + typeof importDefaults.repoProvider !== "string" || + !(GIT_PROVIDERS as readonly string[]).includes( + importDefaults.repoProvider, + ) + ) { + check.fail( + "importExport.importDefaults.repoProvider", + "is not a supported provider", + ); + } + if ( + typeof importDefaults.placeholderEventSource !== "string" || + !EVENT_SOURCE_PATTERN.test(importDefaults.placeholderEventSource) + ) { + check.fail( + "importExport.importDefaults.placeholderEventSource", + "must be a lowercase event source", + ); + } + } +} + +function checkEndpoints(check: InterfaceChecker, endpoints: unknown): void { + if (!check.record(endpoints, "endpoints")) return; + const allowed = [ + ...PLAIN_ENDPOINT_NAMES, + ...OPTIONAL_PLAIN_ENDPOINT_NAMES, + ...ID_ENDPOINT_NAMES, + ]; + check.closed(endpoints, allowed, "endpoints"); + + const endpointAt = (name: string): string | null => { + const value = endpoints[name]; + if ( + typeof value !== "string" || + !ENDPOINT_PATTERN.test(value) || + value.includes("//") + ) { + check.fail(`endpoints.${name}`, "must be a rooted service-relative path"); + return null; + } + return value; + }; + + const plainNames = [ + ...PLAIN_ENDPOINT_NAMES, + ...OPTIONAL_PLAIN_ENDPOINT_NAMES.filter((name) => name in endpoints), + ]; + plainNames.forEach((name) => { + const value = endpointAt(name); + if (value !== null && /[{}]/.test(value)) { + check.fail(`endpoints.${name}`, "must not carry a substitution"); + } + }); + ID_ENDPOINT_NAMES.forEach((name) => { + const value = endpointAt(name); + if (value !== null && !ID_ENDPOINT_PATTERN.test(value)) { + check.fail(`endpoints.${name}`, "must carry exactly one {id}"); + } + }); +} + +function checkSlugList( + check: InterfaceChecker, + value: unknown, + path: string, +): string[] { + if (!Array.isArray(value) || value.length === 0) { + check.fail(path, "must be a non-empty array"); + return []; + } + const slugs = value.filter((item): item is string => { + if (typeof item === "string" && SLUG_PATTERN.test(item)) return true; + check.fail(path, "must contain only lowercase slugs"); + return false; + }); + if (new Set(slugs).size !== slugs.length) { + check.fail(path, "must not repeat an id"); + } + return slugs; +} + +const TOP_LEVEL_KEYS = [ + "version", + "routes", + "navigation", + "pages", + "docsUrl", + "attributes", + "importExport", + "endpoints", + "featuredAutomationIds", + "responderIntegrationIds", +]; + +/** + * Decide whether this host will act on a published interface manifest. + * + * The version is checked first and fails closed: a format this host does not + * recognise is refused rather than interpreted with today's rules. + */ +export function validateInterfaceManifest( + candidate: unknown, + context: InterfaceValidationContext, +): InterfaceValidationResult { + if (!isRecord(candidate)) { + return { valid: false, errors: ["interface: must be an object"] }; + } + if (candidate.version !== INTERFACE_VERSION) { + return { + valid: false, + errors: [`interface.version: must be "${INTERFACE_VERSION}"`], + }; + } + + const check = new InterfaceChecker(); + check.closed(candidate, TOP_LEVEL_KEYS, "interface"); + + checkRoutes(check, candidate.routes, context.mountedRoutes); + checkNavigation(check, candidate.navigation); + checkPages(check, candidate.pages); + if ( + typeof candidate.docsUrl !== "string" || + !candidate.docsUrl.startsWith(DOCS_URL_PREFIX) + ) { + check.fail("docsUrl", `must start with ${DOCS_URL_PREFIX}`); + } + checkAttributes(check, candidate.attributes); + checkImportExport(check, candidate.importExport); + checkEndpoints(check, candidate.endpoints); + + checkSlugList( + check, + candidate.featuredAutomationIds, + "featuredAutomationIds", + ).forEach((id) => { + if (!context.catalogIds.has(id)) { + check.fail("featuredAutomationIds", `${id} is not a catalog entry`); + } + }); + checkSlugList( + check, + candidate.responderIntegrationIds, + "responderIntegrationIds", + ); + + checkSubPageGroup(check, candidate); + + return { valid: check.errors.length === 0, errors: check.errors }; +} diff --git a/src/manifests/manifest-actions.ts b/src/manifests/manifest-actions.ts new file mode 100644 index 0000000000000000000000000000000000000000..04dabec85dd1ce884076b39b5f6a7c5833858427 --- /dev/null +++ b/src/manifests/manifest-actions.ts @@ -0,0 +1,168 @@ +/** + * The action bridge — the only channel through which a manifest can make this + * host *do* something. + * + * A manifest is data from another repository, so it never describes a request. + * It chooses between the two outcomes this host offers, and it chooses by + * declaring a `mode`: a direct entry produces a create request the host derives, + * an assisted entry hands setup to a conversation. + * + * A direct entry that ships a bundle takes one more step before that create + * request - packing and uploading the archive - but it still names no host, no + * path and no method: the endpoints come from the interface manifest and the + * files from the published package. + */ + +import { useCallback, useRef } from "react"; +import AutomationService from "#/api/automation-service/automation-service.api"; +import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"; +import { useConversationStore } from "#/stores/conversation-store"; +import { + setConversationState, + setPendingTaskDraft, +} from "#/utils/conversation-local-storage"; +import { + buildAssistedMessage, + buildCreatePayload, + isBundleEntry, + isUploadAction, +} from "./automation-setup"; +import { packBundle } from "./manifest-bundle"; +import type { + SetupEntry, + SetupFormValue, + SetupFormValues, + SetupRequestBody, +} from "./types"; + +export interface SetupActionResult { + /** The created resource, or the conversation that will finish setup. */ + response: Record; +} + +function isFileValue(value: SetupFormValue | undefined): value is File { + return typeof File !== "undefined" && value instanceof File; +} + +function uploadCacheKey( + entry: SetupEntry, + values: SetupFormValues, + selectedAction?: string | null, +): string { + const upload = values.tarball; + const uploadIdentity = isFileValue(upload) + ? { + name: upload.name, + size: upload.size, + lastModified: upload.lastModified, + } + : upload; + return `${entry.id} +${selectedAction ?? ""} +${JSON.stringify({ ...values, tarball: uploadIdentity })}`; +} + +async function uploadedTarballBytes( + values: SetupFormValues, +): Promise { + const upload = values.tarball; + if (!isFileValue(upload)) { + throw new Error("Choose a tarball before creating this automation."); + } + return new Uint8Array(await upload.arrayBuffer()); +} + +export function useSetupAction() { + const createConversation = useCreateConversation(); + const setMessageToSend = useConversationStore( + (state) => state.setMessageToSend, + ); + + // The last archive uploaded, and what the service called it. An upload that + // is followed by a create the service rejects cannot be taken back - the + // interface declares no endpoint for that - so confirming again after + // correcting a field sends the archive that is already there rather than + // leaving another copy of it behind. The archive is a pure function of the + // entry and the answers, which is what makes the key sound. + const uploadedRef = useRef<{ key: string; path: string } | null>(null); + + const startConversation = useCallback( + async (message: string): Promise => { + const conversation = await createConversation.mutateAsync({}); + + // Seed the message the same way the rest of the app does, so the + // conversation opens with it queued whichever launch path applies. + if ( + conversation.conversation_id.startsWith("task-") && + conversation.task_id + ) { + setPendingTaskDraft(conversation.task_id, message); + } else { + setConversationState(conversation.conversation_id, { + draftMessage: message, + }); + } + window.setTimeout(() => setMessageToSend(message), 0); + + return { response: { ...conversation } }; + }, + [createConversation, setMessageToSend], + ); + + return useCallback( + async ( + entry: SetupEntry, + values: SetupFormValues, + /** Present for a direct entry, and absent for an assisted one. */ + payload: SetupRequestBody | null, + selectedTrigger?: string | null, + selectedAction?: string | null, + ): Promise => { + if (!payload) { + return startConversation(buildAssistedMessage(entry, values)); + } + + // A bundle entry ships a script rather than a prompt, so what it creates + // from is an archive: pack it with the rendered config, upload it, and + // create against the path that came back. The payload built for the form + // carries a stand-in path, which is replaced here with the real one. + if (isBundleEntry(entry) || isUploadAction(entry, selectedAction)) { + const key = uploadCacheKey(entry, values, selectedAction); + let tarballPath = uploadedRef.current?.path ?? null; + if (uploadedRef.current?.key !== key || tarballPath === null) { + const archive = isBundleEntry(entry) + ? await packBundle(entry, values) + : await uploadedTarballBytes(values); + tarballPath = await AutomationService.uploadAutomationTarball( + entry.id, + archive, + ); + uploadedRef.current = { key, path: tarballPath }; + } + const body = buildCreatePayload( + entry, + values, + tarballPath, + selectedTrigger, + selectedAction, + ); + if (!body) throw new Error(`'${entry.id}' produced no create request.`); + return { + response: await AutomationService.createAutomationDraft( + body, + entry, + selectedAction, + ), + }; + } + + const response = await AutomationService.createAutomationDraft( + payload, + entry, + selectedAction, + ); + return { response }; + }, + [startConversation], + ); +} diff --git a/src/manifests/manifest-bundle.ts b/src/manifests/manifest-bundle.ts new file mode 100644 index 0000000000000000000000000000000000000000..777cbbbe6aba3d0aee252ad1de83173f3210dc90 --- /dev/null +++ b/src/manifests/manifest-bundle.ts @@ -0,0 +1,104 @@ +/** + * Packing what a bundle entry ships. + * + * A bundle's manifest names its files by the repository path they live at; the + * contents travel in the published `@openhands/extensions` package, because + * this host has the package and not the repository. That indirection is the + * whole reason this module exists: everything else about a bundle is derived + * the same way a prompt entry's request is. + */ + +import * as automations from "@openhands/extensions/automations"; +import { packTarGzip, type TarFile } from "#/utils/tar-gzip"; +import { buildCreatePayload } from "./automation-setup"; +import { BUNDLE_CONFIG_FILENAME } from "./types"; +import type { SetupBundle, SetupEntry, SetupFormValues } from "./types"; + +/** The rendered configuration, packed beside the entrypoint. */ +export { BUNDLE_CONFIG_FILENAME }; + +type BundleFileReader = (id: string) => Record | undefined; + +/** + * The files the pinned package ships for this entry. + * + * Read defensively: a package predating bundles exports no such function, and + * an entry that declares a bundle there would otherwise pack an empty archive + * that fails only once a run tries to execute it. + */ +export function getBundleFiles(id: string): Record { + const read = (automations as { getAutomationBundleFiles?: BundleFileReader }) + .getAutomationBundleFiles; + const files = read?.(id); + if (!files || Object.keys(files).length === 0) { + throw new Error( + `The published extensions package ships no bundle files for '${id}'.`, + ); + } + return files; +} + +/** + * The packed paths the service executes rather than reads. + * + * The setup script is run through a shell, and the entrypoint's first word is + * the program it runs: an entry whose entrypoint is `./main.py` invokes a + * packed file directly, and a file packed non-executable would fail at the + * moment of running rather than at admission. Every later word is an argument + * to that program, so only the first one is a path being executed. + */ +function executablePaths(bundle: SetupBundle): Set { + const program = bundle.entrypoint.trim().split(" ")[0] ?? ""; + const invoked = program.replace(/^\.\//, ""); + return new Set( + [bundle.setupScript, invoked in bundle.files ? invoked : undefined].filter( + (name): name is string => name !== undefined, + ), + ); +} + +/** + * The archive for this entry, with the form's answers rendered into + * `config.json`. + * + * The config is taken from the create payload rather than rendered again here, + * so what the tarball carries and what the create request records as template + * provenance cannot disagree. + */ +export async function packBundle( + entry: SetupEntry, + values: SetupFormValues, +): Promise { + const bundle = entry.setup.bundle; + if (!bundle) throw new Error(`'${entry.id}' declares no bundle.`); + + const contents = getBundleFiles(entry.id); + const missing = Object.keys(bundle.files).filter( + (name) => typeof contents[name] !== "string", + ); + if (missing.length > 0) { + throw new Error( + `The published extensions package is missing bundle files for ` + + `'${entry.id}': ${missing.join(", ")}.`, + ); + } + + const payload = buildCreatePayload(entry, values); + const template = payload?.template as { config?: unknown } | undefined; + + const executable = executablePaths(bundle); + const files: TarFile[] = Object.keys(bundle.files) + .sort() + .map((name) => ({ + name, + content: contents[name], + mode: executable.has(name) ? 0o755 : 0o644, + })); + + files.push({ + name: BUNDLE_CONFIG_FILENAME, + content: `${JSON.stringify(template?.config ?? {}, null, 2)}\n`, + }); + + return packTarGzip(files); +} diff --git a/src/manifests/manifest-capabilities.ts b/src/manifests/manifest-capabilities.ts new file mode 100644 index 0000000000000000000000000000000000000000..bbb8feae0538e551312a5294758ade7b50680751 --- /dev/null +++ b/src/manifests/manifest-capabilities.ts @@ -0,0 +1,109 @@ +/** + * Stage 2 — decide whether a deployment can run what a manifest requires. + * + * The manifest states what it needs — the features under `requires.features`, + * and, implicitly, the trigger kinds its form declares. The deployment states + * what it supports. Neither side is interpreted here beyond set membership, so + * this stays neutral about what any particular capability means. + */ + +import type { + DeploymentCapabilities, + SetupActionKind, + SetupEntry, + SetupTriggerKind, +} from "./types"; + +/** + * "unknown" is a real outcome, not an error: a deployment that cannot be asked + * must not be treated as one that answered no. + */ +export type SetupCapabilitySupport = boolean | "unknown"; + +export interface SetupCapabilityAssessment { + supported: boolean; + /** + * Requirement names the deployment did not report, so a block can say which + * ones rather than only that there were some. Empty when a deployment that + * reports it is not accepting work blocks the entry, because then no single + * requirement is the reason. + */ + unmet: string[]; +} + +function findUnreported( + reported: readonly string[], + required: readonly string[], +): string[] { + return required.filter((entry) => !reported.includes(entry)); +} + +function actionSupportsCapabilities( + features: readonly string[], + reported: DeploymentCapabilities, +): boolean { + return findUnreported(reported.features ?? [], features).length === 0; +} + +export function supportedActionKinds( + entry: SetupEntry, + reported: DeploymentCapabilities, +): SetupActionKind[] { + const actions = entry.setup.actions ?? {}; + return (Object.keys(actions) as SetupActionKind[]).filter((kind) => + actionSupportsCapabilities(actions[kind]?.features ?? [], reported), + ); +} + +export function supportedTriggerKinds( + entry: SetupEntry, + reported: DeploymentCapabilities, +): SetupTriggerKind[] { + const triggers = Object.keys( + entry.setup.form.triggers ?? {}, + ) as SetupTriggerKind[]; + return triggers.filter((kind) => + (reported.triggerKinds ?? []).includes(kind), + ); +} + +export function assessCapabilityRequirements( + entry: SetupEntry, + reported: DeploymentCapabilities, +): SetupCapabilityAssessment { + // A deployment that is not accepting work blocks every entry, including one + // that requires nothing of it. + if (!reported.ready) return { supported: false, unmet: [] }; + + const unmetFeatures = findUnreported( + reported.features ?? [], + entry.requires.features ?? [], + ); + const triggerKinds = Object.keys(entry.setup.form.triggers ?? {}); + const supportsAnyTrigger = + triggerKinds.length === 0 || + triggerKinds.some((kind) => (reported.triggerKinds ?? []).includes(kind)); + + const actions = entry.setup.actions ?? {}; + const actionFeatureSets = Object.values(actions).map( + (action) => action?.features ?? [], + ); + const supportsAnyAction = + actionFeatureSets.length === 0 || + actionFeatureSets.some((features) => + actionSupportsCapabilities(features, reported), + ); + const unmetActionFeatures = supportsAnyAction + ? [] + : Array.from(new Set(actionFeatureSets.flat())).filter( + (feature) => !(reported.features ?? []).includes(feature), + ); + + const unmet = [ + ...unmetFeatures, + ...unmetActionFeatures, + ...(supportsAnyTrigger ? [] : triggerKinds), + ]; + + return { supported: unmet.length === 0, unmet }; +} diff --git a/src/manifests/manifest-error-map.ts b/src/manifests/manifest-error-map.ts new file mode 100644 index 0000000000000000000000000000000000000000..8dd9b0dc3093a59b4cb820c39804c3dee2d02db8 --- /dev/null +++ b/src/manifests/manifest-error-map.ts @@ -0,0 +1,150 @@ +/** + * Translate service-reported errors back to the form field that produced them. + * + * The host validates and submits the *derived payload*, so errors come back + * addressed by payload path (`trigger.schedule`) rather than by form field + * (`schedule`). The reverse lookup is derived from the same builder that made + * the payload; the mapping is lossy by nature, since one payload value can be + * built from several fields. + */ + +import type { SetupRequestBody } from "./types"; + +/** An error the service reported, addressed by payload path where it gave one. */ +export interface ManifestServiceError { + path: string; + message: string; +} + +export interface MappedManifestErrors { + /** Keyed by form field name. */ + fieldErrors: Record; + /** Errors with no field to attach to; shown against the form as a whole. */ + formErrors: string[]; +} + +const EMPTY_MAPPED_ERRORS: MappedManifestErrors = { + fieldErrors: {}, + formErrors: [], +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Turn a service `loc` array into a path into the payload actually sent. + * + * A validation framework may address a value through segments that are not keys + * of the request body — a discriminated union reports + * `["body","trigger","cron","schedule"]` for a body whose trigger has no `cron` + * key. Walking the payload and skipping segments that do not resolve reduces + * that to `trigger.schedule` without knowing anything about the payload's + * meaning. + */ +export function locToPayloadPath( + loc: readonly (string | number)[], + payload: unknown, +): string { + const segments = loc[0] === "body" ? loc.slice(1) : loc; + const parts: string[] = []; + let current: unknown = payload; + + segments.forEach((segment) => { + if (typeof segment === "number") { + if (Array.isArray(current) && parts.length > 0) { + parts[parts.length - 1] += `[${segment}]`; + current = current[segment]; + } + return; + } + if (isRecord(current) && segment in current) { + parts.push(segment); + current = current[segment]; + } + }); + + return parts.join("."); +} + +/** + * Read errors out of a service response body, whichever of the two shapes it + * uses: a preflight result keyed by field, or a validation failure keyed by + * `loc`. + */ +export function normalizeServiceErrors( + body: unknown, + payload: SetupRequestBody | null, +): ManifestServiceError[] { + if (!isRecord(body)) return []; + + if (Array.isArray(body.errors)) { + return body.errors.flatMap((entry) => { + if (!isRecord(entry) || typeof entry.message !== "string") return []; + return [ + { + path: typeof entry.field === "string" ? entry.field : "", + message: entry.message, + }, + ]; + }); + } + + const { detail } = body; + if (typeof detail === "string") { + return [{ path: "", message: detail }]; + } + if (Array.isArray(detail)) { + return detail.flatMap((entry) => { + if (!isRecord(entry) || typeof entry.msg !== "string") return []; + const loc = Array.isArray(entry.loc) + ? (entry.loc as (string | number)[]) + : []; + return [{ path: locToPayloadPath(loc, payload), message: entry.msg }]; + }); + } + + return []; +} + +/** + * The fields behind a payload path. + * + * The map is derived from a payload where every list holds a single entry, so + * a path addressing a later index names the same field the first one does. + * Reading every index as the first is what lets an error about the second + * repository of a multi-value field reach that field at all, rather than + * falling through to a form-level message that points at nothing. + */ +function fieldsAt( + path: string, + errorMap: Record, +): string[] | undefined { + return errorMap[path] ?? errorMap[path.replace(/\[\d+\]/g, "[0]")]; +} + +/** Apply the derived payload-path map, falling back to a form-level error. */ +export function mapServiceErrors( + errors: readonly ManifestServiceError[], + errorMap: Record, +): MappedManifestErrors { + if (errors.length === 0) return EMPTY_MAPPED_ERRORS; + + const fieldErrors: Record = {}; + const formErrors: string[] = []; + + errors.forEach(({ path, message }) => { + const fields = path ? fieldsAt(path, errorMap) : undefined; + if (!fields?.length) { + formErrors.push(message); + return; + } + fields.forEach((field) => { + // Keep the first error reported against a field; later ones would + // silently replace a message the user has not read yet. + if (!(field in fieldErrors)) fieldErrors[field] = message; + }); + }); + + return { fieldErrors, formErrors }; +} diff --git a/src/manifests/manifest-local-validation.ts b/src/manifests/manifest-local-validation.ts new file mode 100644 index 0000000000000000000000000000000000000000..4505f64af87b1ff12b3afaa0d8c189945c280fef --- /dev/null +++ b/src/manifests/manifest-local-validation.ts @@ -0,0 +1,274 @@ +/** + * The form model, and the host's own check of what the user typed into it. + * + * Local validation is a convenience, not the authority: it is instant and needs + * no round trip, so it catches empty required fields and obvious typos. + * Deployment-specific questions ("is a one-minute schedule allowed here?") + * belong to preflight, which is authoritative. + * + * Errors are returned as codes rather than sentences so the host can render + * them through its own translations; only manifest-authored copy is literal. + */ + +import type { + DeploymentCapabilities, + SetupActionKind, + SetupBlock, + SetupFieldOption, + SetupFormField, + SetupFormFields, + SetupFormValue, + SetupFormValues, +} from "./types"; + +/** Characters that would break out of an expression string literal. */ +const UNSAFE_EXPRESSION_LITERAL_PATTERN = /["'\\]/; + +export type SetupFieldError = + | { code: "required" } + | { code: "minLength"; length: number } + | { code: "maxLength"; length: number } + | { code: "min"; value: number } + | { code: "max"; value: number } + | { code: "invalidOption" } + | { code: "unsafeExpressionLiteral" }; + +export type SetupFieldErrors = Record; + +/** Field constraints supplied by the deployment rather than by the manifest. */ +export interface SetupFieldOverride { + options?: SetupFieldOption[]; +} + +export function triggerKinds(setup: SetupBlock): string[] { + return Object.keys(setup.form.triggers ?? {}); +} + +export function initialTriggerKind(setup: SetupBlock): string | null { + return triggerKinds(setup)[0] ?? null; +} + +export function actionKinds(setup: SetupBlock): SetupActionKind[] { + return Object.keys(setup.actions ?? {}) as SetupActionKind[]; +} + +export function initialActionKind(setup: SetupBlock): SetupActionKind | null { + return actionKinds(setup)[0] ?? null; +} + +export type SetupFieldOverrides = Record; + +/** + * Every input the form declares, keyed by name, whichever half it is in. + * + * Trigger inputs come first so the user is asked when it runs before what it + * runs on, and so every derived view of the form keeps that order. Admission + * rejects a name declared in both halves, so the merge cannot lose a field. + */ +export function collectFields( + setup: SetupBlock, + selectedTrigger?: string | null, + selectedAction?: string | null, +): SetupFormFields { + const triggers = setup.form.triggers ?? {}; + const triggerEntries = Object.entries(triggers); + const activeTrigger = + selectedTrigger && selectedTrigger in triggers + ? triggers[selectedTrigger as keyof typeof triggers] + : triggerEntries.length === 1 + ? triggerEntries[0][1] + : {}; + const actions = setup.actions ?? {}; + const actionEntries = Object.entries(actions); + const activeAction = + selectedAction && selectedAction in actions + ? actions[selectedAction as SetupActionKind]?.args + : actionEntries.length === 1 + ? actionEntries[0][1]?.args + : {}; + return Object.assign( + {}, + activeTrigger, + setup.form.args, + activeAction, + ) as SetupFormFields; +} + +/** + * Feed deployment values into form field constraints, so the form offers only + * what the deployment accepts. + * + * A `timezone` field is the case that needs it: a manifest declares no options + * for one, because the accepted zones belong to the deployment. The cron + * interval floor is deliberately left to preflight — the deployment owns that + * limit and states it in its own words, and a local approximation would + * pre-empt the authoritative message with a worse one. + */ +export function resolveFieldOverrides( + setup: SetupBlock, + capabilities: DeploymentCapabilities | null, + selectedTrigger?: string | null, + selectedAction?: string | null, +): SetupFieldOverrides { + const overrides: SetupFieldOverrides = {}; + const fields = collectFields(setup, selectedTrigger, selectedAction); + const timezones = capabilities?.triggers?.cron?.timezones; + if (timezones?.length) { + const options = timezones.map((zone) => ({ value: zone, label: zone })); + Object.entries(fields) + .filter(([, field]) => field.type === "timezone") + .forEach(([name]) => { + overrides[name] = { options }; + }); + } + + if (capabilities?.eventSources?.length) { + const options = capabilities.eventSources.map((source) => ({ + value: source, + label: source, + })); + Object.entries(fields) + .filter(([, field]) => field.type === "event-source") + .forEach(([name]) => { + overrides[name] = { options }; + }); + } + + if (capabilities?.eventTypes?.length) { + const options = capabilities.eventTypes.map((type) => ({ + value: type, + label: type, + })); + Object.entries(fields) + .filter(([, field]) => field.type === "event-type") + .forEach(([name]) => { + overrides[name] = { options }; + }); + } + + return overrides; +} + +/** The options a field offers, after deployment constraints are applied. */ +export function getFieldOptions( + name: string, + field: SetupFormField, + overrides: SetupFieldOverrides = {}, +): SetupFieldOption[] { + return overrides[name]?.options ?? field.options ?? []; +} + +/** + * Every value a field holds, whether it collects one or many. + * + * Reading both shapes as a list is what keeps validation, interpolation and + * the payload mapping from branching on `multiple` at every use. + */ +function isFileValue(value: SetupFormValue | undefined): value is File { + return typeof File !== "undefined" && value instanceof File; +} + +export function fieldValues(value: SetupFormValue | undefined): string[] { + if (Array.isArray(value)) return value.filter((item) => item.trim() !== ""); + if (isFileValue(value)) return value.name ? [value.name] : []; + if (typeof value === "string" && value.trim() === "") return []; + if (value === undefined || value === null) return []; + return [String(value)]; +} + +/** The single value a field holds, or "" for a field collecting several. */ +export function fieldText(value: SetupFormValue | undefined): string { + if (Array.isArray(value) || value === undefined || value === null) return ""; + if (isFileValue(value)) return value.name; + return String(value); +} + +/** Initial form state: every declared field, seeded with its declared default. */ +export function getInitialFormValues( + setup: SetupBlock, + selectedTrigger?: string | null, + selectedAction?: string | null, +): SetupFormValues { + return Object.fromEntries( + Object.entries(collectFields(setup, selectedTrigger, selectedAction)).map( + ([name, field]) => [ + name, + // A field collecting several values starts empty rather than holding one + // blank entry, so "required" means "add one" rather than "fill this in". + field.multiple ? [] : (field.default ?? ""), + ], + ), + ); +} + +function validateField( + name: string, + field: SetupFormField, + rawValue: SetupFormValue | undefined, + overrides: SetupFieldOverrides, +): SetupFieldError | null { + const entered = fieldValues(rawValue); + + if (entered.length === 0) { + return field.required ? { code: "required" } : null; + } + + // Every entry of a multi-value field answers the same field, so each is held + // to the same rules and the first failure is the one reported. + const failures = entered + .map((item) => validateValue(name, field, item.trim(), overrides)) + .filter((error): error is SetupFieldError => error !== null); + return failures[0] ?? null; +} + +function validateValue( + name: string, + field: SetupFormField, + value: string, + overrides: SetupFieldOverrides, +): SetupFieldError | null { + const { minLength, maxLength, min, max, format } = field.constraints ?? {}; + if (minLength !== undefined && value.length < minLength) { + return { code: "minLength", length: minLength }; + } + if (maxLength !== undefined && value.length > maxLength) { + return { code: "maxLength", length: maxLength }; + } + if (field.type === "number") { + const numeric = Number(value); + if (Number.isNaN(numeric)) return { code: "invalidOption" }; + if (min !== undefined && numeric < min) return { code: "min", value: min }; + if (max !== undefined && numeric > max) return { code: "max", value: max }; + } + if ( + format === "safeExpressionLiteral" && + UNSAFE_EXPRESSION_LITERAL_PATTERN.test(value) + ) { + return { code: "unsafeExpressionLiteral" }; + } + + // Any field offering a closed set of values, whether the manifest declared it + // or the deployment supplied it, must be answered from that set. + const options = getFieldOptions(name, field, overrides); + if (options.length > 0 && !options.some((option) => option.value === value)) { + return { code: "invalidOption" }; + } + + return null; +} + +/** Check every declared field. Returns only the fields that failed. */ +export function validateFormValues( + setup: SetupBlock, + values: SetupFormValues, + overrides: SetupFieldOverrides = {}, + selectedTrigger?: string | null, + selectedAction?: string | null, +): SetupFieldErrors { + return Object.entries( + collectFields(setup, selectedTrigger, selectedAction), + ).reduce((errors, [name, field]) => { + const error = validateField(name, field, values[name], overrides); + return error ? { ...errors, [name]: error } : errors; + }, {}); +} diff --git a/src/manifests/manifest-registry.ts b/src/manifests/manifest-registry.ts new file mode 100644 index 0000000000000000000000000000000000000000..1d5afd1fa4f016fc6738efc6bac8cdb9cafb414f --- /dev/null +++ b/src/manifests/manifest-registry.ts @@ -0,0 +1,52 @@ +/** + * Registry of admitted setup manifests. + * + * Stage 1 of the setup flow: load the catalog, decide which entries this host + * will act on, and index the survivors by id. An entry that fails admission is + * dropped entirely rather than rendered partially, because everything + * downstream treats its content as instructions. + */ + +import { hasSetupBlock, validateSetupEntry } from "./manifest-validation"; +import type { SetupEntry } from "./types"; + +export interface SetupRegistry { + /** Entries this host has admitted, in source order. */ + readonly entries: readonly SetupEntry[]; + findById(id: string): SetupEntry | null; +} + +export function createSetupRegistry( + candidates: readonly unknown[], +): SetupRegistry { + const entries: SetupEntry[] = []; + const byId = new Map(); + + candidates.forEach((candidate) => { + // A catalog entry without a setup block is a card, not a setup experience. + // That is the normal case, so it is skipped rather than reported. + if (!hasSetupBlock(candidate)) return; + + const { valid, errors } = validateSetupEntry(candidate); + if (!valid) { + console.warn("Rejected a setup manifest:", errors.join("; ")); + return; + } + + const entry = candidate as SetupEntry; + if (byId.has(entry.id)) { + console.warn( + `Rejected a setup manifest: id "${entry.id}" is already registered`, + ); + return; + } + + entries.push(entry); + byId.set(entry.id, entry); + }); + + return { + entries, + findById: (id) => byId.get(id) ?? null, + }; +} diff --git a/src/manifests/manifest-sources.ts b/src/manifests/manifest-sources.ts new file mode 100644 index 0000000000000000000000000000000000000000..9cd410d5c3e1973522ba0e8f396ded29fc743aad --- /dev/null +++ b/src/manifests/manifest-sources.ts @@ -0,0 +1,32 @@ +/** + * The one place that decides which manifests this host is offered. + * + * Setup manifests are published by `@openhands/extensions` as an optional + * `setup` block on a catalog entry, so the catalog is the whole source. Entries + * without one are skipped, which means the pinned package deciding to ship them + * is the only step left — there is no wiring to add here. + * + * The catalog is passed as `unknown[]` on purpose: admission is a trust + * boundary, so the host validates the published data rather than trusting the + * types that shipped beside it. + */ + +import * as automationsModule from "@openhands/extensions/automations"; +import { AUTOMATION_CATALOG } from "@openhands/extensions/automations"; +import { createSetupRegistry, type SetupRegistry } from "./manifest-registry"; + +export const SETUP_REGISTRY: SetupRegistry = createSetupRegistry( + AUTOMATION_CATALOG as readonly unknown[], +); + +/** + * The interface manifest the pinned package publishes, if any. It is read off + * the module namespace so a package that predates the export yields + * `undefined` rather than a build error; admission in + * `automation-interface.ts` decides what to do with it. + */ +const automationsExports: object = automationsModule; +export const AUTOMATION_INTERFACE_CANDIDATE: unknown = + "AUTOMATION_INTERFACE" in automationsExports + ? automationsExports.AUTOMATION_INTERFACE + : undefined; diff --git a/src/manifests/types.ts b/src/manifests/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..03e9c798cacad20d5bd2b1684f0bc81d999278c9 --- /dev/null +++ b/src/manifests/types.ts @@ -0,0 +1,539 @@ +/** + * Host-owned shape of an extension-authored setup experience. + * + * A catalog entry is declarative data published by `@openhands/extensions`. Its + * optional `setup` block says what to ask a user for; everything derivable from + * that — the request to send, the route, the review screen, the analytics — is + * this host's to generate, so none of it appears here. + * + * The names mirror `@openhands/extensions/automations`' own declarations, so an + * entry from the published package assigns without an adapter. + */ + +export const SETUP_VERSION = "1.0"; + +export type SetupMode = "direct" | "assisted"; + +export type SetupFieldType = + | "text" + | "textarea" + | "select" + | "number" + | "cron" + | "timezone" + | "repo-picker" + | "llm-profile" + | "event-source" + | "event-type" + | "plugin-sources" + | "tarball-upload"; + +export type SetupGitProvider = "github" | "gitlab" | "bitbucket"; + +export type SetupTriggerKind = "cron" | "event"; + +/** Placeholder namespaces a setup block may reference inside `{{...}}`. */ +export const SETUP_PLACEHOLDER_NAMESPACES = ["form", "automation"] as const; + +export interface SetupFieldOption { + value: string; + label: string; +} + +export interface SetupFieldConstraints { + minLength?: number; + maxLength?: number; + min?: number; + max?: number; + /** + * A host-implemented check named from a closed set. Entries supply no regex + * of their own, so they cannot hand the host a pathological pattern. + */ + format?: "safeExpressionLiteral"; +} + +export interface SetupFormField { + type: SetupFieldType; + label: string; + help: string; + placeholder?: string; + default?: string | number | boolean | null; + required: boolean; + provider?: SetupGitProvider; + /** + * repo-picker only. The field collects several repositories rather than one, + * and its value is a list. A placeholder that is the whole value resolves to + * that list, so a payload can state one and get an array. + */ + multiple?: true; + options?: SetupFieldOption[]; + constraints?: SetupFieldConstraints; +} + +/** Keyed by field name, which is what `{{form.}}` resolves against. */ +export type SetupFormFields = Record; + +export interface SetupForm { + note?: string; + /** Inputs that decide when the automation runs, keyed by trigger kind. Multiple keys are selectable variants. */ + triggers?: Partial>; + /** Every other input: the arguments to the automation itself. */ + args: SetupFormFields; +} + +/** A config.json leaf: templated string, number, boolean, null, or a nesting. */ +export type SetupBundleConfigValue = + | string + | number + | boolean + | null + | SetupBundleConfigValue[] + | { [key: string]: SetupBundleConfigValue }; + +/** + * The name the rendered configuration is packed under, which is therefore a + * name a bundle's own files may not claim. Stated here rather than beside the + * packing, so admission can refuse the collision without importing it. + */ +export const BUNDLE_CONFIG_FILENAME = "config.json"; + +/** + * The script tarball a direct entry may ship instead of a prompt, for an + * automation that is deterministic machinery rather than judgement. + * + * `files` maps a path inside the archive to the repository path the extensions + * package read it from; the contents themselves come from that package's + * `getAutomationBundleFiles`, because this host has the package and not the + * repository. + */ +export interface SetupBundle { + /** Provenance recorded on the created automation, alongside the entry id. */ + version: string; + /** The command the service runs inside the extracted tarball. */ + entrypoint: string; + /** Script run once before the entrypoint. Absent when nothing to install. */ + setupScript?: string; + /** Seconds a run may take, when the service default is not enough. */ + timeout?: number; + files: Record; + /** Rendered from the form and packed as config.json. */ + config: Record; +} + +export type SetupActionKind = "prompt" | "plugin" | "upload"; + +export interface SetupPromptAction { + label: string; + help: string; + features: string[]; + args: SetupFormFields; + prompt: string; +} + +export interface SetupPluginAction { + label: string; + help: string; + features: string[]; + args: SetupFormFields; + prompt: string; + plugins: string; +} + +export interface SetupUploadAction { + label: string; + help: string; + features: string[]; + args: SetupFormFields; + tarballPath: string; + entrypoint: string; + setupScript?: string; +} + +export interface SetupActions { + prompt?: SetupPromptAction; + plugin?: SetupPluginAction; + upload?: SetupUploadAction; +} + +export type SetupAction = + | SetupPromptAction + | SetupPluginAction + | SetupUploadAction; + +export interface SetupBlock { + version: typeof SETUP_VERSION; + mode: SetupMode; + form: SetupForm; + /** direct only. What the automation is told to do. */ + prompt?: string; + /** direct only, and the alternative to `prompt`. Exactly one is present. */ + bundle?: SetupBundle; + /** direct only, and the alternative to `prompt` or `bundle`. */ + actions?: SetupActions; + /** direct only, event trigger only. Which delivered events belong to it. */ + filter?: string; + /** + * Setup context for the conversation that finishes setup. Required for + * assisted mode. Optional for direct mode, where it seeds the fallback + * conversation offered when the deployment cannot run the direct path. + */ + message?: string; +} + +export interface SetupIntegrationRequirement { + /** Why this entry needs the integration. */ + message: string; + /** Defaults to true. `false` lets setup continue while it is unconnected. */ + required?: false; +} + +export interface SetupPrerequisites { + /** Keyed by integration catalog id. */ + integrations: Record; + /** Deployment capabilities this entry cannot run without. */ + features?: string[]; +} + +/** + * The part of a catalog entry this host reads. An admitted entry always has a + * `setup` block; entries without one are simply not setup entries. + */ +export interface SetupEntry { + id: string; + name: string; + description: string; + /** + * Version of the template this entry publishes, sent with the create request + * as provenance. A bundle declares its own at `setup.bundle.version`; a + * prompt entry declares it here. Absent means the entry sends none, so the + * service records no template for what it creates. + */ + version?: string; + requires: SetupPrerequisites; + /** The skill that owns the launch command. Defaults to `id`. */ + skill?: string; + setup: SetupBlock; +} + +export type SetupPayloadValue = + | string + | number + | boolean + | null + | SetupPayloadValue[] + | { [key: string]: SetupPayloadValue }; + +export interface SetupRequestBody { + [key: string]: SetupPayloadValue; +} + +/** + * Form values as collected; the payload mapping shapes them. + * + * A field collecting several values holds a list. Everything else holds a + * string, including fields whose value is a number to the service - the + * payload mapping is where a value stops being what was typed. + */ +export type SetupFormValue = string | number | boolean | null | string[] | File; +export type SetupFormValues = Record; + +/** `GET /v1/capabilities` — what this deployment supports. */ +export interface DeploymentCapabilities { + ready: boolean; + /** Absolute timeout ceiling enforced by this deployment's automation API. */ + maxAutomationTimeoutSeconds?: number; + triggerKinds: string[]; + eventSources: string[]; + eventTypes: string[]; + triggers: { + cron?: { minIntervalSeconds: number; timezones: string[] }; + event?: { filterLanguage: string; filterFunctions: string[] }; + }; + features: string[]; +} + +/** A single problem with a draft, addressed to the field that caused it. */ +export interface DraftValidationError { + /** Dotted path into the draft. Null when the problem spans the whole draft. */ + field: string | null; + code: string; + message: string; +} + +/** `POST /v1/validate` — an invalid draft is still a 200. */ +export interface ValidateDraftResponse { + valid: boolean; + errors: DraftValidationError[]; + sampleEventMatched?: boolean | null; +} + +/** + * Host-owned shape of the production Automation interface manifest, published + * by `@openhands/extensions/automations` as `AUTOMATION_INTERFACE`. + * + * The catalog states what varies per automation; this states the domain-level + * facts of the interface itself. As with setup entries, the published data is + * validated at admission rather than trusted, and a package that predates the + * manifest simply leaves the host on its own defaults. + */ + +export const INTERFACE_VERSION = "1.0"; + +/** The closed set of runtime-model properties a client may offer for setting. */ +export type AutomationAttributeName = + | "name" + | "prompt" + | "model" + | "timeout" + | "schedule"; + +export type InterfaceAttributeType = + | "text" + | "textarea" + | "number" + | "llm-profile" + | "schedule"; + +/** How one settable attribute of an existing Automation is offered. */ +export interface InterfaceAttribute { + type: InterfaceAttributeType; + label: string; + help?: string; + required: boolean; + /** Only a `number` attribute carries constraints. */ + constraints?: { min?: number; max?: number }; +} + +export interface InterfaceRoutes { + list: string; + /** Carries the `:automationId` segment the host substitutes. */ + setup: string; + /** Carries the `:automationId` segment the host substitutes. */ + detail: string; + /** The templates sub-page. Static: there is no parameter to substitute. */ + templates?: string; +} + +/** + * Service-relative paths the host calls. The base path, methods, headers, and + * auth remain the host's. `{id}` marks where the automation id is substituted. + */ +export interface InterfaceEndpoints { + list: string; + detail: string; + dispatch: string; + runs: string; + tarball: string; + health: string; + capabilities: string; + validate: string; + createPrompt: string; + createPlugin: string; + /** + * The raw create endpoint, which a bundle entry is created through, and + * where its tarball is uploaded first. Optional: a manifest published before + * bundles existed declares neither, and is still admitted. + */ + createBundle?: string; + uploads?: string; +} + +export type InterfaceEndpointName = keyof InterfaceEndpoints; + +export interface InterfaceImportExport { + fileKind: string; + fileVersion: number; + filenameSuffix: string; + importDefaults: { + /** Provider inferred for short owner/repo repository URLs on import. */ + repoProvider: SetupGitProvider; + /** Event source of the placeholder trigger that keeps an import inert. */ + placeholderEventSource: string; + }; +} + +/** + * The sub-page surface: navigation, overview tiles, filters, sort, and run + * insights for the list page, and a templates page. Every value below names + * something this host implements from a closed set — a metric it computes, a + * predicate it applies, a comparator it runs, an icon it ships — and the + * manifest picks and captions it, never defines it. The host carries no + * definitions of its own for this surface, so a manifest without it simply + * leaves the sub-pages unrendered. + */ + +export const INTERFACE_SUB_PAGE_IDS = ["list", "templates"] as const; + +export type InterfaceSubPageId = (typeof INTERFACE_SUB_PAGE_IDS)[number]; + +/** Icon names this host maps to artwork. A manifest cannot supply its own. */ +export const INTERFACE_ICON_SLUGS = [ + "layout-dashboard", + "sparkles", + "library", + "bot", + "circle-alert", + "activity", + "timer", +] as const; + +export type InterfaceIconSlug = (typeof INTERFACE_ICON_SLUGS)[number]; + +export interface InterfaceSubPageNavItem { + /** The `pages` entry this item navigates to; its route comes from `routes`. */ + page: InterfaceSubPageId; + label: string; + icon: InterfaceIconSlug; +} + +/** Values this host computes over the loaded automations and their runs. */ +export const OVERVIEW_METRICS = [ + "automations", + "needs-attention", + "total-runs", + "average-duration", +] as const; + +export type OverviewMetric = (typeof OVERVIEW_METRICS)[number]; + +/** + * Placeholder names a tile's `detail`/`zeroDetail` copy may reference inside + * `{{...}}`, per metric. Plain substitution, like setup copy. + */ +export const OVERVIEW_TILE_PLACEHOLDERS: Record< + OverviewMetric, + readonly string[] +> = { + automations: ["active"], + "needs-attention": [], + "total-runs": [], + "average-duration": [], +}; + +export interface InterfaceOverviewTile { + metric: OverviewMetric; + label: string; + /** Caption under the value. */ + detail: string; + /** Replaces `detail` while the metric's value is zero. */ + zeroDetail?: string; + icon: InterfaceIconSlug; +} + +export interface InterfaceOverview { + /** Names the tiles section for assistive technology. */ + label: string; + tiles: InterfaceOverviewTile[]; +} + +/** Filter values name predicates this host implements, per filter id. */ +export const DASHBOARD_FILTER_VALUES = { + status: ["all", "active", "failing", "disabled"], + trigger: ["all", "schedule", "event"], +} as const; + +export type DashboardFilterId = keyof typeof DASHBOARD_FILTER_VALUES; + +export const DASHBOARD_FILTER_IDS = Object.keys( + DASHBOARD_FILTER_VALUES, +) as readonly DashboardFilterId[]; + +export type DashboardStatusValue = + (typeof DASHBOARD_FILTER_VALUES.status)[number]; + +export type DashboardTriggerValue = + (typeof DASHBOARD_FILTER_VALUES.trigger)[number]; + +export interface InterfaceStatusFilter { + id: "status"; + /** The control's accessible name. */ + label: string; + options: { value: DashboardStatusValue; label: string }[]; +} + +export interface InterfaceTriggerFilter { + id: "trigger"; + /** The control's accessible name. */ + label: string; + options: { value: DashboardTriggerValue; label: string }[]; +} + +export type InterfaceDashboardFilter = + | InterfaceStatusFilter + | InterfaceTriggerFilter; + +/** Sort values name comparators this host implements. */ +export const DASHBOARD_SORT_VALUES = ["last-run", "runs", "name"] as const; + +export type DashboardSortValue = (typeof DASHBOARD_SORT_VALUES)[number]; + +export interface InterfaceDashboardSort { + /** The control's accessible name. */ + label: string; + options: { value: DashboardSortValue; label: string }[]; + /** One of the declared option values. */ + default: DashboardSortValue; +} + +/** + * Copy for the per-automation run insights on cards and rows. The states, + * precedence, sampling, and value formatting are this host's; the manifest + * names them. + */ +export interface InterfaceListInsights { + health: { + healthy: string; + failing: string; + running: string; + disabled: string; + neverRun: string; + checking: string; + }; + lastRun: { label: string; never: string; justNow: string }; + stats: { runs: string; recentSuccess: string; averageDuration: string }; +} + +/** + * The templates sub-page identity. Its body — the catalog cards and their + * launch behavior — is this host's existing catalog surface. + */ +export interface InterfaceTemplatesPage { + title: string; + description: string; +} + +export interface InterfaceManifest { + version: typeof INTERFACE_VERSION; + routes: InterfaceRoutes; + navigation: { + sidebar: { label: string }; + commandMenu: { title: string; description: string; keywords: string }; + /** The ordered sub-page navigation of the interface. */ + subPages?: InterfaceSubPageNavItem[]; + }; + pages: { + list: { + title: string; + subtitle: string; + overview?: InterfaceOverview; + /** The filter dropdowns of the list page, in render order. */ + filters?: InterfaceDashboardFilter[]; + sort?: InterfaceDashboardSort; + insights?: InterfaceListInsights; + }; + detail: { backLabel: string }; + edit: { title: string }; + templates?: InterfaceTemplatesPage; + }; + docsUrl: string; + /** + * The input surface of an existing Automation, keyed by the runtime-model + * property the host sends. Rendering it as an edit dialog is this host's + * choice, not stated here. + */ + attributes: Partial>; + importExport: InterfaceImportExport; + endpoints: InterfaceEndpoints; + featuredAutomationIds: string[]; + responderIntegrationIds: string[]; +}