diff --git a/agent-roles-catalog/README.md b/agent-roles-catalog/README.md index b3b253bb..b35d184b 100644 --- a/agent-roles-catalog/README.md +++ b/agent-roles-catalog/README.md @@ -26,6 +26,25 @@ Currently shipped bundles: copy-editor, fact-checker, proofreader, narrator), languages `ru`, `en`. - `research` — a single `researcher` role, languages `ru`, `en`. +## How it's served + +The server does not bundle this data; it reads it at request time from a single +configured location, the `AI_AGENT_ROLES_CATALOG_URL` env var +(`EnvironmentService.getAiAgentRolesCatalogSource()`). The value selects one of +three sources: + +- **`http(s)://…`** — a REMOTE base URL. The server fetches `/index.json` + for the manifest and `/bundles//.json` for each opened + bundle file (e.g. the raw GitHub base of the catalog repo in production). +- **any other non-empty value** — a LOCAL filesystem directory; the same + `index.json` / `bundles//.json` paths are read from disk. +- **empty / unset** (the default) — the in-repo `agent-roles-catalog/` folder + (this directory), i.e. local dev reads these files directly. + +In every case the layout below is what the server expects, and the fetched JSON +is re-validated server-side (the catalog is treated as untrusted input). See +`.env.example` for the variable and the CHANGELOG for the rollout. + ## `index.json` schema ```jsonc diff --git a/agent-roles-catalog/scripts/check.mjs b/agent-roles-catalog/scripts/check.mjs index 664a0146..32c4b698 100644 --- a/agent-roles-catalog/scripts/check.mjs +++ b/agent-roles-catalog/scripts/check.mjs @@ -106,9 +106,8 @@ for (const bundle of bundles) { const where = `${bundleId}/${lang}`; // Only flag duplicates across DIFFERENT bundles or files; the same slug // is expected to appear once per language file of the same bundle. - const key = slug; - if (slugSeen.has(key)) { - const prev = slugSeen.get(key); + if (slugSeen.has(slug)) { + const prev = slugSeen.get(slug); const prevBundle = prev.split("/")[0]; if (prevBundle !== bundleId) { errors.push( @@ -116,7 +115,7 @@ for (const bundle of bundles) { ); } } else { - slugSeen.set(key, where); + slugSeen.set(slug, where); } } } diff --git a/apps/client/src/features/ai-chat/queries/ai-chat-query.ts b/apps/client/src/features/ai-chat/queries/ai-chat-query.ts index 5f6162f5..37cf70f8 100644 --- a/apps/client/src/features/ai-chat/queries/ai-chat-query.ts +++ b/apps/client/src/features/ai-chat/queries/ai-chat-query.ts @@ -319,15 +319,19 @@ export function useUpdateAiRoleFromCatalogMutation() { // The server returns updated:false with a reason for a no-op (already // up to date / removed from catalog / language no longer offered). Map // each reason to a specific message instead of a generic "up to date". + // Narrow the discriminated union via `"reason" in result` (the `updated` + // boolean discriminant does not narrow under this project's + // strictNullChecks:false). Inside the branch, `reason` is the typed literal + // union, so the comparisons below are compiler-checked. let message: string; - if (result.updated) { + if (!("reason" in result)) { message = t("Updated to the latest version"); } else if (result.reason === "not-in-catalog") { message = t("This role is no longer in the catalog"); } else if (result.reason === "language-unavailable") { message = t("This language is no longer available in the catalog"); } else { - // "up-to-date" and any unexpected reason. + // "up-to-date" (the only remaining reason). message = t("Already up to date"); } notifications.show({ message }); diff --git a/apps/client/src/features/ai-chat/queries/update-from-catalog-message.test.tsx b/apps/client/src/features/ai-chat/queries/update-from-catalog-message.test.tsx new file mode 100644 index 00000000..85aab673 --- /dev/null +++ b/apps/client/src/features/ai-chat/queries/update-from-catalog-message.test.tsx @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import React from "react"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { IAiRoleUpdateFromCatalogResult } from "@/features/ai-chat/types/ai-chat.types.ts"; + +// `useUpdateAiRoleFromCatalogMutation` maps the server's discriminated result to +// a user-facing notification message. These tests pin each of the four branches +// (updated / not-in-catalog / language-unavailable / up-to-date) via renderHook +// with a mocked service (precedent: share-query.null-normalization.test.tsx). + +const notificationsShowMock = vi.fn(); +vi.mock("@mantine/notifications", () => ({ + notifications: { show: (opts: unknown) => notificationsShowMock(opts) }, +})); + +// `t` echoes the key so we assert against the exact English message strings. +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({ + updateAiRoleFromCatalog: vi.fn(), + // Other named exports referenced by ai-chat-query.ts must exist on the mock so + // the module import resolves; they are unused by these tests. + createAiRole: vi.fn(), + deleteAiChat: vi.fn(), + deleteAiRole: vi.fn(), + getAiChatMessages: vi.fn(), + getAiChats: vi.fn(), + getAiRoleCatalog: vi.fn(), + getAiRoleCatalogBundle: vi.fn(), + getAiRoles: vi.fn(), + importAiRolesFromCatalog: vi.fn(), + renameAiChat: vi.fn(), + updateAiRole: vi.fn(), +})); + +import { updateAiRoleFromCatalog } from "@/features/ai-chat/services/ai-chat-service.ts"; +import { useUpdateAiRoleFromCatalogMutation } from "@/features/ai-chat/queries/ai-chat-query.ts"; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return function Wrapper({ children }: { children: React.ReactNode }) { + return ( + {children} + ); + }; +} + +async function runMutation(result: IAiRoleUpdateFromCatalogResult) { + vi.mocked(updateAiRoleFromCatalog).mockResolvedValue(result); + const { result: hook } = renderHook( + () => useUpdateAiRoleFromCatalogMutation(), + { wrapper: createWrapper() }, + ); + hook.current.mutate("role-1"); + await waitFor(() => expect(hook.current.isSuccess).toBe(true)); +} + +describe("useUpdateAiRoleFromCatalogMutation — reason → message", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("updated:true -> 'Updated to the latest version'", async () => { + await runMutation({ + updated: true, + fromVersion: 1, + toVersion: 2, + role: { id: "role-1" } as never, + }); + expect(notificationsShowMock).toHaveBeenCalledWith({ + message: "Updated to the latest version", + }); + }); + + it("not-in-catalog -> 'This role is no longer in the catalog'", async () => { + await runMutation({ updated: false, reason: "not-in-catalog" }); + expect(notificationsShowMock).toHaveBeenCalledWith({ + message: "This role is no longer in the catalog", + }); + }); + + it("language-unavailable -> 'This language is no longer available in the catalog'", async () => { + await runMutation({ updated: false, reason: "language-unavailable" }); + expect(notificationsShowMock).toHaveBeenCalledWith({ + message: "This language is no longer available in the catalog", + }); + }); + + it("up-to-date -> 'Already up to date'", async () => { + await runMutation({ updated: false, reason: "up-to-date" }); + expect(notificationsShowMock).toHaveBeenCalledWith({ + message: "Already up to date", + }); + }); +}); diff --git a/apps/client/src/features/ai-chat/types/ai-chat.types.ts b/apps/client/src/features/ai-chat/types/ai-chat.types.ts index a4ac23be..78b049c0 100644 --- a/apps/client/src/features/ai-chat/types/ai-chat.types.ts +++ b/apps/client/src/features/ai-chat/types/ai-chat.types.ts @@ -116,14 +116,19 @@ export interface IAiRoleImportResult { errors: { slug: string; message: string }[]; } -/** Update-from-catalog result (mirrors `updateFromCatalog()`). */ -export interface IAiRoleUpdateFromCatalogResult { - updated: boolean; - fromVersion?: number; - toVersion?: number; - reason?: string; - role?: IAiRole; -} +/** + * Update-from-catalog result (mirrors the server `updateFromCatalog()`). A + * discriminated union on `updated`: a no-op carries a typed `reason` the UI maps + * to a specific message; a successful update carries the version bump + new role. + * Keeping the union (not a widened `reason?: string`) lets the consumer's literal + * comparisons be compiler-checked. + */ +export type IAiRoleUpdateFromCatalogResult = + | { + updated: false; + reason: "not-in-catalog" | "up-to-date" | "language-unavailable"; + } + | { updated: true; fromVersion: number; toVersion: number; role: IAiRole }; /** Admin create payload for a role. */ export interface IAiRoleCreate { diff --git a/apps/client/src/features/ai-chat/utils/catalog-role-install-state.test.ts b/apps/client/src/features/ai-chat/utils/catalog-role-install-state.test.ts new file mode 100644 index 00000000..5aa05cca --- /dev/null +++ b/apps/client/src/features/ai-chat/utils/catalog-role-install-state.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from "vitest"; +import { catalogRoleInstallState } from "./catalog-role-install-state.ts"; +import type { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts"; + +// Build a workspace role with a catalog source. Fields irrelevant to the +// install-state decision are filled with harmless defaults. +function installedRole( + source: { slug: string; language: string; version: number }, + overrides: Partial = {}, +): IAiRole { + return { + id: `role-${source.slug}-${source.language}`, + name: source.slug, + emoji: null, + description: null, + enabled: true, + autoStart: true, + launchMessage: null, + source, + ...overrides, + }; +} + +const catalogRole = { slug: "writer", version: 3 }; + +// Mirrors the role-launch.ts precedent: the modal's role-state computation is a +// pure function so the import/installed/update decision is testable directly. +describe("catalogRoleInstallState", () => { + it("no matching installed role -> import", () => { + const result = catalogRoleInstallState(catalogRole, [], "en"); + expect(result).toEqual({ state: "import" }); + }); + + it("same slug + language, installed version > catalog -> installed", () => { + const installed = installedRole({ + slug: "writer", + language: "en", + version: 5, + }); + const result = catalogRoleInstallState(catalogRole, [installed], "en"); + expect(result).toEqual({ state: "installed", installed }); + }); + + it("same slug + language, installed version == catalog -> installed", () => { + const installed = installedRole({ + slug: "writer", + language: "en", + version: 3, + }); + const result = catalogRoleInstallState(catalogRole, [installed], "en"); + expect(result).toEqual({ state: "installed", installed }); + }); + + it("same slug + language, installed version < catalog -> update (from/to)", () => { + const installed = installedRole({ + slug: "writer", + language: "en", + version: 1, + }); + const result = catalogRoleInstallState(catalogRole, [installed], "en"); + expect(result).toEqual({ + state: "update", + installed, + fromVersion: 1, + toVersion: 3, + }); + }); + + it("same slug but DIFFERENT language -> import (a separate install)", () => { + // 'writer' is installed in 'ru'; browsing the 'en' catalog must offer it as a + // fresh import, not treat the ru copy as already installed. + const installed = installedRole({ + slug: "writer", + language: "ru", + version: 5, + }); + const result = catalogRoleInstallState(catalogRole, [installed], "en"); + expect(result).toEqual({ state: "import" }); + }); + + it("matches the right language when the same slug is installed in several", () => { + const ru = installedRole( + { slug: "writer", language: "ru", version: 5 }, + { id: "ru-role" }, + ); + const en = installedRole( + { slug: "writer", language: "en", version: 1 }, + { id: "en-role" }, + ); + const result = catalogRoleInstallState(catalogRole, [ru, en], "en"); + expect(result).toEqual({ + state: "update", + installed: en, + fromVersion: 1, + toVersion: 3, + }); + }); + + it("ignores manually-created roles (no source) sharing the name", () => { + const manual = installedRole( + { slug: "writer", language: "en", version: 9 }, + { source: null }, + ); + const result = catalogRoleInstallState(catalogRole, [manual], "en"); + expect(result).toEqual({ state: "import" }); + }); +}); diff --git a/apps/client/src/features/ai-chat/utils/catalog-role-install-state.ts b/apps/client/src/features/ai-chat/utils/catalog-role-install-state.ts new file mode 100644 index 00000000..e28229c3 --- /dev/null +++ b/apps/client/src/features/ai-chat/utils/catalog-role-install-state.ts @@ -0,0 +1,49 @@ +import type { + IAiRole, + IAiRoleCatalogRole, +} from "@/features/ai-chat/types/ai-chat.types.ts"; + +/** + * The install state of a single catalog role relative to the workspace's + * existing roles. Extracted as a pure function so the catalog modal's role-state + * computation is unit-testable without mounting the component (mirrors the + * `roleLaunchMessage` precedent in role-launch.ts). + * + * A catalog role is matched to an installed role by BOTH `source.slug` and + * `source.language`: the same slug in a different language is a separate install + * (so it shows as "import", not "installed"). When matched, the installed source + * version decides the state: + * - no match -> "import" + * - matched & installed version >= catalog version -> "installed" + * - matched & installed version < catalog version -> "update" (from -> to) + */ +export type CatalogRoleInstallState = + | { state: "import" } + | { state: "installed"; installed: IAiRole } + | { + state: "update"; + installed: IAiRole; + fromVersion: number; + toVersion: number; + }; + +export function catalogRoleInstallState( + role: Pick, + workspaceRoles: IAiRole[], + language: string, +): CatalogRoleInstallState { + const installed = workspaceRoles.find( + (r) => r.source?.slug === role.slug && r.source?.language === language, + ); + if (!installed) return { state: "import" }; + const fromVersion = installed.source?.version ?? 0; + if (fromVersion >= role.version) { + return { state: "installed", installed }; + } + return { + state: "update", + installed, + fromVersion, + toVersion: role.version, + }; +} diff --git a/apps/client/src/features/workspace/components/settings/components/ai-agent-roles-catalog-modal.tsx b/apps/client/src/features/workspace/components/settings/components/ai-agent-roles-catalog-modal.tsx index 3d381b56..9095396d 100644 --- a/apps/client/src/features/workspace/components/settings/components/ai-agent-roles-catalog-modal.tsx +++ b/apps/client/src/features/workspace/components/settings/components/ai-agent-roles-catalog-modal.tsx @@ -27,6 +27,7 @@ import { IAiRoleCatalogBundleSummary, IAiRoleCatalogRole, } from "@/features/ai-chat/types/ai-chat.types.ts"; +import { catalogRoleInstallState } from "@/features/ai-chat/utils/catalog-role-install-state.ts"; interface AiAgentRolesCatalogModalProps { opened: boolean; @@ -230,19 +231,14 @@ function BundlePanel({ const updateMutation = useUpdateAiRoleFromCatalogMutation(); // Compute each catalog role's install state against the current workspace - // roles: an importable role matched by source.slug + source.language. + // roles (matched by source.slug + source.language). The decision lives in the + // pure `catalogRoleInstallState` helper so it is unit-tested directly. const computed = useMemo(() => { const list = bundleQuery.data?.roles ?? []; - return list.map((role) => { - const installed = roles.find( - (r) => r.source?.slug === role.slug && r.source?.language === language, - ); - if (!installed) return { role, state: "import" as const }; - if ((installed.source?.version ?? 0) >= role.version) { - return { role, state: "installed" as const, installed }; - } - return { role, state: "update" as const, installed }; - }); + return list.map((role) => ({ + role, + ...catalogRoleInstallState(role, roles, language), + })); }, [bundleQuery.data, roles, language]); // Default-check every importable role once the bundle content arrives (unless @@ -300,17 +296,23 @@ function BundlePanel({ {bundleQuery.data && ( - {computed.map(({ role, state, installed }) => ( + {computed.map((entry) => ( onToggleSlug(role.slug, checked)} - fromVersion={installed?.source?.version} + key={entry.role.slug} + role={entry.role} + state={entry.state} + checked={ + entry.state === "import" + ? !!selected?.has(entry.role.slug) + : false + } + onToggle={(checked) => onToggleSlug(entry.role.slug, checked)} + fromVersion={ + entry.state === "update" ? entry.fromVersion : undefined + } onUpdate={ - state === "update" && installed - ? () => updateMutation.mutate(installed.id) + entry.state === "update" + ? () => updateMutation.mutate(entry.installed.id) : undefined } updating={updateMutation.isPending} diff --git a/apps/server/src/core/ai-chat/roles/ai-agent-roles.service.spec.ts b/apps/server/src/core/ai-chat/roles/ai-agent-roles.service.spec.ts index 04d5698e..f278a783 100644 --- a/apps/server/src/core/ai-chat/roles/ai-agent-roles.service.spec.ts +++ b/apps/server/src/core/ai-chat/roles/ai-agent-roles.service.spec.ts @@ -664,9 +664,11 @@ describe('AiAgentRolesService guards', () => { { slug: 'b', version: 1 }, ], }); + // The kysely-postgres-js driver surfaces the violated constraint on + // `constraint_name` (not node-postgres' `.constraint`), matching prod. const sourceRace = Object.assign(new Error('duplicate key'), { code: '23505', - constraint: 'ai_agent_roles_workspace_source_unique', + constraint_name: 'ai_agent_roles_workspace_source_unique', }); repo.insert .mockRejectedValueOnce(sourceRace) @@ -714,6 +716,17 @@ describe('AiAgentRolesService guards', () => { logSpy.mockRestore(); } }); + + it('bundleId absent from the index => BadGateway (no insert)', async () => { + // The requested bundle is not listed in the fetched index (a stale client + // or an index/bundle drift); the import must surface a 502 rather than + // silently doing nothing or dereferencing a missing meta. + const { service, repo } = makeImportService({}); + await expect( + service.importFromCatalog('ws-1', 'u1', dto({ bundleId: 'missing' })), + ).rejects.toBeInstanceOf(BadGatewayException); + expect(repo.insert).not.toHaveBeenCalled(); + }); }); describe('updateFromCatalog', () => { @@ -847,6 +860,22 @@ describe('AiAgentRolesService guards', () => { expect('enabled' in patch).toBe(false); }); + it('slug listed in the index but missing from the bundle file => not-in-catalog', async () => { + // Index/bundle drift: the index still advertises a newer `researcher` + // (v5 > installed v1) in an offered language, but the fetched bundle file + // no longer contains that slug. The update must no-op as not-in-catalog, + // not throw or write a half-resolved role. + const { service, repo } = makeUpdateService({ + role: imported(1), + bundleRoles: [ + { slug: 'someone-else', name: 'Other', instructions: 'x' }, + ], + }); + const res = await service.updateFromCatalog('ws-1', { id: 'r1' } as never); + expect(res).toEqual({ updated: false, reason: 'not-in-catalog' }); + expect(repo.update).not.toHaveBeenCalled(); + }); + it('new catalog name collides with another live role => keeps current name', async () => { const role = imported(1); const other = makeRow({ id: 'r2', name: 'Researcher v5' }); diff --git a/apps/server/src/core/ai-chat/roles/ai-agent-roles.service.ts b/apps/server/src/core/ai-chat/roles/ai-agent-roles.service.ts index 4c1ae20f..2438e9e6 100644 --- a/apps/server/src/core/ai-chat/roles/ai-agent-roles.service.ts +++ b/apps/server/src/core/ai-chat/roles/ai-agent-roles.service.ts @@ -14,7 +14,11 @@ import { CreateAgentRoleDto, UpdateAgentRoleDto } from './dto/agent-role.dto'; import { ImportFromCatalogDto, UpdateFromCatalogDto } from './dto/agent-role-catalog.dto'; import { RoleModelConfig } from './role-model-config'; import { AiAgentRolesCatalogProvider } from './catalog/ai-agent-roles-catalog.provider'; -import { CatalogBundleMeta } from './catalog/catalog-types'; +import { + CatalogBundleFile, + CatalogBundleMeta, + CatalogRole, +} from './catalog/catalog-types'; /** * Full (admin) view of an agent role. There are no secret columns on this table @@ -217,6 +221,30 @@ export class AiAgentRolesService { return { languages, bundles }; } + /** + * Shared read prefix for the two bundle-by-id catalog paths (getCatalogBundle / + * importFromCatalog): fetch the index, resolve the requested bundle's meta + * (502 if the index does not list it), fetch its per-language file, and build + * the slug->version map from the meta. The callers keep their own response / + * write logic; only this duplicated read is factored out here. + */ + private async loadBundleById( + bundleId: string, + language: string, + ): Promise<{ + meta: CatalogBundleMeta; + file: CatalogBundleFile; + versions: Map; + }> { + const index = await this.catalog.fetchIndex(); + const meta = index.bundles.find((b) => b.id === bundleId); + if (!meta) { + throw new BadGatewayException('Catalog bundle not found'); + } + const file = await this.catalog.fetchBundle(bundleId, language); + return { meta, file, versions: versionMap(meta) }; + } + /** * Open one bundle in a language: returns each role's content plus the version * taken from the index (so the client can compare against an imported role's @@ -239,13 +267,7 @@ export class AiAgentRolesService { version: number; }[]; }> { - const index = await this.catalog.fetchIndex(); - const meta = index.bundles.find((b) => b.id === bundleId); - if (!meta) { - throw new BadGatewayException('Catalog bundle not found'); - } - const file = await this.catalog.fetchBundle(bundleId, language); - const versions = versionMap(meta); + const { file, versions } = await this.loadBundleById(bundleId, language); return { bundleId, language, @@ -284,13 +306,10 @@ export class AiAgentRolesService { renamed: number; errors: { slug: string; message: string }[]; }> { - const index = await this.catalog.fetchIndex(); - const meta = index.bundles.find((b) => b.id === dto.bundleId); - if (!meta) { - throw new BadGatewayException('Catalog bundle not found'); - } - const file = await this.catalog.fetchBundle(dto.bundleId, dto.language); - const versions = versionMap(meta); + const { file, versions } = await this.loadBundleById( + dto.bundleId, + dto.language, + ); const errors: { slug: string; message: string }[] = []; @@ -355,15 +374,8 @@ export class AiAgentRolesService { workspaceId, creatorId, name, - emoji: emptyToNull(role.emoji), - description: emptyToNull(role.description), - instructions: role.instructions, - modelConfig: normalizeModelConfig(role.modelConfig) as - | Record - | null, + ...catalogRoleContentFields(role), enabled: true, - autoStart: role.autoStart ?? true, - launchMessage: emptyToNull(role.launchMessage ?? undefined), source: { slug: role.slug, language: dto.language, version }, }); created++; @@ -465,14 +477,7 @@ export class AiAgentRolesService { await this.repo.update(dto.id, workspaceId, { name, - emoji: emptyToNull(fresh.emoji), - description: emptyToNull(fresh.description), - instructions: fresh.instructions, - modelConfig: normalizeModelConfig(fresh.modelConfig) as - | Record - | null, - autoStart: fresh.autoStart ?? true, - launchMessage: emptyToNull(fresh.launchMessage ?? undefined), + ...catalogRoleContentFields(fresh), // enabled is deliberately NOT changed. source: { slug: source.slug, @@ -562,19 +567,49 @@ const SOURCE_UNIQUE_CONSTRAINT = 'ai_agent_roles_workspace_source_unique'; /** * Whether `err` is the 23505 raised by the SOURCE-uniqueness index specifically - * (vs the name-uniqueness index). Postgres sets `constraint` to the violated - * index name on a unique violation; we key off that so a source race is skipped - * while a name race still surfaces as a friendly per-role error. A 23505 with no - * constraint name (e.g. a wrapped/test error) is NOT treated as a source - * collision, preserving the existing name-race behavior. + * (vs the name-uniqueness index). The active driver (`kysely-postgres-js` over + * `postgres@3.4.8`) exposes the violated constraint name on `constraint_name`, + * so we key off that (accepting the node-postgres-style `.constraint` as a + * fallback for other drivers) — that way a source race is skipped while a name + * race still surfaces as a friendly per-role error. A 23505 with no constraint + * name (e.g. a wrapped/test error) is NOT treated as a source collision, + * preserving the existing name-race behavior. */ function isSourceUniqueViolation(err: unknown): boolean { + if (!isUniqueViolation(err)) return false; + const e = err as { constraint_name?: unknown; constraint?: unknown }; return ( - isUniqueViolation(err) && - (err as { constraint?: unknown }).constraint === SOURCE_UNIQUE_CONSTRAINT + e.constraint_name === SOURCE_UNIQUE_CONSTRAINT || + e.constraint === SOURCE_UNIQUE_CONSTRAINT ); } +/** + * The role-content fields shared by import (insert) and update (patch) of a + * catalog role: emoji/description/launchMessage normalized to null, model config + * normalized, autoStart defaulted. The caller adds the write-specific fields + * (`name`, `source`, and on insert `workspaceId`/`creatorId`/`enabled`). + */ +function catalogRoleContentFields(role: CatalogRole): { + emoji: string | null; + description: string | null; + instructions: string; + modelConfig: Record | null; + autoStart: boolean; + launchMessage: string | null; +} { + return { + emoji: emptyToNull(role.emoji), + description: emptyToNull(role.description), + instructions: role.instructions, + modelConfig: normalizeModelConfig(role.modelConfig) as + | Record + | null, + autoStart: role.autoStart ?? true, + launchMessage: emptyToNull(role.launchMessage ?? undefined), + }; +} + /** '' / whitespace-only / undefined / null => null; otherwise the trimmed value. */ function emptyToNull(value: string | null | undefined): string | null { if (value === undefined || value === null) return null;