fix(chat): name acpxAgent for model lists, describe connect rollback, mark budget and approval decisions board-only

- Model list guidance names acpxAgent for ACPX agents and drops the
  'complete answer' claim for fallback inputs.
- tools/connections guidance says a failed fresh apps/connect setup
  saves nothing; the unreachable-server remediation links Connectors
  instead of a connection page that may have been rolled back.
- The API catalog marks budget policy, budget incident resolution,
  company and agent budget updates, and approval approve/reject as
  board-only, matching their assertBoard guards. Before, the catalog
  said board_or_agent and agents explored them as their own action.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 committed 2026-11-09 02:17:08 -06:00
1 parent 3ed4b16e42
commit a59d91de14
5 files changed
+32 -13

No files matched your search

@@ -11,7 +11,6 @@ import { unreachableServerRemediation } from "../services/tool-access/catalog/fa
const COMPANY_ID = "10000000-0000-4000-8000-000000000001";
const OTHER_COMPANY_ID = "10000000-0000-4000-8000-000000000009";
const CONNECTION_ID = "30000000-0000-4000-8000-000000000003";
const agentActor: Express.Request["actor"] = { type: "agent", source: "agent_key", agentId: "agent-1", companyId: COMPANY_ID };
const boardActor: Express.Request["actor"] = { type: "board", source: "local_implicit", userId: "local-board" };
@@ -56,25 +55,25 @@ describe("agent refusals that are final", () => {
it("tells an agent an unreachable MCP server is the person's to fix, and not to probe other addresses", async () => {
const error = await refusedConnection("ECONNREFUSED");
const { remediation } = unreachableServerRemediation(error, CONNECTION_ID, { actorType: "agent" }) as { remediation: string };
const { remediation } = unreachableServerRemediation(error, { actorType: "agent" }) as { remediation: string };
expect(remediation).toContain("nothing accepted the connection");
expect(remediation).toContain("they must correct the URL, or start the server");
expect(remediation).toContain("you must not contact other hosts, ports or network addresses yourself");
expect(remediation).toContain("Reply now");
expect(remediation).toContain(`[Open the connection](/<prefix>/apps/${CONNECTION_ID}/permissions)`);
expect(remediation).toContain("[Open Connectors](/<prefix>/apps)");
});
it("adds no MCP remediation for people or for failures that reached the server", async () => {
const error = await refusedConnection("ECONNREFUSED");
expect(unreachableServerRemediation(error, CONNECTION_ID, { actorType: "user" })).toEqual({});
expect(unreachableServerRemediation(new Error("tool list was invalid"), CONNECTION_ID, { actorType: "agent" })).toEqual({});
expect(unreachableServerRemediation(error, { actorType: "user" })).toEqual({});
expect(unreachableServerRemediation(new Error("tool list was invalid"), { actorType: "agent" })).toEqual({});
});
it("returns the MCP remediation in the error body an agent receives", async () => {
const source = await refusedConnection("ENOTFOUND");
const routed = express();
routed.post("/check", () => {
throw new HttpError(502, "fetch failed", { code: "runtime_error", ...unreachableServerRemediation(source, CONNECTION_ID, { actorType: "agent" }) });
throw new HttpError(502, "fetch failed", { code: "runtime_error", ...unreachableServerRemediation(source, { actorType: "agent" }) });
});
routed.use(errorHandler);
const res = await request(routed).post("/check").expect(502);
@@ -82,12 +81,25 @@ describe("agent refusals that are final", () => {
expect(res.body.remediation).toContain("its host name did not resolve");
});
it("marks budget changes and approval decisions board-only in the API catalog", () => {
const paths = buildOpenApiDocument().paths;
for (const [path, method] of [
["/api/companies/{companyId}/budgets/policies", "post"],
["/api/companies/{companyId}/budget-incidents/{incidentId}/resolve", "post"],
["/api/companies/{companyId}/budgets", "patch"],
["/api/agents/{agentId}/budgets", "patch"],
["/api/approvals/{id}/approve", "post"],
["/api/approvals/{id}/reject", "post"],
] as const) expect(paths[path][method]["x-paperclip-authorization"], `${method} ${path}`).toEqual({ actor: "board" });
});
it("points the API catalog at the checked MCP setup route and the complete model lists", () => {
const paths = buildOpenApiDocument().paths;
expect(paths["/api/companies/{companyId}/tools/connections"].post.description).toContain("POST /api/companies/{companyId}/tools/apps/connect with link");
const models = paths["/api/companies/{companyId}/adapters/{type}/models"].get;
expect(models.description).toContain("type opencode_local and provider=openrouter");
expect(models.description).toContain("These calls are the complete answer");
expect(models.description).toContain("its adapterConfig.acpxAgent when the provider is acpx");
expect(models.description).toContain("these calls answer the question");
const provider = models.parameters.find((row: { name: string }) => row.name === "provider");
expect(provider.schema.enum).toContain("openrouter");
});
+10 -2
View File
@@ -1430,6 +1430,14 @@ const browserUseOperations = [
] as const;
const BOARD_ONLY_OPERATIONS = new Set([
// Budget changes are board-only (assertBoard in routes/costs.ts).
"POST /api/companies/{companyId}/budgets/policies",
"POST /api/companies/{companyId}/budget-incidents/{incidentId}/resolve",
"PATCH /api/companies/{companyId}/budgets",
"PATCH /api/agents/{agentId}/budgets",
// Approval decisions are board-only (assertBoard in routes/approvals.ts).
"POST /api/approvals/{id}/approve",
"POST /api/approvals/{id}/reject",
"GET /api/agents/{id}/project-access",
"POST /api/agents/{id}/project-access",
"GET /api/issues/{id}/pending-questions",
@@ -4125,7 +4133,7 @@ registry.registerPath({
path: "/api/companies/{companyId}/adapters/{type}/models",
tags: ["adapters"],
summary: "List models for an adapter type and runner provider",
description: "Answers which models an agent's runtime can select. For a paperclip_runner agent, pass its adapterConfig.provider and its defaultEnvironmentId; the response is the runtime's own list. An OpenCode agent whose model starts with openrouter/ uses an OpenRouter key: list those choices with type opencode_local and provider=openrouter. That list is OpenRouter's public catalog, so whether a model works depends on the key's account and is not a qualification. These calls are the complete answer: report the options with that caveat and reply, rather than enumerating providers through other routes, the shell or the web.",
description: "Answers which models an agent's runtime can select. For a paperclip_runner agent, pass its adapterConfig.provider, its adapterConfig.acpxAgent when the provider is acpx, and its defaultEnvironmentId; the response is that runtime's own list. An OpenCode agent whose model starts with openrouter/ uses an OpenRouter key: list those choices with type opencode_local and provider=openrouter. That list is OpenRouter's public catalog, so whether a model works depends on the key's account and is not a qualification. With those inputs, these calls answer the question: report the options with that caveat and reply, rather than enumerating providers through other routes, the shell or the web.",
request: {
params: z.object({ companyId: z.string(), type: z.string() }),
query: z.object({
@@ -11817,7 +11825,7 @@ registerCurrentRoute({
path: "/api/companies/{companyId}/tools/connections",
tags: ["tool-access"],
summary: "Create a tool connection",
description: "Saves a connection as given without checking that its server answers. To connect an MCP server from its URL, use POST /api/companies/{companyId}/tools/apps/connect with link instead: it checks the server before saving and keeps an unreachable server as an editable draft.",
description: "Saves a connection as given without checking that its server answers. To connect an MCP server from its URL, use POST /api/companies/{companyId}/tools/apps/connect with link instead: it checks the server first, and a fresh setup saves nothing when the server cannot be reached.",
body: createToolConnectionSchema,
responses: {
201: r.ok(),
@@ -172,7 +172,6 @@ const UNREACHABLE_SERVER_REASONS: Readonly<Record<string, string>> = {
*/
export function unreachableServerRemediation(
source: unknown,
connectionId: string,
actor?: Pick<ActorInfo, "actorType">,
): { remediation: string } | Record<string, never> {
if (actor?.actorType !== "agent") return {};
@@ -180,6 +179,6 @@ export function unreachableServerRemediation(
const detail = reason ? UNREACHABLE_SERVER_REASONS[reason] : undefined;
if (!detail) return {};
return {
remediation: `Paperclip could not reach this MCP server at its saved URL: ${detail}. This result is final until the person fixes it: they must correct the URL, or start the server or make it reachable from Paperclip, and then check it again. Retrying now will not connect it, and you must not contact other hosts, ports or network addresses yourself as a workaround. Reply now: tell the person the server could not be reached and what they need to fix, do not say it is connected, and link the connection as [Open the connection](/<prefix>/apps/${connectionId}/permissions), with this conversation's company prefix as <prefix>.`,
remediation: `Paperclip could not reach this MCP server at its saved URL: ${detail}. This result is final until the person fixes it: they must correct the URL, or start the server or make it reachable from Paperclip, and then check it again. Retrying now will not connect it, and you must not contact other hosts, ports or network addresses yourself as a workaround. Reply now: tell the person the server could not be reached and what they need to fix, do not say it is connected, and link [Open Connectors](/<prefix>/apps), with this conversation's company prefix as <prefix>, where they can fix or remove the connection or set it up again.`,
};
}
@@ -226,7 +226,7 @@ export function createConnectionHealth(db: Db, { now, audit, getConnectionRow, d
setupUrl: connectionSetupUrl(connection),
reconnectUrl: connectionReconnectUrl(connection),
...githubRecovery,
...unreachableServerRemediation(error, connection.id, actor),
...unreachableServerRemediation(error, actor),
}));
}
}
@@ -98,7 +98,7 @@ export function createToolCatalogPublication(db: Db, { now, audit, getConnection
code: failure.code,
setupUrl: connectionSetupUrl(connection),
reconnectUrl: connectionReconnectUrl(connection),
...unreachableServerRemediation(error, connection.id, actor),
...unreachableServerRemediation(error, actor),
}));
}