import type { ApiIntegrationInstallationSummary, CapabilityCatalogItem, ClientConfig, GitHubRepository, PersonalGitHubRepositoryCatalogItem, ReasoningEffort, ResourceRef, Session, ToolRef, } from "../types"; import { DEFAULT_FIRST_PARTY_MCP_TOOLS, FIRST_PARTY_MCP_TOOL_NAMES, SESSION_ADMIN_ACCESS_TOOL_NAMES, defaultRepositoryMountPath, mergeResourceRefs, normalizeRepositoryTransportUri, resourceMountPathCollisionKey, type FirstPartyMcpToolName, } from "@opengeni/contracts"; import { parseCanonicalGitHubRepositoryUrl } from "@opengeni/contracts/github-repository"; export type RepoDraft = { id: number; url: string; ref: string; expectedCommitSha?: string | undefined; attached?: boolean | undefined; /** * Workspace GitHub App identity retained from a restored resource that the * catalog could not confirm yet (still loading or unavailable). The row keeps * producing the same bound resource until catalog reconciliation selects it, * so restoring a draft never silently turns a bound repository into a bare * URL with a different mount path. */ github?: { repositoryId: number; installationId: number } | undefined; /** * An attached row open in the editor with unsaved local changes. Catalog * reconciliation leaves it alone until the edit is attached or cancelled, so * the catalog finishing loading never discards the edit. */ editing?: true | undefined; }; // The composer's effort picker spans the FULL host enum, not a UI-only subset: // the old `Extract<…,"low"|…>` silently dropped `none`/`minimal`, so a deployment // whose default is one of those was overridden to "low" on every web turn // (billing impact — "low" beats the deployer's configured default server-side). export type IntelligenceEffort = ReasoningEffort; export type McpServerOption = { id: string; name: string; logoSrc?: string | null | undefined; detail?: string | undefined; connectionStatus?: "ready" | "connect" | "reconnect" | "unavailable" | "unknown" | undefined; }; /** Composer connector menus omit builtins managed by workspace tool settings. */ export function isComposerConnector(server: Pick): boolean { return !["opengeni", "files", "docs"].includes(server.id); } const NON_SELECTABLE_SESSION_MCP_SERVER_IDS = new Set(["opengeni"]); /** * Runtime infrastructure is not user policy: * - `opengeni` is the mandatory carrier for the individually selected * first-party tools. * - `files` is a real user-facing capability. It is selected by default, but * workspace admins and individual sessions may narrow it like other tools. */ export function isSelectableSessionMcpServerId(id: string): boolean { return !NON_SELECTABLE_SESSION_MCP_SERVER_IDS.has(id); } export function selectableSessionMcpServerIds(ids: Iterable): Set { return new Set([...ids].filter(isSelectableSessionMcpServerId)); } /** Compare a retained draft with the current executable catalog, not saved defaults. */ export function unavailableSessionMcpServerIds( selectedIds: Iterable, servers: readonly McpServerOption[], catalogLoadedSuccessfully: boolean, ): string[] { if (!catalogLoadedSuccessfully) return []; const available = new Set( servers .filter((server) => server.connectionStatus !== "unavailable") .map((server) => server.id), ); return [...selectableSessionMcpServerIds(selectedIds)].filter((id) => !available.has(id)); } const FIRST_PARTY_ACTION_LABELS: Partial> = { set_session_title: "Rename this session", notify_user: "Notify you", notification_withdraw: "Withdraw a notification", inbox_tidy: "Tidy your inbox", set_other_session_title: "Rename another session", run_on: "Choose where work runs", excel_run: "Work in your Excel workbooks", }; // Admin access tools are granted only through a session's admin access, never // through the per-session tool picker. const SESSION_ADMIN_ACCESS_TOOL_SET: ReadonlySet = new Set(SESSION_ADMIN_ACCESS_TOOL_NAMES); export const firstPartySessionToolOptions = FIRST_PARTY_MCP_TOOL_NAMES.filter( (id) => !SESSION_ADMIN_ACCESS_TOOL_SET.has(id), ).map((id) => ({ id, name: FIRST_PARTY_ACTION_LABELS[id] ?? firstPartyToolLabel(id), })); export function clientFirstPartyMcpToolPolicy(config: Pick): { default: FirstPartyMcpToolName[]; allowed: FirstPartyMcpToolName[]; } { return ( config.firstPartyMcpTools ?? { default: [...DEFAULT_FIRST_PARTY_MCP_TOOLS], allowed: [...FIRST_PARTY_MCP_TOOL_NAMES], } ); } export function firstPartySessionToolOptionsFor( allowed: readonly FirstPartyMcpToolName[] | undefined, ): Array<{ id: FirstPartyMcpToolName; name: string }> { if (!allowed) return firstPartySessionToolOptions; const allowedSet = new Set(allowed); return firstPartySessionToolOptions.filter((option) => allowedSet.has(option.id)); } function firstPartyToolLabel(id: FirstPartyMcpToolName): string { const label = id.replaceAll("_", " "); return label.slice(0, 1).toUpperCase() + label.slice(1); } // Canonical low→high ordering over the full enum; the picker renders efforts in // this order, filtered to whatever the host curates in `allowedReasoningEfforts`. export const reasoningEffortOrder: IntelligenceEffort[] = [ "none", "minimal", "low", "medium", "high", "xhigh", "max", ]; export function isIntelligenceEffort(value: unknown): value is IntelligenceEffort { return typeof value === "string" && (reasoningEffortOrder as readonly string[]).includes(value); } export function labelEffort(value: IntelligenceEffort): string { if (value === "xhigh") { return "Extra high"; } return value.slice(0, 1).toUpperCase() + value.slice(1); } // The host-curated effort options for the picker, in canonical order. Falls back // to the full enum when the host exposes no allow-list. No lossy UI filter: every // effort the host allows (including `none`/`minimal`) is offered, so the deployer's // configured default is always representable/selectable. export function effortOptionsFor( config: Pick | null, ): IntelligenceEffort[] { const allowed = config?.allowedReasoningEfforts ?? reasoningEffortOrder; return reasoningEffortOrder.filter((effort) => allowed.includes(effort)); } // The composer's initial effort once config lands: the deployment default, // faithfully — no clamping to a UI subset (the bug that pinned `none`/`minimal` // defaults to "low"). export function initialReasoningEffort( config: Pick, ): IntelligenceEffort { return config.defaultReasoningEffort; } export function buildTools( existing: ToolRef[] | undefined, mcpServerIds: string[] = [], ): ToolRef[] { const out = [...(existing ?? [])]; for (const id of mcpServerIds) { if (id && !out.some((tool) => tool.kind === "mcp" && tool.id === id)) { out.push({ kind: "mcp", id }); } } return out; } /** * Materialize the exact user-facing MCP selection. The mandatory internal * `opengeni` carrier is attached server-side and never appears here. */ export function buildOpenGeniUiTools( existing: ToolRef[] | undefined, selectedMcpServerIds: Iterable, ): ToolRef[] { return buildTools(existing, [...selectableSessionMcpServerIds(selectedMcpServerIds)]); } function canonicalToolIds(tools: ToolRef[]): string[] { return [...new Set(tools.map((tool) => `${tool.kind}:${tool.id}`))].sort(); } /** * Materialize the picker's selection only when it narrows/pins the inherited * baseline. `undefined` is the wire-level omitted-tools contract. */ export function toolsForPolicySelection(input: { existing?: ToolRef[] | undefined; selectedMcpServerIds: Iterable; baselineMcpServerIds: Iterable; forceExplicit?: boolean | undefined; }): ToolRef[] | undefined { const selected = buildOpenGeniUiTools(input.existing, input.selectedMcpServerIds); if (input.forceExplicit === true) { return selected; } const baseline = buildOpenGeniUiTools(undefined, input.baselineMcpServerIds); return canonicalToolIds(selected).join("\u0000") === canonicalToolIds(baseline).join("\u0000") ? undefined : selected; } /** Canonical persisted new-session policy, including the omitted/explicit bit. */ export function newSessionDraftToolPolicy(input: { selectedMcpServerIds: Iterable; workspaceDefaultMcpServerIds: Iterable; catalogReady: boolean; explicit: boolean; /** Header switch. Distinct from `explicit`, which pins the tool id list. */ customizing?: boolean | undefined; excludedMcpServerIds?: Iterable | undefined; }): { tools: ToolRef[]; toolsProvided: boolean; excludedMcpServerIds?: string[] | undefined } { if (!input.catalogReady) return { tools: [], toolsProvided: false }; const customizing = input.customizing ?? input.explicit; if (!customizing) return { tools: [], toolsProvided: false }; if (input.explicit) { return { tools: buildOpenGeniUiTools(undefined, input.selectedMcpServerIds), toolsProvided: true, }; } return { tools: [], toolsProvided: true, excludedMcpServerIds: [...new Set(input.excludedMcpServerIds ?? [])].sort(), }; } /** * Project the server-authoritative session policy into currently selectable * picker IDs. Workspace-default sessions follow the live capability baseline; * fixed policies use their effective IDs (or their persisted refs for rolling * compatibility). Unavailable IDs remain visible in policy truth/inspector but * cannot be selected by a picker that cannot execute them. */ export function sessionPolicyPickerIds( session: Pick, selectableIds: Iterable, workspaceDefaultIds: Iterable, ): Set { const selectable = new Set(selectableIds); const mode = session.effectiveToolPolicy?.mode ?? session.toolPolicy.mode; const policyIds = mode === "workspace_default" ? [...workspaceDefaultIds, ...session.tools.map((tool) => tool.id)].filter( (id) => !session.toolPolicy.excludedMcpServerIds?.includes(id), ) : (session.effectiveToolPolicy?.effectiveIds ?? session.tools.map((tool) => tool.id)); return new Set(policyIds.filter((id) => selectable.has(id))); } export function buildResources( manualRepos: RepoDraft[], repos: GitHubRepository[], selected: Set, selectedRefs: Record, personalRepositories: PersonalGitHubRepositoryCatalogItem[] = [], selectedPersonalRepositoryIds: Set = new Set(), selectedPersonalRepositoryRefs: Record = {}, personalCredentialBindingId: string | null = null, ): ResourceRef[] { const raw = [ ...repos .filter((repo) => selected.has(repo.id)) .map((repo) => ({ url: repo.cloneUrl, ref: (selectedRefs[repo.id] ?? repo.defaultBranch).trim(), expectedCommitSha: null, repositoryId: repo.id, installationId: repo.installationId, private: repo.private, provider: "github" as const, connectionType: null, credentialBindingId: null, access: null, })), ...personalRepositories .filter( (repo) => repo.selectedAccess !== null && selectedPersonalRepositoryIds.has(repo.repositoryId), ) .map((repo) => ({ url: repo.canonicalUrl, ref: (selectedPersonalRepositoryRefs[repo.repositoryId] ?? repo.defaultBranch).trim(), expectedCommitSha: null, repositoryId: repo.repositoryId, installationId: null, private: repo.private, provider: "github" as const, connectionType: "github_personal" as const, credentialBindingId: personalCredentialBindingId, access: repo.selectedAccess!, })), ...manualRepos .filter((repo) => repo.attached !== false) .map((repo) => ({ url: repo.url.trim(), ref: repo.ref.trim(), expectedCommitSha: repo.expectedCommitSha ?? null, repositoryId: repo.github?.repositoryId ?? null, installationId: repo.github?.installationId ?? null, private: false, provider: repo.github ? ("github" as const) : null, connectionType: null, credentialBindingId: null, access: null, })), ].filter((repo) => repo.url.length > 0); const mountPaths = new Set(); return raw.map((repo) => { if (!repo.ref) { throw new Error("Repository ref is required."); } const uri = normalizeRepositoryTransportUri( repo.url.includes("://") ? repo.url : `https://${repo.url}`, ); const mountPath = defaultRepositoryMountPath(uri, repo.provider); const mountKey = resourceMountPathCollisionKey(mountPath); if (mountPaths.has(mountKey)) { throw new Error(`Duplicate repository mount path: ${mountPath}`); } mountPaths.add(mountKey); if (repo.connectionType === "github_personal") { if (!repo.credentialBindingId || typeof repo.repositoryId !== "string" || !repo.access) { throw new Error("Personal GitHub repository identity is unavailable."); } return { kind: "repository", uri, ref: repo.ref, mountPath, provider: "github", connectionType: "github_personal", credentialBindingId: repo.credentialBindingId, repositoryId: repo.repositoryId, access: repo.access, }; } return { kind: "repository", uri, ref: repo.ref, mountPath, ...(repo.provider ? { provider: repo.provider } : {}), ...(repo.expectedCommitSha ? { expectedCommitSha: repo.expectedCommitSha } : {}), // Every catalog repository is in the workspace's GitHub App allowlist, // public or private, so every selection carries the stable ids that // mint the scoped installation token. Manual URLs stay bare. ...(repo.repositoryId ? { githubRepositoryId: repo.repositoryId } : {}), ...(repo.installationId ? { githubInstallationId: repo.installationId } : {}), }; }); } /** * Build only the repository refs pending on an existing session and prove they * can be additively merged with its already-mounted resources. Existing * resources are immutable through follow-up messages, so ref/path conflicts * fail in the composer before the turn is submitted. */ export function buildAdditionalRepositoryResources(input: { mountedResources: ResourceRef[]; manualRepos: RepoDraft[]; repositories: GitHubRepository[]; selectedRepoIds: Set; selectedRepoRefs: Record; personalRepositories?: PersonalGitHubRepositoryCatalogItem[] | undefined; selectedPersonalRepositoryIds?: Set | undefined; selectedPersonalRepositoryRefs?: Record | undefined; personalCredentialBindingId?: string | null | undefined; }): ResourceRef[] { const additions = buildResources( input.manualRepos, input.repositories, input.selectedRepoIds, input.selectedRepoRefs, input.personalRepositories, input.selectedPersonalRepositoryIds, input.selectedPersonalRepositoryRefs, input.personalCredentialBindingId, ); mergeResourceRefs(input.mountedResources, additions, { rejectConflicts: true }); return additions; } export function gitHubRepositoryResource( repo: GitHubRepository, ref: string, ): Extract { const uri = normalizeRepositoryTransportUri(repo.cloneUrl); return { kind: "repository", uri, ref: ref.trim() || repo.defaultBranch, provider: "github", mountPath: defaultRepositoryMountPath(uri, "github"), // Bound is bound: a public repository in the allowlist receives the same // scoped installation token as a private one, so it carries the same ids. githubRepositoryId: repo.id, githubInstallationId: repo.installationId, }; } export function isRepositoryResourceForGitHubRepo( resource: Extract, repo: GitHubRepository, ): boolean { const hasGitHubIdentity = resource.githubRepositoryId !== undefined || resource.githubInstallationId !== undefined; if (hasGitHubIdentity) { return ( resource.githubRepositoryId === repo.id && resource.githubInstallationId === repo.installationId ); } // A bare resource (manual URL or a session created before bound public // repositories carried ids) still matches a public catalog entry by URI. // Private repositories are only ever selected by identity. if (repo.private) return false; return sameRepositoryUri(resource, gitHubRepositoryResource(repo, repo.defaultBranch).uri); } export function sameRepositoryUri(resource: ResourceRef, uri: string): boolean { return resource.kind === "repository" && resource.uri === uri; } /** * Revalidate repository resources against the browser's authoritative GitHub * catalog. Private selections must retain both stable GitHub identities; * public/manual selections are URI-based and remain usable as manual refs when * the catalog no longer contains a matching public repository. */ export function rehydrateRepositoryResources( resources: ResourceRef[], repositories: GitHubRepository[], options?: { catalogReady?: boolean | undefined; personalRepositories?: PersonalGitHubRepositoryCatalogItem[] | undefined; personalCatalogReady?: boolean | undefined; }, ): ResourceRef[] { // An unreadied catalog is unknown, not empty. Dropping GitHub-identity rows // here would autosave that loss and brick the create composer while the // first status/list request is still in flight or has failed. if (options?.catalogReady === false) return resources; return resources.flatMap((resource) => { if (resource.kind !== "repository") return [resource]; if ( resource.connectionType === "github_personal" && typeof resource.repositoryId === "string" ) { if (options?.personalCatalogReady === false) return [resource]; return (options?.personalRepositories ?? []).some( (repository) => repository.repositoryId === resource.repositoryId && repository.canonicalUrl === resource.uri && repository.selectedAccess !== null, ) ? [resource] : []; } const hasGitHubIdentity = resource.githubRepositoryId !== undefined || resource.githubInstallationId !== undefined; if (hasGitHubIdentity) { return repositories.some((repo) => isRepositoryResourceForGitHubRepo(resource, repo)) ? [resource] : []; } return [resource]; }); } /** Project hydrated repository resources into the existing picker state. */ export function repositorySelectionFromResources( resources: ResourceRef[], repositories: GitHubRepository[], ): { manualRepos: RepoDraft[]; selectedRepoIds: Set; selectedRepoRefs: Record; selectedPersonalRepoIds: Set; selectedPersonalRepoRefs: Record; } { const manualRepos: RepoDraft[] = []; const selectedRepoIds = new Set(); const selectedRepoRefs: Record = {}; const selectedPersonalRepoIds = new Set(); const selectedPersonalRepoRefs: Record = {}; let nextManualId = 1; for (const resource of resources) { if (resource.kind !== "repository") continue; if ( resource.connectionType === "github_personal" && typeof resource.repositoryId === "string" ) { selectedPersonalRepoIds.add(resource.repositoryId); selectedPersonalRepoRefs[resource.repositoryId] = resource.ref; continue; } const matched = repositories.find((repo) => { if ( resource.githubRepositoryId !== undefined || resource.githubInstallationId !== undefined ) { return isRepositoryResourceForGitHubRepo(resource, repo); } return sameRepositoryUri(resource, gitHubRepositoryResource(repo, repo.defaultBranch).uri); }); if (matched) { selectedRepoIds.add(matched.id); selectedRepoRefs[matched.id] = resource.ref; } else { const github = retainedGitHubIdentity(resource); manualRepos.push({ id: nextManualId++, url: resource.uri, ref: resource.ref, ...(resource.expectedCommitSha ? { expectedCommitSha: resource.expectedCommitSha } : {}), attached: true, ...(github ? { github } : {}), }); } } return { manualRepos, selectedRepoIds, selectedRepoRefs, selectedPersonalRepoIds, selectedPersonalRepoRefs, }; } function retainedGitHubIdentity( resource: Extract, ): RepoDraft["github"] | undefined { const repositoryId = resource.githubRepositoryId; const installationId = resource.githubInstallationId; return typeof repositoryId === "number" && Number.isSafeInteger(repositoryId) && repositoryId > 0 && typeof installationId === "number" && Number.isSafeInteger(installationId) && installationId > 0 ? { repositoryId, installationId } : undefined; } /** * The workspace catalog repository an attached manual row stands for, if any. * A retained identity matches only that exact installation and repository. A * bare row matches the same way attaching a typed URL does: an exact * https://github.com URL naming the catalog repository. Other hosts never * match, so custom remotes keep their own attachment. */ export function manualRepositoryCatalogMatch( repo: RepoDraft, repositories: readonly GitHubRepository[], ): GitHubRepository | null { if (repo.github) { const { repositoryId, installationId } = repo.github; return ( repositories.find( (candidate) => candidate.id === repositoryId && candidate.installationId === installationId, ) ?? null ); } let fullName: string; try { fullName = parseCanonicalGitHubRepositoryUrl(repo.url.trim()).fullName.toLowerCase(); } catch { return null; } return repositories.find((candidate) => candidate.fullName.toLowerCase() === fullName) ?? null; } export type RepositorySelectionState = { manualRepos: RepoDraft[]; selectedRepoIds: Set; selectedRepoRefs: Record; }; /** The composer's personal GitHub selection, read-only to catalog reconciliation. */ export type PersonalRepositorySelection = { repositories: readonly PersonalGitHubRepositoryCatalogItem[]; selectedRepoIds: ReadonlySet; selectedRepoRefs: Readonly>; }; /** * The personal GitHub selection that stands for the same GitHub repository as a * workspace catalog repository: undefined when there is none, otherwise its ref * when that selection is attached right now, or null when it is not (catalog not * loaded, or no selected access), so it cannot replace another attachment yet. * Personal ids are GitHub repository ids, so a restored selection matches * before the personal catalog has loaded. */ function selectedPersonalCounterpartRef( repository: GitHubRepository, personal: PersonalRepositorySelection | undefined, ): string | null | undefined { if (!personal) return undefined; const fullName = repository.fullName.toLowerCase(); const repositoryId = personal.selectedRepoIds.has(String(repository.id)) ? String(repository.id) : personal.repositories.find( (candidate) => personal.selectedRepoIds.has(candidate.repositoryId) && candidate.fullName.toLowerCase() === fullName, )?.repositoryId; if (repositoryId === undefined) return undefined; // Mirror buildResources: only a catalog item with selected access attaches. const attachable = personal.repositories.find( (candidate) => candidate.repositoryId === repositoryId && candidate.selectedAccess !== null, ); if (!attachable) return null; return (personal.selectedRepoRefs[repositoryId] ?? attachable.defaultBranch).trim() || null; } /** * Fold attached manual rows that stand for a workspace catalog repository into * the catalog selection, so one repository is attached once and the picker * shows it checked. Returns null when nothing changes. * * - An unselected match becomes selected at the row's ref. * - A match already selected at the same ref is an exact duplicate and is dropped. * - A match selected at a different ref keeps its row: two refs are never * collapsed silently. * - A repository selected through personal GitHub keeps that authority: the * workspace App copy is never selected for it. A row at the ref of an * attached personal selection is an exact duplicate and is dropped; any * other row stays, including while the personal selection cannot attach yet. * - A row open in the editor is left alone until the edit ends. * - A retained identity that a ready catalog no longer contains is dropped, * exactly like draft hydration drops it when the catalog is already loaded. * - A bare row for another GitHub installation than the current selection is * left alone, matching the picker's one-installation rule. * - An unknown catalog changes nothing. */ export function reconcileManualRepositoriesWithCatalog( state: RepositorySelectionState, repositories: readonly GitHubRepository[], catalogReady: boolean, personal?: PersonalRepositorySelection, ): RepositorySelectionState | null { if (!catalogReady) return null; let changed = false; const selectedRepoIds = new Set(state.selectedRepoIds); const selectedRepoRefs = { ...state.selectedRepoRefs }; const selectedInstallationId = () => repositories.find((candidate) => selectedRepoIds.has(candidate.id))?.installationId ?? null; const manualRepos: RepoDraft[] = []; // Retained identities first: they are the authoritative restored selection, // and a bare duplicate of the same repository then reconciles against it. const ordered = [ ...state.manualRepos.filter((repo) => repo.github), ...state.manualRepos.filter((repo) => !repo.github), ]; const kept = new Set(); for (const repo of ordered) { const ref = repo.ref.trim(); if (repo.attached === false || repo.editing || !repo.url.trim() || !ref) { kept.add(repo); continue; } const match = manualRepositoryCatalogMatch(repo, repositories); if (!match) { if (repo.github) { changed = true; } else { kept.add(repo); } continue; } const personalRef = selectedPersonalCounterpartRef(match, personal); if (personalRef !== undefined) { if (personalRef === ref) { changed = true; } else { kept.add(repo); } continue; } if (!selectedRepoIds.has(match.id)) { const installationId = selectedInstallationId(); if (!repo.github && installationId !== null && installationId !== match.installationId) { kept.add(repo); continue; } selectedRepoIds.add(match.id); selectedRepoRefs[match.id] = ref; changed = true; continue; } if ((selectedRepoRefs[match.id] ?? match.defaultBranch).trim() === ref) { changed = true; continue; } kept.add(repo); } if (!changed) return null; for (const repo of state.manualRepos) { if (kept.has(repo)) manualRepos.push(repo); } return { manualRepos, selectedRepoIds, selectedRepoRefs }; } export function repositoryDisplayName( resource: Extract, ): string { try { return new URL(resource.uri).pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, ""); } catch { return resource.uri; } } export function normalizeRepositoryUrl(value: string): { host: string; repo: string } { const url = new URL(value.includes("://") ? value : `https://${value}`); if (url.protocol !== "https:") { throw new Error("Repository URL must use HTTPS."); } const path = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, ""); const parts = path.split("/").filter(Boolean); if (parts.length < 2) { throw new Error("Repository URL must include owner and repo."); } return { host: url.host.toLowerCase(), repo: parts.join("/") }; } export type RepositoryGroup = { installationId: number; label: string; detail: string; repositories: GitHubRepository[]; }; export function groupRepositories(repositories: GitHubRepository[]): RepositoryGroup[] { return repositories.reduce((groups, repo) => { let group = groups.find((item) => item.installationId === repo.installationId); if (!group) { group = { installationId: repo.installationId, label: repo.accountLogin, detail: repo.accountType ?? "GitHub account", repositories: [], }; groups.push(group); } group.repositories.push(repo); return groups; }, []); } export function selectableMcpServers(config: ClientConfig | null): McpServerOption[] { if (!config) { return []; } return config.mcpServers.filter((server) => isSelectableSessionMcpServerId(server.id)); } export function enabledWorkspaceCapabilityMcpServers( items: CapabilityCatalogItem[], ): McpServerOption[] { return items.flatMap((item) => { if ( item.kind !== "mcp" || !item.enabled || !item.runtime.available || !item.runtime.mcpServerId ) { return []; } return isSelectableSessionMcpServerId(item.runtime.mcpServerId) ? [{ id: item.runtime.mcpServerId, name: item.name }] : []; }); } /** One selectable local MCP surface per installed OpenAPI/GraphQL instance. */ export function installedApiIntegrationMcpServers( integrations: ReadonlyArray< Pick >, ): McpServerOption[] { return integrations.map((integration) => ({ id: integration.serverId, name: integration.displayName.trim() || integration.name, })); } export function mergeMcpServerOptions(...groups: McpServerOption[][]): McpServerOption[] { const byId = new Map(); for (const group of groups) { for (const server of group) { if (server.id && !byId.has(server.id)) { byId.set(server.id, server); } } } return [...byId.values()]; } export function selectedAvailableCapabilityToolIds( current: Set, availableIds: string[], previouslyAvailableIds: Set = new Set(), defaultIds: string[] = availableIds, ): Set { const available = new Set(availableIds); const next = new Set([...current].filter((id) => available.has(id))); for (const id of defaultIds) { if (id && available.has(id) && !previouslyAvailableIds.has(id)) { next.add(id); } } return next; } export function isAbortError(error: unknown): boolean { return error instanceof DOMException && error.name === "AbortError"; }