mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-02 02:07:25 +08:00
feat(apps): make MCP action test results readable (#14859)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Connected Apps let an operator control which MCP actions an agent can use. > - The Permissions page lets the operator run a real action as an agent. > - The Test dialog displayed the nested MCP response as escaped JSON. > - A useful result was hard to read, even when the action worked. > - This pull request renders known MCP content as a readable preview and keeps the raw response available. > - The benefit is faster validation without losing the data needed to diagnose a failure. ## Linked Issues or Issue Description **What existing behavior does this improve?** The per-action Test dialog on a connection's Permissions page. **Subsystem affected** ui/ — React board UI. **Current behavior** The dialog shows the gateway response as an escaped JSON blob. Text content that contains JSON stays inside a string. The obsolete connection Test page also keeps a separate set of stories. **Proposed behavior** The dialog uses structured MCP content when present. It parses JSON text blocks when possible. It shows compact tables, cards, fields, or plain text. It keeps the full raw response behind a control and opens that view for errors or unknown block shapes. Stories exercise the Permissions page dialog, and the obsolete Test page and stories are removed. **Reason and benefit** An operator can inspect a successful action result at a glance and still inspect the exact gateway response when a call fails or looks wrong. **Breaking changes** No API or stored data changes. The Test dialog presentation changes. The raw response stays available. **Additional context** I tested a read-only Notion search through the real Permissions page. The dialog showed three result cards and the raw response control worked. Storybook uses invented example data. No directly matching public issue or open PR was found in the GitHub search. ## What Changed - Render structured MCP output and JSON text content in the action Test dialog. - Show wide rows as cards, keep short rows as tables, and retain the raw response for diagnosis. - Remove the obsolete connection Test page and its stories. - Add focused dialog tests and Permissions page Storybook cases for success, errors, mixed blocks, and malformed blocks. - Document the Test dialog result behavior in the connection playbook. - Keep agent mention icons visible when the Lucide icon node is unavailable in server rendering, which repaired a repeatable CI failure. ## Verification - `pnpm -r typecheck` — passed. - `pnpm exec vitest run --project @paperclipai/ui` — passed (7,111 tests). - `pnpm exec vitest run ui/src/pages/apps/app-detail/ActionTestDialog.test.tsx` — passed (11 tests). - `pnpm exec vitest run --project @paperclipai/ui ui/src/components/MarkdownBody.test.tsx` — passed (53 tests). - `pnpm test:run` — started, then stopped after the review fixes changed the head; the full sharded suite passed in CI. - `pnpm build` — passed. - `pnpm check:token-gates` — passed. - Use a connected MCP app. Open Permissions, select a read action, and run Test. Inspect the preview and the raw response control. ## Risks - MCP tools can return provider-specific block shapes. Unknown blocks open the raw response so the operator can inspect the exact result. - Row and field previews limit visible data. The raw response preserves the complete result. > This is a targeted improvement to the existing Connected Apps item in `ROADMAP.md`. ## Model Used OpenAI Codex, GPT-6. The session used tool access, code execution, and browser validation. The exact deployment ID and context window were not exposed to the session. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -513,6 +513,9 @@ connection work or enforce a real tenant boundary. Follow these rules:
|
||||
controls and per-action Test dialog. Do not build a parallel permissions list or
|
||||
provider-specific testing page. Discovery errors stay inline on Connect; an empty
|
||||
returned catalog belongs to the ordinary saved-connection state.
|
||||
- The per-action Test dialog shows structured MCP results as readable fields,
|
||||
tables, or cards when possible. It keeps the full raw response available for
|
||||
diagnosis and opens it by default when the result cannot be rendered safely.
|
||||
- Enable every discovered tool automatically. Put later Allowed / Ask first / Off
|
||||
controls in management, separate from connection access. Reconnect and refresh
|
||||
retain existing restrictions; new tools are Allowed under the existing access rules.
|
||||
|
||||
@@ -177,8 +177,12 @@ function buildAgentIconMask(iconName: string | null): string | null {
|
||||
if (cached) return cached;
|
||||
|
||||
const Icon = getAgentIcon(iconName);
|
||||
const iconNode = resolveLucideIconNode(Icon);
|
||||
if (!Array.isArray(iconNode) || iconNode.length === 0) return null;
|
||||
// Lucide's component internals differ between browser and server builds.
|
||||
// Keep the agent marker visible when its private iconNode is unavailable.
|
||||
const iconNode = resolveLucideIconNode(Icon) ?? [
|
||||
["rect", { x: "3", y: "3", width: "18", height: "18", rx: "2" }],
|
||||
["path", { d: "M8 10h.01M16 10h.01M8 15h8" }],
|
||||
];
|
||||
|
||||
const body = iconNode.map(([tag, attrs]) => {
|
||||
const attrString = Object.entries(attrs)
|
||||
|
||||
@@ -10,7 +10,7 @@ describe("Apps agent selector contract", () => {
|
||||
const appConnect = source("./AppsConnect.tsx");
|
||||
const connectionSetupFlow = source("../../features/connections/ConnectionSetupFlow.tsx");
|
||||
const permissions = source("./app-detail/PermissionsPanel.tsx");
|
||||
const tester = source("./app-detail/TestPanel.tsx");
|
||||
const tester = source("./app-detail/ActionTestDialog.tsx");
|
||||
const profiles = source("../tools/ProfilesTab.tsx");
|
||||
const audit = source("../tools/AuditTab.tsx");
|
||||
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ToolCatalogEntry } from "@paperclipai/shared";
|
||||
import { ActionTestDialog, errorHints } from "./ActionTestDialog";
|
||||
|
||||
const listTestAgentsMock = vi.hoisted(() => vi.fn());
|
||||
const getTestAgentAccessMock = vi.hoisted(() => vi.fn());
|
||||
const runTestCallMock = vi.hoisted(() => vi.fn());
|
||||
const getTestCallStatusMock = vi.hoisted(() => vi.fn());
|
||||
const declineActionRequestMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/tools", () => ({
|
||||
toolsApi: {
|
||||
listTestAgents: (connectionId: string) => listTestAgentsMock(connectionId),
|
||||
getTestAgentAccess: (connectionId: string, agentId: string) => getTestAgentAccessMock(connectionId, agentId),
|
||||
runTestCall: (connectionId: string, input: unknown) => runTestCallMock(connectionId, input),
|
||||
getTestCallStatus: (connectionId: string, requestId: string) => getTestCallStatusMock(connectionId, requestId),
|
||||
declineActionRequest: (companyId: string, requestId: string) => declineActionRequestMock(companyId, requestId),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({ selectedCompanyId: "company-1" }),
|
||||
}));
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => <a href={to} {...props}>{children}</a>,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const entry = {
|
||||
id: "asana-list-projects",
|
||||
companyId: "company-1",
|
||||
applicationId: "app-asana",
|
||||
connectionId: "conn-1",
|
||||
entryKind: "tool",
|
||||
toolName: "list_projects",
|
||||
title: "List projects",
|
||||
description: "Find Asana projects.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
outputSchema: null,
|
||||
annotations: null,
|
||||
riskLevel: "read",
|
||||
isReadOnly: true,
|
||||
isWrite: false,
|
||||
isDestructive: false,
|
||||
status: "active",
|
||||
addedAt: new Date("2026-09-01T00:00:00Z"),
|
||||
version: null,
|
||||
schemaHash: null,
|
||||
firstSeenAt: new Date("2026-09-01T00:00:00Z"),
|
||||
lastSeenAt: new Date("2026-10-01T00:00:00Z"),
|
||||
reviewedAt: null,
|
||||
reviewedByAgentId: null,
|
||||
reviewedByUserId: null,
|
||||
createdAt: new Date("2026-09-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-10-01T00:00:00Z"),
|
||||
} as ToolCatalogEntry;
|
||||
|
||||
const askFirstEntry = {
|
||||
...entry,
|
||||
id: "asana-create-task",
|
||||
toolName: "create_task",
|
||||
title: "Create task",
|
||||
riskLevel: "write",
|
||||
isReadOnly: false,
|
||||
isWrite: true,
|
||||
} as ToolCatalogEntry;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
let client: QueryClient;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> | undefined;
|
||||
flushSync(() => { result = callback(); });
|
||||
await result;
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function renderDialog(testEntry = entry) {
|
||||
await act(() => root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<ActionTestDialog connectionId="conn-1" appName="Asana" entry={testEntry} open onOpenChange={() => undefined} />
|
||||
</QueryClientProvider>,
|
||||
));
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
async function openAndRun(testEntry = entry) {
|
||||
await renderDialog(testEntry);
|
||||
const button = [...document.body.querySelectorAll("button")].find((candidate) => candidate.textContent?.trim() === "Run");
|
||||
expect(button).toBeTruthy();
|
||||
await act(() => button!.click());
|
||||
await act(async () => { await new Promise((resolve) => window.setTimeout(resolve, 260)); });
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
function allowAskFirst() {
|
||||
getTestAgentAccessMock.mockResolvedValue({
|
||||
access: {
|
||||
connectionId: "conn-1", toolCount: 1, allowedCount: 0, askFirstCount: 1, offCount: 0,
|
||||
lastChangedAt: null, lastChangedByAgentId: null, lastChangedByName: null,
|
||||
tools: [{ toolName: "create_task", gatewayToolName: "asana__create_task", displayName: "Create task", risk: "write", decision: "ask_first", reasonCode: null, matchedPolicyIds: [] }],
|
||||
},
|
||||
});
|
||||
runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "ask-invocation", actionRequestId: "request-1" });
|
||||
}
|
||||
|
||||
function requestStatus(phase: "waiting" | "running" | "done" | "denied") {
|
||||
return {
|
||||
actionRequestId: "request-1", invocationId: "ask-invocation", phase,
|
||||
parameters: {}, requestedAt: "2026-10-01T00:00:00.000Z",
|
||||
resolvedAt: phase === "waiting" ? null : "2026-10-01T00:00:05.000Z",
|
||||
durationMs: phase === "done" ? 1200 : null,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
listTestAgentsMock.mockReset().mockResolvedValue({
|
||||
agents: [{ id: "agent-ceo", name: "CEO", role: "ceo", title: "CEO", status: "active", orgDepth: 0 }],
|
||||
});
|
||||
getTestAgentAccessMock.mockReset().mockResolvedValue({
|
||||
access: {
|
||||
connectionId: "conn-1", toolCount: 1, allowedCount: 1, askFirstCount: 0, offCount: 0,
|
||||
lastChangedAt: null, lastChangedByAgentId: null, lastChangedByName: null,
|
||||
tools: [{ toolName: "list_projects", gatewayToolName: "asana__list_projects", displayName: "List projects", risk: "read", decision: "allowed", reasonCode: null, matchedPolicyIds: [] }],
|
||||
},
|
||||
});
|
||||
runTestCallMock.mockReset();
|
||||
getTestCallStatusMock.mockReset().mockResolvedValue(requestStatus("waiting"));
|
||||
declineActionRequestMock.mockReset();
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(() => root.unmount());
|
||||
container.remove();
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
describe("Permissions action Test dialog", () => {
|
||||
it("renders structured MCP rows and keeps the full response behind the raw disclosure", async () => {
|
||||
runTestCallMock.mockResolvedValue({
|
||||
decision: "allowed", invocationId: "structured",
|
||||
result: {
|
||||
content: '{"data":[{"name":"Q4 launch"},{"name":"Customer onboarding"}]}',
|
||||
data: {
|
||||
content: [{ type: "text", text: '{"data":[{"name":"Q4 launch"},{"name":"Customer onboarding"}]}' }],
|
||||
structuredContent: { data: [{ name: "Q4 launch" }, { name: "Customer onboarding" }] },
|
||||
isError: false, transport: "mcp_http",
|
||||
},
|
||||
},
|
||||
});
|
||||
await openAndRun();
|
||||
expect(runTestCallMock).toHaveBeenCalledWith("conn-1", { agentId: "agent-ceo", toolName: "list_projects", parameters: {} });
|
||||
expect(document.body.textContent).toContain("Worked. 2 rows came back.");
|
||||
expect(document.body.querySelector("table")?.textContent).toContain("Q4 launch");
|
||||
expect(document.body.textContent).not.toContain("structuredContent");
|
||||
const raw = [...document.body.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Show raw response");
|
||||
await act(() => raw!.click());
|
||||
expect(document.body.textContent).toContain("structuredContent");
|
||||
});
|
||||
|
||||
it("parses JSON text when structured content is absent", async () => {
|
||||
runTestCallMock.mockResolvedValue({
|
||||
decision: "allowed", invocationId: "text",
|
||||
result: { content: '{"data":[{"name":"Q4 launch"}]}', data: { content: [{ type: "text", text: '{"data":[{"name":"Q4 launch"}]}' }], structuredContent: null, isError: false } },
|
||||
});
|
||||
await openAndRun();
|
||||
expect(document.body.querySelector("table")?.textContent).toContain("Q4 launch");
|
||||
});
|
||||
|
||||
it("uses structured content when an MCP result has no content blocks", async () => {
|
||||
runTestCallMock.mockResolvedValue({
|
||||
decision: "allowed", invocationId: "structured-only",
|
||||
result: { content: "", data: { structuredContent: { results: [{ name: "Northstar launch plan" }] }, isError: false } },
|
||||
});
|
||||
await openAndRun();
|
||||
expect(document.body.textContent).toContain("Worked. 1 row came back.");
|
||||
expect(document.body.querySelector("table")?.textContent).toContain("Northstar launch plan");
|
||||
expect(document.body.textContent).not.toContain("structuredContent");
|
||||
});
|
||||
|
||||
it("shows wide search results as readable cards with a safe page link", async () => {
|
||||
const rows = [{
|
||||
id: "11111111-2222-4333-8444-555555555555",
|
||||
title: "Northstar launch plan",
|
||||
url: "https://www.notion.so/11111111222243338444555555555555",
|
||||
type: "page",
|
||||
highlight: "The launch plan covers milestones, owners, and a detailed summary that should not stretch a table row in the dialog.",
|
||||
timestamp: "2026-09-10T14:30:00.000Z",
|
||||
}];
|
||||
runTestCallMock.mockResolvedValue({
|
||||
decision: "allowed", invocationId: "notion-wide",
|
||||
result: { content: JSON.stringify({ results: rows }), data: { content: [{ type: "text", text: JSON.stringify({ results: rows }) }], structuredContent: null, isError: false } },
|
||||
});
|
||||
await openAndRun();
|
||||
expect(document.body.querySelector("table")).toBeNull();
|
||||
expect(document.body.textContent).toContain("Northstar launch plan");
|
||||
expect(document.body.textContent).toContain("The launch plan covers milestones");
|
||||
expect(document.body.querySelector('a[href="https://www.notion.so/11111111222243338444555555555555"]')?.textContent).toBe("Open link");
|
||||
});
|
||||
|
||||
it("shows a non-link URL as a field in a result card", async () => {
|
||||
const rows = [{ title: "Northstar launch plan", url: "notion://workspace/launch", description: "A long description that keeps the card layout useful even when the provider supplies a URL that cannot open in a browser." }];
|
||||
runTestCallMock.mockResolvedValue({
|
||||
decision: "allowed", invocationId: "non-link-url",
|
||||
result: { content: JSON.stringify({ results: rows }), data: { content: [{ type: "text", text: JSON.stringify({ results: rows }) }], isError: false } },
|
||||
});
|
||||
await openAndRun();
|
||||
expect(document.body.querySelector('a[href="notion://workspace/launch"]')).toBeNull();
|
||||
expect(document.body.textContent).toContain("notion://workspace/launch");
|
||||
});
|
||||
|
||||
it("keeps an ask-first request visible after reopening the dialog", async () => {
|
||||
allowAskFirst();
|
||||
await openAndRun(askFirstEntry);
|
||||
expect(document.body.textContent).toContain("Sent for your OK.");
|
||||
expect(document.body.textContent).toContain("Cancel this request");
|
||||
expect(document.body.querySelector('a[href="/apps/conn-1/review"]')).toBeTruthy();
|
||||
|
||||
await act(() => root.unmount());
|
||||
root = createRoot(container);
|
||||
await renderDialog(askFirstEntry);
|
||||
expect(runTestCallMock).toHaveBeenCalledTimes(1);
|
||||
expect(document.body.textContent).toContain("Sent for your OK.");
|
||||
});
|
||||
|
||||
it("shows an approved request running and then the completed result", async () => {
|
||||
allowAskFirst();
|
||||
getTestCallStatusMock.mockResolvedValue(requestStatus("running"));
|
||||
await openAndRun(askFirstEntry);
|
||||
expect(document.body.textContent).toContain("Approved · running");
|
||||
expect(document.body.textContent).not.toContain("Cancel this request");
|
||||
|
||||
getTestCallStatusMock.mockResolvedValue({ ...requestStatus("done"), result: [{ name: "Northstar launch plan" }] });
|
||||
await act(async () => { await client.invalidateQueries({ queryKey: ["tools"] }); });
|
||||
await flushReact();
|
||||
expect(document.body.textContent).toContain("Worked.");
|
||||
expect(document.body.textContent).toContain("Northstar launch plan");
|
||||
expect(runTestCallMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows a denied request without offering cancellation", async () => {
|
||||
allowAskFirst();
|
||||
getTestCallStatusMock.mockResolvedValue(requestStatus("denied"));
|
||||
await openAndRun(askFirstEntry);
|
||||
expect(document.body.textContent).toContain("Denied — see Review for why");
|
||||
expect(document.body.textContent).not.toContain("Cancel this request");
|
||||
});
|
||||
|
||||
it("shows a tool error and opens its raw response", async () => {
|
||||
runTestCallMock.mockResolvedValue({
|
||||
decision: "allowed", invocationId: "error",
|
||||
result: { content: "Asana denied access to this workspace.", data: { content: [{ type: "text", text: "Asana denied access to this workspace." }], isError: true }, error: "MCP tool returned an error result" },
|
||||
});
|
||||
await openAndRun();
|
||||
expect(document.body.textContent).toContain("It didn't work.");
|
||||
expect(document.body.textContent).toContain("Asana denied access to this workspace.");
|
||||
expect(document.body.textContent).toContain("Hide raw response");
|
||||
});
|
||||
|
||||
it("opens the raw response when an MCP block has an unexpected shape", async () => {
|
||||
runTestCallMock.mockResolvedValue({
|
||||
decision: "allowed", invocationId: "malformed",
|
||||
result: { content: "", data: { content: [{ type: "text", payload: "missing text" }], structuredContent: null, isError: false } },
|
||||
});
|
||||
await openAndRun();
|
||||
expect(document.body.textContent).toContain("Hide raw response");
|
||||
expect(document.body.textContent).toContain("missing text");
|
||||
});
|
||||
|
||||
it("keeps useful and generic error hints", () => {
|
||||
expect(errorHints("Not found", "NOT_FOUND").join(" ")).toMatch(/check|verify|ID/i);
|
||||
expect(errorHints("Unexpected provider failure", null).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
+140
-443
@@ -4,7 +4,6 @@ import {
|
||||
AlertTriangle,
|
||||
Ban,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
Clock,
|
||||
Loader2,
|
||||
@@ -28,12 +27,6 @@ import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -50,7 +43,6 @@ import {
|
||||
} from "@/components/JsonSchemaForm";
|
||||
import { cn, relativeTime } from "@/lib/utils";
|
||||
import { appTabHref } from "../app-tabs";
|
||||
import { formatActionPermissionSummary } from "./action-permission-summary";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small format helpers
|
||||
@@ -67,13 +59,6 @@ function relTime(date: Date): string {
|
||||
return t.charAt(0).toUpperCase() + t.slice(1);
|
||||
}
|
||||
|
||||
/** Sub-line copy: first sentence of the catalog description, no trailing period. */
|
||||
function actionSubLine(entry: ToolCatalogEntry): string | null {
|
||||
if (!entry.description) return null;
|
||||
const firstSentence = entry.description.split(/(?<=\.)\s/)[0] ?? entry.description;
|
||||
return firstSentence.replace(/\.+$/, "").trim() || null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Decision badges
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -88,9 +73,7 @@ const TEST_ACCESS_STALE_TIME_MS = 5 * 60_000;
|
||||
const TEST_ACCESS_GC_TIME_MS = 30 * 60_000;
|
||||
|
||||
/**
|
||||
* Focused action tester used by the combined Permissions page. The modal keeps
|
||||
* the existing schema form and result renderer, but scopes agent selection and
|
||||
* test state to the action the user opened.
|
||||
* Action tester opened from an action row on the Permissions page.
|
||||
*/
|
||||
export function ActionTestDialog({
|
||||
connectionId,
|
||||
@@ -185,7 +168,6 @@ export function ActionTestDialog({
|
||||
onSelect={setRequestedAgentId}
|
||||
connectionId={connectionId}
|
||||
appName={appName}
|
||||
inline
|
||||
/>
|
||||
<DecisionBadge decision={decision} />
|
||||
</div>
|
||||
@@ -236,262 +218,6 @@ function DecisionBadge({ decision }: { decision: ToolConnectionTestDecision }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function TestPanel({
|
||||
connectionId,
|
||||
appName,
|
||||
active,
|
||||
quarantined = [],
|
||||
}: {
|
||||
connectionId: string;
|
||||
appName: string;
|
||||
/** Active (non-quarantined, non-removed) catalog entries. */
|
||||
active: ToolCatalogEntry[];
|
||||
/** New, not-yet-reviewed actions — shown as Off so they're reachable to test. */
|
||||
quarantined?: ToolCatalogEntry[];
|
||||
}) {
|
||||
const hasActions = active.length > 0 || quarantined.length > 0;
|
||||
const testAgentsQuery = useQuery({
|
||||
queryKey: queryKeys.tools.testAgents(connectionId),
|
||||
queryFn: () => toolsApi.listTestAgents(connectionId),
|
||||
enabled: !!connectionId && hasActions,
|
||||
});
|
||||
|
||||
const agents = useMemo(
|
||||
() => [...(testAgentsQuery.data?.agents ?? [])].sort(
|
||||
(a, b) => a.orgDepth - b.orgDepth || a.name.localeCompare(b.name),
|
||||
),
|
||||
[testAgentsQuery.data],
|
||||
);
|
||||
|
||||
const [requestedAgentId, setRequestedAgentId] = useState<string | null>(null);
|
||||
// The API returns only agents this user may write to. Prefer the highest
|
||||
// agent in that accessible slice of the org tree, regardless of whether a
|
||||
// lower-ranked agent happens to have a broader app policy today.
|
||||
const agentId = requestedAgentId && agents.some((agent) => agent.id === requestedAgentId)
|
||||
? requestedAgentId
|
||||
: agents[0]?.id ?? null;
|
||||
const selectedAgentBase = agents.find((agent) => agent.id === agentId) ?? null;
|
||||
const testAgentAccessQuery = useQuery({
|
||||
queryKey: queryKeys.tools.testAgentAccess(connectionId, agentId ?? "__none__"),
|
||||
queryFn: () => toolsApi.getTestAgentAccess(connectionId, agentId!),
|
||||
enabled: !!connectionId && !!agentId && hasActions,
|
||||
staleTime: TEST_ACCESS_STALE_TIME_MS,
|
||||
gcTime: TEST_ACCESS_GC_TIME_MS,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
const selectedAgent = useMemo<TestAgentWithAccess | null>(() => (
|
||||
selectedAgentBase && testAgentAccessQuery.data
|
||||
? { ...selectedAgentBase, effectiveAccess: testAgentAccessQuery.data.access }
|
||||
: null
|
||||
), [selectedAgentBase, testAgentAccessQuery.data]);
|
||||
|
||||
// Per-action decision for the selected agent, keyed by both the upstream and
|
||||
// gateway tool names so we can match whatever the catalog stores.
|
||||
const decisionByTool = useMemo(() => {
|
||||
const map = new Map<string, ToolConnectionTestDecision>();
|
||||
for (const tool of selectedAgent?.effectiveAccess.tools ?? []) {
|
||||
map.set(tool.toolName, tool.decision);
|
||||
map.set(tool.gatewayToolName, tool.decision);
|
||||
}
|
||||
return map;
|
||||
}, [selectedAgent]);
|
||||
|
||||
const decisionFor = (entry: ToolCatalogEntry): ToolConnectionTestDecision =>
|
||||
decisionByTool.get(entry.toolName) ?? "off";
|
||||
|
||||
// Search + read/write filter.
|
||||
const [query, setQuery] = useState("");
|
||||
const [kindFilter, setKindFilter] = useState<"all" | "read" | "write">("all");
|
||||
|
||||
const byName = (a: ToolCatalogEntry, b: ToolCatalogEntry) =>
|
||||
(a.title ?? a.toolName).localeCompare(b.title ?? b.toolName);
|
||||
const readActions = active.filter((e) => e.isReadOnly).sort(byName);
|
||||
const writeActions = active.filter((e) => !e.isReadOnly).sort(byName);
|
||||
|
||||
const matches = (entry: ToolCatalogEntry) => {
|
||||
if (kindFilter === "read" && !entry.isReadOnly) return false;
|
||||
if (kindFilter === "write" && entry.isReadOnly) return false;
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return true;
|
||||
return (
|
||||
(entry.title ?? entry.toolName).toLowerCase().includes(needle) ||
|
||||
(entry.description ?? "").toLowerCase().includes(needle)
|
||||
);
|
||||
};
|
||||
|
||||
const quarantinedActions = [...quarantined].sort(byName);
|
||||
|
||||
const visibleRead = readActions.filter(matches);
|
||||
const visibleWrite = writeActions.filter(matches);
|
||||
const visibleQuarantined = quarantinedActions.filter(matches);
|
||||
const visibleCount = visibleRead.length + visibleWrite.length + visibleQuarantined.length;
|
||||
|
||||
if (!hasActions) {
|
||||
return <EmptyState connectionId={connectionId} appName={appName} />;
|
||||
}
|
||||
|
||||
if (testAgentsQuery.isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground" role="status">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading agents…
|
||||
</div>
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (testAgentsQuery.isError) {
|
||||
return (
|
||||
<TestLoadError
|
||||
message="We couldn't load the agents available for testing."
|
||||
onRetry={() => { void testAgentsQuery.refetch(); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (agents.length === 0) {
|
||||
return (
|
||||
<div className="py-6 text-center">
|
||||
<p className="text-sm font-medium text-foreground">No agents to test as</p>
|
||||
<p className="mx-auto mt-1 max-w-md text-sm text-muted-foreground">
|
||||
Only agents you can assign tasks to can preview {appName}. Give an agent access in{" "}
|
||||
<Link className="font-medium text-primary hover:underline" to={appTabHref(connectionId, "permissions")}>
|
||||
Permissions
|
||||
</Link>{" "}
|
||||
to test it here.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (testAgentAccessQuery.isError && !testAgentAccessQuery.data) {
|
||||
return (
|
||||
<TestLoadError
|
||||
message={`We couldn't load ${selectedAgentBase?.name ?? "this agent"}'s permissions.`}
|
||||
onRetry={() => { void testAgentAccessQuery.refetch(); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (testAgentAccessQuery.isLoading || !selectedAgent) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground" role="status">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading agent permissions…
|
||||
</div>
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sharedRowProps = {
|
||||
connectionId,
|
||||
appName,
|
||||
allAgents: agents,
|
||||
onSelectAgent: setRequestedAgentId,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{selectedAgent && (
|
||||
<TestAsHeader
|
||||
appName={appName}
|
||||
agents={agents}
|
||||
selectedAgent={selectedAgent}
|
||||
onSelect={setRequestedAgentId}
|
||||
connectionId={connectionId}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="space-y-4 border-t border-border pt-8">
|
||||
<h2 className="text-lg font-semibold text-foreground">Actions</h2>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-(--sz-12rem) flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Find an action"
|
||||
placeholder="Find an action…"
|
||||
className="pl-9"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<FilterChip label={`All ${active.length + quarantinedActions.length}`} active={kindFilter === "all"} onClick={() => setKindFilter("all")} />
|
||||
<FilterChip label={`Read ${readActions.length}`} active={kindFilter === "read"} onClick={() => setKindFilter("read")} />
|
||||
<FilterChip label={`Write ${writeActions.length}`} active={kindFilter === "write"} onClick={() => setKindFilter("write")} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{visibleCount} matches · sorted A–Z</p>
|
||||
</section>
|
||||
|
||||
{visibleCount === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
No actions match “{query}”. Clear the search to see them all.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{visibleRead.length > 0 && selectedAgent && (
|
||||
<ActionGroup
|
||||
heading={`Read (${visibleRead.length})`}
|
||||
entries={visibleRead}
|
||||
decisionFor={decisionFor}
|
||||
agent={selectedAgent}
|
||||
{...sharedRowProps}
|
||||
/>
|
||||
)}
|
||||
{visibleWrite.length > 0 && selectedAgent && (
|
||||
<ActionGroup
|
||||
heading={`Write (${visibleWrite.length})`}
|
||||
entries={visibleWrite}
|
||||
decisionFor={decisionFor}
|
||||
agent={selectedAgent}
|
||||
{...sharedRowProps}
|
||||
/>
|
||||
)}
|
||||
{visibleQuarantined.length > 0 && selectedAgent && (
|
||||
<ActionGroup
|
||||
heading={`New (${visibleQuarantined.length})`}
|
||||
subheading="New actions wait, switched off, until you turn them on."
|
||||
entries={visibleQuarantined}
|
||||
decisionFor={() => "off" as const}
|
||||
agent={selectedAgent}
|
||||
{...sharedRowProps}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Empty state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EmptyState({ connectionId, appName }: { connectionId: string; appName: string }) {
|
||||
return (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-base font-bold text-foreground">Nothing to test yet</p>
|
||||
<p className="mx-auto mt-1.5 max-w-md text-sm text-muted-foreground">
|
||||
Once {appName} is connected, the actions it offers will show up here so you can try them out.
|
||||
</p>
|
||||
<Button asChild className="mt-4" variant="outline">
|
||||
<Link to={appTabHref(connectionId, "permissions")}>Go to Permissions</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TestLoadError({ message, onRetry }: { message: string; onRetry: () => void }) {
|
||||
return (
|
||||
<div className="py-8 text-center">
|
||||
@@ -504,66 +230,21 @@ function TestLoadError({ message, onRetry }: { message: string; onRetry: () => v
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test-as header + agent picker
|
||||
// Agent picker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TestAsHeader({
|
||||
appName,
|
||||
agents,
|
||||
selectedAgent,
|
||||
onSelect,
|
||||
connectionId,
|
||||
}: {
|
||||
appName: string;
|
||||
agents: ToolConnectionTestAgent[];
|
||||
selectedAgent: TestAgentWithAccess;
|
||||
onSelect: (agentId: string) => void;
|
||||
connectionId: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Test an action</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Run a real action as an agent.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-muted-foreground">Agent</p>
|
||||
<AgentPicker
|
||||
agents={agents}
|
||||
selectedAgent={selectedAgent}
|
||||
onSelect={onSelect}
|
||||
connectionId={connectionId}
|
||||
appName={appName}
|
||||
/>
|
||||
</div>
|
||||
<Link
|
||||
className="text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
||||
to={appTabHref(connectionId, "permissions")}
|
||||
>
|
||||
{formatActionPermissionSummary(selectedAgent.effectiveAccess)}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentPicker({
|
||||
agents,
|
||||
selectedAgent,
|
||||
onSelect,
|
||||
connectionId,
|
||||
appName,
|
||||
inline,
|
||||
}: {
|
||||
agents: ToolConnectionTestAgent[];
|
||||
selectedAgent: ToolConnectionTestAgent;
|
||||
onSelect: (agentId: string) => void;
|
||||
connectionId: string;
|
||||
appName: string;
|
||||
inline?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -577,17 +258,14 @@ function AgentPicker({
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"items-center gap-1.5 text-foreground outline-none hover:text-primary focus-visible:text-primary",
|
||||
inline ? "inline-flex font-semibold underline-offset-2 hover:underline" : "mt-0.5 flex text-lg font-bold",
|
||||
)}
|
||||
className="inline-flex items-center gap-1.5 font-semibold text-foreground underline-offset-2 outline-none hover:text-primary hover:underline focus-visible:text-primary"
|
||||
aria-label="Choose which agent to test as"
|
||||
>
|
||||
{selectedAgent.name}
|
||||
<ChevronsUpDown className={cn("text-muted-foreground", inline ? "h-3.5 w-3.5" : "h-4 w-4")} />
|
||||
<ChevronsUpDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-80 p-0" disablePortal={inline}>
|
||||
<PopoverContent align="start" className="w-80 p-0" disablePortal>
|
||||
<div className="border-b border-border p-2">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
@@ -661,114 +339,6 @@ function AgentPicker({
|
||||
);
|
||||
}
|
||||
|
||||
function FilterChip({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action group + rows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type RowSharedProps = {
|
||||
connectionId: string;
|
||||
appName: string;
|
||||
allAgents: ToolConnectionTestAgent[];
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
};
|
||||
|
||||
function ActionGroup({
|
||||
heading,
|
||||
subheading,
|
||||
entries,
|
||||
decisionFor,
|
||||
agent,
|
||||
...shared
|
||||
}: {
|
||||
heading: string;
|
||||
subheading?: string;
|
||||
entries: ToolCatalogEntry[];
|
||||
decisionFor: (entry: ToolCatalogEntry) => ToolConnectionTestDecision;
|
||||
agent: TestAgentWithAccess;
|
||||
} & RowSharedProps) {
|
||||
return (
|
||||
<section>
|
||||
<h3 className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{heading}</h3>
|
||||
{subheading && <p className="mb-1.5 -mt-1 text-xs text-muted-foreground">{subheading}</p>}
|
||||
<div className="divide-y divide-border">
|
||||
{entries.map((entry) => (
|
||||
<ActionRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
decision={decisionFor(entry)}
|
||||
agent={agent}
|
||||
{...shared}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionRow({
|
||||
entry,
|
||||
decision,
|
||||
agent,
|
||||
...shared
|
||||
}: {
|
||||
entry: ToolCatalogEntry;
|
||||
decision: ToolConnectionTestDecision;
|
||||
agent: TestAgentWithAccess;
|
||||
} & RowSharedProps) {
|
||||
const [open, setOpen] = useState(() => Boolean(loadStoredAskFirstOutcome(shared.connectionId, entry, agent)));
|
||||
const title = entry.title ?? entry.toolName;
|
||||
const sub = actionSubLine(entry);
|
||||
|
||||
useEffect(() => {
|
||||
if (loadStoredAskFirstOutcome(shared.connectionId, entry, agent)) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [shared.connectionId, entry, agent]);
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left outline-none hover:bg-accent/40 focus-visible:bg-accent/40"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn("h-4 w-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-foreground">{title}</span>
|
||||
{sub && <span className="block truncate text-xs text-muted-foreground">{sub}</span>}
|
||||
</span>
|
||||
<DecisionBadge decision={decision} />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="border-t border-border py-4 pl-11">
|
||||
<ActionTester entry={entry} decision={decision} agent={agent} {...shared} />
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The actual tester (form + run + result)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -854,7 +424,11 @@ function ActionTester({
|
||||
entry: ToolCatalogEntry;
|
||||
decision: ToolConnectionTestDecision;
|
||||
agent: TestAgentWithAccess;
|
||||
} & RowSharedProps) {
|
||||
connectionId: string;
|
||||
appName: string;
|
||||
allAgents: ToolConnectionTestAgent[];
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const rawSchema = (entry.inputSchema ?? { type: "object", properties: {} }) as JsonSchemaNode;
|
||||
@@ -1202,6 +776,59 @@ function isEmptyResult(value: unknown): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The gateway keeps the original MCP blocks inside `data`; the top-level
|
||||
* `content` is only a flattened convenience string. Prefer structured output,
|
||||
* then parse individual text blocks when they happen to contain JSON. */
|
||||
function resultPreview(value: unknown): { items: Array<{ label: string | null; value: unknown }>; summary: unknown; rawFallback?: boolean } {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return { items: [{ label: null, value }], summary: value };
|
||||
}
|
||||
const wrapper = value as Record<string, unknown>;
|
||||
const data = wrapper.data && typeof wrapper.data === "object" && !Array.isArray(wrapper.data)
|
||||
? wrapper.data as Record<string, unknown>
|
||||
: null;
|
||||
const result = data && (Array.isArray(data.content) || data.structuredContent != null) ? data : wrapper;
|
||||
const blocks = Array.isArray(result.content) ? result.content : null;
|
||||
if (!blocks && result.structuredContent == null) {
|
||||
return { items: [{ label: null, value }], summary: value };
|
||||
}
|
||||
|
||||
const parseText = (text: string): unknown => {
|
||||
try { return JSON.parse(text); } catch { return text; }
|
||||
};
|
||||
const structured = result.structuredContent;
|
||||
const items: Array<{ label: string | null; value: unknown }> = [];
|
||||
const structuredValue = typeof structured === "string" ? parseText(structured) : structured;
|
||||
if (structured !== null && structured !== undefined) {
|
||||
items.push({ label: null, value: structuredValue });
|
||||
}
|
||||
for (const block of blocks ?? []) {
|
||||
if (!block || typeof block !== "object" || Array.isArray(block)) {
|
||||
return { items: [{ label: null, value }], summary: value, rawFallback: true };
|
||||
}
|
||||
const content = block as Record<string, unknown>;
|
||||
if (content.type === "text" && typeof content.text === "string") {
|
||||
const parsed = parseText(content.text);
|
||||
if (structured === null || structured === undefined || safeStringify(parsed) !== safeStringify(structuredValue)) {
|
||||
items.push({ label: (blocks?.length ?? 0) > 1 || (structured !== null && structured !== undefined) ? "Text" : null, value: parsed });
|
||||
}
|
||||
} else if (content.type === "image" || content.type === "audio") {
|
||||
items.push({ label: content.type === "image" ? "Image" : "Audio", value: typeof content.mimeType === "string" ? content.mimeType : "Media attachment" });
|
||||
} else if (content.type === "resource_link") {
|
||||
items.push({ label: "Resource", value: content.title ?? content.name ?? content.uri ?? "Resource link" });
|
||||
} else if (content.type === "resource" && content.resource && typeof content.resource === "object") {
|
||||
const resource = content.resource as Record<string, unknown>;
|
||||
items.push({ label: "Resource", value: typeof resource.text === "string" ? parseText(resource.text) : resource.uri ?? "Embedded resource" });
|
||||
} else {
|
||||
return { items: [{ label: null, value }], summary: value, rawFallback: true };
|
||||
}
|
||||
}
|
||||
if (items.length === 0 && typeof wrapper.content === "string") {
|
||||
items.push({ label: null, value: parseText(wrapper.content) });
|
||||
}
|
||||
return { items, summary: structured !== null && structured !== undefined ? structuredValue : items[0]?.value };
|
||||
}
|
||||
|
||||
function writeVerb(entry: ToolCatalogEntry): string | null {
|
||||
const n = `${entry.toolName} ${entry.title ?? ""}`.toLowerCase();
|
||||
if (/\b(append|add|insert|create|new)\b/.test(n)) return "added";
|
||||
@@ -1231,27 +858,35 @@ function AllowedResult({
|
||||
connectionId: string;
|
||||
}) {
|
||||
const value = outcome.result.result;
|
||||
const preview = resultPreview(value);
|
||||
return (
|
||||
<div className="rounded-md border border-emerald-500/40 bg-emerald-500/5 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||
<span className="text-sm font-medium text-foreground">{successHeadline(value, entry, appName)}</span>
|
||||
<span className="text-sm font-medium text-foreground">{successHeadline(preview.summary, entry, appName)}</span>
|
||||
</div>
|
||||
<p className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
Ran as {outcome.agentName} · {seconds(outcome.durationMs)} · {relTime(outcome.ranAt)}
|
||||
</p>
|
||||
|
||||
{!isEmptyResult(value) && (
|
||||
{preview.items.some((item) => !isEmptyResult(item.value)) && (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Preview</p>
|
||||
<div className="mt-1.5">
|
||||
<PrettyPreview value={value} />
|
||||
<div className="space-y-2">
|
||||
{preview.items.map((item, index) => (
|
||||
<div key={index}>
|
||||
{item.label && <p className="mb-1 text-xs font-medium text-muted-foreground">{item.label}</p>}
|
||||
<PrettyPreview value={item.value} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RawResponseDisclosure value={value} />
|
||||
<RawResponseDisclosure value={value} initiallyOpen={preview.rawFallback} />
|
||||
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
This call is in the{" "}
|
||||
@@ -1265,12 +900,25 @@ function AllowedResult({
|
||||
);
|
||||
}
|
||||
|
||||
/** Pretty preview: table for row arrays, depth-limited JSON otherwise, plain text for strings. */
|
||||
/** Pretty preview: tables for row arrays, labeled fields for objects, plain text for prose. */
|
||||
function PrettyPreview({ value }: { value: unknown }) {
|
||||
const rows = asRows(value);
|
||||
if (rows) {
|
||||
const columns = Array.from(new Set(rows.flatMap((r) => Object.keys(r)))).slice(0, 6);
|
||||
const shown = rows.slice(0, 6);
|
||||
const needsCards = columns.length > 4 || shown.some((row) =>
|
||||
columns.some((column) => cellText(row[column]).length > 80)
|
||||
);
|
||||
if (needsCards) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{shown.map((row, index) => <PreviewRowCard key={index} row={row} index={index} />)}
|
||||
{rows.length > shown.length && (
|
||||
<p className="text-(length:--text-micro) text-muted-foreground">… {rows.length - shown.length} more rows in the raw response</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-md border border-border">
|
||||
<table className="w-full text-left text-xs">
|
||||
@@ -1300,6 +948,18 @@ function PrettyPreview({ value }: { value: unknown }) {
|
||||
if (typeof value === "string") {
|
||||
return <pre className="max-h-64 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-background p-3 text-xs text-foreground">{value}</pre>;
|
||||
}
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return (
|
||||
<dl className="max-h-64 overflow-auto divide-y divide-border rounded-md border border-border bg-background text-xs">
|
||||
{Object.entries(value as Record<string, unknown>).map(([key, field]) => (
|
||||
<div key={key} className="grid grid-cols-3 gap-3 px-3 py-2">
|
||||
<dt className="min-w-0 break-words text-muted-foreground">{key}</dt>
|
||||
<dd className="col-span-2 min-w-0 break-words whitespace-pre-wrap text-foreground">{typeof field === "object" && field !== null ? safeStringify(collapseDeep(field, 2)) : cellText(field)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<pre className="max-h-64 overflow-auto rounded-md border border-border bg-background p-3 text-xs text-foreground">
|
||||
{safeStringify(collapseDeep(value, 2))}
|
||||
@@ -1307,6 +967,42 @@ function PrettyPreview({ value }: { value: unknown }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewRowCard({ row, index }: { row: Record<string, unknown>; index: number }) {
|
||||
const titleKey = ["title", "name", "label"].find((key) => typeof row[key] === "string" && row[key] !== "");
|
||||
const summaryKey = ["highlight", "description", "summary"].find((key) => typeof row[key] === "string" && row[key] !== "");
|
||||
const url = typeof row.url === "string" && /^https?:\/\//i.test(row.url) ? row.url : null;
|
||||
const details = Object.entries(row)
|
||||
.filter(([key]) => key !== titleKey && key !== summaryKey && (key !== "url" || !url))
|
||||
.sort(([left], [right]) => {
|
||||
const order = ["url", "type", "status", "timestamp", "id"];
|
||||
const leftRank = order.indexOf(left);
|
||||
const rightRank = order.indexOf(right);
|
||||
return (leftRank < 0 ? order.length : leftRank) - (rightRank < 0 ? order.length : rightRank);
|
||||
});
|
||||
return (
|
||||
<div className="min-w-0 rounded-md border border-border bg-background p-3 text-xs">
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<p className="min-w-0 break-words text-sm font-medium text-foreground">
|
||||
{titleKey ? cellText(row[titleKey]) : `Result ${index + 1}`}
|
||||
</p>
|
||||
{url && <a className="shrink-0 text-primary hover:underline" href={url} target="_blank" rel="noopener noreferrer">Open link</a>}
|
||||
</div>
|
||||
{summaryKey && <p className="mt-1 line-clamp-2 break-words text-muted-foreground">{cellText(row[summaryKey]).replace(/\*\*(.*?)\*\*/g, "$1")}</p>}
|
||||
{details.length > 0 && (
|
||||
<dl className="mt-2 space-y-1">
|
||||
{details.slice(0, 3).map(([key, field]) => (
|
||||
<div key={key} className="grid grid-cols-3 gap-2">
|
||||
<dt className="min-w-0 break-words text-muted-foreground">{key}</dt>
|
||||
<dd className="col-span-2 min-w-0 break-all text-foreground">{cellText(field)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
{details.length > 3 && <p className="mt-2 text-muted-foreground">{details.length - 3} more {details.length === 4 ? "field" : "fields"} in the raw response</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function cellText(value: unknown): string {
|
||||
if (value === null || value === undefined) return "—";
|
||||
if (typeof value === "object") return Array.isArray(value) ? `[${value.length}]` : "{…}";
|
||||
@@ -1325,8 +1021,8 @@ function collapseDeep(value: unknown, maxDepth: number, depth = 0): unknown {
|
||||
return out;
|
||||
}
|
||||
|
||||
function RawResponseDisclosure({ value }: { value: unknown }) {
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
function RawResponseDisclosure({ value, initiallyOpen = false }: { value: unknown; initiallyOpen?: boolean }) {
|
||||
const [showRaw, setShowRaw] = useState(initiallyOpen);
|
||||
if (value === undefined || value === null) return null;
|
||||
return (
|
||||
<div className="mt-3">
|
||||
@@ -1384,6 +1080,7 @@ function ErrorResult({
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{outcome.result.result !== undefined && <RawResponseDisclosure value={outcome.result.result} initiallyOpen />}
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
{needsReconnect ? "After reconnecting, run this action again." : "Adjust the input above and try again."}
|
||||
</p>
|
||||
@@ -12,7 +12,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type InstallState } from "@/lib/tool-installs";
|
||||
import { QuarantinedActionsReview } from "./SetupPanel";
|
||||
import { ActionTestDialog } from "./TestPanel";
|
||||
import { ActionTestDialog } from "./ActionTestDialog";
|
||||
import type { AccessDraft, AppDetailSectionProps } from "./types";
|
||||
|
||||
type ActionPermission = "off" | "ask" | "allowed";
|
||||
|
||||
@@ -1,575 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ToolCatalogEntry } from "@paperclipai/shared";
|
||||
import { TestPanel, errorHints } from "./TestPanel";
|
||||
|
||||
const listTestAgentsMock = vi.hoisted(() => vi.fn());
|
||||
const getTestAgentAccessMock = vi.hoisted(() => vi.fn());
|
||||
const runTestCallMock = vi.hoisted(() => vi.fn());
|
||||
const getTestCallStatusMock = vi.hoisted(() => vi.fn());
|
||||
const declineActionRequestMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/tools", () => ({
|
||||
toolsApi: {
|
||||
listTestAgents: (connectionId: string) => listTestAgentsMock(connectionId),
|
||||
getTestAgentAccess: (connectionId: string, agentId: string) =>
|
||||
getTestAgentAccessMock(connectionId, agentId),
|
||||
runTestCall: (connectionId: string, input: unknown) => runTestCallMock(connectionId, input),
|
||||
getTestCallStatus: (connectionId: string, actionRequestId: string) =>
|
||||
getTestCallStatusMock(connectionId, actionRequestId),
|
||||
declineActionRequest: (companyId: string, actionRequestId: string) =>
|
||||
declineActionRequestMock(companyId, actionRequestId),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({ selectedCompanyId: "company-1", selectedCompany: { id: "company-1", name: "Paperclip" } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => (
|
||||
<a href={to} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** The form field input — excludes the "Find an action" search box. */
|
||||
function formInput(): HTMLInputElement {
|
||||
const inputs = [...container.querySelectorAll("input")].filter(
|
||||
(i) => i.getAttribute("aria-label") !== "Find an action",
|
||||
);
|
||||
return inputs[0] as HTMLInputElement;
|
||||
}
|
||||
|
||||
async function fillFormField(value: string) {
|
||||
const input = formInput();
|
||||
expect(input).toBeTruthy();
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
|
||||
await act(async () => {
|
||||
setter.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
/** Wait past the 200ms minimum-spinner delay so the result panel renders. */
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 260));
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
async function clickByText(text: string) {
|
||||
const btn = [...container.querySelectorAll("button")].find((b) => b.textContent?.trim() === text);
|
||||
expect(btn).toBeTruthy();
|
||||
await act(async () => btn!.click());
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
function catalogEntry(overrides: Partial<ToolCatalogEntry> = {}): ToolCatalogEntry {
|
||||
return {
|
||||
id: "catalog-read",
|
||||
companyId: "company-1",
|
||||
applicationId: "app-1",
|
||||
connectionId: "conn-1",
|
||||
entryKind: "tool",
|
||||
toolName: "read_sheet",
|
||||
title: "Read a sheet",
|
||||
description: "Get rows and cell values from a sheet.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { spreadsheetId: { type: "string", title: "Spreadsheet" } },
|
||||
required: ["spreadsheetId"],
|
||||
},
|
||||
outputSchema: null,
|
||||
annotations: null,
|
||||
riskLevel: "read",
|
||||
isReadOnly: true,
|
||||
isWrite: false,
|
||||
isDestructive: false,
|
||||
status: "active",
|
||||
addedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
version: null,
|
||||
schemaHash: null,
|
||||
firstSeenAt: new Date("2026-01-01T00:00:00Z"),
|
||||
lastSeenAt: new Date("2026-01-01T00:00:00Z"),
|
||||
reviewedAt: null,
|
||||
reviewedByAgentId: null,
|
||||
reviewedByUserId: null,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
...overrides,
|
||||
} as ToolCatalogEntry;
|
||||
}
|
||||
|
||||
function agent(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "agent-claude",
|
||||
name: "ClaudeCoder",
|
||||
role: "engineer",
|
||||
title: "Engineer",
|
||||
status: "active",
|
||||
orgDepth: 1,
|
||||
effectiveAccess: {
|
||||
connectionId: "conn-1",
|
||||
toolCount: 3,
|
||||
allowedCount: 1,
|
||||
askFirstCount: 1,
|
||||
offCount: 1,
|
||||
lastChangedAt: null,
|
||||
lastChangedByAgentId: null,
|
||||
lastChangedByName: null,
|
||||
tools: [
|
||||
{ toolName: "read_sheet", gatewayToolName: "gs__read_sheet", displayName: "Read a sheet", risk: "read", decision: "allowed", reasonCode: null, matchedPolicyIds: [] },
|
||||
{ toolName: "append_row", gatewayToolName: "gs__append_row", displayName: "Append a row", risk: "write", decision: "ask_first", reasonCode: null, matchedPolicyIds: [] },
|
||||
{ toolName: "delete_row", gatewayToolName: "gs__delete_row", displayName: "Delete a row", risk: "destructive", decision: "off", reasonCode: null, matchedPolicyIds: [] },
|
||||
],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const readEntry = catalogEntry();
|
||||
const writeAskEntry = catalogEntry({
|
||||
id: "catalog-append",
|
||||
toolName: "append_row",
|
||||
title: "Append a row",
|
||||
description: "Add a new row to the bottom of a sheet.",
|
||||
isReadOnly: false,
|
||||
isWrite: true,
|
||||
riskLevel: "write",
|
||||
});
|
||||
const offEntry = catalogEntry({
|
||||
id: "catalog-delete",
|
||||
toolName: "delete_row",
|
||||
title: "Delete a row",
|
||||
description: "Remove a row from a sheet permanently.",
|
||||
isReadOnly: false,
|
||||
isWrite: true,
|
||||
isDestructive: true,
|
||||
riskLevel: "destructive",
|
||||
});
|
||||
|
||||
let container: HTMLDivElement;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let root: any;
|
||||
let client: QueryClient;
|
||||
|
||||
function renderPanel(
|
||||
active: ToolCatalogEntry[] = [readEntry, writeAskEntry, offEntry],
|
||||
quarantined: ToolCatalogEntry[] = [],
|
||||
) {
|
||||
root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<TestPanel connectionId="conn-1" appName="Google Sheets" active={active} quarantined={quarantined} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
listTestAgentsMock.mockReset();
|
||||
getTestAgentAccessMock.mockReset();
|
||||
runTestCallMock.mockReset();
|
||||
getTestCallStatusMock.mockReset();
|
||||
declineActionRequestMock.mockReset();
|
||||
listTestAgentsMock.mockResolvedValue({ agents: [agent()] });
|
||||
getTestAgentAccessMock.mockResolvedValue({ access: agent().effectiveAccess });
|
||||
// Default ask-first polls report the request still waiting on approval.
|
||||
getTestCallStatusMock.mockResolvedValue({
|
||||
actionRequestId: "req-1",
|
||||
invocationId: "inv-2",
|
||||
phase: "waiting",
|
||||
parameters: { spreadsheetId: "sheet-123" },
|
||||
requestedAt: "2026-06-18T00:00:00.000Z",
|
||||
resolvedAt: null,
|
||||
durationMs: null,
|
||||
});
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
describe("TestPanel", () => {
|
||||
it("shows a lightweight agent-loading state before requesting permissions", async () => {
|
||||
listTestAgentsMock.mockImplementation(() => new Promise(() => undefined));
|
||||
|
||||
await act(async () => renderPanel());
|
||||
|
||||
expect(container.textContent).toContain("Loading agents…");
|
||||
expect(container.querySelector(".animate-spin")).toBeTruthy();
|
||||
expect(container.querySelectorAll('[data-slot="skeleton"]')).not.toHaveLength(0);
|
||||
expect(getTestAgentAccessMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads permissions only for the selected agent and reuses the cached summary", async () => {
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
|
||||
expect(getTestAgentAccessMock).toHaveBeenCalledTimes(1);
|
||||
expect(getTestAgentAccessMock).toHaveBeenCalledWith("conn-1", "agent-claude");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
root = createRoot(container);
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
|
||||
expect(getTestAgentAccessMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders the test hierarchy and grouped actions with access badges", async () => {
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Test an action");
|
||||
expect(container.textContent).toContain("Agent");
|
||||
expect(container.textContent).toContain("ClaudeCoder");
|
||||
expect(container.textContent).toContain("Allowed for 1 action · Ask first for 1 action · Off for 1 action");
|
||||
expect(container.textContent).toContain("Read (1)");
|
||||
expect(container.textContent).toContain("Write (2)");
|
||||
// Each action shows its decision badge.
|
||||
expect(container.textContent).toContain("Allowed");
|
||||
expect(container.textContent).toContain("Ask first");
|
||||
expect(container.textContent).toContain("Off");
|
||||
expect(container.querySelector(".bg-card")).toBeNull();
|
||||
});
|
||||
|
||||
it("defaults to the highest-ranked accessible agent", async () => {
|
||||
listTestAgentsMock.mockResolvedValue({
|
||||
agents: [
|
||||
agent({ id: "agent-report", name: "A report", orgDepth: 2 }),
|
||||
agent({ id: "agent-root", name: "Root agent", orgDepth: 0 }),
|
||||
agent({ id: "agent-manager", name: "Manager", orgDepth: 1 }),
|
||||
],
|
||||
});
|
||||
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Root agent");
|
||||
expect(container.textContent).not.toContain("A report");
|
||||
});
|
||||
|
||||
it("shows the empty state when there are no actions", async () => {
|
||||
await act(async () => renderPanel([]));
|
||||
await flushReact();
|
||||
expect(container.textContent).toContain("Nothing to test yet");
|
||||
expect(container.textContent).toContain("Go to Permissions");
|
||||
});
|
||||
|
||||
it("renders an allowed result panel with a row-count headline after a successful run", async () => {
|
||||
runTestCallMock.mockResolvedValue({
|
||||
decision: "allowed",
|
||||
invocationId: "inv-1",
|
||||
result: [
|
||||
{ name: "Acme", stage: "Demo" },
|
||||
{ name: "Globex", stage: "Trial" },
|
||||
],
|
||||
});
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
|
||||
// Expand the allowed (read) action.
|
||||
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Read a sheet"));
|
||||
expect(trigger).toBeTruthy();
|
||||
await act(async () => trigger!.click());
|
||||
await flushReact();
|
||||
|
||||
// Fill the required field then Run.
|
||||
await fillFormField("sheet-123");
|
||||
await clickByText("Run");
|
||||
await settle();
|
||||
|
||||
expect(runTestCallMock).toHaveBeenCalledWith("conn-1", {
|
||||
agentId: "agent-claude",
|
||||
toolName: "read_sheet",
|
||||
parameters: { spreadsheetId: "sheet-123" },
|
||||
});
|
||||
expect(container.textContent).toContain("Worked. 2 rows came back.");
|
||||
expect(container.textContent).toContain("Ran as ClaudeCoder");
|
||||
expect(container.textContent).toContain("Preview");
|
||||
});
|
||||
|
||||
it.each(["grant_credential_invalid", "oauth_insufficient_scope"])("guides %s failures to reconnect instead of changing action inputs", async (reasonCode) => {
|
||||
runTestCallMock.mockResolvedValue({
|
||||
decision: "allowed",
|
||||
invocationId: "inv-credential",
|
||||
error: { message: "The connection must be reconnected.", reasonCode },
|
||||
});
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Read a sheet"));
|
||||
await act(async () => trigger!.click());
|
||||
await flushReact();
|
||||
await fillFormField("sheet-123");
|
||||
await clickByText("Run");
|
||||
await settle();
|
||||
|
||||
expect(container.textContent).toContain("After reconnecting, run this action again.");
|
||||
expect(container.textContent).not.toContain("Adjust the input above");
|
||||
expect(container.textContent).not.toContain("Check the field formats");
|
||||
expect(container.textContent).toContain(reasonCode === "grant_credential_invalid"
|
||||
? "Ask the connection owner to reconnect"
|
||||
: "allow the permissions required for this action");
|
||||
});
|
||||
|
||||
it("renders a failure card (not 'Worked') when an allowed call returns isError:true", async () => {
|
||||
// The gateway let the call through (decision:"allowed") but the upstream MCP
|
||||
// tool failed at the tool layer — the envelope carries isError + an error.
|
||||
runTestCallMock.mockResolvedValue({
|
||||
decision: "allowed",
|
||||
invocationId: "inv-err",
|
||||
result: {
|
||||
content: "Invalid arguments: range is required",
|
||||
data: { content: [{ type: "text", text: "Invalid arguments: range is required" }], isError: true },
|
||||
error: "MCP tool returned an error result",
|
||||
},
|
||||
});
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
|
||||
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Read a sheet"));
|
||||
await act(async () => trigger!.click());
|
||||
await flushReact();
|
||||
await fillFormField("sheet-123");
|
||||
await clickByText("Run");
|
||||
await settle();
|
||||
|
||||
expect(container.textContent).toContain("It didn't work.");
|
||||
expect(container.textContent).not.toContain("Worked.");
|
||||
// The actual MCP message wins over the generic gateway wrapper string.
|
||||
expect(container.textContent).toContain("Invalid arguments: range is required");
|
||||
});
|
||||
|
||||
it("renders the ask-first card linking to Review", async () => {
|
||||
runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" });
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
|
||||
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row"));
|
||||
await act(async () => trigger!.click());
|
||||
await flushReact();
|
||||
|
||||
await fillFormField("sheet-123");
|
||||
await clickByText("Run");
|
||||
await settle();
|
||||
|
||||
expect(container.textContent).toContain("Sent for your OK.");
|
||||
expect(container.textContent).toContain("Cancel this request");
|
||||
// "Where" is populated from the polled action-request snapshot.
|
||||
expect(container.textContent).toContain("spreadsheetId: sheet-123");
|
||||
expect(container.textContent).toContain("Waiting ·");
|
||||
const reviewLink = [...container.querySelectorAll("a")].find((a) => a.textContent?.includes("Open Review tab"));
|
||||
expect(reviewLink?.getAttribute("href")).toBe("/apps/conn-1/review");
|
||||
});
|
||||
|
||||
it("restores the ask-first card after leaving and returning to the Test tab", async () => {
|
||||
runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" });
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
|
||||
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row"));
|
||||
await act(async () => trigger!.click());
|
||||
await flushReact();
|
||||
await fillFormField("sheet-123");
|
||||
await clickByText("Run");
|
||||
await settle();
|
||||
expect(container.textContent).toContain("Sent for your OK.");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
root = createRoot(container);
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
|
||||
expect(runTestCallMock).toHaveBeenCalledTimes(1);
|
||||
expect(container.textContent).toContain("Sent for your OK.");
|
||||
expect(container.textContent).toContain("spreadsheetId: sheet-123");
|
||||
expect(container.textContent).toContain("Cancel this request");
|
||||
});
|
||||
|
||||
it("shows 'Approved · running' while an approved ask-first call is executing", async () => {
|
||||
runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" });
|
||||
getTestCallStatusMock.mockResolvedValue({
|
||||
actionRequestId: "req-1",
|
||||
invocationId: "inv-2",
|
||||
phase: "running",
|
||||
parameters: { spreadsheetId: "sheet-123" },
|
||||
requestedAt: "2026-06-18T00:00:00.000Z",
|
||||
resolvedAt: "2026-06-18T00:00:05.000Z",
|
||||
durationMs: null,
|
||||
});
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
|
||||
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row"));
|
||||
await act(async () => trigger!.click());
|
||||
await flushReact();
|
||||
await fillFormField("sheet-123");
|
||||
await clickByText("Run");
|
||||
await settle();
|
||||
|
||||
expect(container.textContent).toContain("Approved · running");
|
||||
// While running we don't offer cancel — the call is already on its way.
|
||||
expect(container.textContent).not.toContain("Cancel this request");
|
||||
});
|
||||
|
||||
it("mutates the ask-first card into the allowed result once approved and done", async () => {
|
||||
runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" });
|
||||
getTestCallStatusMock.mockResolvedValue({
|
||||
actionRequestId: "req-1",
|
||||
invocationId: "inv-2",
|
||||
phase: "done",
|
||||
parameters: { spreadsheetId: "sheet-123" },
|
||||
result: [{ name: "Acme", stage: "Demo" }],
|
||||
requestedAt: "2026-06-18T00:00:00.000Z",
|
||||
resolvedAt: "2026-06-18T00:00:05.000Z",
|
||||
durationMs: 1200,
|
||||
});
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
|
||||
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row"));
|
||||
await act(async () => trigger!.click());
|
||||
await flushReact();
|
||||
await fillFormField("sheet-123");
|
||||
await clickByText("Run");
|
||||
await settle();
|
||||
|
||||
// No longer the pending card — the real response is shown without re-running.
|
||||
expect(container.textContent).not.toContain("Sent for your OK.");
|
||||
expect(container.textContent).toContain("Worked.");
|
||||
expect(container.textContent).toContain("1.2s");
|
||||
});
|
||||
|
||||
it("shows the denied status when an ask-first call is declined in Review", async () => {
|
||||
runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" });
|
||||
getTestCallStatusMock.mockResolvedValue({
|
||||
actionRequestId: "req-1",
|
||||
invocationId: "inv-2",
|
||||
phase: "denied",
|
||||
parameters: { spreadsheetId: "sheet-123" },
|
||||
requestedAt: "2026-06-18T00:00:00.000Z",
|
||||
resolvedAt: "2026-06-18T00:00:05.000Z",
|
||||
durationMs: null,
|
||||
});
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
|
||||
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row"));
|
||||
await act(async () => trigger!.click());
|
||||
await flushReact();
|
||||
await fillFormField("sheet-123");
|
||||
await clickByText("Run");
|
||||
await settle();
|
||||
|
||||
expect(container.textContent).toContain("Denied — see Review for why");
|
||||
expect(container.textContent).not.toContain("Cancel this request");
|
||||
});
|
||||
|
||||
it("explains an off action and links to Permissions without calling the API", async () => {
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
|
||||
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Delete a row"));
|
||||
await act(async () => trigger!.click());
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Delete a row is off for ClaudeCoder.");
|
||||
const permLink = [...container.querySelectorAll("a")].find((a) => a.getAttribute("href") === "/apps/conn-1/permissions");
|
||||
expect(permLink).toBeTruthy();
|
||||
// No Run button is offered for an off action.
|
||||
expect([...container.querySelectorAll("button")].some((b) => b.textContent?.trim() === "Run")).toBe(false);
|
||||
expect(runTestCallMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the 'Last changed by' audit hint when the access summary carries one", async () => {
|
||||
getTestAgentAccessMock.mockResolvedValue({
|
||||
access: {
|
||||
...agent().effectiveAccess,
|
||||
lastChangedAt: new Date(Date.now() - 5 * 60_000).toISOString(),
|
||||
lastChangedByAgentId: "agent-admin",
|
||||
lastChangedByName: "Dotta",
|
||||
},
|
||||
});
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
|
||||
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Delete a row"));
|
||||
await act(async () => trigger!.click());
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Last changed by Dotta");
|
||||
});
|
||||
|
||||
it("surfaces quarantined actions in a 'New' group with the not-yet-on explanation", async () => {
|
||||
const quarantinedEntry = catalogEntry({
|
||||
id: "catalog-new",
|
||||
toolName: "rename_sheet",
|
||||
title: "Rename a sheet",
|
||||
description: "Change a sheet's name.",
|
||||
isReadOnly: false,
|
||||
isWrite: true,
|
||||
riskLevel: "write",
|
||||
status: "quarantined",
|
||||
});
|
||||
await act(async () => renderPanel([readEntry, writeAskEntry, offEntry], [quarantinedEntry]));
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("New (1)");
|
||||
|
||||
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Rename a sheet"));
|
||||
expect(trigger).toBeTruthy();
|
||||
await act(async () => trigger!.click());
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("This action is new and hasn't been turned on yet.");
|
||||
expect(runTestCallMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("errorHints", () => {
|
||||
it("maps known reason codes to tailored hints", () => {
|
||||
expect(errorHints("Requested entity was not found.", "NOT_FOUND").length).toBeGreaterThan(0);
|
||||
expect(errorHints("permission denied", "PERMISSION_DENIED")[0]).toContain("permission");
|
||||
expect(errorHints("bad input", "INVALID_ARGUMENT").length).toBeGreaterThan(0);
|
||||
expect(errorHints("slow down", "RATE_LIMIT")[0]).toContain("rate-limit");
|
||||
});
|
||||
|
||||
it("returns the locked generic fallback for an unknown error", () => {
|
||||
expect(errorHints("boom", "tool_execution_failed")).toEqual(["Check the inputs above and try again."]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ToolCatalogEntry, ToolConnectionTestCallResult } from "@paperclipai/shared";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { ActionsSection } from "@/pages/apps/app-detail/PermissionsPanel";
|
||||
|
||||
const CONNECTION = "conn-asana-storybook";
|
||||
const TOOL: ToolCatalogEntry = {
|
||||
id: "asana-list-projects",
|
||||
companyId: "company-storybook",
|
||||
applicationId: "app-asana",
|
||||
connectionId: CONNECTION,
|
||||
entryKind: "tool",
|
||||
toolName: "list_projects",
|
||||
title: "List projects",
|
||||
description: "Find projects available in this Asana workspace.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
outputSchema: null,
|
||||
annotations: null,
|
||||
riskLevel: "read",
|
||||
isReadOnly: true,
|
||||
isWrite: false,
|
||||
isDestructive: false,
|
||||
status: "active",
|
||||
addedAt: new Date("2026-09-01T00:00:00Z"),
|
||||
version: null,
|
||||
schemaHash: null,
|
||||
firstSeenAt: new Date("2026-09-01T00:00:00Z"),
|
||||
lastSeenAt: new Date("2026-10-01T00:00:00Z"),
|
||||
reviewedAt: null,
|
||||
reviewedByAgentId: null,
|
||||
reviewedByUserId: null,
|
||||
createdAt: new Date("2026-09-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-10-01T00:00:00Z"),
|
||||
} as ToolCatalogEntry;
|
||||
|
||||
const PROJECTS = [
|
||||
{ gid: "1214644669600482", name: "Q4 launch", resource_subtype: "default_project", team: "Marketing" },
|
||||
{ gid: "1214644669600483", name: "Customer onboarding", resource_subtype: "default_project", team: "Product" },
|
||||
{ gid: "1214644669600484", name: "Support operations", resource_subtype: "default_project", team: "Operations" },
|
||||
];
|
||||
|
||||
const NOTION_TOOL = {
|
||||
...TOOL,
|
||||
id: "notion-search",
|
||||
applicationId: "app-notion",
|
||||
toolName: "search",
|
||||
title: "Search Notion",
|
||||
description: "Search pages in your Notion workspace.",
|
||||
inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
|
||||
} as ToolCatalogEntry;
|
||||
|
||||
const NOTION_RESULTS = [
|
||||
{
|
||||
id: "11111111-2222-4333-8444-555555555555",
|
||||
title: "Northstar launch plan",
|
||||
url: "https://www.notion.so/11111111222243338444555555555555",
|
||||
type: "page",
|
||||
highlight: "The team is tracking launch milestones, owners, dependencies, and the decisions needed before the release window.",
|
||||
timestamp: "2026-09-10T14:30:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "66666666-7777-4888-8999-aaaaaaaaaaaa",
|
||||
title: "Launch customer research",
|
||||
url: "https://www.notion.so/66666666777748888999aaaaaaaaaaaa",
|
||||
type: "page",
|
||||
highlight: "Interviews with early customers point to a clearer onboarding flow and better examples for complex team setups.",
|
||||
timestamp: "2026-09-08T09:15:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
function mcpResult(content: Array<Record<string, unknown>>, structuredContent: unknown = null, isError = false) {
|
||||
return {
|
||||
content: content.map((block) => block.type === "text" ? block.text : JSON.stringify(block)).join("\n"),
|
||||
data: { content, structuredContent, isError, transport: "mcp_http", spawnedLocalProcess: false },
|
||||
...(isError ? { error: "MCP tool returned an error result" } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function seededClient(tool: ToolCatalogEntry) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false } },
|
||||
});
|
||||
client.setQueryData(queryKeys.tools.testAgents(CONNECTION), {
|
||||
agents: [{ id: "agent-ceo", name: "CEO", role: "ceo", title: "CEO", status: "active", orgDepth: 0 }],
|
||||
});
|
||||
client.setQueryData(queryKeys.tools.testAgentAccess(CONNECTION, "agent-ceo"), {
|
||||
access: {
|
||||
connectionId: CONNECTION,
|
||||
toolCount: 1,
|
||||
allowedCount: 1,
|
||||
askFirstCount: 0,
|
||||
offCount: 0,
|
||||
lastChangedAt: null,
|
||||
lastChangedByAgentId: null,
|
||||
lastChangedByName: null,
|
||||
tools: [{
|
||||
toolName: tool.toolName,
|
||||
gatewayToolName: `${tool.applicationId}__${tool.toolName}`,
|
||||
displayName: tool.title,
|
||||
risk: "read",
|
||||
decision: "allowed",
|
||||
reasonCode: null,
|
||||
matchedPolicyIds: [],
|
||||
}],
|
||||
},
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
function openAndRun(query?: string) {
|
||||
let step = 0;
|
||||
const tick = (attempt: number) => {
|
||||
if (step === 0) {
|
||||
const button = [...document.querySelectorAll("button")].find((item) => item.textContent?.trim() === "Test");
|
||||
if (button) { button.click(); step = 1; }
|
||||
} else {
|
||||
const dialog = document.querySelector('[role="dialog"]');
|
||||
if (query && step === 1) {
|
||||
const input = dialog?.querySelector('input[placeholder=""]') as HTMLInputElement | null;
|
||||
if (input) {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(input, query);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
step = 2;
|
||||
}
|
||||
}
|
||||
const run = [...(dialog?.querySelectorAll("button") ?? [])].find((item) => item.textContent?.trim() === "Run");
|
||||
if (run && (!query || step === 2)) { run.click(); return; }
|
||||
}
|
||||
if (attempt < 80) window.setTimeout(() => tick(attempt + 1), 50);
|
||||
};
|
||||
tick(0);
|
||||
}
|
||||
|
||||
function PermissionsTestStory({ result, tool = TOOL, appName = "Asana", query }: {
|
||||
result: ToolConnectionTestCallResult;
|
||||
tool?: ToolCatalogEntry;
|
||||
appName?: string;
|
||||
query?: string;
|
||||
}) {
|
||||
const client = useMemo(() => seededClient(tool), [tool]);
|
||||
useEffect(() => {
|
||||
const original = window.fetch.bind(window);
|
||||
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
if (url.includes(`/tool-connections/${CONNECTION}/test-calls`)) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 120));
|
||||
return Response.json(result);
|
||||
}
|
||||
return original(input, init);
|
||||
};
|
||||
const timer = window.setTimeout(() => openAndRun(query), 150);
|
||||
return () => { window.clearTimeout(timer); window.fetch = original; };
|
||||
}, [query, result]);
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<div className="mx-auto max-w-3xl p-6">
|
||||
<ActionsSection
|
||||
connectionId={CONNECTION}
|
||||
appName={appName}
|
||||
readOnly={[tool]}
|
||||
canChange={[]}
|
||||
quarantined={[]}
|
||||
enabledIds={new Set([tool.id])}
|
||||
askFirstIds={new Set()}
|
||||
disabled={false}
|
||||
refreshPending={false}
|
||||
canConfigure
|
||||
onSetPermission={() => undefined}
|
||||
onReviewQuarantined={() => undefined}
|
||||
onRefreshActions={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Apps/Action permissions test",
|
||||
parameters: { layout: "fullscreen" },
|
||||
} satisfies Meta;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const StructuredRows: Story = {
|
||||
name: "Structured rows",
|
||||
render: () => <PermissionsTestStory result={{ decision: "allowed", invocationId: "structured", result: mcpResult(
|
||||
[{ type: "text", text: JSON.stringify({ data: PROJECTS }) }],
|
||||
{ data: PROJECTS },
|
||||
) }} />,
|
||||
};
|
||||
|
||||
export const StructuredOnly: Story = {
|
||||
name: "Structured data without content blocks",
|
||||
render: () => <PermissionsTestStory result={{
|
||||
decision: "allowed",
|
||||
invocationId: "structured-only",
|
||||
result: { content: "", data: { structuredContent: { data: PROJECTS }, isError: false } },
|
||||
}} />,
|
||||
};
|
||||
|
||||
export const JsonInText: Story = {
|
||||
name: "JSON inside text",
|
||||
render: () => <PermissionsTestStory result={{ decision: "allowed", invocationId: "json-text", result: mcpResult(
|
||||
[{ type: "text", text: JSON.stringify({ data: PROJECTS }) }],
|
||||
) }} />,
|
||||
};
|
||||
|
||||
export const NotionWideSearch: Story = {
|
||||
name: "Notion search with long fields",
|
||||
render: () => <PermissionsTestStory tool={NOTION_TOOL} appName="Notion" query="launch" result={{
|
||||
decision: "allowed", invocationId: "notion-search", result: mcpResult(
|
||||
[{ type: "text", text: JSON.stringify({ results: NOTION_RESULTS }) }],
|
||||
),
|
||||
}} />,
|
||||
};
|
||||
|
||||
export const MixedBlocks: Story = {
|
||||
name: "Mixed content blocks",
|
||||
render: () => <PermissionsTestStory result={{ decision: "allowed", invocationId: "mixed", result: mcpResult(
|
||||
[
|
||||
{ type: "text", text: "Three active projects are visible in this workspace." },
|
||||
{ type: "resource_link", uri: "asana://projects/q4-launch", name: "Q4 launch" },
|
||||
{ type: "image", mimeType: "image/png", data: "cHJldmlldw==" },
|
||||
],
|
||||
{ data: PROJECTS },
|
||||
) }} />,
|
||||
};
|
||||
|
||||
export const ToolError: Story = {
|
||||
name: "Tool error with raw response",
|
||||
render: () => <PermissionsTestStory result={{ decision: "allowed", invocationId: "error", result: mcpResult(
|
||||
[{ type: "text", text: "Asana denied access to this workspace." }], null, true,
|
||||
) }} />,
|
||||
};
|
||||
|
||||
export const MalformedBlock: Story = {
|
||||
name: "Unexpected block with raw fallback",
|
||||
render: () => <PermissionsTestStory result={{ decision: "allowed", invocationId: "malformed", result: mcpResult(
|
||||
[{ type: "text", payload: "Missing the required text field" }],
|
||||
) }} />,
|
||||
};
|
||||
@@ -1,413 +0,0 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type {
|
||||
ToolCatalogEntry,
|
||||
ToolConnectionAccessSummary,
|
||||
ToolConnectionTestAgent,
|
||||
ToolConnectionTestCallResult,
|
||||
ToolConnectionTestDecision,
|
||||
} from "@paperclipai/shared";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { TestPanel } from "@/pages/apps/app-detail/TestPanel";
|
||||
|
||||
const CONNECTION = "conn-sheets";
|
||||
|
||||
type StoryAgent = ToolConnectionTestAgent & {
|
||||
effectiveAccess: ToolConnectionAccessSummary;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Catalog (12 actions: 7 read, 5 write) — mirrors the PAP-11348 wireframes.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function tool(
|
||||
id: string,
|
||||
toolName: string,
|
||||
title: string,
|
||||
description: string,
|
||||
read: boolean,
|
||||
inputSchema: Record<string, unknown> | null = null,
|
||||
): ToolCatalogEntry {
|
||||
return {
|
||||
id,
|
||||
companyId: "company-storybook",
|
||||
applicationId: "app-sheets",
|
||||
connectionId: CONNECTION,
|
||||
entryKind: "tool",
|
||||
name: toolName,
|
||||
toolName,
|
||||
title,
|
||||
description,
|
||||
inputSchema,
|
||||
outputSchema: null,
|
||||
annotations: null,
|
||||
riskLevel: read ? "read" : "write",
|
||||
isReadOnly: read,
|
||||
isWrite: !read,
|
||||
isDestructive: false,
|
||||
status: "active",
|
||||
addedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
version: null,
|
||||
versionHash: null,
|
||||
schemaHash: null,
|
||||
firstSeenAt: new Date("2026-06-01T00:00:00Z"),
|
||||
lastSeenAt: new Date("2026-06-01T00:00:00Z"),
|
||||
reviewedAt: null,
|
||||
reviewedByAgentId: null,
|
||||
reviewedByUserId: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
} as ToolCatalogEntry;
|
||||
}
|
||||
|
||||
const READ_SHEET_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
spreadsheetId: { type: "string", title: "Spreadsheet" },
|
||||
sheet: { type: "string", title: "Sheet" },
|
||||
range: { type: "string", title: "Range", description: "e.g. A1:F50" },
|
||||
},
|
||||
required: ["spreadsheetId", "sheet"],
|
||||
};
|
||||
|
||||
const APPEND_ROW_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
spreadsheetId: { type: "string", title: "Spreadsheet" },
|
||||
sheet: { type: "string", title: "Sheet" },
|
||||
values: { type: "string", title: "Values", description: "Comma-separated cell values" },
|
||||
},
|
||||
required: ["spreadsheetId", "sheet", "values"],
|
||||
};
|
||||
|
||||
const DELETE_ROW_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
spreadsheetId: { type: "string", title: "Spreadsheet" },
|
||||
rowNumber: { type: "number", title: "Row number" },
|
||||
},
|
||||
required: ["spreadsheetId", "rowNumber"],
|
||||
};
|
||||
|
||||
const CATALOG: ToolCatalogEntry[] = [
|
||||
tool("r1", "list_spreadsheets", "List spreadsheets", "See the spreadsheets in this account.", true),
|
||||
tool("r2", "read_sheet", "Read a sheet", "Get rows and cell values from a sheet.", true, READ_SHEET_SCHEMA),
|
||||
tool("r3", "search_rows", "Search rows", "Find rows that match a query.", true),
|
||||
tool("r4", "get_cell", "Get a cell", "Read a single cell value.", true),
|
||||
tool("r5", "list_sheets", "List sheets", "See the tabs in a spreadsheet.", true),
|
||||
tool("r6", "get_metadata", "Get spreadsheet info", "Read a spreadsheet's title and tabs.", true),
|
||||
tool("r7", "describe_columns", "Describe columns", "List the column headers in a sheet.", true),
|
||||
tool("w1", "append_row", "Append a row", "Add a new row to the bottom of a sheet.", false, APPEND_ROW_SCHEMA),
|
||||
tool("w2", "update_cell", "Update a cell", "Change a single cell value.", false),
|
||||
tool("w3", "create_sheet", "Add a sheet", "Create a new tab in a spreadsheet.", false),
|
||||
tool("w4", "clear_range", "Clear a range", "Empty the cells in a range.", false),
|
||||
tool("w5", "delete_row", "Delete a row", "Remove a row from a sheet permanently.", false, DELETE_ROW_SCHEMA),
|
||||
];
|
||||
|
||||
function decisionTool(entry: ToolCatalogEntry, decision: ToolConnectionTestDecision) {
|
||||
return {
|
||||
toolName: entry.toolName,
|
||||
gatewayToolName: `sheets__${entry.toolName}`,
|
||||
displayName: entry.title,
|
||||
risk: (entry.isReadOnly ? "read" : "write") as "read" | "write" | "destructive",
|
||||
decision,
|
||||
reasonCode: null,
|
||||
matchedPolicyIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Decisions: 9 allowed (7 read + 2 write), 2 ask first, 1 off.
|
||||
const DECISIONS: Record<string, ToolConnectionTestDecision> = {
|
||||
list_spreadsheets: "allowed",
|
||||
read_sheet: "allowed",
|
||||
search_rows: "allowed",
|
||||
get_cell: "allowed",
|
||||
list_sheets: "allowed",
|
||||
get_metadata: "allowed",
|
||||
describe_columns: "allowed",
|
||||
append_row: "ask_first",
|
||||
update_cell: "allowed",
|
||||
create_sheet: "allowed",
|
||||
clear_range: "ask_first",
|
||||
delete_row: "off",
|
||||
};
|
||||
|
||||
function buildAgent(
|
||||
id: string,
|
||||
name: string,
|
||||
decisions: Record<string, ToolConnectionTestDecision>,
|
||||
orgDepth = 1,
|
||||
): StoryAgent {
|
||||
const tools = CATALOG.map((entry) => decisionTool(entry, decisions[entry.toolName]));
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
role: "engineer",
|
||||
title: "Engineer",
|
||||
status: "active",
|
||||
orgDepth,
|
||||
effectiveAccess: {
|
||||
connectionId: CONNECTION,
|
||||
toolCount: tools.length,
|
||||
allowedCount: tools.filter((t) => t.decision === "allowed").length,
|
||||
askFirstCount: tools.filter((t) => t.decision === "ask_first").length,
|
||||
offCount: tools.filter((t) => t.decision === "off").length,
|
||||
lastChangedAt: null,
|
||||
lastChangedByAgentId: null,
|
||||
lastChangedByName: null,
|
||||
tools,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const AGENTS: StoryAgent[] = [
|
||||
buildAgent("agent-claude", "ClaudeCoder", DECISIONS, 0),
|
||||
buildAgent("agent-codex", "CodexCoder", {
|
||||
...DECISIONS,
|
||||
create_sheet: "ask_first",
|
||||
clear_range: "off",
|
||||
}),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOM driver — expand a row, fill its fields, press Run.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Step =
|
||||
| { kind: "expand"; title: string }
|
||||
| { kind: "fill"; values: string[] }
|
||||
| { kind: "click"; label: string };
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
function runScript(steps: Step[]) {
|
||||
let index = 0;
|
||||
const tick = (attempt: number) => {
|
||||
if (index >= steps.length) return;
|
||||
const step = steps[index];
|
||||
if (step.kind === "expand") {
|
||||
const btn = Array.from(document.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.includes(step.title),
|
||||
);
|
||||
if (btn) {
|
||||
btn.click();
|
||||
index += 1;
|
||||
window.setTimeout(() => tick(0), 60);
|
||||
return;
|
||||
}
|
||||
} else if (step.kind === "fill") {
|
||||
const inputs = Array.from(document.querySelectorAll("input")).filter(
|
||||
(i) => i.getAttribute("aria-label") !== "Find an action" && i.getAttribute("aria-label") !== "Search agents",
|
||||
);
|
||||
if (inputs.length >= step.values.length) {
|
||||
step.values.forEach((value, i) => setInputValue(inputs[i] as HTMLInputElement, value));
|
||||
index += 1;
|
||||
window.setTimeout(() => tick(0), 60);
|
||||
return;
|
||||
}
|
||||
} else if (step.kind === "click") {
|
||||
const btn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === step.label,
|
||||
);
|
||||
if (btn) {
|
||||
btn.click();
|
||||
index += 1;
|
||||
window.setTimeout(() => tick(0), 60);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (attempt < 40) window.setTimeout(() => tick(attempt + 1), 50);
|
||||
};
|
||||
tick(0);
|
||||
}
|
||||
|
||||
function seededClient(agents: StoryAgent[]): QueryClient {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false } },
|
||||
});
|
||||
client.setQueryData(queryKeys.tools.testAgents(CONNECTION), {
|
||||
agents: agents.map(({ effectiveAccess: _effectiveAccess, ...agent }) => agent),
|
||||
});
|
||||
for (const agent of agents) {
|
||||
client.setQueryData(queryKeys.tools.testAgentAccess(CONNECTION, agent.id), {
|
||||
access: agent.effectiveAccess,
|
||||
});
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
function TestHost({
|
||||
script,
|
||||
runResult,
|
||||
runDelayMs = 120,
|
||||
agents = AGENTS,
|
||||
quarantined = [],
|
||||
}: {
|
||||
script?: Step[];
|
||||
runResult?: ToolConnectionTestCallResult;
|
||||
runDelayMs?: number;
|
||||
agents?: StoryAgent[];
|
||||
quarantined?: ToolCatalogEntry[];
|
||||
}) {
|
||||
const client = useMemo(() => seededClient(agents), [agents]);
|
||||
const fetchPatched = useRef(false);
|
||||
|
||||
// Stub the test-call POST so the run states render without a backend.
|
||||
useEffect(() => {
|
||||
if (fetchPatched.current) return;
|
||||
fetchPatched.current = true;
|
||||
const original = window.fetch.bind(window);
|
||||
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
if (url.includes("/test-calls")) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, runDelayMs));
|
||||
return Response.json(runResult ?? { decision: "allowed", invocationId: "inv-storybook", result: {} });
|
||||
}
|
||||
return original(input, init);
|
||||
};
|
||||
return () => {
|
||||
window.fetch = original;
|
||||
};
|
||||
}, [runResult, runDelayMs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!script) return;
|
||||
const timer = window.setTimeout(() => runScript(script), 150);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [script]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<div className="mx-auto max-w-3xl p-6">
|
||||
<TestPanel connectionId={CONNECTION} appName="Google Sheets" active={CATALOG} quarantined={quarantined} />
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Apps/Test tab (PAP-11350)",
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
export const Default: Story = {
|
||||
name: "Default — grouped actions + access badges",
|
||||
render: () => <TestHost />,
|
||||
};
|
||||
|
||||
export const AllowedResult: Story = {
|
||||
name: "Allowed — result panel",
|
||||
render: () => (
|
||||
<TestHost
|
||||
runResult={{
|
||||
decision: "allowed",
|
||||
invocationId: "inv-allowed",
|
||||
result: [
|
||||
{ Name: "Acme Co.", Stage: "Demo", Owner: "Dotta", Updated: "Tue" },
|
||||
{ Name: "Globex", Stage: "Trial", Owner: "Dotta", Updated: "Mon" },
|
||||
{ Name: "Initech", Stage: "Closed", Owner: "CodexCoder", Updated: "Sun" },
|
||||
{ Name: "Soylent", Stage: "Discovery", Owner: "QA", Updated: "Fri" },
|
||||
{ Name: "Umbrella", Stage: "Trial", Owner: "Dotta", Updated: "Thu" },
|
||||
],
|
||||
}}
|
||||
script={[
|
||||
{ kind: "expand", title: "Read a sheet" },
|
||||
{ kind: "fill", values: ["Q3 Pipeline Tracker", "Leads"] },
|
||||
{ kind: "click", label: "Run" },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const AskFirstResult: Story = {
|
||||
name: "Ask first — sent for approval",
|
||||
render: () => (
|
||||
<TestHost
|
||||
runResult={{ decision: "ask_first", invocationId: "inv-ask", actionRequestId: "req-1" }}
|
||||
script={[
|
||||
{ kind: "expand", title: "Append a row" },
|
||||
{ kind: "fill", values: ["Q3 Pipeline Tracker", "Leads", "Wayne Industries, Demo, Dotta"] },
|
||||
{ kind: "click", label: "Run" },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const ErrorResult: Story = {
|
||||
name: "Error — what the app said + what to try",
|
||||
render: () => (
|
||||
<TestHost
|
||||
runResult={{
|
||||
decision: "allowed",
|
||||
invocationId: "inv-error",
|
||||
error: { message: "Requested entity was not found.", reasonCode: "NOT_FOUND" },
|
||||
}}
|
||||
script={[
|
||||
{ kind: "expand", title: "Read a sheet" },
|
||||
{ kind: "fill", values: ["1AbCxyz…NotFound", "Leads"] },
|
||||
{ kind: "click", label: "Run" },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const RunningState: Story = {
|
||||
name: "Running — in-flight card",
|
||||
render: () => (
|
||||
<TestHost
|
||||
runDelayMs={20000}
|
||||
script={[
|
||||
{ kind: "expand", title: "Read a sheet" },
|
||||
{ kind: "fill", values: ["Q3 Pipeline Tracker", "Leads"] },
|
||||
{ kind: "click", label: "Run" },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const OffAction: Story = {
|
||||
name: "Off — explanation + open Permissions",
|
||||
render: () => <TestHost script={[{ kind: "expand", title: "Delete a row" }]} />,
|
||||
};
|
||||
|
||||
// PAP-11404 — Off side panel polish: audit hint + quarantined variant.
|
||||
|
||||
const AGENTS_WITH_AUDIT: StoryAgent[] = AGENTS.map((agent, i) =>
|
||||
i === 0
|
||||
? {
|
||||
...agent,
|
||||
effectiveAccess: {
|
||||
...agent.effectiveAccess,
|
||||
lastChangedAt: new Date("2026-06-17T09:00:00Z").toISOString(),
|
||||
lastChangedByAgentId: "agent-admin",
|
||||
lastChangedByName: "Dotta",
|
||||
},
|
||||
}
|
||||
: agent,
|
||||
);
|
||||
|
||||
export const OffAuditHint: Story = {
|
||||
name: "Off — last-changed audit hint (PAP-11404)",
|
||||
render: () => (
|
||||
<TestHost agents={AGENTS_WITH_AUDIT} script={[{ kind: "expand", title: "Delete a row" }]} />
|
||||
),
|
||||
};
|
||||
|
||||
const QUARANTINED: ToolCatalogEntry[] = [
|
||||
{ ...tool("q1", "rename_sheet", "Rename a sheet", "Change a sheet's name.", false), status: "quarantined" } as ToolCatalogEntry,
|
||||
];
|
||||
|
||||
export const QuarantinedAction: Story = {
|
||||
name: "New — quarantined action not yet on (PAP-11404)",
|
||||
render: () => (
|
||||
<TestHost quarantined={QUARANTINED} script={[{ kind: "expand", title: "Rename a sheet" }]} />
|
||||
),
|
||||
};
|
||||
Reference in New Issue
Block a user