diff --git a/server/src/__tests__/company-model-service-settings-guidance.test.ts b/server/src/__tests__/company-model-service-settings-guidance.test.ts new file mode 100644 index 00000000000..ae66a624877 --- /dev/null +++ b/server/src/__tests__/company-model-service-settings-guidance.test.ts @@ -0,0 +1,40 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, it } from "vitest"; +import { runnerApiCatalog, searchRunnerApi } from "../services/native-runtime/runner-api-catalog.js"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); + +// An agent asked to turn on a company model service cannot do it and must link the page that holds the control. +// The general /company/settings page has none of these sections, so the catalog names the Connections page. +it.each([ + ["fast response feature", "/api/companies/{companyId}/fast-response", "Fast response"], + ["company settings page fast responses web UI", "/api/companies/{companyId}/fast-response", "Fast response"], + ["decision model settings", "/api/companies/{companyId}/decision-model", "Decision model"], + ["prompt injection prevention settings", "/api/companies/{companyId}/prompt-injection-prevention", "Prompt Injection Prevention"], +])("search %j returns the settings page that holds the %s control", (query, path, section) => { + const operation = searchRunnerApi({ query, limit: 10 }).results.find(row => row.path === path && row.method === "PUT"); + expect(operation, query).toBeDefined(); + expect(operation!.description).toContain(`${section} section of the company-prefixed /company/settings/connections page`); + expect(operation!.description).toContain("//company/settings/connections"); +}); + +it("names the experiment that hides the Fast response section", () => { + const operation = searchRunnerApi({ query: "fast response", limit: 10 }).results.find(row => row.operationId === "GET /api/companies/{companyId}/fast-response"); + expect(operation?.description).toContain("//company/settings/instance/experimental"); +}); + +// The catalog text is the only place these page paths live on the server, so a renamed UI route +// would leave agents linking a page that no longer exists. Every page the guidance names must be +// a route the board UI mounts under the company prefix. +it("names only pages that the board UI mounts", () => { + const appRoutes = readFileSync(path.join(repoRoot, "ui/src/App.tsx"), "utf8"); + const pages = new Set( + runnerApiCatalog() + .filter(row => /^\/api\/companies\/\{companyId\}\/(fast-response|decision-model|prompt-injection-prevention)/.test(row.path)) + .flatMap(row => [...(row.description ?? "").matchAll(/\/\/([a-z0-9/-]+)/g)].map(match => match[1]!)), + ); + expect([...pages].sort()).toEqual(["company/settings/connections", "company/settings/instance/experimental"]); + for (const page of pages) expect(appRoutes, page).toContain(`/company/settings/connections, not the general /company/settings page.${condition} Agent credentials cannot change or test it.`; +} + for (const [method, suffix, summary] of [ ["get", "", "Get prompt injection settings and eligible shared OpenRouter connections; non-managers receive no settings or choices"], ["put", "", "Configure opt-in prompt injection model checks as a board connection manager"], @@ -11513,7 +11518,7 @@ for (const [method, suffix, summary] of [ ["post", "/test", "Classify two fixed billed samples using saved settings as a board connection manager; fallback is not a passing model test"], ] as const) registerCurrentRoute({ method, path: `/api/companies/{companyId}/prompt-injection-prevention${suffix}`, - tags: ["prompt-injection-prevention"], summary, + tags: ["prompt-injection-prevention"], summary, description: companyModelServiceSettingsPage("Prompt Injection Prevention"), ...(method === "put" ? { body: updatePromptInjectionPreventionSchema } : {}), ...(method === "post" ? { body: z.object({}).strict() } : {}), ...(suffix === "/models" ? { query: z.object({ connectionId: z.string().uuid() }) } : {}), @@ -11528,6 +11533,7 @@ for (const [method, suffix, summary] of [ ["post", "/test", "Generate a fixed billed fast response sample"], ["get", "/history", "List fast response accounting and publication outcomes"], ] as const) registerCurrentRoute({ method, path: `/api/companies/{companyId}/fast-response${suffix}`, tags: ["fast-responses"], summary, + description: companyModelServiceSettingsPage("Fast response", " The section is shown only while an instance admin has fast responses turned on in //company/settings/instance/experimental."), ...(method === "put" ? { body: updateFastResponseSchema } : {}), responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 422: r.unprocessable } }); const observabilitySignalReceiptSchema = z.object({ @@ -11567,26 +11573,31 @@ registerCurrentRoute({ registerCurrentRoute({ method: "get", path: "/api/companies/{companyId}/decision-model", tags: ["decision-models"], + description: companyModelServiceSettingsPage("Decision model"), summary: "Get decision settings, compatible connections, and manager capability", responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden }, }); registerCurrentRoute({ method: "put", path: "/api/companies/{companyId}/decision-model", tags: ["decision-models"], + description: companyModelServiceSettingsPage("Decision model"), summary: "Configure the company decision model as a connection manager", body: updateDecisionModelSchema, responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 422: r.unprocessable }, }); registerCurrentRoute({ method: "get", path: "/api/companies/{companyId}/decision-model/availability", tags: ["decision-models"], + description: companyModelServiceSettingsPage("Decision model"), summary: "Check local decision configuration and caller authorization without contacting a provider", responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden }, }); registerCurrentRoute({ method: "post", path: "/api/companies/{companyId}/decision-model/test", tags: ["decision-models"], + description: companyModelServiceSettingsPage("Decision model"), summary: "Run the fixed billed three-question setup test as a connection manager", responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden }, }); registerCurrentRoute({ method: "get", path: "/api/companies/{companyId}/decision-model/history", tags: ["decision-models"], + description: companyModelServiceSettingsPage("Decision model"), summary: "List decision metadata and charges with authorized task links", query: costReportQuerySchema.extend({ limit: z.coerce.number().int().min(1).max(500).optional() }), responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden },