Files
PaperClipAI/server/src/__tests__/connection-aggregator-fallback.test.ts
T
DottaandClaude Opus 5.5 4ece2107b8 fix(connections): offer aggregator Slack sign-in on Cloud instead of a customer OAuth app (#19328)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Connectors let people give agents tools, such as Slack, from the
Connectors catalog or from an agent's inline "Connect" card
> - On Paperclip Cloud, the only Slack path is "Slack needs its own
OAuth app". The user must create a Slack app, paste a callback URL, and
enter a Client ID and secret
> - Managed Slack chat setup exists, but it is behind the experimental
Chat connectors setting, which is off on Cloud. Composio's and Arcade's
managed Slack sign-in exists, but the native Slack row hides it
> - In an onboarding test on Cloud, a tester got this card from the
onboarding agent, spent 7 minutes on it, and declined it. No Slack
connection was made
> - This pull request follows the existing Google Workspace pattern: on
Cloud, a native connector whose every setup path needs a
customer-created OAuth app yields new setup to the aggregators' managed
sign-in, in the catalog and in agent connection discovery
> - The benefit is that Cloud users can connect Slack with a sign-in
instead of creating a Slack app

## Linked Issues or Issue Description

**What happened?**
On a Paperclip Cloud instance, the onboarding agent asked the user to
connect Slack. Connectors > Slack > Connect showed "Slack needs its own
OAuth app. Slack does not let Paperclip register itself automatically,
so this connector needs an OAuth app you create." The form asks for a
callback URL, a Client ID and a Client secret. It has no link to Slack
app settings. The method description said "Use the provider-hosted
connection for the quickest setup." The tester gave up. The original
notes said that the cloud connector asked for an HTTPS server. That is
not correct: no HTTPS or deployment-mode check is in this path. The real
blocker is the customer OAuth app requirement.

**Expected behavior**
Slack can be connected from the supported Cloud flow without setup steps
that only an operator can do.

**Steps to reproduce**
1. On a Cloud instance with Chat connectors off (the Cloud default),
open Connectors and search for "Slack".
2. Click Connect on Slack. The "needs its own OAuth app" form opens.
3. Or, let an agent call `connections_search("Slack")`. It returns
native `slack` first, and its card opens the same form.

**Additional context**
Evidence: the Slack gallery entry on the Cloud instance had
`ownershipAvailability` `platform_shared: false`, `platform_provisioned:
false`, `customer: true`. `GET
/api/companies/:id/chat-slack/setup-options` returned 403 "Chat
connectors are disabled".

## What Changed

- `packages/shared`: add `nativeConnectorRequiresCustomerOAuthApp(app,
settings)`. It is true when every available setup path of a native
connector needs a customer-created OAuth app: no Paperclip-managed
client, no dynamic client registration, and no enabled chat setup.
- `ui/src/pages/apps/Browse.tsx`: on Cloud, such a connector yields to
aggregators, the same as the built-in Google connectors. The catalog
shows the "Connect through Composio or Arcade" row, and its Connect
button opens the provider choice. A saved native connection, a chat
endpoint, Chat connectors on, a self-hosted instance, or no aggregator
route keeps the native row.
- `server/src/services/connection-intents.ts` and new
`connection-native-yield.ts`: agent `connections_search` uses the same
rule. On Cloud, it does not prefer native Slack for new setup, so the
agent asks the provider-choice question (Composio, Arcade or Zapier).
The rule applies the advertised Paperclip Cloud connector profiles, so
apps with a managed Cloud client (for example Asana) do not yield. If
the profiles cannot be read, the native connector stays.
- `slack.json`: correct the Slack tool method copy, which said "quickest
setup". Add a "register" console link to `https://api.slack.com/apps`,
so the form now shows "Open Slack app settings".
- Tests in all three layers.

## Verification

Red/green. I committed the tests first (`2386fd83df`) and ran them
against unchanged source code.

**Red (before the fix):**

```
server › aggregator connection fallback › Slack on Paperclip Cloud
× offers aggregator Slack routes while native Slack only supports a customer-created OAuth app
AssertionError: expected [ 'slack', 'google-calendar', …(7) ] to deeply equal [ 'via:composio:slack', …(2) ]
+   "slack", "google-calendar", "google-docs", ...
Tests  1 failed | 3 passed | 45 skipped (49)

ui › Connectors landing page
× offers aggregator Slack sign-in on Cloud while the built-in Slack connector needs a customer OAuth app
AssertionError: expected 'SlackSend and read messages in your t…' to contain 'Connect through Composio or Arcade.'
Received: "SlackSend and read messages in your team's channels.Connect"
Tests  1 failed | 5 passed | 92 skipped (98)

shared › Slack tool connection guidance
× describes the customer-created Slack app honestly and links to Slack app settings
AssertionError: expected 'Use the provider-hosted connection fo…' not to match /quickest/i
Tests  1 failed | 3 passed (4)
```

**Green (after the fix):**

```
✓ server › Slack on Paperclip Cloud › offers aggregator Slack routes while native Slack only supports a customer-created OAuth app
✓ server › Slack on Paperclip Cloud › keeps native Slack when Chat connectors provide its own setup path
✓ server › Slack on Paperclip Cloud › keeps native Slack for a company that already saved a Slack connection
✓ server › keeps native Slack on self-hosted instances, where operators register their own Slack app
✓ ui › offers aggregator Slack sign-in on Cloud while the built-in Slack connector needs a customer OAuth app
✓ ui › keeps the built-in Slack connector when Chat connectors offer its setup path
✓ ui › keeps the built-in Slack connector when the instance is self-hosted
✓ ui › keeps a saved built-in Slack connection on its native row on Cloud
✓ shared › describes the customer-created Slack app honestly and links to Slack app settings
✓ shared › flags Slack only while its chat setup is unavailable
✓ shared › does not flag connectors with a managed, dynamic-registration, or credential path
```

Neighbouring suites:
- `packages/shared` (all): 98 files, 1060 tests passed
- `ui` `src/pages/apps` + `src/features/connections`: 54 files, 980
tests passed
- `server` `connection-aggregator-fallback` 49/49,
`connection-intents-service`, `native-connection-baseline-fixture`,
`runner-connection-eval-fixtures` passed. `satisfied-connection-intents`
52/52 passed when run alone. It had a suite-setup error only when three
embedded-Postgres runs went in parallel.
- `pnpm check:token-gates`: clean. `tsc --noEmit` shows no errors in
changed files. Local runs show unrelated errors because the
`paperclip-runner` workspace package is not built. CI runs the full
typecheck.

Still needs a live Slack workspace to prove:
- Complete Slack sign-in through Composio or Arcade on a Cloud instance,
and an agent tool call to Slack.
- An agent run on Cloud that asks the provider-choice question for Slack
and then connects through the selected aggregator.

## Risks

- Behaviour change on Cloud only, for native connectors whose every path
needs a customer-created OAuth app and that an aggregator serves. Today
that is Slack (when Chat connectors are off), and Box and Xero if the
aggregator catalog has them. On Cloud, these move to the aggregator
route for new setup, the same as the built-in Google connectors. Saved
connections keep their native row and discovery.
- Cloud users who want their own Slack app can still open
`/apps/connect?source=slack`, but the catalog row no longer links to it
while the rule applies.
- Self-hosted instances keep the native connector unchanged.
- Follow-up, not in this PR: enable managed Slack chat on Cloud (Chat
connectors Cloud default and `PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED`).
This is a flag and operations change. It needs the Cloud Slack broker to
be proven first. When it is enabled, this rule stops yielding Slack
automatically.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, browser inspection of a staging instance, file
editing).

## 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>
2026-11-05 16:12:45 -06:00

975 lines
43 KiB
TypeScript

import { Ajv2020 } from "ajv/dist/2020.js";
import { requestHumanInputAction } from "../../../packages/paperclip-runner/src/protocol-actions/request-human-input.js";
import { questionSetToAskUserQuestionsPayload } from "@paperclipai/shared";
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
agents,
authUsers,
companies,
companyMemberships,
goals,
heartbeatRuns,
issueThreadInteractions,
issueComments,
issues,
createDb,
toolApplications,
toolConnections,
connectionGrants,
toolCatalogEntries,
toolProfiles,
toolProfileBindings,
toolConnectionInstalls,
} from "@paperclipai/db";
import type { RuntimeToolsTokenClaims } from "../runtime-tools-token.js";
import { connectionIntentService } from "../services/connection-intents.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { issueThreadInteractionService } from "../services/issue-thread-interactions.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)(
"aggregator connection fallback",
() => {
let db!: ReturnType<typeof createDb>;
let connectionString!: string;
let cleanup: (() => Promise<void>) | undefined;
let claims!: RuntimeToolsTokenClaims;
let runId!: string;
beforeAll(async () => {
const tempDb = await startEmbeddedPostgresTestDatabase(
"paperclip-connection-intents-",
);
cleanup = tempDb.cleanup;
connectionString = tempDb.connectionString;
db = createDb(connectionString);
const companyId = randomUUID();
const agentId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
runId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Connection tests",
issuePrefix: "AGG",
requireBoardApprovalForNewAgents: false,
});
await db.insert(authUsers).values({
id: "responsible-user", name: "Responsible user", email: "responsible-user@example.test",
createdAt: new Date(), updatedAt: new Date(),
});
await db.insert(companyMemberships).values({
companyId,
principalType: "user",
principalId: "responsible-user",
status: "active",
membershipRole: "member",
});
await db.insert(goals).values({
id: goalId,
companyId,
title: "Connect a service",
level: "task",
status: "active",
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Researcher",
role: "researcher",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Read Notion",
status: "in_progress",
priority: "medium",
assigneeAgentId: agentId,
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
status: "running",
responsibleUserId: "responsible-user",
contextSnapshot: { issueId },
});
claims = {
sub: agentId,
company_id: companyId,
run_id: runId,
responsible_user_id: "responsible-user",
scope: "connection_intents",
iat: 1,
exp: 2,
instance_id: "test",
};
}, 60_000);
afterAll(async () => {
await cleanup?.();
});
async function userRequest(body: string, userId = "responsible-user") {
const [run] = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId));
await db
.insert(issueComments)
.values({
companyId: claims.company_id,
issueId: run!.contextSnapshot!.issueId as string,
authorUserId: userId,
body,
});
}
async function resetQuestions() {
await db
.delete(issueComments)
.where(eq(issueComments.companyId, claims.company_id));
await db
.delete(issueThreadInteractions)
.where(eq(issueThreadInteractions.companyId, claims.company_id));
await db
.delete(toolConnections)
.where(eq(toolConnections.companyId, claims.company_id));
await db
.delete(toolProfiles)
.where(eq(toolProfiles.companyId, claims.company_id));
await db
.delete(toolApplications)
.where(eq(toolApplications.companyId, claims.company_id));
}
async function selectProvider(
option: string,
userId = "responsible-user",
query = "hubspot",
) {
const result = await connectionIntentService(db).search(claims, query);
const [run] = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId));
const issue = {
id: run!.contextSnapshot!.issueId as string,
companyId: claims.company_id,
};
const interaction = await issueThreadInteractionService(db).create(
issue,
{
kind: "ask_user_questions",
payload: { version: 1, questions: [result.providerQuestion!] },
sourceRunId: runId,
addresseeUserId: "responsible-user",
},
{ agentId: claims.sub, runId },
);
// Public resolution service enforces human answer semantics; company membership
// and wrong-identity request checks are also exercised below.
await issueThreadInteractionService(db).answerQuestions(
issue,
interaction.id,
{
answers: [
{ questionId: result.providerQuestion!.id, optionIds: [option] },
],
},
{ userId },
);
return interaction;
}
async function seedProvider(
provider: string,
toolName: string,
userId = "responsible-user",
allowed = true,
) {
const [app] = await db
.insert(toolApplications)
.values({
companyId: claims.company_id,
applicationKey: randomUUID(),
name: provider,
type: "mcp_http",
status: "active",
metadata: { sourceTemplateKey: provider },
})
.returning();
const [connection] = await db
.insert(toolConnections)
.values({
companyId: claims.company_id,
applicationId: app!.id,
uid: randomUUID(),
name: provider,
transport: "mcp_remote",
authKind: "none",
credentialPolicy: "per_user",
status: "active",
enabled: true,
healthStatus: "ok",
config: { sourceTemplateKey: provider },
})
.returning();
await db.insert(connectionGrants).values({
companyId: claims.company_id,
connectionId: connection!.id,
kind: "user",
subjectUserId: userId,
status: "active",
});
await db.insert(toolCatalogEntries).values({
companyId: claims.company_id,
connectionId: connection!.id,
toolName,
name: toolName,
versionHash: "fixture",
status: "active",
entryKind: "tool",
lastSeenAt: new Date("2026-09-20T00:00:00Z"),
});
await db.insert(toolConnectionInstalls).values({
companyId: claims.company_id,
connectionId: connection!.id,
targetType: "agent",
targetId: claims.sub,
});
if (allowed) {
const [profile] = await db
.insert(toolProfiles)
.values({
companyId: claims.company_id,
profileKey: randomUUID(),
name: "Fixture reads",
defaultAction: "allow",
status: "active",
})
.returning();
await db.insert(toolProfileBindings).values({
companyId: claims.company_id,
profileId: profile!.id,
targetType: "agent",
targetId: claims.sub,
});
}
return connection!;
}
it.each([
["AgentMail create an email address and manage an agent mailbox", "agentmail"],
["Please acquire an email address through agent mail and remember it", "agentmail"],
["I need an Agentmial inbox for receiving mail from customers", "agentmail"],
["Please connect my Linear workspace so I can triage the team's backlog", "linear"],
["Find a Notion connection to search our engineering documentation", "notion"],
["Can you find the OpenRouter connection for the model I want to use?", "openrouter"],
["Please help me connect to Git Hub to look at my pull requests", "github"],
])("finds native connections in natural-language queries: %s", async (query, service) => {
await resetQuestions();
await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true });
try {
const result = await connectionIntentService(db).search(claims, query);
expect(result.results[0]).toMatchObject({ service, state: "available" });
expect(result.providerQuestion).toBeUndefined();
} finally {
await instanceSettingsService(db).updateExperimental({ enableChatConnectors: false });
}
});
it.each([false, true])("discovers AgentMail with truthful setup actions regardless of the chat setting (%s)", async (enabled) => {
await resetQuestions();
const service = connectionIntentService(db);
await instanceSettingsService(db).updateExperimental({ enableChatConnectors: enabled });
try {
const result = await service.search(claims, "agentmail");
expect(result.results[0]?.methods).toEqual([expect.objectContaining({
key: "email-agent", purpose: "channel", setupPath: `/AGG/apps/chat/connect?provider=agentmail&purpose=chat&agentId=${claims.sub}`,
})]);
expect(result.instruction).toContain("connection_request");
const requested = await service.request(claims, "agentmail");
expect(requested).toMatchObject({ state: "needs_user_action", interactionId: expect.any(String) });
expect(await service.request(claims, "agentmail")).toMatchObject({ interactionId: requested.interactionId });
const options = await service.setupOptions(requested.interactionId!);
expect(options.interaction.payload).toMatchObject({ purpose: "channel", serviceSlug: "agentmail", requestingAgentId: claims.sub });
expect(options.emailSetup).toEqual({ credentialConnectionId: null, readyConnectionId: null });
await service.decline(requested.interactionId!, "responsible-user");
await expect(service.request(claims, "agentmail")).rejects.toThrow(/already been resolved/);
const browse = await service.search(claims, "");
expect(browse.results.some(item => item.service === "agentmail")).toBe(true);
expect(browse.results.some(item => item.service === "anthropic")).toBe(true);
} finally {
await instanceSettingsService(db).updateExperimental({ enableChatConnectors: false });
}
});
it("keeps useful capability matches even when other query words do not occur in the catalog", async () => {
await resetQuestions();
const result = await connectionIntentService(db).search(claims,
"I need something that can search documents and spreadsheets for our quarterly planning discussion");
expect(result.results.some(item => item.service === "google-drive")).toBe(true);
expect(result.results.some(item => item.service === "google-sheets")).toBe(true);
expect(result.instruction).not.toContain("search its exact name");
});
it("keeps installed capability matches when a generic query resembles an external app name", async () => {
await resetQuestions();
const installed = await seedProvider("Studio library", "notion:list_pages");
await db.update(toolCatalogEntries).set({ description: "Read recent pages and return their titles and verification code" })
.where(eq(toolCatalogEntries.connectionId, installed.id));
const service = connectionIntentService(db);
const result = await service.search(claims,
"connected page service that can find or list recent pages and return page titles plus a verification code");
expect(result.results).toEqual(expect.arrayContaining([expect.objectContaining({
service: `connection:${installed.id}`, connectionId: installed.id, state: "ready",
})]));
expect(result.providerQuestion).toBeUndefined();
expect(result.instruction).not.toContain("Ask the responsible user with providerQuestion");
// A deliberate app name still reaches the existing governed provider choice.
const named = await service.search(claims, "Page X");
expect(named.providerQuestion?.id).toBe("connection-provider:page-x");
expect(named.results[0]?.service).toBe("via:composio:page-x");
});
it("keeps an external typo as a discovery suggestion until its app is selected", async () => {
await resetQuestions();
const result = await connectionIntentService(db).search(claims,
"Find Circlebak meeting transcripts and action items from yesterday");
expect(result.results.some(item => item.service === "via:composio:circleback-mcp")).toBe(true);
expect(result.providerQuestion).toBeUndefined();
expect(result.instruction).toContain("aggregator.targetService");
});
it.each([
["help me find tools for circle back", "circleback-mcp"],
["Can you connect Circleback MCP to get all our meeting notes?", "circleback-mcp"],
["Find Attio tools to review all our customer contacts before next week's meeting", "attio"],
["Help me find a ClickUp connection to organize our team's projects", "clickup"],
])("finds verified aggregator apps in natural-language queries: %s", async (query, target) => {
await resetQuestions();
const result = await connectionIntentService(db).search(claims, query);
expect(result.results).toEqual(expect.arrayContaining([expect.objectContaining({
service: `via:composio:${target}`, source: "aggregator",
aggregator: expect.objectContaining({ evidenceUrl: expect.stringContaining("composio.dev/"), targetService: target }),
})]));
expect(result.providerQuestion?.options.map(option => option.id)).toContain(`via:composio:${target}`);
await expect(connectionIntentService(db).request(claims, `via:composio:${target}`)).rejects.toThrow();
});
it.each([false, true])("prefers an exact Motion match over fuzzy Notion (Notion denied: %s)", async denied => {
await resetQuestions();
if (denied) await seedProvider("notion", "notion_search", "responsible-user", false);
const result = await connectionIntentService(db).search(claims, "Help me find Motion tools");
expect(result.results[0]?.service).toBe("via:composio:motion");
expect(result.providerQuestion?.id).toBe("connection-provider:motion");
expect(result.instruction).not.toContain("administratively restricted");
});
it("finds namespaced installed aggregator tools with extra query words", async () => {
await resetQuestions();
await seedProvider("executor", "heliotrope:list_records");
const result = await connectionIntentService(db).search(claims, "Please find heliotrope tools for our weekly report");
expect(result.results[0]?.service).toBe("via:executor:heliotrope");
});
it("returns multiple named services so the agent can choose the relevant result", async () => {
await resetQuestions();
const result = await connectionIntentService(db).search(claims, "Find Linear or Notion for our project planning");
expect(result.results.map(item => item.service)).toEqual(expect.arrayContaining(["linear", "notion"]));
});
it("returns multiple aggregator apps without inventing a combined route or choosing consent", async () => {
await resetQuestions();
const result = await connectionIntentService(db).search(claims, "Find Circleback and Attio tools");
expect(result.results.map(item => item.service)).toEqual(expect.arrayContaining(["via:composio:circleback-mcp", "via:composio:attio"]));
expect(result.providerQuestion).toBeUndefined();
expect(result.instruction).toContain("aggregator.targetService");
});
it("keeps native and aggregator app matches from the same query", async () => {
await resetQuestions();
const result = await connectionIntentService(db).search(claims, "Find Notion and Circleback tools");
expect(result.results.map(item => item.service)).toEqual(expect.arrayContaining(["notion", "via:composio:circleback-mcp"]));
expect(result.providerQuestion).toBeUndefined();
});
it("prefers the built-in Jira connection and returns a direct instruction", async () => {
await resetQuestions();
const result = await connectionIntentService(db).search(
claims,
"atlassian jira",
);
expect(result.results[0]?.service).toBe("jira");
expect(result.providerQuestion).toBeUndefined();
expect(result.instruction).toContain("connection_request");
});
// BUG-06: on Paperclip Cloud, the native Slack tool path requires a customer-created
// Slack OAuth app and managed Slack chat setup is behind the Chat connectors opt-in.
// Agents must offer the one-click aggregator routes instead of that card.
describe("Slack on Paperclip Cloud", () => {
const slackQuery = "Slack (team updates / notifications)";
beforeAll(() => { vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "cloud-test-token"); });
afterAll(() => { vi.unstubAllEnvs(); });
it("offers aggregator Slack routes while native Slack only supports a customer-created OAuth app", async () => {
await resetQuestions();
const result = await connectionIntentService(db).search(claims, slackQuery);
expect(result.results.map((item) => item.service)).toEqual([
"via:composio:slack",
"via:arcade:slack",
"via:zapier:slack",
]);
expect(result.providerQuestion?.prompt).toContain("Slack");
});
it("keeps native Slack when Chat connectors provide its own setup path", async () => {
await resetQuestions();
await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true });
try {
const result = await connectionIntentService(db).search(claims, slackQuery);
expect(result.results[0]).toMatchObject({ service: "slack", state: "available" });
expect(result.providerQuestion).toBeUndefined();
} finally {
await instanceSettingsService(db).updateExperimental({ enableChatConnectors: false });
}
});
it("does not offer native Slack setup beside another app's native match", async () => {
await resetQuestions();
const result = await connectionIntentService(db).search(claims, "Find Notion and Slack tools");
const services = result.results.map((item) => item.service);
expect(services).toEqual(expect.arrayContaining(["notion", "via:composio:slack"]));
expect(services).not.toContain("slack");
});
it("keeps Slack's aggregator routes in capability-only discovery", async () => {
await resetQuestions();
const result = await connectionIntentService(db).search(claims, "start and continue Paperclip work");
const services = result.results.map((item) => item.service);
expect(services).toContain("via:composio:slack");
expect(services).not.toContain("slack");
// A route needs the provider-choice flow before connection_request.
expect(result.instruction).toContain("search its aggregator.targetService");
});
it("keeps native connectors that declare a Paperclip Cloud managed method", async () => {
await resetQuestions();
const result = await connectionIntentService(db).search(claims, "asana");
expect(result.results[0]).toMatchObject({ service: "asana" });
expect(result.providerQuestion).toBeUndefined();
});
it("keeps native Slack for a company that already saved a Slack connection", async () => {
await resetQuestions();
await seedProvider("slack", "slack_send_message");
const result = await connectionIntentService(db).search(claims, slackQuery);
expect(result.results[0]).toMatchObject({ service: "slack" });
expect(result.providerQuestion).toBeUndefined();
});
});
it("keeps native Slack on self-hosted instances, where operators register their own Slack app", async () => {
await resetQuestions();
const result = await connectionIntentService(db).search(claims, "Slack (team updates / notifications)");
expect(result.results[0]).toMatchObject({ service: "slack", state: "available" });
expect(result.providerQuestion).toBeUndefined();
});
it("returns ranked verified routes and does not create questions or connections during search", async () => {
await resetQuestions();
const result = await connectionIntentService(db).search(
claims,
"HubSpot recent contacts",
);
expect(result.results.map((item) => item.service)).toEqual([
"via:composio:hubspot",
"via:arcade:hubspot",
"via:zapier:hubspot",
]);
expect(result.providerQuestion?.prompt).toContain("external service");
const ajv = new Ajv2020({ allErrors: true, allowUnionTypes: true, strict: false });
const input = { idempotencyKey: "provider-choice", interactionKind: "questions", title: "Choose a provider",
prompt: result.providerQuestion!.prompt, continuationPolicy: "wake_assignee" };
for (const payload of [{ version: 1, questionSet: result.providerQuestionSet }, { version: 1, questions: [result.providerQuestion] }]) {
expect(ajv.validate(requestHumanInputAction.live.descriptor.inputSchema, { ...input, payload }), JSON.stringify(ajv.errors)).toBe(true);
}
expect(questionSetToAskUserQuestionsPayload(result.providerQuestionSet!).questions).toEqual([result.providerQuestion]);
expect(result.instruction).toContain("questionSet:providerQuestionSet");
expect(result.instruction).not.toContain("do not add questionSet");
expect(ajv.validate(requestHumanInputAction.live.descriptor.inputSchema, { ...input, payload: { version: 1 } })).toBe(false);
expect(ajv.validate(requestHumanInputAction.live.descriptor.inputSchema, { ...input, payload: { version: 1,
questionSet: { ...result.providerQuestionSet, questions: [{ ...result.providerQuestionSet!.questions[0], answerMode: "text" }] } } })).toBe(false);
expect(result.results.every((item) => item.state !== "ready")).toBe(true);
expect(result.results.every((item) => item.aggregator?.evidenceUrl)).toBe(
true,
);
expect(
await db
.select()
.from(issueThreadInteractions)
.where(eq(issueThreadInteractions.companyId, claims.company_id)),
).toHaveLength(0);
});
it("requires a real saved provider answer before requesting setup", async () => {
await resetQuestions();
await expect(
connectionIntentService(db).request(claims, "via:composio:hubspot"),
).rejects.toMatchObject({ status: 403 });
await expect(
connectionIntentService(db).request(claims, "via:composio:hubspot", {
selectionInteractionId: randomUUID(),
}),
).rejects.toMatchObject({ status: 403 });
});
it("uses the second selected provider and preserves target disclosure and idempotency", async () => {
await resetQuestions();
const answer = await selectProvider("via:arcade:hubspot");
const service = connectionIntentService(db);
const search = await service.search(claims, "hubspot");
expect(search.results.map((item) => item.service)).toEqual([
"via:arcade:hubspot",
]);
expect(search.selectionInteractionId).toBe(answer.id);
const first = await service.request(claims, "via:arcade:hubspot", {
selectionInteractionId: answer.id,
});
const again = await service.request(claims, "via:arcade:hubspot", {
selectionInteractionId: answer.id,
});
expect(again.interactionId).toBe(first.interactionId);
const [row] = await db
.select()
.from(issueThreadInteractions)
.where(eq(issueThreadInteractions.id, first.interactionId!));
expect(row!.payload).toMatchObject({
serviceSlug: "arcade",
serviceName: "HubSpot through Arcade",
upstreamService: { slug: "hubspot", selectionInteractionId: answer.id },
});
await expect(
service.request(claims, "via:composio:hubspot", {
selectionInteractionId: answer.id,
}),
).rejects.toMatchObject({ status: 403 });
});
it("remembers None and refuses setup after reloading the service", async () => {
await resetQuestions();
const answer = await selectProvider("none");
const result = await connectionIntentService(db).search(
claims,
"hubspot",
);
expect(result.results).toEqual([]);
expect(result.providerQuestion).toBeUndefined();
expect(result.instruction).toContain("declined");
const retry = await connectionIntentService(db).search(
claims,
"hubspot",
{ retryProviderChoice: true },
);
expect(retry.providerQuestion).toBeDefined();
expect(retry.selectionInteractionId).toBeUndefined();
await expect(
connectionIntentService(db).request(claims, "via:arcade:hubspot", {
selectionInteractionId: answer.id,
}),
).rejects.toMatchObject({ status: 403 });
});
it("waits on an existing unanswered question instead of asking twice", async () => {
await resetQuestions();
const result = await connectionIntentService(db).search(
claims,
"hubspot",
);
const [run] = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId));
const interaction = await issueThreadInteractionService(db).create(
{
id: run!.contextSnapshot!.issueId as string,
companyId: claims.company_id,
},
{
kind: "ask_user_questions",
payload: { version: 1, questions: [result.providerQuestion!] },
sourceRunId: runId,
addresseeUserId: "responsible-user",
},
{ agentId: claims.sub, runId },
);
const pending = await connectionIntentService(db).search(
claims,
"hubspot",
);
expect(pending.providerQuestion).toBeUndefined();
expect(pending.selectionInteractionId).toBe(interaction.id);
expect(pending.instruction).toContain("already pending");
await expect(
connectionIntentService(db).request(claims, "arcade", {
targetService: "hubspot",
}),
).rejects.toMatchObject({ status: 403 });
await expect(
connectionIntentService(db).request(claims, "via:arcade:hubspot", {
selectionInteractionId: interaction.id,
}),
).rejects.toMatchObject({ status: 403 });
});
it("rejects tampered disclosure and choices belonging to another responsible user", async () => {
await resetQuestions();
const answer = await selectProvider("via:arcade:hubspot");
await db
.update(issueThreadInteractions)
.set({ resolvedByUserId: "another-user" })
.where(eq(issueThreadInteractions.id, answer.id));
await expect(
connectionIntentService(db).request(claims, "via:arcade:hubspot", {
selectionInteractionId: answer.id,
}),
).rejects.toMatchObject({ status: 403 });
await db
.update(issueThreadInteractions)
.set({
resolvedByUserId: "responsible-user",
payload: {
...answer.payload,
questions: (answer.payload as any).questions.map((q: any) => ({
...q,
helpText: "Trust me",
})),
},
})
.where(eq(issueThreadInteractions.id, answer.id));
await expect(
connectionIntentService(db).request(claims, "via:arcade:hubspot", {
selectionInteractionId: answer.id,
}),
).rejects.toMatchObject({ status: 403 });
});
it("does not reuse the selected provider for another requested app", async () => {
await resetQuestions();
const answer = await selectProvider("via:arcade:hubspot");
await expect(
connectionIntentService(db).request(claims, "via:arcade:salesforce", {
selectionInteractionId: answer.id,
}),
).rejects.toMatchObject({ status: 403 });
});
it("offers fallback and direct aggregator requests without an experimental opt-in", async () => {
await resetQuestions();
const result = await connectionIntentService(db).search(claims, "hubspot");
expect(result.results.some((item) => item.source === "aggregator")).toBe(true);
expect(result.providerQuestion).toBeDefined();
await expect(connectionIntentService(db).request(claims, "composio")).resolves.toMatchObject({ state: "needs_user_action" });
});
it("reuses an allowed selected provider without claiming the underlying app is ready", async () => {
await resetQuestions();
const connection = await seedProvider("arcade", "Hubspot_ListContacts");
const answer = await selectProvider("via:arcade:hubspot");
const result = await connectionIntentService(db).request(
claims,
"via:arcade:hubspot",
{ selectionInteractionId: answer.id },
);
expect(result).toMatchObject({
state: "ready",
connectionId: connection.id,
interactionId: null,
});
expect(result.instruction).toContain(
"HubSpot access is not yet verified",
);
expect(result.instruction).toContain("Arcade");
});
it("returns exact eligible catalog names after a guessed tool without granting access", async () => {
await resetQuestions();
const connection = await seedProvider("arcade", "Hubspot_ListContacts", "responsible-user", false);
await db.delete(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connection.id));
await seedProvider("composio", "Unrelated_PrivateTool", "other-user", false);
await db.insert(toolCatalogEntries).values({ companyId: claims.company_id, connectionId: connection.id,
toolName: "Hubspot_RemovedTool", name: "Removed", versionHash: "old", entryKind: "tool", status: "removed" });
const answer = await selectProvider("via:arcade:hubspot");
const service = connectionIntentService(db);
const error = await service.request(claims, "via:arcade:hubspot", {
selectionInteractionId: answer.id, toolNames: ["hubspot_list_contacts"],
}).catch(error => error);
expect(error).toMatchObject({ status: 422 });
expect(error.message).toContain('"Hubspot_ListContacts"');
expect(error.message).toContain("Request only the needed exact names");
expect(error.message).not.toMatch(/Unrelated_PrivateTool|Hubspot_RemovedTool/);
expect(await db.select().from(toolProfiles).where(eq(toolProfiles.companyId, claims.company_id))).toEqual([]);
const interactions = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.companyId, claims.company_id));
expect(interactions.map(row => [row.id, row.status])).toEqual([[answer.id, "answered"]]);
const requested = await service.request(claims, "via:arcade:hubspot", {
selectionInteractionId: answer.id, toolNames: ["Hubspot_ListContacts"],
});
expect(requested.state).toBe("needs_user_action");
const loaded = await service.loadIntent(requested.interactionId!);
expect(loaded.interaction.payload.accessRequest?.tools.map(tool => tool.toolName)).toEqual(["Hubspot_ListContacts"]);
expect(loaded.interaction.status).toBe("pending");
expect(await db.select().from(toolProfiles).where(eq(toolProfiles.companyId, claims.company_id))).toEqual([]);
});
it("bounds catalog-name recovery and does not disclose an ineligible connection", async () => {
await resetQuestions();
const connection = await seedProvider("arcade", "Read_00", "responsible-user", false);
await db.insert(toolCatalogEntries).values(Array.from({ length: 24 }, (_, index) => ({
companyId: claims.company_id, connectionId: connection.id, toolName: `Read_${String(index + 1).padStart(2, "0")}`,
name: `Read_${String(index + 1).padStart(2, "0")}`, versionHash: "v1", entryKind: "tool" as const, status: "active" as const,
})));
const service = connectionIntentService(db);
const error = await service.request(claims, "arcade", { connectionId: connection.id, toolNames: ["guessed"] }).catch(error => error);
expect(error).toMatchObject({ status: 422 });
expect(error.message).toContain("first 20 of 25");
expect(error.message.match(/Read_\d{2}/g)).toHaveLength(20);
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.connectionId, connection.id));
const denied = await service.request(claims, "arcade", { connectionId: connection.id, toolNames: ["guessed"] }).catch(error => error);
expect(denied.message).not.toContain("Read_");
expect(denied.message).toContain("not eligible");
});
it("does not switch providers when the chosen route loses permission", async () => {
await resetQuestions();
const answer = await selectProvider("via:arcade:hubspot");
await seedProvider(
"arcade",
"Hubspot_ListContacts",
"responsible-user",
false,
);
const result = await connectionIntentService(db).search(
claims,
"hubspot",
);
expect(result.results).toEqual([]);
expect(result.providerQuestion).toBeUndefined();
expect(result.instruction).toContain("no longer available");
await expect(
connectionIntentService(db).request(claims, "via:composio:hubspot", {
selectionInteractionId: answer.id,
}),
).rejects.toMatchObject({ status: 403 });
});
it("keeps a decline effective when disclosure wording has changed", async () => {
await resetQuestions();
const answer = await selectProvider("none");
await db
.update(issueThreadInteractions)
.set({
payload: {
...answer.payload,
questions: (answer.payload as any).questions.map((q: any) => ({
...q,
prompt: "Older disclosure wording",
})),
},
})
.where(eq(issueThreadInteractions.id, answer.id));
expect(
(await connectionIntentService(db).search(claims, "hubspot"))
.instruction,
).toContain("declined");
});
it("persists app authorization guidance in the completed setup outcome", async () => {
await resetQuestions();
const answer = await selectProvider("via:composio:hubspot");
const service = connectionIntentService(db);
const requested = await service.request(claims, "via:composio:hubspot", {
selectionInteractionId: answer.id,
});
const connection = await seedProvider(
"composio",
"COMPOSIO_SEARCH_TOOLS",
);
const completed = await service.complete(
requested.interactionId!,
connection.id,
"responsible-user",
);
expect(completed).toMatchObject({
status: "accepted",
result: {
outcome: "connected",
connectionId: connection.id,
instruction: expect.stringContaining(
"HubSpot access is not yet verified",
),
},
});
expect((completed.result as any).instruction).toContain(
"COMPOSIO_MANAGE_CONNECTIONS",
);
});
it("uses only authorized Executor tool evidence and preserves the actual observation date", async () => {
await resetQuestions();
const connection = await seedProvider(
"executor",
"heliotrope:list_records",
"someone-else",
);
expect(
(await connectionIntentService(db).search(claims, "heliotrope"))
.results,
).toEqual([]);
await db
.update(connectionGrants)
.set({ subjectUserId: "responsible-user" })
.where(eq(connectionGrants.connectionId, connection.id));
const result = await connectionIntentService(db).search(
claims,
"heliotrope",
);
expect(result.results[0]).toMatchObject({
service: "via:executor:heliotrope",
aggregator: { verifiedAt: "2026-09-20T00:00:00.000Z" },
});
await db
.update(toolConnections)
.set({ status: "archived" })
.where(eq(toolConnections.id, connection.id));
expect(
(await connectionIntentService(db).search(claims, "heliotrope"))
.results,
).toEqual([]);
});
it("does not bypass an administratively denied built-in connection", async () => {
await resetQuestions();
await seedProvider("jira", "jira_read", "responsible-user", false);
const result = await connectionIntentService(db).search(
claims,
"Jira recent issues",
);
expect(result.results[0]).toMatchObject({
service: "jira",
state: "unavailable",
});
expect(result.providerQuestion).toBeUndefined();
expect(result.instruction).toContain("Do not bypass");
});
it("does not invent support for unknown apps", async () => {
await resetQuestions();
const result = await connectionIntentService(db).search(
claims,
"nimbuscrm-not-real",
);
expect(result.results).toEqual([]);
expect(result.instruction).toContain("could not be verified");
});
it("preserves indexed-only display names when a saved choice is requested by slug", async () => {
await resetQuestions();
await seedProvider("executor", "heliotrope:list_records");
const answer = await selectProvider(
"via:executor:heliotrope",
"responsible-user",
"HELIOTROPE",
);
const result = await connectionIntentService(db).request(
claims,
"via:executor:heliotrope",
{ selectionInteractionId: answer.id },
);
expect(result.state).toBe("ready");
expect(result.instruction).toContain(
"Heliotrope access is not yet verified",
);
});
it("honors explicitly named providers and preserves target app context", async () => {
await resetQuestions();
await userRequest("Connect HubSpot through Arcade");
const service = connectionIntentService(db);
const result = await service.search(claims, "HubSpot through Arcade");
expect(result.results.map((item) => item.service)).toEqual(["arcade"]);
expect(result.providerQuestion).toBeUndefined();
expect(result.instruction).toContain("targetService hubspot");
const request = await service.request(claims, "arcade", {
targetService: "hubspot",
});
const intent = await service.loadIntent(request.interactionId!);
expect(intent.interaction.payload).toMatchObject({
serviceName: "HubSpot through Arcade",
upstreamService: { slug: "hubspot", name: "HubSpot" },
});
expect(
(await service.search(claims, "HubSpot through Executor")).results,
).toEqual([]);
await expect(
service.request(claims, "executor", { targetService: "hubspot" }),
).rejects.toThrow("cannot connect");
await expect(
service.request(claims, "github", { targetService: "hubspot" }),
).rejects.toThrow("direct external-provider");
});
it("permits explicit provider preference for a supported native app but not a native denial", async () => {
await resetQuestions();
await userRequest("Connect Jira via Arcade, not Zapier");
const service = connectionIntentService(db);
expect(
(await service.search(claims, "Jira via Arcade, not Zapier")).results.map(
(item) => item.service,
),
).toEqual(["arcade"]);
await seedProvider("jira", "jira_read", "responsible-user", false);
expect((await service.search(claims, "Jira via Arcade, not Zapier")).results).toEqual(
[expect.objectContaining({ service: "jira", state: "unavailable" })],
);
});
it("does not let an agent-supplied explicit query or direct target override a saved choice", async () => {
await resetQuestions();
const service = connectionIntentService(db);
const unproven = await service.search(claims, "HubSpot via Arcade");
expect(
unproven.providerQuestion?.options.map((option) => option.id),
).toEqual(["via:arcade:hubspot", "none"]);
await expect(
service.request(claims, "arcade", { targetService: "hubspot" }),
).rejects.toThrow("cannot connect");
await userRequest("Connect HubSpot via Arcade is just an example; do not connect yet");
await expect(service.request(claims,"arcade",{targetService:"hubspot"})).rejects.toThrow("cannot connect");
await userRequest("Connect HubSpot via Arcade");
await selectProvider("none");
expect(
(await service.search(claims, "HubSpot via Arcade")).results,
).toEqual([]);
await expect(
service.request(claims, "arcade", { targetService: "hubspot" }),
).rejects.toThrow("cannot connect");
await userRequest("Connect HubSpot via Arcade", "someone-else");
await expect(
service.request(claims, "arcade", { targetService: "hubspot" }),
).rejects.toThrow("cannot connect");
await userRequest("Please connect HubSpot through Arcade");
expect(
(await service.request(claims, "arcade", { targetService: "hubspot" }))
.state,
).toBe("needs_user_action");
await resetQuestions();
const chosen = await selectProvider("via:composio:hubspot");
await expect(
service.request(claims, "arcade", { targetService: "hubspot" }),
).rejects.toThrow("cannot connect");
const accepted = await service.request(claims, "composio", {
targetService: "hubspot",
});
expect(
(await service.loadIntent(accepted.interactionId!)).interaction.payload,
).toMatchObject({
upstreamService: { selectionInteractionId: chosen.id },
});
});
it("asks for a choice when human and agent messages name alternatives", async () => {
await resetQuestions();
await userRequest("Connect HubSpot via Arcade or Composio");
const result = await connectionIntentService(db).search(
claims,
"HubSpot via Arcade or Composio",
);
expect(
result.providerQuestion?.options.map((option) => option.id),
).toEqual([
"via:composio:hubspot",
"via:arcade:hubspot",
"via:zapier:hubspot",
"none",
]);
await expect(
connectionIntentService(db).request(claims, "arcade", {
targetService: "hubspot",
}),
).rejects.toThrow("cannot connect");
});
},
);