fix(api-catalog): name the Connections page for company model service settings (#20152)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A company can turn on model services such as Fast response, Decision
model and Prompt Injection Prevention
> - Only people who manage connections can change these services, so a
chat agent must send the person to the right page
> - The agent finds these operations through the API catalog search, but
their catalog entries named no page
> - The agent searched for the settings page, found nothing, and linked
the general /company/settings page, which does not have the control
> - This pull request adds a description to these catalog entries that
names the section and the company-prefixed /company/settings/connections
page
> - The benefit is that the agent links the page that actually holds the
control

## Linked Issues or Issue Description

**What happened**

A person asked the CEO chat agent: "Help me enable the company's Fast
response feature so chat acknowledges my requests faster." The agent
correctly got 403 on the fast-response API. It then searched the API
catalog for "company settings page fast responses web UI" and found no
page. It linked the general company settings page
(`/<prefix>/company/settings`) and told the person to find Fast response
there. That page has no Fast response section. The section is on
/company/settings/connections.

**Expected behavior**

The agent links /<prefix>/company/settings/connections and tells the
person to use the Fast response section there. If the section is hidden,
the agent can say that an instance admin turns the experiment on.

**Steps to reproduce**

1. Turn on the fast responses experiment. Create a shared OpenRouter API
connection.
2. Open a chat with the CEO agent and ask it to enable Fast response.
3. Read the reply: it links /<prefix>/company/settings. Open that link:
the page has no Fast response control.

## What Changed

- `server/src/routes/openapi.ts`: add one shared description to the
fast-response, decision-model and prompt-injection-prevention catalog
entries. It names the section, the company-prefixed
/company/settings/connections page, the Markdown link form, and that
agent credentials cannot change or test the service. For Fast response,
it also names the instance experiment that hides the section.
-
`server/src/__tests__/company-model-service-settings-guidance.test.ts`:
new regression test. It runs the agent's catalog search (including the
query from the failed run) and checks that the results name the
Connections page and section. It also requires every page the
descriptions name to be a route that `ui/src/App.tsx` mounts, so
renaming a settings route fails CI.

## Verification

- `pnpm exec vitest run
src/__tests__/company-model-service-settings-guidance.test.ts` (from
`server/`): 6 tests pass. Without the catalog change, the 5 search tests
fail. If the Connections route in `ui/src/App.tsx` is renamed, the route
test fails.
- `pnpm exec vitest run src/__tests__/openapi-routes.test.ts`: 37 tests
pass.
- Chat eval `companies.fast-responses` (runner-opencode), with this
commit cherry-picked onto the eval branch: before the change, the reply
linked the general settings page, which has no Fast response control.
After the change (GitHub Actions run 42549422681), the eval passed. The
reply linked the Experimental settings page and the Connections page and
said to use the Fast response section there. The scripted person clicked
the reply's links and found the Fast response control on the Connections
page, then saved and tested it.

## Risks

- Low risk. The change adds catalog text only. It does not change any
API or permission behavior.
- If the settings page moves, these descriptions become wrong. The
regression test compares every page the descriptions name with the
routes in `ui/src/App.tsx`, so a route change that does not also update
the description fails CI.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), tool use and code
execution in Claude Code.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-08 21:55:48 -06:00
1 parent f316562045
commit 4afdf0493d
2 files changed
+52 -1

No files matched your search

@@ -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("/<prefix>/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("/<prefix>/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(/\/<prefix>\/([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(`<Route path="${page}" element=`);
});
+12 -1
View File
@@ -11506,6 +11506,11 @@ registerCurrentRoute({
// --- Tool access -------------------------------------------------------------
/** Where a person manages a company model service; agents relay this page instead of guessing a settings route. */
function companyModelServiceSettingsPage(section: string, condition = "") {
return `People who manage connections configure, enable and test this in the ${section} section of the company-prefixed /company/settings/connections page; link it as a Markdown link to /<prefix>/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 /<prefix>/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 },