import { useEffect, useMemo, useRef, useState } from "react"; import { useAtomValue, useAtomSet } from "@effect/atom-react"; import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { toast } from "sonner"; import { ToolAddress, type Connection, type ConnectionName, type IntegrationSlug, } from "@executor-js/sdk/shared"; import { executeCode, toolSchemaAtom } from "../api/atoms"; import { trackEvent } from "../api/analytics"; import { Badge } from "./badge"; import { Button } from "./button"; import { Label } from "./label"; import { Textarea } from "./textarea"; import { CodeBlock } from "./code-block"; import { CardStack, CardStackHeader, CardStackContent } from "./card-stack"; import { NativeSelect, NativeSelectOption } from "./native-select"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "./tabs"; import { JsonSchemaForm, isRenderableObjectSchema, missingRequiredFields, } from "./json-schema-form"; import { cn } from "../lib/utils"; // --------------------------------------------------------------------------- // ToolRunPanel — invoke a single tool against a chosen connection (account) to // verify the credentials actually work and see the result/error. // // Backend: `POST /executions` runs codemode source. We generate a one-liner // that invokes the exact per-connection tool through the sandbox `tools` proxy: // // return await tools["..."](); // // The `` segment is the SELECTED CONNECTION's own owner — never an // ambient value — because the connection list merges both owners (the global // owner toggle is retired). The tool name segment may itself contain dots (e.g. // `aliases.deleteAlias`), so the address is built by string-joining the segments // and passed to the proxy via bracket access — never split on dots. // --------------------------------------------------------------------------- /** Build the proxy address (no `tools.` prefix) for a tool against a specific * connection, using the CONNECTION's own owner. Pure — unit-testable. */ export const toolProxyAddress = (input: { readonly integration: IntegrationSlug; readonly connection: Connection; readonly toolName: string; }): string => `${input.integration}.${input.connection.owner}.${input.connection.name}.${input.toolName}`; export const shouldRenderConnectionPicker = (connections: readonly Connection[]): boolean => connections.length > 1; type RunResult = | { readonly kind: "completed"; readonly text: string; readonly structured: unknown; readonly isError: boolean; } | { readonly kind: "paused"; readonly text: string }; /** Detect the `{ ok: false }` invocation-failure envelope — a failed-credential * signal even when the HTTP execution itself "completed" without isError. */ const isFailedEnvelope = (structured: unknown): boolean => typeof structured === "object" && structured !== null && "ok" in structured && (structured as { ok: unknown }).ok === false; /** Decode the args Textarea as JSON. Effect Schema is used at the JSON boundary * (the codebase forbids raw `JSON.parse`); a malformed string yields `None`, * which we surface as an inline validation error. */ const decodeArgsJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString); const prettyJson = (value: unknown): string => { try { return JSON.stringify(value, null, 2); } catch { return String(value); } }; export function ToolRunPanel(props: { /** Integration slug — the `` address segment. */ readonly integration: IntegrationSlug; /** Bare tool name — the `` address segment (may contain dots). */ readonly toolName: string; /** This integration's connections (accounts) across BOTH owners. Each is * addressed under its OWN owner — there is no ambient owner. */ readonly connections: readonly Connection[]; /** Pre-select this connection (the account the selected tool belongs to in the * grouped Tools tab). Falls back to the first connection when null/unmatched. */ readonly initialConnectionName?: ConnectionName | string | null; }) { const { integration, toolName, connections, initialConnectionName } = props; const [selectedConnection, setSelectedConnection] = useState( (initialConnectionName as ConnectionName | null) ?? connections[0]?.name ?? null, ); const [argsJson, setArgsJson] = useState("{}"); const [argsError, setArgsError] = useState(null); const [argsMode, setArgsMode] = useState<"form" | "json">("json"); const [running, setRunning] = useState(false); const [result, setResult] = useState(null); const doExecute = useAtomSet(executeCode, { mode: "promiseExit" }); // Reset the picker + result when the connection set or the tool changes, so a // result never lingers against a stale tool/account. Prefer the tool's own // account (`initialConnectionName`) when it is present in the list. useEffect(() => { setSelectedConnection((current) => { if ( initialConnectionName && connections.some((c: Connection) => c.name === initialConnectionName) ) { return initialConnectionName as ConnectionName; } if (current && connections.some((c: Connection) => c.name === current)) return current; return connections[0]?.name ?? null; }); }, [connections, initialConnectionName]); useEffect(() => { setResult(null); setArgsError(null); }, [toolName]); // The full `Connection` for the selected name — carries its OWN owner, which // is the `` address segment (never an ambient owner). const selectedConnectionObj = useMemo( () => connections.find((c: Connection) => c.name === selectedConnection) ?? null, [connections, selectedConnection], ); // Build the per-connection address `tools....` and the // proxy form (the same string minus the `tools.` root), using the SELECTED // CONNECTION's own owner. const addressNoPrefix = useMemo( () => selectedConnectionObj ? toolProxyAddress({ integration, connection: selectedConnectionObj, toolName }) : null, [integration, selectedConnectionObj, toolName], ); const fullAddress = addressNoPrefix ? ToolAddress.make(`tools.${addressNoPrefix}`) : null; // The tool's schema view drives both the args-shape hint (TypeScript) and the // dynamic form (raw input JSON Schema + shared $defs). const schemaResult = useAtomValue(toolSchemaAtom(fullAddress ?? ToolAddress.make(""))); const schemaView = AsyncResult.isSuccess(schemaResult) ? schemaResult.value : null; const inputSchema = schemaView?.inputSchema ?? null; const schemaDefinitions = schemaView?.schemaDefinitions; const schemaAvailable = isRenderableObjectSchema(inputSchema, schemaDefinitions); // The form is a controlled view over the single `argsJson` string. Decode it // (same Option-returning decode the run path uses); only a plain JSON object // can drive the form. const parsedArgs = useMemo | null>(() => { const decoded = decodeArgsJson(argsJson); if (Option.isNone(decoded)) return null; const v = decoded.value; return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record) : null; }, [argsJson]); // Required-arg gate: Run is only available once every `required` top-level // field has a value. With no renderable schema we can't know what's required, // so we don't block. const requiredMissing = useMemo( () => schemaAvailable ? missingRequiredFields(inputSchema, schemaDefinitions, parsedArgs ?? {}) : [], [schemaAvailable, inputSchema, schemaDefinitions, parsedArgs], ); // Default to the form the first time a renderable schema arrives with a JSON // object in the args. A ref guard makes this fire exactly once, so a later // manual tab choice is respected even as `parsedArgs` keeps changing. const autoSelectedFormRef = useRef(false); useEffect(() => { if (autoSelectedFormRef.current) return; if (schemaAvailable && parsedArgs !== null) { autoSelectedFormRef.current = true; setArgsMode("form"); } }, [schemaAvailable, parsedArgs]); // Form edits stringify straight back into the same `argsJson` state — no // second copy of state, so the JSON tab always shows the live form value. const handleFormChange = (next: Record): void => { setArgsJson(JSON.stringify(next, null, 2)); setArgsError(null); }; const handleRun = async () => { if (!addressNoPrefix) return; const decoded = decodeArgsJson(argsJson); if (Option.isNone(decoded)) { setArgsError("Invalid JSON — enter a valid JSON object of arguments."); return; } const parsed = decoded.value; setArgsError(null); setRunning(true); setResult(null); // Read-only invoke: pass the validated args through as JSON. Empty // `reactivityKeys` — invoking a tool doesn't mutate `tools.list`. // `autoApprove` because the operator clicked Run: that IS the approval, so // an approval-gated tool should run here instead of dead-ending on a pause. const code = `return await tools[${JSON.stringify(addressNoPrefix)}](${JSON.stringify(parsed)});`; const exit = await doExecute({ payload: { code, autoApprove: true }, reactivityKeys: [] }); setRunning(false); if (Exit.isFailure(exit)) { trackEvent("tool_run_submitted", { integration_slug: integration, tool_name: toolName, args_mode: argsMode, result: "failed", }); toast.error("Failed to run tool"); return; } const value = exit.value; if (value.status === "paused") { trackEvent("tool_run_submitted", { integration_slug: integration, tool_name: toolName, args_mode: argsMode, result: "paused", }); setResult({ kind: "paused", text: value.text }); return; } trackEvent("tool_run_submitted", { integration_slug: integration, tool_name: toolName, args_mode: argsMode, result: "completed", ...(value.isError ? { is_error: true } : {}), }); setResult({ kind: "completed", text: value.text, structured: value.structured, isError: value.isError, }); }; if (connections.length === 0) { return

Add a connection to test tools.

; } const renderConnectionPicker = shouldRenderConnectionPicker(connections); return (
{renderConnectionPicker && (
) => setSelectedConnection(e.target.value as ConnectionName) } disabled={running} > {connections.map((connection: Connection) => ( {connection.identityLabel || connection.name} ))}

Pick the account whose credentials you want to verify.

)} {/* Arguments editor — dynamic form built from the input JSON Schema, with a Raw JSON escape hatch. Both views share the `argsJson` string as the single source of truth. */}
setArgsMode(v === "form" ? "form" : "json")} > Form Raw JSON {schemaAvailable && parsedArgs !== null ? ( ) : (

No schema available — use Raw JSON to enter arguments.

)}