import { getActiveBackend, getRegisteredBackends, } from "../backend-registry/active-store"; import type { Backend } from "../backend-registry/types"; import { getStoredConversationMetadata } from "../conversation-metadata-store"; import type { AppConversation, AppConversationPage, AppConversationStartRequest, AppConversationStartTask, } from "../conversation-service/agent-server-conversation-service.types"; import { AGENT_CANVAS_CLIENT_HEADERS } from "../client-source"; import { callCloudProxy } from "./proxy"; /** * The cloud backend does not always echo `selected_repository` / * `selected_branch` / `git_provider` back from * `GET /api/v1/app-conversations` until its own background hydration * completes. We persist the selection to local storage at connect time * (see `AgentServerConversationService.updateConversationRepository`) * and overlay it here so the chat-page git control bar reflects the * connection immediately, instead of snapping back to the empty * "Connect Repo" state on every refetch. * * Server values take precedence whenever they're populated; the * local-storage fallback only fills in fields the server returned as * `null`/`undefined`. */ function overlayStoredRepoSelection( conversation: AppConversation | null, ): AppConversation | null { if (!conversation?.id) return conversation; const stored = getStoredConversationMetadata(conversation.id); if (!stored) return conversation; return { ...conversation, selected_repository: conversation.selected_repository ?? stored.selected_repository ?? null, selected_branch: conversation.selected_branch ?? stored.selected_branch ?? null, git_provider: conversation.git_provider ?? stored.git_provider ?? null, selected_workspace: conversation.selected_workspace ?? stored.selected_workspace ?? null, }; } function getActiveCloudBackend(): Backend { const active = getActiveBackend().backend; if (active.kind !== "cloud") { throw new Error("Cloud conversations call requires a cloud backend."); } return active; } /** * Resolve the cloud backend a Cloud conversation should be launched against * when the active backend may not be a cloud one. Cloud conversations cannot * register client tools, so the typed launch action always runs from a local * parent and has to reach a registered-but-inactive cloud backend. * * Prefers the active backend when it is already cloud, then the first * registered cloud backend that carries credentials. Returns `null` when the * user has no cloud backend connected, so callers can give the agent * corrective guidance instead of failing opaquely. * * Note: `createCloudClient` only sends `X-Org-Id` for the *active* backend, so * a conversation launched against an inactive backend lands in the API key's * own organization. */ export function pickCloudBackendForLaunch(): Backend | null { const active = getActiveBackend().backend; if (active.kind === "cloud") return active; return ( getRegisteredBackends().find( (backend) => backend.kind === "cloud" && !!backend.apiKey, ) ?? null ); } /** * Search the cloud app-conversations list. Mirrors the local * `AgentServerConversationService.searchConversations` interface but calls * the cloud endpoint `/api/v1/app-conversations/search`. */ export async function searchCloudConversations( limit: number = 20, pageId?: string, ): Promise { const backend = getActiveCloudBackend(); const params = new URLSearchParams(); params.set("limit", String(limit)); if (pageId) params.set("page_id", pageId); params.set("sort_order", "UPDATED_AT_DESC"); const data = await callCloudProxy<{ items: AppConversation[]; next_page_id: string | null; }>({ backend, method: "GET", path: `/api/v1/app-conversations/search?${params.toString()}`, }); return { items: (data?.items ?? []).map( (item) => overlayStoredRepoSelection(item) as AppConversation, ), next_page_id: data?.next_page_id ?? null, }; } /** * Batch-fetch cloud app-conversations by id. Mirrors the local * `AgentServerConversationService.batchGetAppConversations` interface. */ export async function batchGetCloudConversations( ids: string[], ): Promise<(AppConversation | null)[]> { if (ids.length === 0) return []; const backend = getActiveCloudBackend(); const params = new URLSearchParams(); for (const id of ids) params.append("ids", id); const data = await callCloudProxy<(AppConversation | null)[]>({ backend, method: "GET", path: `/api/v1/app-conversations?${params.toString()}`, }); return (data ?? []).map(overlayStoredRepoSelection); } /** * Create a v1 app-conversation on the cloud backend. * * Mirrors OpenHands' cloud flow: POST /api/v1/app-conversations with the * `AppConversationStartRequest` payload, returning a * `AppConversationStartTask`. The task is initially WORKING; the caller * polls `getCloudAppConversationStartTask` (3s cadence per OpenHands) * until status is READY (then `app_conversation_id`, `agent_server_url`, * and `session_api_key` are populated) or ERROR. * * This path does NOT use encrypted-settings round-tripping. Secrets stay * server-side on the cloud backend — the only auth carried is the cloud bearer * token, and the conversation runtime is * provisioned with its own ephemeral session_api_key returned in the * task. */ export async function createCloudAppConversation( request: AppConversationStartRequest, // Defaults to the active backend. The typed launch action passes an explicit // backend because it always runs from a *local* parent conversation. backendOverride?: Backend, ): Promise { const backend = backendOverride ?? getActiveCloudBackend(); const data = await callCloudProxy({ backend, method: "POST", path: "/api/v1/app-conversations", body: request as unknown as Record, headers: AGENT_CANVAS_CLIENT_HEADERS, }); return data; } /** * Download a v1 app-conversation as a ZIP from the cloud backend. Mirrors * the local `AgentServerConversationService.downloadConversation` interface but * calls * `GET /api/v1/app-conversations/{id}/download`, which returns * `application/zip` with `Content-Disposition` set by the cloud backend. */ export async function downloadCloudConversation( conversationId: string, ): Promise { const backend = getActiveCloudBackend(); return callCloudProxy({ backend, method: "GET", path: `/api/v1/app-conversations/${conversationId}/download`, responseType: "blob", }); } /** * Delete a v1 app-conversation on the cloud backend. Mirrors the local * `AgentServerConversationService.deleteConversation` interface but calls * `DELETE /api/v1/app-conversations/{id}`, which returns a JSON * `Success` envelope (discarded here — the caller only needs to know * the request didn't error). */ export async function deleteCloudConversation( conversationId: string, ): Promise { const backend = getActiveCloudBackend(); await callCloudProxy({ backend, method: "DELETE", path: `/api/v1/app-conversations/${conversationId}`, }); } /** * Toggle the public-sharing flag on a cloud v1 app-conversation. Mirrors * OpenHands' `AgentServerConversationService.updateConversationPublicFlag`: * `PATCH /api/v1/app-conversations/{id}` with `{ public }`, returning * the updated conversation. */ export async function updateCloudConversationPublicFlag( conversationId: string, isPublic: boolean, ): Promise { const backend = getActiveCloudBackend(); const data = await callCloudProxy({ backend, method: "PATCH", path: `/api/v1/app-conversations/${conversationId}`, body: { public: isPublic }, }); return data; } /** * Rename a cloud v1 app-conversation. The title belongs to the Cloud * app-conversation resource, so updating a runtime Agent Server would not * persist it in the Cloud conversation list. */ export async function updateCloudConversationTitle( conversationId: string, title: string, ): Promise { const backend = getActiveCloudBackend(); return callCloudProxy({ backend, method: "PATCH", path: `/api/v1/app-conversations/${conversationId}`, body: { title }, }); } /** * Pause the cloud sandbox backing a v1 app-conversation. Mirrors * OpenHands' `SandboxService.pauseSandbox`: * `POST /api/v1/sandboxes/{sandboxId}/pause` on the cloud backend, which stops * the runtime owning the conversation. */ export async function pauseCloudSandbox(sandboxId: string): Promise { const backend = getActiveCloudBackend(); await callCloudProxy({ backend, method: "POST", path: `/api/v1/sandboxes/${sandboxId}/pause`, }); } /** * Resume a paused cloud sandbox. Mirrors OpenHands' `SandboxService.resumeSandbox` * by calling `POST /api/v1/sandboxes/{sandboxId}/resume` on the SaaS. * * This is the correct endpoint for waking a PAUSED sandbox. It is a * lightweight unpause — NOT the same as creating a new start task via * `POST /api/v1/app-conversations`, which provisions a fresh conversation * and is subject to the 120-second sandbox-start timeout. */ export async function resumeCloudSandbox(sandboxId: string): Promise { const backend = getActiveCloudBackend(); await callCloudProxy({ backend, method: "POST", path: `/api/v1/sandboxes/${sandboxId}/resume`, }); } /** * Read a file from a cloud conversation's sandbox workspace. Mirrors * OpenHands' `AgentServerConversationService.readConversationFile` — hits * `GET /api/v1/app-conversations/{id}/file?file_path=...` on the cloud backend * and returns the file content as a string. */ export async function readCloudConversationFile( conversationId: string, filePath: string, ): Promise { const backend = getActiveCloudBackend(); const params = new URLSearchParams(); params.append("file_path", filePath); const data = await callCloudProxy({ backend, method: "GET", path: `/api/v1/app-conversations/${conversationId}/file?${params.toString()}`, }); return data ?? ""; } /** * List every file in a cloud conversation's sandbox workspace. Hits * `GET /api/v1/app-conversations/{id}/files?path=...` on the cloud backend, * which resolves the conversation's runtime and runs a bounded `find` * server-side (see enterprise `list_conversation_files`). Unlike the * git-changes source, this returns the full tree so the Files tab matches the * local-backend experience. Paths come back relative to `path`. */ export async function listCloudConversationFiles( conversationId: string, path: string, ): Promise { const backend = getActiveCloudBackend(); const params = new URLSearchParams(); params.append("path", path); const data = await callCloudProxy({ backend, method: "GET", path: `/api/v1/app-conversations/${conversationId}/files?${params.toString()}`, }); return Array.isArray(data) ? data : []; } /** * Fetch a single v1 app-conversation start task. Mirrors OpenHands' * `AgentServerConversationService.getStartTask` — uses the batch search endpoint * with a single id and unwraps the first result. */ export async function getCloudAppConversationStartTask( taskId: string, backendOverride?: Backend, ): Promise { const backend = backendOverride ?? getActiveCloudBackend(); const params = new URLSearchParams(); params.set("ids", taskId); const data = await callCloudProxy<(AppConversationStartTask | null)[]>({ backend, method: "GET", path: `/api/v1/app-conversations/start-tasks?${params.toString()}`, }); return data?.[0] ?? null; }