import { ConversationSortOrder, type ForkConversationRequest, type LLMConfig, type VSCodeStatusResponse, } from "@openhands/typescript-client"; import { ConversationClient, FileClient, ProfilesClient, VSCodeClient, } from "@openhands/typescript-client/clients"; import { v4 as uuidv4 } from "uuid"; import { AgentKind, Provider } from "#/types/settings"; import type { ConversationRuntimeContext } from "#/api/conversation-file-upload.api"; import { buildHttpBaseUrl } from "#/utils/websocket-url"; import { buildConversationWorkingDirForBackend, getAgentServerWorkingDir, getWorkspaceRootForBackend, } from "../agent-server-config"; import { resolveAbsoluteAgentServerPath } from "../agent-server-home"; import { getActiveBackend, getEffectiveLocalBackend, } from "../backend-registry/active-store"; import { callCloudProxy } from "../cloud/proxy"; import ProfilesService from "../profiles-service/profiles-service.api"; import { batchGetCloudConversations, createCloudAppConversation, deleteCloudConversation, downloadCloudConversation, getCloudAppConversationStartTask, readCloudConversationFile, searchCloudConversations, updateCloudConversationPublicFlag, updateCloudConversationTitle, } from "../cloud/conversation-service.api"; import { DirectConversationInfo, assertSubscriptionAuthReady, buildStartConversationRequestWithEncryptedSettings, buildStartPlanningConversationRequestWithEncryptedSettings, emptyHooksResponse, getDefaultConversationTitle, toAppConversation, toConversationPage, } from "../agent-server-adapter"; import { GetVSCodeUrlResponse } from "../open-hands.types"; import { getAgentServerClientOptions, NoBackendAvailableError, } from "../agent-server-client-options"; import SettingsService from "../settings-service/settings-service.api"; import { getTelemetryDistinctId } from "../../services/telemetry"; import { ConversationMetadata, getStoredConversationMetadata, mergeStoredConversationMetadata, removeStoredConversationMetadata, setStoredConversationMetadata, type WorkspaceMode, } from "../conversation-metadata-store"; import { resolveTitleLlmProfile } from "#/utils/title-llm-profile"; import { isPlannerConversationOf } from "#/utils/plan-file"; import type { GetHooksResponse, PluginSpec, AppConversation, AppConversationPage, AppConversationStartRequest, AppConversationStartTask, MetricsSnapshot, RuntimeConversationInfo, RuntimeConversationStats, SendMessageRequest, SendMessageResponse, } from "./agent-server-conversation-service.types"; const DEFAULT_CONVERSATION_TIMESTAMP = "1970-01-01T00:00:00.000Z"; // Creating the first conversation right after a cold agent-server boot (fresh // machine or the packaged desktop app, where uvx may still be warming caches) // can exceed the client's 60s default timeout. const CREATE_CONVERSATION_TIMEOUT_MS = 5 * 60 * 1000; const INVALID_CONVERSATION_RESPONSE_MESSAGE = "Unable to load conversations because the selected agent server returned " + "data this UI does not understand. Check the backend URL/session key and " + "update the agent server if needed."; function invalidConversationResponse(): Error { return new Error(INVALID_CONVERSATION_RESPONSE_MESSAGE); } 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" ? value : null; } function numberOrZero(value: unknown): number { return typeof value === "number" ? value : 0; } function stringOrNull(value: unknown): string | null { return typeof value === "string" ? value : null; } function readTimestamp( item: Record, snakeKey: "created_at" | "updated_at", camelKey: "createdAt" | "updatedAt", ): string { const value = item[snakeKey] ?? item[camelKey]; return typeof value === "string" && value.trim() ? value : DEFAULT_CONVERSATION_TIMESTAMP; } function normalizeTokenUsage( value: unknown, ): NonNullable | null { if (!isRecord(value)) return null; return { prompt_tokens: numberOrZero(value.prompt_tokens), completion_tokens: numberOrZero(value.completion_tokens), cache_read_tokens: numberOrZero(value.cache_read_tokens), cache_write_tokens: numberOrZero(value.cache_write_tokens), context_window: numberOrZero(value.context_window), per_turn_token: numberOrZero(value.per_turn_token), }; } function normalizeMetrics(value: unknown): MetricsSnapshot | null { if (!isRecord(value)) return null; return { accumulated_cost: numberOrNull(value.accumulated_cost), max_budget_per_task: numberOrNull(value.max_budget_per_task), accumulated_token_usage: normalizeTokenUsage(value.accumulated_token_usage), }; } // Shallow check only (matches the trust level `getRuntimeConversation` used // before this field was threaded through `DirectConversationInfo`): the // per-usage-id entries are consumed via `combineUsageMetrics`, which already // tolerates missing/malformed fields, so there's no need to validate them here. function normalizeStats(value: unknown): RuntimeConversationStats | null { return isRecord(value) ? (value as unknown as RuntimeConversationStats) : null; } function normalizeAgent(value: unknown): DirectConversationInfo["agent"] { if (!isRecord(value)) return null; const llm = isRecord(value.llm) ? { model: stringOrNull(value.llm.model) } : null; // ``kind`` is the SDK's pydantic discriminator (``"Agent"`` vs ``"ACPAgent"``); // ``toAppConversation`` reads it to derive ``agent_kind``. ``acp_server`` is // the ACP provider identity (``ACPAgent.acp_server``, SDK #3692) — required so // the adapter can source the chip + in-conversation model list from the agent // when the ``acpserver`` tag is absent (a profile launch doesn't stamp it, // #1571). ``acp_model`` is the Canvas-configured model — preserved so the chip // can fall back to it when the SDK runtime model fields aren't populated. // Preserving these makes the wire path agree with the unit-test path that // builds ``DirectConversationInfo`` directly // (e.g. ``__tests__/api/agent-server-adapter.test.ts``). return { kind: stringOrNull(value.kind), acp_server: stringOrNull(value.acp_server), acp_model: stringOrNull(value.acp_model), llm, }; } function normalizeWorkspace( value: unknown, ): DirectConversationInfo["workspace"] { if (!isRecord(value)) return null; return { working_dir: stringOrNull(value.working_dir) }; } /** * Accept the agent-server's ``tags: Record[str, str]`` payload defensively: * the wire shape is guaranteed by the server-side validator (keys * ``^[a-z0-9]+$``, string values), but a non-conforming response (older * server, raw API write, future schema drift) must never crash the parser * — Canvas only consumes ``acpserver`` and falls back to a generic chip * for anything it doesn't recognize. Drop entries whose value isn't a * plain string; return ``null`` when the wire field is absent or not an * object so consumers can use ``info.tags?.[KEY] ?? null`` uniformly. */ function normalizeTags(value: unknown): Record | null { if (!isRecord(value)) return null; const tags: Record = {}; for (const [key, entry] of Object.entries(value)) { if (typeof entry === "string") { tags[key] = entry; } } return tags; } /** * ``ConversationInfo.sub_conversation_ids`` — the agent-server derives it from * its own catalog of conversations that name this one as parent (SDK #4188), * which is what lets Canvas find a conversation's local planner without any * browser-local state. Absent on agent-servers older than 1.37.1; parsed * defensively so a non-conforming payload degrades to "no children" instead of * crashing the list. */ function normalizeSubConversationIds(value: unknown): string[] | null { if (!Array.isArray(value)) return null; return value.filter((entry): entry is string => typeof entry === "string"); } function normalizeLaunchedAgentProfile( value: unknown, ): DirectConversationInfo["launched_agent_profile"] { if (!isRecord(value)) return null; const { agent_profile_id: agentProfileId, revision } = value; if (typeof agentProfileId !== "string" || typeof revision !== "number") { return null; } return { agent_profile_id: agentProfileId, revision }; } function normalizeAbsolutePath(path: string): string | null { if (!path.startsWith("/")) return null; const segments: string[] = []; for (const segment of path.split("/")) { if (segment && segment !== ".") { if (segment === "..") { if (!segments.length) return null; segments.pop(); } else { segments.push(segment); } } } return `/${segments.join("/")}`; } function requirePathInsideDirectory(path: string, directory: string): string { const normalizedPath = normalizeAbsolutePath(path); const normalizedDirectory = normalizeAbsolutePath(directory); if ( !normalizedPath || !normalizedDirectory || (normalizedPath !== normalizedDirectory && !normalizedPath.startsWith(`${normalizedDirectory}/`)) ) { throw new Error("Conversation file path must stay inside the workspace"); } return normalizedPath; } function requireDirectConversationInfo(item: unknown): DirectConversationInfo { if (!isRecord(item) || typeof item.id !== "string" || !item.id.trim()) { throw invalidConversationResponse(); } return { id: item.id.trim(), title: stringOrNull(item.title), created_at: readTimestamp(item, "created_at", "createdAt"), updated_at: readTimestamp(item, "updated_at", "updatedAt"), execution_status: stringOrNull(item.execution_status), sandbox_status: stringOrNull(item.sandbox_status), metrics: normalizeMetrics(item.metrics), stats: normalizeStats(item.stats), agent: normalizeAgent(item.agent), workspace: normalizeWorkspace(item.workspace), tags: normalizeTags(item.tags), launched_agent_profile: normalizeLaunchedAgentProfile( item.launched_agent_profile, ), sub_conversation_ids: normalizeSubConversationIds( item.sub_conversation_ids, ), // SDK-runtime ACP model fields (populated when the agent-server supports // ``ConversationInfo.current_model_*``). Consumed by the conversation // adapter to drive the per-card chip's model text. Older agent-servers // omit these — adapter handles ``undefined`` / ``null`` gracefully. current_model_id: stringOrNull(item.current_model_id), current_model_name: stringOrNull(item.current_model_name), }; } function requireDirectConversationItems( items: unknown, ): DirectConversationInfo[] { if (!Array.isArray(items)) { throw invalidConversationResponse(); } return items.map(requireDirectConversationInfo); } function requireConversationSearchPage(page: unknown): { items: DirectConversationInfo[]; next_page_id: string | null; } { if (Array.isArray(page)) { return { items: requireDirectConversationItems(page), next_page_id: null, }; } if (!isRecord(page)) { throw invalidConversationResponse(); } return { items: requireDirectConversationItems(page.items), next_page_id: typeof page.next_page_id === "string" ? page.next_page_id : null, }; } const RUNTIME_STATUSES = new Set([ "idle", "running", "paused", "waiting_for_confirmation", "finished", "error", "stuck", ]); function toRuntimeStatus( status: DirectConversationInfo["execution_status"], ): RuntimeConversationInfo["status"] { const nextStatus = status ?? "idle"; return ( RUNTIME_STATUSES.has(nextStatus) ? nextStatus : "idle" ) as RuntimeConversationInfo["status"]; } function requireAppConversation( conversation: AppConversation | null | undefined, conversationId: string, ): AppConversation { if (!conversation) { throw new Error(`Conversation ${conversationId} was not found`); } return conversation; } /** * Options for {@link AgentServerConversationService.createConversation}. */ export interface CreateConversationOptions { initialUserMsg?: string; conversationInstructions?: string; plugins?: PluginSpec[]; metadata?: ConversationMetadata | null; workingDirOverride?: string; workspaceMode?: WorkspaceMode; parentConversationId?: string; agentType?: "default" | "plan"; sandboxId?: string; // Launch from a saved AgentProfile (resolved server-side) instead of the // current encrypted agent_settings (#3727). Supported on both local and the // cloud app-server (OpenHands #15060): local threads it through the // encrypted-settings builder; cloud sends it as a flat request field. agentProfileId?: string; agentProfileKind?: AgentKind; } class AgentServerConversationService { static async sendMessage( conversationId: string, message: SendMessageRequest, runtime?: ConversationRuntimeContext | null, ): Promise { const active = getActiveBackend().backend; let conversationUrl = runtime?.conversationUrl ?? null; let sessionApiKey = runtime?.sessionApiKey ?? null; if (active.kind === "cloud") { if (!conversationUrl || !sessionApiKey) { const [conversation] = await batchGetCloudConversations([ conversationId, ]); conversationUrl = conversation?.conversation_url?.trim() ?? null; sessionApiKey = conversation?.session_api_key?.trim() ?? null; } if (!conversationUrl || !sessionApiKey) { throw new Error( "Conversation sandbox is still starting. Wait for it to finish, then try again.", ); } await callCloudProxy({ backend: active, method: "POST", hostOverride: buildHttpBaseUrl(conversationUrl), path: `/api/conversations/${conversationId}/events`, body: { ...message, run: true }, authMode: "session-api-key", sessionApiKey, }); return message; } await new ConversationClient( getAgentServerClientOptions({ conversationUrl, sessionApiKey }), ).sendEvent(conversationId, message, { run: true, }); return message; } static async createConversation( options: CreateConversationOptions = {}, ): Promise { const { initialUserMsg, conversationInstructions, plugins, metadata, workingDirOverride, workspaceMode, parentConversationId, agentType, sandboxId, agentProfileId, agentProfileKind, } = options; if (getActiveBackend().backend.kind === "cloud") { // Cloud path mirrors OpenHands' frontend: build a flat // AppConversationStartRequest, POST /api/v1/app-conversations // (returns a WORKING task), and let the conversation route's // useTaskPolling drive it to READY. NO encrypted-settings // round-trip — the cloud backend holds secrets server-side. // When launching from a profile, send `agent_profile_id`; the backend // resolves it to agent_settings server-side. const request: AppConversationStartRequest = { initial_message: initialUserMsg ? { role: "user", content: [{ type: "text", text: initialUserMsg }], } : null, title: conversationInstructions ?? null, selected_repository: metadata?.selected_repository ?? null, selected_branch: metadata?.selected_branch ?? null, git_provider: metadata?.git_provider ?? null, plugins: plugins ?? null, parent_conversation_id: parentConversationId ?? null, agent_type: agentType, sandbox_id: sandboxId ?? null, agent_profile_id: agentProfileId ?? null, trigger: "gui", }; return createCloudAppConversation(request); } const [settings, profiles] = await Promise.all([ SettingsService.getSettings(), ProfilesService.listProfiles().catch(() => undefined), ]); const titleLlmProfile = resolveTitleLlmProfile( settings.title_llm_profile, profiles, ); const conversationId = uuidv4(); // @spec WUP-001 — Send an absolute working_dir to the agent-server. // The default is `workspace/project/` (relative); without // resolving it here, `/api/file/upload` later prepends `/` and writes // to `/workspace/...` (read-only on macOS and fresh containers). When // the user picks an explicit workspace, `workingDirOverride` is // already absolute (it comes from `search_subdirs`). // // Pick the base working dir per-backend: // 1. explicit user workspace pick → use it as-is; // 2. no pick, backend that served this frontend → the baked default // (honors a launcher-baked absolute `VITE_WORKING_DIR`); // 3. no pick, any other backend → the backend-relative default. // A baked absolute dir is a path on the host that served this frontend, // so it is only valid on that backend. Using it for a different backend // (e.g. a remote sandbox) makes the agent-server mkdir an unwritable path // and the conversation fails at the first prompt (e.g. `Permission // denied: '/Users'`). The relative default is anchored per-backend by // `resolveAbsoluteAgentServerPath()` via `/api/file/home`. The gate keys // on the active backend's host (not its id): the seeded `default-local` // entry is mutable, so a user can edit it to point at a remote host while // its id stays `default-local`. const backendHost = getActiveBackend().backend.host; const baseWorkingDir = workingDirOverride ?? buildConversationWorkingDirForBackend(conversationId, backendHost); const workingDir = await resolveAbsoluteAgentServerPath(baseWorkingDir); // The agent-server checks `/.openhands/hooks.json` literally, // so hooks need the workspace root: the per-conversation subdir below it is // created only after this request (#16907). An explicit pick is the root. const hooksProjectDir = workingDirOverride ? workingDir : await resolveAbsoluteAgentServerPath( getWorkspaceRootForBackend(backendHost), ); const resolvedWorkspaceMode = workspaceMode ?? (workingDirOverride ? "local_repo" : "new_worktree"); // Use encrypted settings to avoid exposing secrets in the browser const payload = await buildStartConversationRequestWithEncryptedSettings({ settings, query: initialUserMsg, conversationInstructions, plugins, conversationId, // The agent-server rejects a parent in a different workspace, so callers // launching a child must pass the parent's own `working_dir` as // `workingDirOverride` (see `resolveConversationWorkingDir`). Servers // older than 1.37.1 ignore the field and create an unlinked conversation. parentConversationId, workingDir, hooksProjectDir, worktree: resolvedWorkspaceMode === "new_worktree", agentProfileId, agentProfileKind, titleLlmProfile, }); const telemetryDistinctId = await getTelemetryDistinctId(); const data = await new ConversationClient( getAgentServerClientOptions({ timeout: CREATE_CONVERSATION_TIMEOUT_MS }), ).createConversation({ ...payload, ...(telemetryDistinctId ? { user_id: telemetryDistinctId } : {}), }); const localBackend = getEffectiveLocalBackend(); if (!localBackend) throw new NoBackendAvailableError(); if (metadata?.selected_repository || workingDirOverride) { // The agent-server runtime has no concept of selected repo/branch/ // workspace, so persist the home-page selection client-side. // `toAppConversation` reads the repo/branch fields back to hydrate // the chat-page badges; `useHasAttachedSource` reads // `selected_workspace` to default the Files tab to Diff mode when // the user explicitly attached a local workspace. setStoredConversationMetadata(data.id, { selected_repository: metadata?.selected_repository ?? null, selected_branch: metadata?.selected_branch ?? null, git_provider: metadata?.git_provider ?? null, selected_workspace: workingDirOverride ?? null, workspace_mode: resolvedWorkspaceMode, }); } return { id: data.id, created_by_user_id: null, status: "READY", detail: null, app_conversation_id: data.id, agent_server_url: localBackend.host, request: { initial_message: payload.initial_message as | AppConversationStartRequest["initial_message"] | undefined, plugins: plugins ?? null, }, created_at: data.created_at, updated_at: data.updated_at, }; } static async createLocalPlanningConversation( parentConversationId: string, initialMessage?: string, ): Promise { if (getActiveBackend().backend.kind === "cloud") { throw new Error("Local planning conversations require a local backend."); } const [parent] = await this.batchGetAppConversations([ parentConversationId, ]); const workingDir = parent?.workspace?.working_dir ?? getAgentServerWorkingDir(); const payload = await buildStartPlanningConversationRequestWithEncryptedSettings({ workingDir, parentConversationId, // Pin the planner to the parent's own current model. Only meaningful // for "openhands"-kind parents: an ACP parent's active_profile is a // stale launch-time snapshot (/model is a no-op for ACP), not a live // value, so treating it as authoritative would pin the planner to // the wrong model instead of falling through to global settings. parentActiveProfileName: parent?.agent_kind === "openhands" ? (parent?.active_profile ?? null) : null, // Fallback when active_profile can't be resolved (e.g. an ACP parent). parentAgentProfileId: parent?.launched_agent_profile?.agent_profile_id ?? null, initialMessage, }); const data = await new ConversationClient( getAgentServerClientOptions(), ).createConversation(payload); // Client-side fallback only: agent-servers >= 1.37.1 persist the link via // `parent_conversation_id` and hand it back on the parent's // `sub_conversation_ids`, which is the source of truth. This hint covers // older backends that ignore the field. mergeStoredConversationMetadata(parentConversationId, { local_planning_conversation_id: data.id, }); return toAppConversation(data); } /** * Ids of the conversations owned by `parentConversationId` on a local * backend — today that means its planner helper, the only child Canvas * creates locally. * * `sub_conversation_ids` is the generic server-derived child list, so each * child is kept only if it's tagged `plannerparent` for this parent — * otherwise deleting the parent would also delete an unrelated non-planner * child (e.g. a delegated sub-agent). The stored metadata hint is merged in * for agent-servers older than 1.37.1, which report no children at all. */ static async getLocalPlanningConversationIds( parentConversationId: string, ): Promise { if (getActiveBackend().backend.kind === "cloud") return []; const ids = new Set(); try { const [parent] = await this.batchGetAppConversations([ parentConversationId, ]); const childIds = parent?.sub_conversation_ids ?? []; if (childIds.length > 0) { const children = await this.batchGetAppConversations(childIds); for (const child of children) { if (child && isPlannerConversationOf(child, parentConversationId)) { ids.add(child.id); } } } } catch (error) { // The stored hint below still covers the common case, and callers // (delete) must not be blocked by a failed lookup. console.warn( `Failed to read sub-conversations of ${parentConversationId}`, error, ); } const stored = getStoredConversationMetadata( parentConversationId, )?.local_planning_conversation_id; if (stored) ids.add(stored); return [...ids]; } static async getStartTask( taskId: string, ): Promise { if (getActiveBackend().backend.kind === "cloud") { return getCloudAppConversationStartTask(taskId); } // Local agent-server creates conversations synchronously — every // local "task" is already READY when createConversation returns, so // there's nothing to poll for. return null; } static async getVSCodeUrl( conversationId: string, conversationUrl: string | null | undefined, sessionApiKey?: string | null, ): Promise { // Local-only path. Cloud conversations read the VSCode URL straight // from the cloud-computed `sandbox.exposed_urls` (see // `useUnifiedVSCodeUrl` + `useCloudSandbox`); the runtime's own // `/api/vscode/url` only knows its internal `localhost:8001`, which // the user's browser can't reach. const workspaceDir = await this.resolveConversationWorkingDir(conversationId); // Local mode: the typescript-client targets the local agent-server // directly via the conversationUrl override. const vscodeUrl = await new VSCodeClient( getAgentServerClientOptions({ conversationUrl, sessionApiKey, }), ).getUrl({ baseUrl: typeof window !== "undefined" ? window.location.origin : undefined, workspaceDir, }); return { vscode_url: vscodeUrl }; } /** * Read the editor's capability state from the agent-server. * * `/api/vscode/status` answers 200 with `enabled: false` when the * deployment set `enable_vscode: false`, which distinguishes "this * deployment offers no editor" from a transport, auth, or server * failure — `/api/vscode/url` answers 503 for the former and so * cannot be told apart from the latter. */ static async getVSCodeStatus( conversationUrl: string | null | undefined, sessionApiKey?: string | null, ): Promise { return new VSCodeClient( getAgentServerClientOptions({ conversationUrl, sessionApiKey, }), ).getStatus(); } static async resolveConversationWorkingDir( conversationId: string, ): Promise { const [conversation] = await this.batchGetAppConversations([ conversationId, ]); return conversation?.workspace?.working_dir ?? getAgentServerWorkingDir(); } static async batchGetAppConversations( ids: string[], ): Promise<(AppConversation | null)[]> { if (ids.length === 0) return []; if (getActiveBackend().backend.kind === "cloud") { return batchGetCloudConversations(ids); } const data = await new ConversationClient( getAgentServerClientOptions(), ).getConversations(ids); return requireDirectConversationItems(data).map((item) => toAppConversation(item), ); } static async updateConversationPublicFlag( conversationId: string, isPublic: boolean, ): Promise { if (getActiveBackend().backend.kind !== "cloud") { throw new Error("Public sharing requires a cloud backend."); } return updateCloudConversationPublicFlag(conversationId, isPublic); } static async updateConversationRepository( conversationId: string, repository: string | null, branch?: string | null, gitProvider?: string | null, ): Promise { if (repository) { const existing = getStoredConversationMetadata(conversationId); setStoredConversationMetadata(conversationId, { ...(existing ?? {}), selected_repository: repository, selected_branch: branch ?? null, git_provider: (gitProvider as Provider | null | undefined) ?? null, }); } else { removeStoredConversationMetadata(conversationId); } const [conversation] = await this.batchGetAppConversations([ conversationId, ]); return requireAppConversation(conversation, conversationId); } static async readConversationFile( conversationId: string, filePath?: string, ): Promise { if (getActiveBackend().backend.kind === "cloud") { // Cloud exposes a per-conversation file endpoint; the sandbox // working dir is fixed (`/workspace/project`), so PLAN.md lives at // a known absolute path. Mirrors OpenHands' readConversationFile. const path = requirePathInsideDirectory( filePath ?? "/workspace/project/.agents_tmp/PLAN.md", "/workspace/project", ); return readCloudConversationFile(conversationId, path); } const workingDir = await this.resolveConversationWorkingDir(conversationId); const path = requirePathInsideDirectory( filePath ?? `${workingDir}/.agents_tmp/PLAN.md`, workingDir, ); return new FileClient(getAgentServerClientOptions()).downloadTextFile(path); } static async downloadConversation(conversationId: string): Promise { if (getActiveBackend().backend.kind === "cloud") { return downloadCloudConversation(conversationId); } return new FileClient(getAgentServerClientOptions()).downloadTrajectory( conversationId, ); } static async getHooks(conversationId: string): Promise { if (!conversationId) { return emptyHooksResponse(); } return emptyHooksResponse(); } static async getRuntimeConversation( conversationId: string, conversationUrl: string | null | undefined, sessionApiKey?: string | null, ): Promise { // Fetch directly from the per-conversation runtime agent-server at conversationUrl. const response = await new ConversationClient( getAgentServerClientOptions({ conversationUrl, sessionApiKey, }), ).getConversation(conversationId); const data = requireDirectConversationInfo(response); return { id: data.id, title: data.title?.trim() ? data.title : getDefaultConversationTitle(data.id), metrics: normalizeMetrics(data.metrics), created_at: data.created_at, updated_at: data.updated_at, status: toRuntimeStatus(data.execution_status), stats: data.stats ?? { usage_to_metrics: {} }, }; } /** * Force condensation ("compact") of the conversation history via * `POST /api/conversations/{id}/condense`. Routed the same way as * {@link sendMessage}: through the cloud proxy at the conversation's own * runtime host for cloud backends, directly against that runtime otherwise. */ static async condenseConversation( conversationId: string, conversationUrl: string | null | undefined, sessionApiKey?: string | null, ): Promise { const active = getActiveBackend().backend; if (active.kind === "cloud" && conversationUrl) { await callCloudProxy({ backend: active, method: "POST", hostOverride: buildHttpBaseUrl(conversationUrl), path: `/api/conversations/${conversationId}/condense`, authMode: "session-api-key", sessionApiKey, }); return; } await new ConversationClient( getAgentServerClientOptions({ conversationUrl, sessionApiKey }), ).condenseConversation(conversationId); } static async searchConversations( limit: number = 20, pageId?: string, ): Promise { if (getActiveBackend().backend.kind === "cloud") { return searchCloudConversations(limit, pageId); } const data = await new ConversationClient( getAgentServerClientOptions(), ).searchConversations({ limit, page_id: pageId, sort_order: ConversationSortOrder.UPDATED_AT_DESC, }); return toConversationPage(requireConversationSearchPage(data)); } static async deleteConversation(conversationId: string): Promise { if (getActiveBackend().backend.kind === "cloud") { await deleteCloudConversation(conversationId); removeStoredConversationMetadata(conversationId); return; } // The agent-server orphans children rather than cascading, and the local // planner helper is hidden from the conversation list by its // `plannerparent` tag — so without this it would survive its parent as an // invisible, unreachable conversation (plus its events and state). const planningConversationIds = await this.getLocalPlanningConversationIds(conversationId); const client = new ConversationClient(getAgentServerClientOptions()); await Promise.all( planningConversationIds.map(async (planningConversationId) => { try { await client.deleteConversation(planningConversationId); } catch (error) { // Already gone (or unreachable): never block deleting the parent the // user actually asked to remove. console.warn( `Failed to delete planning conversation ${planningConversationId}`, error, ); } removeStoredConversationMetadata(planningConversationId); }), ); await client.deleteConversation(conversationId); removeStoredConversationMetadata(conversationId); } static async updateConversationTitle( conversationId: string, title: string, ): Promise { if (getActiveBackend().backend.kind === "cloud") { return updateCloudConversationTitle(conversationId, title); } await new ConversationClient( getAgentServerClientOptions(), ).updateConversation(conversationId, { title, }); const [conversation] = await this.batchGetAppConversations([ conversationId, ]); return requireAppConversation(conversation, conversationId); } /** * Replaces the conversation's complete server-side tag map (the PATCH is * replace-all, so callers must merge user edits with any reserved/internal * keys before calling). Mirrors `updateConversationTitle`; local * agent-server conversations only — Cloud conversations don't carry tags. */ static async updateConversationTags( conversationId: string, tags: Record, ): Promise { await new ConversationClient( getAgentServerClientOptions(), ).updateConversation(conversationId, { tags, }); const [conversation] = await this.batchGetAppConversations([ conversationId, ]); return requireAppConversation(conversation, conversationId); } /** * Forks a conversation, copying event history up to and including * `fromEventId`. Local agent-server only; needs agent-server >= 1.31.0 for * `from_event_id` (older backends copy the whole conversation). */ static async forkConversation( sourceConversationId: string, fromEventId: string, title?: string, ): Promise { if (getActiveBackend().backend.kind === "cloud") { throw new Error( "Branching a conversation isn't supported on the cloud backend yet.", ); } // `from_event_id` is accepted by `/fork` but not yet typed in // ForkConversationRequest (through client 1.32.0); the client forwards the // body verbatim, so cast to carry it. A title also suppresses the backend // auto-title, so the "(branch)" marker sticks. const data = await new ConversationClient( getAgentServerClientOptions(), ).forkConversation(sourceConversationId, { from_event_id: fromEventId, ...(title ? { title } : {}), } as ForkConversationRequest & { from_event_id: string }); // Carry over the source's client-side metadata (repo/branch/workspace/ // profile/plugins) so the fork hydrates its chat-page badges the same way. const sourceMetadata = getStoredConversationMetadata(sourceConversationId); if (sourceMetadata) { setStoredConversationMetadata(data.id, sourceMetadata); } return data; } /** * Returns an event's `parent_id` (the fork point for branching *before* it), * or undefined at the root. Uses the single-event endpoint because the events * *search* API omits `parent_id`. */ static async getEventParentId( conversationId: string, eventId: string, ): Promise { const event = (await new ConversationClient( getAgentServerClientOptions(), ).getEvent(conversationId, eventId)) as { parent_id?: string | null }; return event.parent_id ?? undefined; } /** * Switches the LLM profile for the running conversation when one is open * (POST /switch_profile — per-conversation swap, doesn't change the user's * default profile). When called without a conversationId (home page), * falls back to POST /activate so the next conversation created picks up * the chosen profile. * * The per-conversation endpoint accepts only the profile name, so the UI does * not need to fetch or forward profile secrets. That keeps switching working * even when the agent server has no OH_SECRET_KEY for encrypted secret export. * * Cloud backends route to the app-server's per-conversation * `/switch_profile`, which owns the profiles and resolves the swap * server-side (base_url/api_key fixups, usage_id derivation, then the * agent-server's switch_llm) — so the client only forwards the profile name, * mirroring {@link switchAcpModel}. */ static async switchProfile( conversationId: string | null, profileName: string, ): Promise { const { backend } = getActiveBackend(); if (backend.kind === "cloud") { // No conversation (home page): activate globally so the next // conversation starts with it. ProfilesService routes to the cloud // activate endpoint. if (!conversationId) { await ProfilesService.activateProfile(profileName); return; } await callCloudProxy({ backend, method: "POST", path: `/api/v1/app-conversations/${conversationId}/switch_profile`, body: { profile_name: profileName }, }); return; } if (!conversationId) { await new ProfilesClient(getAgentServerClientOptions()).activateProfile( profileName, ); return; } const clientOptions = getAgentServerClientOptions(); const conversationClient = new ConversationClient(clientOptions); const profile = await new ProfilesClient(clientOptions).getProfile( profileName, { exposeSecrets: "encrypted" }, ); const model = typeof profile.config.model === "string" ? profile.config.model : ""; if (!model) throw new Error(`Profile '${profileName}' has no model.`); await assertSubscriptionAuthReady({ llm: profile.config }); await conversationClient.switchLLM(conversationId, { ...profile.config, model, // Keep streaming on after a switch (parity with conversation start); // the profile config would otherwise default it to stream=False. stream: true, // Avoid stale first-write-wins entries in the backend LLM registry. usage_id: `profile:${profileName}:${uuidv4()}`, } as LLMConfig); } /** * Switches the model of a running ACP conversation in place (POST * /switch_acp_model — the ACP analog of {@link switchProfile}'s /switch_profile). * The agent-server calls the ACP wrapper's ``session/set_model`` on the live * session, preserving context. Mirrors {@link switchProfile}'s * local-backend-only guard and per-conversation ConversationClient call. * * Works on a created-but-not-yet-run conversation too: the agent-server * treats a pre-first-run switch as a persist-only deferral (the model is * mirrored into the conversation's stored agent and applied when the session * starts). The home/no-conversation default is persisted via the active ACP * profile / Settings instead (see ``use-switch-acp-model``). */ static async switchAcpModel( conversationId: string, model: string, ): Promise { const { backend } = getActiveBackend(); if (backend.kind === "cloud") { await callCloudProxy({ backend, method: "POST", path: `/api/v1/app-conversations/${conversationId}/switch_acp_model`, body: { model }, }); return; } await new ConversationClient(getAgentServerClientOptions()).switchAcpModel( conversationId, model, ); } } export default AgentServerConversationService;