Files
PaperClipAI/ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx
T
Aashish Mahato 457d3209b1 fix: name the real reason a connection request expires on reassignment (#14067)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - When an agent's selected AI account is unavailable, the issue thread
shows a `connection_intent` card. A person must reconnect the account or
pick another connection.
> - If the issue assignment changes while that card is still pending,
the server expires it. This is the ownership-change path in
`expireConnectionIntentsForOwnershipChange`.
> - The expired card showed the reason "The task assignment changed".
That text is wrong in two ways. It does not say that the connection
request was never answered, and it does not say the connection is still
missing.
> - Worse, the reason never reached the screen at all: the expired
branch of the card hard-codes "This request is no longer active.", so
any guidance the server stores is discarded. My first round of this PR
claimed the opposite, which was incorrect — the UI change is required
for the fix to be visible.
> - Issue #14040 reported the symptom: users saw a run fail with
"assignment" blame and no hint that a connection needed attention. It
happened on both `claude_local` and `codex_local` agents, so the message
— not one adapter — is the defect.
> - This pull request therefore fixes both ends: the server stores a
sentence that names what happened and what to do next, per kind of
request, and the card prints the stored reason instead of a generic
string.
> - The issue asks for a larger behavior change (hold reassignment until
the card resolves). That needs a product discussion, so this PR keeps
the scope to the message and where it is displayed.
> - The benefit is that the card tells the truth: the blocker is the
connection, not the assignment, and it says so on screen.

## Linked Issues or Issue Description

- Fixes: #14040

## What Changed

- `server/src/services/issue-thread-interactions.ts`:
`expireConnectionIntentsForOwnershipChange` now selects each pending
row's payload before expiring it and writes a per-request reason: "The
task was reassigned before the connection request was answered, so the
AI account is still not connected. Reconnect it or choose an available
connection." for AI-account intents, and the same sentence naming "the
`<serviceName>` connection" for tool intents. The subject is chosen from
`payload.purpose === "ai"`, falling back to "the requested connection"
for a payload that carries neither, because this one path expires both
kinds and the recovery step differs.
- `ui/src/features/connections/ConnectionIntentInteractionBody.tsx`: the
expired branch now renders `interaction.result?.reason` and keeps "This
request is no longer active." only as the fallback for results stored
before this change. The `superseded` outcome keeps its own wording. This
component is the shared body for both the compact task card and the full
thread card, so both views get the reason.
- `server/src/__tests__/issues-service.test.ts`: the expiry test now
expires a tool (`Notion`) and an AI (`claude_local`) intent in the same
run and asserts both stored reasons, so neither wording can regress into
the other.
-
`ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx`:
new test renders an expired card whose result carries a reason and
asserts the reason is on screen and the generic fallback string is not.

## Verification

- `npx vitest run server/src/__tests__/issues-service.test.ts
ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx` —
151/151 passed (127 server, 24 UI; 33.4s, Node 22.22.2, local). My first
revision of this body cited 168 for the same set; that count came from a
run on the wrong branch, and 151 is what this command prints on
`fix/connection-intent-expiry-reason`.
- The pinned server test drives the real path: it inserts pending
`connection_intent` rows plus a live `toolOauthStates` row, reassigns
the issue through `svc.update(...)`, then asserts both expired reasons
and the deleted OAuth state row.
- Mutation checks: forcing the AI subject for every row makes the expiry
test fail on the tool-row assertion (`1 failed | 126 skipped`, `expected
[ { …29 fields } ] to deeply equal [ ObjectContaining{…} ]`), and
deleting the `result.reason` branch in the card fails exactly `shows the
stored reason on an expired request` (`1 failed | 23 passed`). Both
revert clean. The reverse wording mutation (tool subject forced onto AI
rows) was not run, so that half of the test is pinned by its assertion
rather than by a demonstrated mutation.
- No schema, wire-format, or payload change. Only the free-text
`result.reason` value differs and the card now reads the field it was
already given.

## Risks

- Low risk. The value is display text on a terminal interaction outcome.
No code parses the old string, and no stored data is migrated; cards
that expired before this change keep their old text and now render it as
the stored reason they carry.
- The deeper request in #14040 (do not expire a pending
`connection_intent` on reassignment) is intentionally out of scope here
and needs a maintainer decision.

## Model Used

- Anthropic Claude (Opus 4.x, agentic CLI coding assistant with file
edit and shell tool use) assisted with the code change, the test
updates, and this PR text. A human reviewed the diff and the issue
context before submission.

## 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
2026-10-15 16:11:35 -07:00

878 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import { act as reactAct, useState, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type {
ConnectionIntentInteraction,
ToolConnection,
} from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
connectedConnectionIntentInteraction,
issueThreadInteractionFixtureMeta,
pendingConnectionIntentInteraction,
retryConnectionIntentInteraction,
} from "@/fixtures/issueThreadInteractionFixtures";
import { ConnectionIntentInteractionBody } from "./ConnectionIntentInteractionBody";
const emailConnectMock = vi.hoisted(() => vi.fn());
const emailSetupMock = vi.hoisted(() => vi.fn());
const emailListMock = vi.hoisted(() => vi.fn());
const emailControlMock = vi.hoisted(() => vi.fn());
const emailCredentialsMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/email", () => ({ emailApi: {
credentials: (...args: unknown[]) => emailCredentialsMock(...args),
list: (...args: unknown[]) => emailListMock(...args),
control: (...args: unknown[]) => emailControlMock(...args),
connect: (...args: unknown[]) => emailConnectMock(...args),
setup: (...args: unknown[]) => emailSetupMock(...args),
} }));
const credentialRender = vi.hoisted(() => vi.fn());
const routedCredentialRender = vi.hoisted(() => vi.fn());
const setupOptionsMock = vi.hoisted(() => vi.fn());
const completeMock = vi.hoisted(() => vi.fn());
const declineMock = vi.hoisted(() => vi.fn());
const setPhaseMock = vi.hoisted(() => vi.fn());
const getAgentMock = vi.hoisted(() => vi.fn());
const updateAgentMock = vi.hoisted(() => vi.fn());
const setDefaultMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: { setDefault: (...args: unknown[]) => setDefaultMock(...args) } }));
const adoptMock = vi.hoisted(() => vi.fn());
const installMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/tools", () => ({ toolsApi: {
getConnectionInstalls: async () => ({ installs: [] }),
putConnectionInstalls: (...args: unknown[]) => installMock(...args),
} }));
vi.mock("@/api/agents", () => ({ agentsApi: {
adoptAiConnection: (...args: unknown[]) => adoptMock(...args),
get: (...args: unknown[]) => getAgentMock(...args),
update: (...args: unknown[]) => updateAgentMock(...args),
} }));
vi.mock("@/api/connection-intents", () => ({
connectionIntentsApi: {
setupOptions: (...args: unknown[]) => setupOptionsMock(...args),
complete: (...args: unknown[]) => completeMock(...args),
decline: (...args: unknown[]) => declineMock(...args),
setPhase: (...args: unknown[]) => setPhaseMock(...args),
},
}));
vi.mock("@/components/ai-connections/AiConnectionCredentialStep", () => ({
AiConnectionCredentialStep: (props: { connectionId?: string; name: string; hideName?: boolean; fixedMethod?: boolean; onComplete: (result: {connectionId: string; grantId: string; method: "api_key"}) => void; onCancel: () => void }) => { credentialRender(props); return <div data-testid="shared-ai-credentials">
{!props.hideName && <span>{props.name}</span>}<span>{String(props.fixedMethod)}</span>
<button onClick={() => props.onComplete({ connectionId: props.connectionId ?? "new-ai-account", grantId: "grant", method: "api_key" })}>Reconnect selected account</button>
<button onClick={props.onCancel}>Cancel repair</button>
</div>; },
}));
vi.mock("@/components/ai-connections/AiProviderSetup", () => ({
AiProviderSetup: (props: { reconnect: { id: string }; onComplete: (binding: { connectionId: string; grantId: string; method: "api_key" }) => void; onCancel: () => void }) => {
routedCredentialRender(props);
return <div data-testid="routed-ai-repair">
<button onClick={() => props.onComplete({ connectionId: props.reconnect.id, grantId: "grant", method: "api_key" })}>Reconnect routed account</button>
<button onClick={props.onCancel}>Cancel routed repair</button>
</div>;
},
}));
vi.mock("./ConnectionSetupFlow", () => ({
ConnectionSetupFlow: (props: {
requestedAgentId?: string;
existingConnections?: ToolConnection[];
onUseExisting?: (id: string) => Promise<void>;
onComplete?: (completion: { connectionId: string } | { resolvedByCallback: true }) => void;
onPhaseChange?: (phase: "needs_retry") => void;
onCancel?: () => void;
}) => (
<div data-testid="shared-connection-setup">
<span data-testid="requested-agent">{props.requestedAgentId}</span>
<span data-testid="existing-count">
{props.existingConnections?.length ?? 0}
</span>
{props.existingConnections?.map((connection) => (
<button
key={connection.id}
onClick={() => void props.onUseExisting?.(connection.id)}
>
Use {connection.name}
</button>
))}
<button
onClick={() => props.onComplete?.({ connectionId: "connection-new" })}
>
Connect new
</button>
<button onClick={() => props.onComplete?.({ resolvedByCallback: true })}>Simulate OAuth callback</button>
<button onClick={() => props.onPhaseChange?.("needs_retry")}>
Simulate retry
</button>
<button onClick={props.onCancel}>Cancel setup</button>
</div>
),
}));
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
let root: Root | null = null;
let host: HTMLDivElement | null = null;
let queryClient: QueryClient;
async function act(callback: () => void | Promise<void>) {
await reactAct(callback);
}
async function flush() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
async function waitForAssertion(assertion: () => void, attempts = 20) {
let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt += 1) {
await flush();
try {
assertion();
return;
} catch (error) {
lastError = error;
}
}
throw lastError;
}
function terminal(
status: ConnectionIntentInteraction["status"],
outcome: "declined" | "superseded" | "expired",
): ConnectionIntentInteraction {
return {
...pendingConnectionIntentInteraction,
id: `interaction-${outcome}`,
status,
resolvedAt: new Date("2026-08-26T12:00:00.000Z"),
result: { version: 1, outcome },
} as ConnectionIntentInteraction;
}
function renderNode(node: ReactNode) {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
void act(() => {
root?.render(
<QueryClientProvider client={queryClient}>
{node}
</QueryClientProvider>,
);
});
return host;
}
function renderBody(
interaction: ConnectionIntentInteraction = pendingConnectionIntentInteraction,
currentUserId:
string | null = issueThreadInteractionFixtureMeta.currentUserId,
) {
return renderNode(
<ConnectionIntentInteractionBody
interaction={interaction}
currentUserId={currentUserId}
addresseeLabel="Carol"
/>,
);
}
function button(label: string) {
return Array.from(document.body.querySelectorAll("button")).find(
(candidate) => candidate.textContent?.trim() === label || (label === "Connect / Use existing" && candidate.textContent?.trim() === "Connect"),
) as HTMLButtonElement | undefined;
}
beforeEach(() => {
sessionStorage.clear();
emailCredentialsMock.mockReset().mockResolvedValue([]);
emailListMock.mockReset().mockResolvedValue([]);
emailControlMock.mockReset().mockResolvedValue({});
emailConnectMock.mockReset().mockResolvedValue({ id: "email-account" });
emailSetupMock.mockReset().mockResolvedValue({ connectionId: "email-inbox" });
setDefaultMock.mockReset();
getAgentMock.mockReset();
updateAgentMock.mockReset();
installMock.mockReset();
adoptMock.mockReset();
setupOptionsMock.mockReset();
completeMock.mockReset();
declineMock.mockReset();
setPhaseMock.mockReset();
setupOptionsMock.mockResolvedValue({
requestedAgentId:
pendingConnectionIntentInteraction.payload.requestingAgentId,
existingConnections: [],
});
completeMock.mockResolvedValue({});
declineMock.mockResolvedValue({});
setPhaseMock.mockResolvedValue({});
});
afterEach(async () => {
if (root) await act(() => root?.unmount());
await queryClient.cancelQueries();
queryClient.clear();
host?.remove();
document.body
.querySelectorAll("[data-radix-focus-guard]")
.forEach((node) => node.remove());
root = null;
host = null;
});
describe("ConnectionIntentInteractionBody states and audience", () => {
it.each([
[pendingConnectionIntentInteraction, "Connect"],
[
{
...pendingConnectionIntentInteraction,
payload: {
...pendingConnectionIntentInteraction.payload,
phase: "authorizing",
},
},
"Continue setup",
],
[retryConnectionIntentInteraction, "Try again"],
[connectedConnectionIntentInteraction, "Notion connected"],
[terminal("rejected", "declined"), "Connection declined"],
[terminal("expired", "superseded"), "Request superseded"],
[terminal("expired", "expired"), "Connection request expired"],
] as const)("renders the %s state", (interaction, expected) => {
renderBody(interaction as ConnectionIntentInteraction);
expect(document.body.textContent).toContain(expected);
});
it("shows the stored reason on an expired request", () => {
// The server writes what actually expired the request and what to do next.
// A generic "no longer active" line left the reader with no next step.
const reason = "The task was reassigned before the connection request was answered, so the Notion connection is still not connected. Reconnect it or choose an available connection.";
renderBody({
...terminal("expired", "expired"),
result: { version: 1, outcome: "expired", reason },
} as ConnectionIntentInteraction);
expect(document.body.textContent).toContain(reason);
expect(document.body.textContent).not.toContain("This request is no longer active.");
});
it("shows non-addressees only the waiting state and never loads setup", () => {
renderBody(pendingConnectionIntentInteraction, "other-user");
expect(document.body.textContent).toContain("Waiting for Carol");
expect(button("Connect / Use existing")).toBeUndefined();
expect(button("Not now")).toBeUndefined();
expect(setupOptionsMock).not.toHaveBeenCalled();
expect(document.body.innerHTML).not.toMatch(
/authorizationUrl|bearer|credential/i,
);
});
});
describe("ConnectionIntentInteractionBody dialog behavior", () => {
it("verifies inline OAuth callback hints against durable server acceptance", async () => {
const options = { requestedAgentId: pendingConnectionIntentInteraction.payload.requestingAgentId, existingConnections: [], interaction: pendingConnectionIntentInteraction };
setupOptionsMock.mockResolvedValue(options);
renderBody();
await act(() => button("Connect / Use existing")?.click());
await flush();
const readsBeforeMessage = setupOptionsMock.mock.calls.length;
await act(() => button("Simulate OAuth callback")?.click());
await flush();
expect(setupOptionsMock.mock.calls.length).toBeGreaterThan(readsBeforeMessage);
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
expect(completeMock).not.toHaveBeenCalled();
setupOptionsMock.mockResolvedValue({ ...options, interaction: connectedConnectionIntentInteraction });
await act(() => button("Simulate OAuth callback")?.click());
await waitForAssertion(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
expect(completeMock).not.toHaveBeenCalled();
});
it("shows loading, then passes existing choices and the locked requesting agent to the shared flow", async () => {
let resolveSetup!: (value: unknown) => void;
setupOptionsMock.mockReturnValue(
new Promise((resolve) => {
resolveSetup = resolve;
}),
);
renderBody();
await act(() => button("Connect / Use existing")?.click());
expect(document.body.textContent).toContain("Loading connection options…");
await act(async () => {
resolveSetup({
requestedAgentId: "agent-requesting",
existingConnections: [
{ id: "connection-one", name: "Carol's Notion" },
{ id: "connection-two", name: "Team Notion" },
],
});
});
await flush();
expect(
document.querySelector('[data-testid="requested-agent"]')?.textContent,
).toBe("agent-requesting");
expect(
document.querySelector('[data-testid="existing-count"]')?.textContent,
).toBe("2");
expect(button("Use Carol's Notion")).toBeDefined();
expect(button("Use Team Notion")).toBeDefined();
expect(button("Connect new")).toBeDefined();
});
it("keeps a load failure open and recovers through the query retry", async () => {
setupOptionsMock.mockRejectedValueOnce(new Error("setup unavailable"));
renderBody();
await act(() => button("Connect / Use existing")?.click());
await flush();
expect(document.body.textContent).toContain(
"Couldn’t load connection setup",
);
expect(document.body.textContent).toContain("setup unavailable");
setupOptionsMock.mockResolvedValueOnce({
requestedAgentId: "agent-requesting",
existingConnections: [],
});
await act(() => button("Try again")?.click());
await flush();
expect(
document.querySelector('[data-testid="shared-connection-setup"]'),
).not.toBeNull();
});
it("completes an existing connection, closes, restores focus, and invalidates each task query once", async () => {
setupOptionsMock.mockResolvedValue({
requestedAgentId: "agent-requesting",
existingConnections: [{ id: "connection-one", name: "Carol's Notion" }],
});
renderBody();
const trigger = button("Connect / Use existing")!;
const invalidation = vi.spyOn(queryClient, "invalidateQueries");
await act(() => trigger.click());
await flush();
await act(() => button("Use Carol's Notion")?.click());
await flush();
expect(completeMock).toHaveBeenCalledWith(
pendingConnectionIntentInteraction.id,
"connection-one",
);
expect(
document.querySelector('[data-testid="shared-connection-setup"]'),
).toBeNull();
await waitForAssertion(() =>
expect(document.activeElement).toBe(
document.querySelector(
'[data-testid="connection-intent-focus-target"]',
),
),
);
expect(invalidation).toHaveBeenCalledTimes(2);
expect(invalidation).toHaveBeenCalledWith({
queryKey: ["issues", "interactions"],
});
expect(invalidation).toHaveBeenCalledWith({
queryKey: ["issues", "detail"],
});
});
it("restores focus when completion remounts the intent in a different task host", async () => {
setupOptionsMock.mockResolvedValue({
requestedAgentId: "agent-requesting",
existingConnections: [{ id: "connection-one", name: "Carol's Notion" }],
});
const acceptedInteraction = {
...pendingConnectionIntentInteraction,
status: "accepted",
resolvedAt: new Date("2026-08-26T12:00:00.000Z"),
result: {
version: 1,
outcome: "connected",
connectionId: "connection-one",
},
} as ConnectionIntentInteraction;
let showAcceptedInteraction!: () => void;
function RemountingTaskHost() {
const [interaction, setInteraction] = useState(
pendingConnectionIntentInteraction,
);
showAcceptedInteraction = () => setInteraction(acceptedInteraction);
return (
<ConnectionIntentInteractionBody
key={interaction.status}
interaction={interaction}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
addresseeLabel="Carol"
/>
);
}
completeMock.mockImplementation(async () => {
showAcceptedInteraction();
return acceptedInteraction;
});
renderNode(<RemountingTaskHost />);
await act(() => button("Connect / Use existing")?.click());
await flush();
await act(() => button("Use Carol's Notion")?.click());
await waitForAssertion(() => {
const target = document.getElementById(
`connection-intent-focus-target-${acceptedInteraction.id}`,
);
expect(document.body.textContent).toContain("Notion connected");
expect(document.activeElement).toBe(target);
});
});
it("keeps the dialog open and surfaces completion failures", async () => {
completeMock.mockRejectedValue(new Error("install commit failed"));
renderBody();
await act(() => button("Connect / Use existing")?.click());
await flush();
await act(() => button("Connect new")?.click());
await flush();
expect(
document.querySelector('[data-testid="shared-connection-setup"]'),
).not.toBeNull();
expect(
document.body.querySelector('[role="alert"]')?.textContent,
).toContain("install commit failed");
});
it("declines once with pending controls disabled and invalidates each task query once", async () => {
let finishDecline!: () => void;
declineMock.mockReturnValue(
new Promise<void>((resolve) => {
finishDecline = resolve;
}),
);
renderBody();
const invalidation = vi.spyOn(queryClient, "invalidateQueries");
const decline = button("Not now")!;
await act(() => decline.click());
await flush();
expect(decline.disabled).toBe(true);
decline.click();
expect(declineMock).toHaveBeenCalledTimes(1);
await act(async () => finishDecline());
await flush();
expect(invalidation).toHaveBeenCalledTimes(2);
});
it("turns shared-flow retry signals into the server-authored retry phase", async () => {
renderBody();
await act(() => button("Connect / Use existing")?.click());
await flush();
await act(() => button("Simulate retry")?.click());
await flush();
expect(setPhaseMock).toHaveBeenCalledWith(
pendingConnectionIntentInteraction.id,
"needs_retry",
);
});
});
describe("AI repair inside the card", () => {
const interaction: ConnectionIntentInteraction = { ...pendingConnectionIntentInteraction, payload: { ...pendingConnectionIntentInteraction.payload, purpose: "ai" } };
const connection = { id: "selected-account", name: "My Codex account", provider: "openai", method: "api_key", ownership: "personal", ownerName: "Dotta", status: "revoked" };
it.each(["openrouter", "bedrock", "gateway"] as const)("retains the selected %s route through task-card account repair", async kind => {
const routing = kind === "bedrock"
? { kind, protocol: "bedrock", region: "us-east-1", auth: "bearer", models: [] }
: { kind, protocol: "responses", auth: "bearer", models: [], ...(kind === "gateway" ? { baseUrl: "https://gateway.example/v1" } : {}) };
const routedConnection = { ...connection, provider: kind === "bedrock" ? "anthropic" : kind === "openrouter" ? "openrouter" : "openai", routing };
setupOptionsMock.mockResolvedValue({ interaction, existingConnections: [], aiRepair: { connection: routedConnection, canReconnect: true } });
completeMock.mockResolvedValue({ ...interaction, status: "accepted" });
renderBody(interaction); await flush();
await act(() => button("Fix connection")!.click());
expect(document.querySelector('[data-testid="shared-ai-credentials"]')).toBeNull();
expect(routedCredentialRender.mock.lastCall![0]).toMatchObject({ companyId: interaction.companyId, agentId: interaction.payload.requestingAgentId, reconnect: routedConnection });
await act(() => button("Reconnect routed account")!.click());
expect(completeMock).toHaveBeenCalledWith(interaction.id, connection.id);
});
it.each([false, true])("requires atomic validated legacy adoption (validation fails: %s)", async (fails) => {
const binding = { provider: "openai", method: "subscription", mode: "responsible_user" };
setupOptionsMock.mockResolvedValue({ interaction, existingConnections: [], aiConnection: binding, aiConnectionRequiresAdoption: true });
if (fails) adoptMock.mockRejectedValue(new Error("Connection test failed"));
else adoptMock.mockResolvedValue({ ...interaction, status: "accepted" });
renderBody(interaction);
await waitForAssertion(() => expect(button("Connect OpenAI")).toBeDefined());
await act(() => button("Connect OpenAI")!.click());
await act(() => button("Reconnect selected account")!.click());
expect(adoptMock).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("Use your OpenAI account");
expect(document.body.textContent).toContain("Your account is connected.");
expect(document.body.textContent).toContain("This replaces the agent’s existing authentication");
await act(() => button("Use connection and continue")!.click());
await flush();
expect(adoptMock).toHaveBeenCalledWith(interaction.payload.requestingAgentId, interaction.id, "new-ai-account", interaction.companyId);
expect(updateAgentMock).not.toHaveBeenCalled();
expect(installMock).not.toHaveBeenCalled();
expect(completeMock).not.toHaveBeenCalled();
if (fails) expect(document.querySelector('[role="alert"]')?.textContent).toContain("Connection test failed");
});
it.each(["anthropic", "openai"])("connects a missing %s default directly in the task", async (provider) => {
setupOptionsMock.mockResolvedValue({ interaction, existingConnections: [], aiConnection: { provider, method: "api_key", mode: "responsible_user" } });
completeMock.mockResolvedValue({ ...interaction, status: "accepted" });
renderBody(interaction); await flush();
const providerName = provider === "anthropic" ? "Claude" : "OpenAI";
await waitForAssertion(() => expect(document.body.textContent).toContain(`Connect your ${providerName} account`));
expect(document.body.textContent).toContain("needs your own AI connection");
await act(() => button(`Connect ${providerName}`)!.click());
expect(document.querySelector('[role="dialog"]')).toBeNull();
expect(document.querySelector('[data-testid="shared-connection-setup"]')).toBeNull();
expect(document.querySelector('[data-testid="shared-ai-credentials"]')).not.toBeNull();
expect(credentialRender.mock.lastCall![0]).toMatchObject({ provider, hideName: true, ownership: "personal", agentIds: [interaction.payload.requestingAgentId], allAgents: false });
expect(credentialRender.mock.lastCall![0].connectionId).toBeUndefined();
expect(credentialRender.mock.lastCall![0].fixedMethod).toBeUndefined();
expect(document.body.textContent).toContain("the task will resume automatically");
await act(() => button("Reconnect selected account")!.click());
expect(completeMock).toHaveBeenCalledWith(interaction.id, "new-ai-account");
});
it("reuses authentication inline, preserves the selected account, cancels with focus, and completes", async () => {
setupOptionsMock.mockResolvedValue({ interaction, existingConnections: [], aiRepair: { connection, canReconnect: true } });
completeMock.mockResolvedValue({ ...interaction, status: "accepted" });
renderBody(interaction);
await flush();
await act(() => button("Fix connection")!.click());
expect(document.querySelector('[role="dialog"]')).toBeNull();
expect(document.querySelector('[data-testid="ai-connection-inline-repair"]')?.textContent).not.toContain("My Codex account");
expect(credentialRender.mock.lastCall![0]).toMatchObject({ name: "My Codex account", hideName: true, fixedMethod: false, connectionId: "selected-account" });
await act(() => button("Cancel repair")!.click());
await waitForAssertion(() => expect(document.activeElement?.getAttribute("data-testid")).toBe("connection-intent-focus-target"));
expect(completeMock).not.toHaveBeenCalled();
await act(() => button("Fix connection")!.click());
await act(() => button("Reconnect selected account")!.click());
expect(completeMock).toHaveBeenCalledWith(interaction.id, "selected-account");
});
it.each(["responsible_user", "delegated", "shared"] as const)("selects a different authentication method for a %s binding before resuming", async (mode) => {
const binding = { provider: "openai", method: "api_key", mode, ...(mode !== "responsible_user" ? { connectionId: connection.id, grantId: "old-grant" } : {}) };
setupOptionsMock.mockResolvedValue({ interaction, existingConnections: [], aiConnection: binding, aiRepair: { connection: { ...connection, grantId: "old-grant" }, canReconnect: true } });
getAgentMock.mockResolvedValue({ id: interaction.payload.requestingAgentId, runtimeConfig: { aiConnection: binding, heartbeat: { enabled: true } } });
completeMock.mockResolvedValue({ ...interaction, status: "accepted" });
renderBody(interaction); await flush();
await act(() => button("Fix connection")!.click());
await act(() => credentialRender.mock.lastCall![0].onComplete({ connectionId: "new-subscription", grantId: "new-grant", method: "subscription" }));
await flush();
if (mode === "responsible_user") {
expect(setDefaultMock).toHaveBeenCalledWith(interaction.companyId, "new-grant");
expect(updateAgentMock).not.toHaveBeenCalled();
} else {
expect(updateAgentMock).toHaveBeenCalledWith(interaction.payload.requestingAgentId, { runtimeConfig: { heartbeat: { enabled: true }, aiConnection: { ...binding, method: "subscription", connectionId: "new-subscription", grantId: "new-grant" } } }, interaction.companyId);
expect(setDefaultMock).not.toHaveBeenCalled();
}
expect(completeMock).toHaveBeenCalledWith(interaction.id, "new-subscription");
});
it("keeps a failed account switch pending and retries the saved account", async () => {
setupOptionsMock.mockResolvedValue({ interaction, existingConnections: [], aiConnection: { provider: "openai", method: "api_key", mode: "responsible_user" }, aiRepair: { connection, canReconnect: true } });
setDefaultMock.mockRejectedValueOnce(new Error("Could not select this account"));
renderBody(interaction); await flush();
await act(() => button("Fix connection")!.click());
await act(() => credentialRender.mock.lastCall![0].onComplete({ connectionId: "new-subscription", grantId: "new-grant", method: "subscription" }));
await flush();
expect(completeMock).not.toHaveBeenCalled();
expect(document.querySelector('[role="alert"]')?.textContent).toContain("Could not select this account");
await act(() => button("Retry using this connection")!.click());
await flush();
expect(completeMock).toHaveBeenCalledWith(interaction.id, "new-subscription");
});
it("clears an account-selection retry when its setup is abandoned", async () => {
setupOptionsMock.mockResolvedValue({ interaction, existingConnections: [], aiConnection: { provider: "openai", method: "api_key", mode: "responsible_user" }, aiRepair: { connection, canReconnect: true } });
setDefaultMock.mockRejectedValueOnce(new Error("Could not select this account"));
renderBody(interaction); await flush();
await act(() => button("Fix connection")!.click());
await act(() => credentialRender.mock.lastCall![0].onComplete({ connectionId: "new-subscription", grantId: "new-grant", method: "subscription" }));
await flush();
expect(button("Retry using this connection")).toBeDefined();
await act(() => button("Cancel repair")!.click());
await act(() => button("Fix connection")!.click());
expect(button("Retry using this connection")).toBeUndefined();
expect(document.querySelector('[role="alert"]')).toBeNull();
expect(completeMock).not.toHaveBeenCalled();
});
it("keeps a late credential save after cancellation from accepting the request", async () => {
setupOptionsMock.mockResolvedValue({ interaction, existingConnections: [], aiRepair: { connection, canReconnect: true } });
renderBody(interaction); await flush();
await act(() => button("Fix connection")!.click());
const abandoned = credentialRender.mock.lastCall![0];
await act(() => button("Cancel repair")!.click());
await act(() => button("Fix connection")!.click());
await act(() => abandoned.onComplete({ connectionId: connection.id, grantId: "grant", method: "api_key" }));
expect(completeMock).not.toHaveBeenCalled();
});
it("offers continuation for the restored account without another login", async () => {
setupOptionsMock.mockResolvedValue({ interaction, existingConnections: [connection], aiRepair: { connection, canReconnect: true } });
renderBody(interaction); await flush();
await act(() => button("Fix connection")!.click());
expect(document.querySelector('[data-testid="shared-ai-credentials"]')).toBeNull();
await act(() => button("Continue task")!.click());
expect(completeMock).toHaveBeenCalledWith(interaction.id, connection.id);
});
it("does not let another user reconnect the owner's account", async () => {
setupOptionsMock.mockResolvedValue({ interaction, existingConnections: [], aiRepair: { connection, canReconnect: false } });
renderBody(interaction); await flush();
await act(() => button("Fix connection")!.click());
expect(document.body.textContent).toContain("Dotta must reconnect My Codex account");
expect(document.querySelector('[data-testid="shared-ai-credentials"]')).toBeNull();
});
it("shows a withdrawn AI request as terminal without another setup action", () => {
renderBody({ ...interaction, status: "cancelled", resolvedAt: new Date() });
expect(document.body.textContent).toContain("Connection request withdrawn");
expect(document.body.textContent).not.toContain("This task can’t run");
expect(button("Fix connection")).toBeUndefined();
expect(document.querySelector('[data-testid="connection-intent-terminal"]')).not.toBeNull();
expect(setupOptionsMock).not.toHaveBeenCalled();
expect(credentialRender).not.toHaveBeenCalled();
});
it("does not promise to run without credentials when declined", () => {
renderBody({ ...interaction, status: "rejected" });
expect(document.body.textContent).toContain("The task still needs a working AI connection");
expect(document.body.textContent).not.toContain("can continue without it");
});
});
describe("AgentMail inline setup", () => {
const interaction: ConnectionIntentInteraction = {
...pendingConnectionIntentInteraction,
payload: { ...pendingConnectionIntentInteraction.payload, purpose: "channel", serviceSlug: "agentmail", serviceName: "AgentMail" },
};
async function clickReadyButton(label: string) {
await waitForAssertion(() => expect(button(label)?.disabled).toBe(false));
await act(() => button(label)!.click());
await flush();
}
async function enterKey() {
await waitForAssertion(() => expect(document.querySelector('input[type="password"]')).not.toBeNull());
const input = document.querySelector('input[type="password"]') as HTMLInputElement;
await act(() => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "fixture-api-key");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
it("shows only the key inline, saves company access for this agent and completes after inbox setup", async () => {
renderBody(interaction);
await flush();
expect(document.querySelector('[role="dialog"]')).toBeNull();
await waitForAssertion(() => expect(document.querySelectorAll("input")).toHaveLength(1));
expect(document.querySelector('a[href="https://console.agentmail.to/dashboard/api-keys"]')).not.toBeNull();
expect(document.querySelector('[role="radiogroup"], select')).toBeNull();
expect(button("Connect AgentMail")?.disabled).toBe(true);
await enterKey();
await clickReadyButton("Connect AgentMail");
expect(emailConnectMock).toHaveBeenCalledWith(interaction.companyId, {
apiKey: "fixture-api-key", grantKind: "organization", allAgents: false,
agentIds: [interaction.payload.requestingAgentId], idempotencyKey: interaction.id,
});
expect(emailSetupMock).toHaveBeenCalledWith(interaction.companyId, {
assignedAgentId: interaction.payload.requestingAgentId, credentialConnectionId: "email-account",
receiveMode: "websocket", idempotencyKey: interaction.id,
});
expect(completeMock).toHaveBeenCalledWith(interaction.id, "email-inbox");
});
it("keeps invalid credentials retryable and does not create an inbox or accept the request", async () => {
emailConnectMock.mockRejectedValueOnce(new Error("AgentMail request failed (401)"));
renderBody(interaction); await flush(); await enterKey();
await clickReadyButton("Connect AgentMail");
expect(document.querySelector('[role="alert"]')?.textContent).toContain("401");
expect(emailSetupMock).not.toHaveBeenCalled();
expect(completeMock).not.toHaveBeenCalled();
await clickReadyButton("Connect AgentMail");
expect(completeMock).toHaveBeenCalledTimes(1);
});
it("retries inbox setup without asking for or saving the key again", async () => {
emailSetupMock.mockRejectedValueOnce(new Error("Inbox setup unavailable"));
renderBody(interaction); await flush(); await enterKey();
await clickReadyButton("Connect AgentMail");
expect(completeMock).not.toHaveBeenCalled();
expect(document.querySelector('input[type="password"]')).toBeNull();
await clickReadyButton("Finish setup");
expect(emailConnectMock).toHaveBeenCalledTimes(1);
expect(emailSetupMock).toHaveBeenCalledTimes(2);
expect(completeMock).toHaveBeenCalledWith(interaction.id, "email-inbox");
});
it("can choose another key after a saved key fails, retiring an empty draft and preserving the retry identity", async () => {
emailCredentialsMock.mockResolvedValue([{ id: "saved-account", label: "Saved key", scope: "organization", createdAt: "2026-10-01T14:00:00Z" }]);
emailSetupMock.mockRejectedValueOnce(new Error("Inbox already assigned"))
.mockRejectedValueOnce(new Error("Temporary provider failure"));
emailListMock.mockResolvedValue([{ id: interaction.id, address: null, status: "draft" }]);
renderBody(interaction); await flush();
await waitForAssertion(() => expect(button("Connect AgentMail")?.disabled).toBe(false));
expect((document.querySelector("select") as HTMLSelectElement)?.value).toBe("saved-account");
expect(emailConnectMock).not.toHaveBeenCalled();
await clickReadyButton("Connect AgentMail");
expect(emailConnectMock).not.toHaveBeenCalled();
await clickReadyButton("Change API key");
expect(emailControlMock).toHaveBeenCalledWith(interaction.id, "remove");
await enterKey();
await clickReadyButton("Connect AgentMail");
const next = emailSetupMock.mock.calls[1][1].idempotencyKey;
expect(next).not.toBe(interaction.id);
expect(emailConnectMock).toHaveBeenCalledWith(interaction.companyId, expect.objectContaining({ idempotencyKey: next }));
expect(sessionStorage.getItem(`paperclip.agentmail-inline:${interaction.companyId}:${interaction.id}`)).not.toContain("fixture-api-key");
await act(() => root!.unmount()); host!.remove(); queryClient.clear();
renderBody(interaction); await flush();
await clickReadyButton("Finish setup");
expect(emailSetupMock.mock.calls[2][1].idempotencyKey).toBe(next);
expect(emailConnectMock).toHaveBeenCalledTimes(1);
expect(completeMock).toHaveBeenCalledWith(interaction.id, "email-inbox");
});
it("does not switch keys after an inbox address is allocated", async () => {
setupOptionsMock.mockResolvedValue({ existingConnections: [], emailSetup: { credentialConnectionId: "saved-account", readyConnectionId: null } });
emailListMock.mockResolvedValue([{ id: interaction.id, address: "reserved@example.test", status: "draft" }]);
renderBody(interaction); await flush();
await clickReadyButton("Change API key");
expect(document.body.textContent).toContain("reserved@example.test is already reserved");
expect(emailControlMock).not.toHaveBeenCalled();
expect(document.querySelector('input[type="password"]')).toBeNull();
});
it("recovers the server-saved account when refresh interrupted its response", async () => {
const resumedInteraction = { ...interaction, id: "a381e91e-7127-427d-9d2d-519d4deba89f" };
sessionStorage.setItem(`paperclip.agentmail-inline:${interaction.companyId}:${resumedInteraction.id}`, JSON.stringify({
setupRequestId: resumedInteraction.id, credentialId: null, inboxConnectionId: null, selectedCredentialId: "",
}));
setupOptionsMock.mockResolvedValue({ existingConnections: [], emailSetup: { credentialConnectionId: "server-saved-account", readyConnectionId: null } });
renderBody(resumedInteraction); await flush();
expect(document.querySelector('input[type="password"]')).toBeNull();
await clickReadyButton("Finish setup");
expect(emailConnectMock).not.toHaveBeenCalled();
expect(emailSetupMock).toHaveBeenCalledWith(interaction.companyId, expect.objectContaining({
credentialConnectionId: "server-saved-account", idempotencyKey: resumedInteraction.id,
}));
});
it("keeps a different saved-key selection when recovering an interrupted save", async () => {
const resumedInteraction = { ...interaction, id: "a381e91e-7127-427d-9d2d-519d4deba89f" };
sessionStorage.setItem(`paperclip.agentmail-inline:${interaction.companyId}:${resumedInteraction.id}`, JSON.stringify({
setupRequestId: resumedInteraction.id, credentialId: null, inboxConnectionId: null, selectedCredentialId: "selected-account",
}));
emailCredentialsMock.mockResolvedValue([{ id: "selected-account", label: "My selected key", scope: "organization", createdAt: "2026-10-01T14:00:00Z" }]);
setupOptionsMock.mockResolvedValue({ existingConnections: [], emailSetup: { credentialConnectionId: "earlier-account", readyConnectionId: null } });
renderBody(resumedInteraction); await flush();
await waitForAssertion(() => expect((document.querySelector('select') as HTMLSelectElement)?.value).toBe("selected-account"));
await clickReadyButton("Connect AgentMail");
expect(emailConnectMock).not.toHaveBeenCalled();
expect(emailSetupMock).toHaveBeenCalledWith(interaction.companyId, expect.objectContaining({
credentialConnectionId: "selected-account", idempotencyKey: resumedInteraction.id,
}));
});
it("does not restore an abandoned server account after the user changes keys", async () => {
const replacementRequestId = "a381e91e-7127-427d-9d2d-519d4deba89f";
sessionStorage.setItem(`paperclip.agentmail-inline:${interaction.companyId}:${interaction.id}`, JSON.stringify({
setupRequestId: replacementRequestId, credentialId: null, inboxConnectionId: null, selectedCredentialId: "",
}));
setupOptionsMock.mockResolvedValue({ existingConnections: [], emailSetup: { credentialConnectionId: "abandoned-account", readyConnectionId: null } });
renderBody(interaction); await flush(); await enterKey();
await clickReadyButton("Connect AgentMail");
expect(emailConnectMock).toHaveBeenCalledWith(interaction.companyId, expect.objectContaining({ idempotencyKey: replacementRequestId }));
expect(emailSetupMock).toHaveBeenCalledWith(interaction.companyId, expect.objectContaining({
credentialConnectionId: "email-account", idempotencyKey: replacementRequestId,
}));
});
it("resumes a saved account after reload and retries acceptance without recreating the inbox", async () => {
setupOptionsMock.mockResolvedValue({ existingConnections: [], emailSetup: { credentialConnectionId: "email-account", readyConnectionId: "email-inbox" } });
completeMock.mockRejectedValueOnce(new Error("Retry completion"));
renderBody(interaction); await flush();
await clickReadyButton("Continue");
await clickReadyButton("Continue");
expect(emailConnectMock).not.toHaveBeenCalled();
expect(emailSetupMock).not.toHaveBeenCalled();
expect(completeMock).toHaveBeenCalledTimes(2);
});
it("declines without provider requests and hides credentials from other viewers", async () => {
renderBody(interaction); await flush();
await clickReadyButton("Not now");
expect(declineMock).toHaveBeenCalledWith(interaction.id);
expect(emailConnectMock).not.toHaveBeenCalled();
expect(emailSetupMock).not.toHaveBeenCalled();
await act(() => root!.render(<QueryClientProvider client={queryClient}><ConnectionIntentInteractionBody interaction={interaction} currentUserId="different-user" addresseeLabel="Carol" /></QueryClientProvider>));
expect(document.querySelector('input')).toBeNull();
});
it("can decline even when inbox setup options fail to load", async () => {
setupOptionsMock.mockRejectedValue(new Error("Email setup is unavailable"));
renderBody(interaction);
await waitForAssertion(() => expect(document.body.textContent).toContain("Email setup is unavailable"));
await clickReadyButton("Not now");
expect(declineMock).toHaveBeenCalledWith(interaction.id);
expect(emailConnectMock).not.toHaveBeenCalled();
});
});
describe("embedded agent access request", () => {
const accessRequest = {
connectionId: "22222222-2222-4222-8222-222222222222",
connectionName: "Saved Composio",
tools: [
{ catalogEntryId: "33333333-3333-4333-8333-333333333333", toolName: "COMPOSIO_SEARCH_TOOLS", versionHash: "v1", permission: "allowed" as const },
{ catalogEntryId: "44444444-4444-4444-8444-444444444444", toolName: "COMPOSIO_MANAGE_CONNECTIONS", versionHash: "v1", permission: "ask_first" as const },
],
};
const interaction: ConnectionIntentInteraction = { ...pendingConnectionIntentInteraction, payload: { ...pendingConnectionIntentInteraction.payload, serviceName: "Composio", accessRequest } };
it("shows the exact tool permissions before granting inline without a setup modal", async () => {
setupOptionsMock.mockResolvedValue({ canGrantAccess: true });
getAgentMock.mockResolvedValue({ id: interaction.payload.requestingAgentId, name: "Researcher" });
completeMock.mockResolvedValue({ ...interaction, status: "accepted", result: { version: 1, outcome: "connected", connectionId: accessRequest.connectionId } });
renderBody(interaction);
await waitForAssertion(() => expect(button("Grant access")?.disabled).toBe(false));
expect(document.body.textContent).toContain("Grant Researcher access to “Saved Composio”?");
expect(document.body.querySelector('[data-slot="agent-avatar"]')?.getAttribute("aria-label")).toBe("Researcher");
expect(Array.from(document.body.querySelectorAll('ul[aria-label="Tool permissions"] li'), row => row.textContent)).toEqual([
"COMPOSIO_SEARCH_TOOLSAllowed",
"COMPOSIO_MANAGE_CONNECTIONSAsk first",
]);
expect(completeMock).not.toHaveBeenCalled();
await act(() => button("Grant access")?.click());
await waitForAssertion(() => expect(completeMock).toHaveBeenCalledWith(interaction.id, accessRequest.connectionId));
expect(document.body.querySelector('[role="dialog"]')).toBeNull();
});
it("keeps errors visible and does not allow a non-manager to grant", async () => {
setupOptionsMock.mockResolvedValue({ canGrantAccess: false });
renderBody(interaction);
await waitForAssertion(() => expect(document.body.textContent).toContain("Connection manager required"));
expect(button("Grant access")?.disabled).toBe(true);
expect(completeMock).not.toHaveBeenCalled();
});
it("supports declining and displays permission errors on the card", async () => {
setupOptionsMock.mockResolvedValue({ canGrantAccess: true });
completeMock.mockRejectedValue(new Error("Tools changed. Request access again."));
renderBody(interaction);
await waitForAssertion(() => expect(button("Grant access")?.disabled).toBe(false));
await act(() => button("Grant access")?.click());
await waitForAssertion(() => expect(document.body.querySelector('[role="alert"]')?.textContent).toContain("Tools changed"));
await act(() => button("Not now")?.click());
await waitForAssertion(() => expect(declineMock).toHaveBeenCalledWith(interaction.id));
});
it("shows resolved access and does not expose controls to other users", () => {
renderBody({ ...interaction, status: "accepted", result: { version: 1, outcome: "connected", connectionId: accessRequest.connectionId } });
expect(document.body.textContent).toContain("Composio access granted");
expect(button("Grant access")).toBeUndefined();
});
});