From bc62d0479c7198daf0d1504a18d531eeba57fbb3 Mon Sep 17 00:00:00 2001 From: Dylan Martin Date: Tue, 7 Jul 2026 09:20:35 -0700 Subject: [PATCH 1/4] feat(agent-builder): author, save, and dry-run custom tools Wire the backend's custom-tool authoring loop (posthog#66584) into PostHog Code so an internal user can create, save, and dry-run a custom tool end to end, behind the existing agent-platform feature flag. v0: correctness over polish. API client + types (hand-written; agent-platform types are not generated): - Add ToolCompileError(Kind), ToolCapabilities, WriteToolRequest/Result, DryRunToolRequest/Envelope/Result to @posthog/shared. - Add putRevisionTool (422 -> typed compile-failed result, inline; other non-2xx throw), deleteRevisionTool (404 -> success), and dryRunRevisionTool (reads `ok` from the body; 200 and 500 return the envelope; 429 -> throttled; 503 -> unavailable; never thrown, never retried). A parseFailedRequest helper recovers status + body from the shared fetcher's throw. Hooks: - useSaveRevisionTool (invalidates the bundle only when the compile persisted), useDeleteRevisionTool, useDryRunRevisionTool. All expose isPending. UI (agent builder): - ToolSourceEditor: draft-only editable source with a compile-on-save, inline ToolCompileError diagnostics (kind + message + 1-based line/column), double-submit guard, and a capabilities readout from the save response. - ToolDryRunPanel: args JSON editor, optional mock_secrets rows, Test button, and a result area handling success / tool failure / 429 busy / 503 unavailable. - Gate edit + dry-run behind AGENT_PLATFORM_FLAG; non-draft/non-custom stays read-only. Tests: client cases for 200/422/409 writes, delete 404-as-success, and dry-run 200-ok/200-fail/500/429/503 + mock_secrets passthrough; a hook test for the invalidate-only-when-persisted branch. Note: end-to-end dry-run needs the deployed backend (posthog#66584) with the sandbox configured; until then it returns 503, the expected "not available here" state. Generated-By: PostHog Code Task-Id: 3dc0ab45-e514-4c17-a185-9aa78871a3ba --- .../api-client/src/posthog-client.test.ts | 222 +++++++++++++++ packages/api-client/src/posthog-client.ts | 165 +++++++++++ packages/shared/src/agent-platform-types.ts | 89 ++++++ .../components/AgentConfigurationPane.tsx | 67 ++++- .../components/ToolDryRunPanel.tsx | 267 ++++++++++++++++++ .../components/ToolSourceEditor.tsx | 180 ++++++++++++ .../hooks/useDeleteRevisionTool.ts | 24 ++ .../hooks/useDryRunRevisionTool.ts | 25 ++ .../hooks/useSaveRevisionTool.test.ts | 80 ++++++ .../hooks/useSaveRevisionTool.ts | 42 +++ 10 files changed, 1158 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/features/agent-applications/components/ToolDryRunPanel.tsx create mode 100644 packages/ui/src/features/agent-applications/components/ToolSourceEditor.tsx create mode 100644 packages/ui/src/features/agent-applications/hooks/useDeleteRevisionTool.ts create mode 100644 packages/ui/src/features/agent-applications/hooks/useDryRunRevisionTool.ts create mode 100644 packages/ui/src/features/agent-applications/hooks/useSaveRevisionTool.test.ts create mode 100644 packages/ui/src/features/agent-applications/hooks/useSaveRevisionTool.ts diff --git a/packages/api-client/src/posthog-client.test.ts b/packages/api-client/src/posthog-client.test.ts index ffb2a9f962..69c607f4b4 100644 --- a/packages/api-client/src/posthog-client.test.ts +++ b/packages/api-client/src/posthog-client.test.ts @@ -1216,4 +1216,226 @@ describe("PostHogAPIClient", () => { expect(fetch).toHaveBeenCalledTimes(1); }); }); + + describe("custom tool authoring", () => { + function makeClient(fetch: ReturnType) { + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + ); + ( + client as unknown as { + api: { baseUrl: string; fetcher: { fetch: typeof fetch } }; + } + ).api = { baseUrl: "http://localhost:8000", fetcher: { fetch } }; + return client; + } + + // The shared fetcher throws `Failed request: [] ` on non-2xx. + const failWith = (status: number, body: unknown) => + new Error(`Failed request: [${status}] ${JSON.stringify(body)}`); + + describe("putRevisionTool", () => { + it("returns an ok result with capabilities on 200", async () => { + const fetch = vi.fn().mockResolvedValue({ + json: async () => ({ + ok: true, + tool_id: "t1", + capabilities: { + secret_refs: ["API_KEY"], + dynamic_secret_refs: false, + }, + }), + }); + const client = makeClient(fetch); + + await expect( + client.putRevisionTool("agent", "rev-1", "t1", { + description: "d", + args_schema: {}, + source: "export default {}", + }), + ).resolves.toEqual({ + ok: true, + tool_id: "t1", + capabilities: { + secret_refs: ["API_KEY"], + dynamic_secret_refs: false, + }, + }); + const call = fetch.mock.calls[0][0]; + expect(call.method).toBe("put"); + expect(call.path).toBe( + "/api/projects/123/agent_applications/agent/revisions/rev-1/tools/t1/", + ); + }); + + it("returns a typed compile-failed result on 422 (not a throw)", async () => { + const errors = [ + { + kind: "parse_failed", + message: "Unexpected token", + line: 3, + column: 5, + }, + ]; + const fetch = vi.fn().mockRejectedValue( + failWith(422, { + error: "tool_compile_failed", + tool_id: "t1", + errors, + }), + ); + const client = makeClient(fetch); + + await expect( + client.putRevisionTool("agent", "rev-1", "t1", { + description: "d", + args_schema: {}, + source: "bad(", + }), + ).resolves.toEqual({ + ok: false, + error: "tool_compile_failed", + tool_id: "t1", + errors, + }); + }); + + it("rethrows non-422 failures (e.g. 409 sealed revision)", async () => { + const fetch = vi + .fn() + .mockRejectedValue(failWith(409, { error: "revision_sealed" })); + const client = makeClient(fetch); + + await expect( + client.putRevisionTool("agent", "rev-1", "t1", { + description: "d", + args_schema: {}, + source: "x", + }), + ).rejects.toThrow("[409]"); + }); + }); + + describe("deleteRevisionTool", () => { + it("resolves on 200", async () => { + const fetch = vi.fn().mockResolvedValue({ json: async () => ({}) }); + const client = makeClient(fetch); + await expect( + client.deleteRevisionTool("agent", "rev-1", "t1"), + ).resolves.toBeUndefined(); + expect(fetch.mock.calls[0][0].method).toBe("delete"); + }); + + it("treats a 404 (tool_not_found) as success", async () => { + const fetch = vi + .fn() + .mockRejectedValue(failWith(404, { error: "tool_not_found" })); + const client = makeClient(fetch); + await expect( + client.deleteRevisionTool("agent", "rev-1", "gone"), + ).resolves.toBeUndefined(); + }); + + it("rethrows other failures", async () => { + const fetch = vi.fn().mockRejectedValue(failWith(500, "boom")); + const client = makeClient(fetch); + await expect( + client.deleteRevisionTool("agent", "rev-1", "t1"), + ).rejects.toThrow("[500]"); + }); + }); + + describe("dryRunRevisionTool", () => { + it("returns a completed envelope on a 200 success", async () => { + const envelope = { + ok: true, + tool_id: "t1", + result: { hello: "world" }, + duration_ms: 42, + }; + const fetch = vi.fn().mockResolvedValue({ json: async () => envelope }); + const client = makeClient(fetch); + + await expect( + client.dryRunRevisionTool("agent", "rev-1", "t1", { args: {} }), + ).resolves.toEqual({ outcome: "completed", envelope }); + }); + + it("returns a completed envelope for a 200 with ok:false (tool threw)", async () => { + const envelope = { + ok: false, + tool_id: "t1", + error: { code: "timeout", message: "wall clock exceeded" }, + duration_ms: 5000, + }; + const fetch = vi.fn().mockResolvedValue({ json: async () => envelope }); + const client = makeClient(fetch); + + await expect( + client.dryRunRevisionTool("agent", "rev-1", "t1", { args: {} }), + ).resolves.toEqual({ outcome: "completed", envelope }); + }); + + it("surfaces a 500 envelope as completed (infra failure carries error.code)", async () => { + const envelope = { + ok: false, + tool_id: "t1", + error: { code: "sandbox_acquire_failed", message: "no sandbox" }, + duration_ms: 12, + }; + const fetch = vi.fn().mockRejectedValue(failWith(500, envelope)); + const client = makeClient(fetch); + + await expect( + client.dryRunRevisionTool("agent", "rev-1", "t1", { args: {} }), + ).resolves.toEqual({ outcome: "completed", envelope }); + }); + + it("returns a throttled outcome on 429 (never throws, carries max_concurrent)", async () => { + const fetch = vi + .fn() + .mockRejectedValue( + failWith(429, { error: "dry_run_throttled", max_concurrent: 2 }), + ); + const client = makeClient(fetch); + + await expect( + client.dryRunRevisionTool("agent", "rev-1", "t1", { args: {} }), + ).resolves.toEqual({ outcome: "throttled", max_concurrent: 2 }); + }); + + it("returns an unavailable outcome on 503", async () => { + const fetch = vi + .fn() + .mockRejectedValue(failWith(503, "not configured")); + const client = makeClient(fetch); + + await expect( + client.dryRunRevisionTool("agent", "rev-1", "t1", { args: {} }), + ).resolves.toEqual({ outcome: "unavailable" }); + }); + + it("passes mock_secrets through in the request body", async () => { + const fetch = vi.fn().mockResolvedValue({ + json: async () => ({ ok: true, tool_id: "t1", duration_ms: 1 }), + }); + const client = makeClient(fetch); + + await client.dryRunRevisionTool("agent", "rev-1", "t1", { + args: { q: 1 }, + mock_secrets: { API_KEY: "placeholder" }, + }); + + const body = JSON.parse(fetch.mock.calls[0][0].overrides.body); + expect(body).toEqual({ + args: { q: 1 }, + mock_secrets: { API_KEY: "placeholder" }, + }); + }); + }); + }); }); diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 8d072cbc21..860277d0e3 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -38,7 +38,14 @@ import type { AgentUsersListResponse, BundleFile, DecideApprovalRequest, + DryRunToolEnvelope, + DryRunToolRequest, + DryRunToolResult, ModelCatalog, + ToolCapabilities, + ToolCompileError, + WriteToolRequest, + WriteToolResult, } from "@posthog/shared/agent-platform-types"; import type { ActionabilityJudgmentArtefact, @@ -595,6 +602,29 @@ function extractRequestErrorMessage(error: unknown, fallback: string): string { return `${fallback} (HTTP ${match[1]})`; } +/** + * Parse the shared fetcher's `Failed request: [] ` throw back + * into its status + parsed JSON body, so status-specific responses (422, 429, + * 500, 503) can be handled as data instead of a generic error. Returns null when + * the error isn't that shape (e.g. a network failure). + */ +function parseFailedRequest( + error: unknown, +): { status: number; body: unknown } | null { + const raw = error instanceof Error ? error.message : String(error); + const match = raw.match(/^Failed request: \[(\d+)\] (.*)$/s); + if (!match) { + return null; + } + let body: unknown; + try { + body = JSON.parse(match[2]); + } catch { + body = match[2]; + } + return { status: Number(match[1]), body }; +} + type AnyArtefact = | SignalReportArtefact | PriorityJudgmentArtefact @@ -5050,6 +5080,141 @@ export class PostHogAPIClient { return out; } + /** + * Author/compile one custom tool on a draft revision (PUT). Draft-only — + * ready/live/archived bundles are sealed and the server returns a conflict. + * A compile failure (HTTP 422) is returned as a typed `{ ok: false }` result + * carrying `errors`, so the caller renders diagnostics inline against the + * source rather than surfacing a generic failure; other non-2xx (400 + * invalid_request, 409 sealed revision, …) still throw. + */ + async putRevisionTool( + idOrSlug: string, + revisionId: string, + toolId: string, + body: WriteToolRequest, + ): Promise { + const teamId = await this.getTeamId(); + const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/revisions/${encodeURIComponent(revisionId)}/tools/${encodeURIComponent(toolId)}/`; + const url = new URL(`${this.api.baseUrl}${path}`); + try { + const response = await this.api.fetcher.fetch({ + method: "put", + url, + path, + overrides: { body: JSON.stringify(body) }, + }); + const data = (await response.json()) as { + tool_id: string; + capabilities: ToolCapabilities; + }; + return { + ok: true, + tool_id: data.tool_id, + capabilities: data.capabilities, + }; + } catch (error) { + const failure = parseFailedRequest(error); + if ( + failure?.status === 422 && + isObjectRecord(failure.body) && + failure.body.error === "tool_compile_failed" + ) { + return { + ok: false, + error: "tool_compile_failed", + tool_id: optionalString(failure.body.tool_id) ?? toolId, + errors: Array.isArray(failure.body.errors) + ? (failure.body.errors as ToolCompileError[]) + : [], + }; + } + throw error; + } + } + + /** + * Remove one custom tool from a draft revision (draft-only). A 404 + * (tool_not_found) is treated as success — the tool is already gone, which is + * the desired end state. + */ + async deleteRevisionTool( + idOrSlug: string, + revisionId: string, + toolId: string, + ): Promise { + const teamId = await this.getTeamId(); + const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/revisions/${encodeURIComponent(revisionId)}/tools/${encodeURIComponent(toolId)}/`; + const url = new URL(`${this.api.baseUrl}${path}`); + try { + await this.api.fetcher.fetch({ method: "delete", url, path }); + } catch (error) { + const failure = parseFailedRequest(error); + if (failure?.status === 404) { + return; + } + throw error; + } + } + + /** + * Execute a persisted tool once in a sandbox (POST …/dry_run). The envelope's + * `ok` is authoritative: a tool-side failure is HTTP 200 with `ok: false`, so + * both 2xx and 500 return `{ outcome: "completed", envelope }` and the caller + * reads `error.code`/`message` from the body. Throttling (429) and an + * unconfigured backend (503) are returned as distinct outcomes — never thrown, + * never retried, since dry-run is interactive and process-capped. + */ + async dryRunRevisionTool( + idOrSlug: string, + revisionId: string, + toolId: string, + body: DryRunToolRequest, + ): Promise { + const teamId = await this.getTeamId(); + const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/revisions/${encodeURIComponent(revisionId)}/tools/${encodeURIComponent(toolId)}/dry_run/`; + const url = new URL(`${this.api.baseUrl}${path}`); + try { + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path, + overrides: { body: JSON.stringify(body) }, + }); + return { + outcome: "completed", + envelope: (await response.json()) as DryRunToolEnvelope, + }; + } catch (error) { + const failure = parseFailedRequest(error); + // A 500 still carries the envelope (ok:false + error.code/duration_ms) — + // surface it as completed so infra failures read like any tool failure. + if ( + failure?.status === 500 && + isObjectRecord(failure.body) && + "ok" in failure.body + ) { + return { + outcome: "completed", + envelope: failure.body as unknown as DryRunToolEnvelope, + }; + } + if (failure?.status === 429) { + const max = isObjectRecord(failure.body) + ? failure.body.max_concurrent + : undefined; + return { + outcome: "throttled", + max_concurrent: typeof max === "number" ? max : 0, + }; + } + if (failure?.status === 503) { + return { outcome: "unavailable" }; + } + throw error; + } + } + /** * The Slack app manifest derived from a revision's slack trigger + tools, * plus the live Event/Interactivity request URLs and setup notes. diff --git a/packages/shared/src/agent-platform-types.ts b/packages/shared/src/agent-platform-types.ts index efc8b51d7a..e3cf28e814 100644 --- a/packages/shared/src/agent-platform-types.ts +++ b/packages/shared/src/agent-platform-types.ts @@ -201,6 +201,95 @@ export interface BundleFile { language: BundleFileLanguage; } +// Custom-tool authoring on a draft revision (`agent_platform`): compile-on-save +// (PUT …/tools/{id}), delete, and dry-run (POST …/tools/{id}/dry_run). Draft-only +// for writes — ready/live/archived bundles are sealed. + +/** Discriminator for a custom-tool compile failure. Mirrors the backend AST/ + * transform checks; a failed compile returns one or more of these. */ +export type ToolCompileErrorKind = + | "parse_failed" + | "ast_no_default_export" + | "ast_default_not_object" + | "ast_missing_actions" + | "ast_actions_not_object" + | "ast_missing_default_action" + | "ast_default_action_not_callable" + | "ast_dynamic_export" + | "transform_failed"; + +/** One diagnostic from a failed tool compile. `line`/`column` are 1-based. */ +export interface ToolCompileError { + kind: ToolCompileErrorKind; + message: string; + line?: number; + column?: number; +} + +/** Static capabilities the compiler extracted from a tool's source. */ +export interface ToolCapabilities { + /** Secret names the tool references via `ctx.secrets.ref("NAME")`. */ + secret_refs: string[]; + /** True when the tool derives secret names dynamically (can't be enumerated). */ + dynamic_secret_refs: boolean; +} + +/** Body for PUT …/tools/{toolId} — author/compile a tool. */ +export interface WriteToolRequest { + description: string; + args_schema: Record; + source: string; +} + +/** + * Outcome of a tool write. A compile failure (HTTP 422) is a first-class result, + * NOT a thrown error, so the caller renders `errors` inline against the source. + * Other non-2xx (400 invalid_request, 409 sealed revision, …) still throw. + */ +export type WriteToolResult = + | { ok: true; tool_id: string; capabilities: ToolCapabilities } + | { + ok: false; + error: "tool_compile_failed"; + tool_id: string; + errors: ToolCompileError[]; + }; + +/** Body for POST …/tools/{toolId}/dry_run. */ +export interface DryRunToolRequest { + /** Free-form JSON passed to the tool's `actions.default`; NOT validated + * against `args_schema` (the author's responsibility). */ + args: unknown; + /** `secretName -> placeholder` returned by `ctx.secrets.ref(name)` in the + * sandbox; real secret values never leave the backend. */ + mock_secrets?: Record; +} + +/** + * The dry-run response envelope (returned on HTTP 200 AND 500). A tool-side + * failure is an HTTP 200 with `ok: false` — read `ok` from HERE, not the status. + */ +export interface DryRunToolEnvelope { + ok: boolean; + tool_id: string; + result?: unknown; + /** `error.code` is one of sandbox_acquire_failed | sandbox_invoke_failed | + * timeout | secret_not_provisioned | action_not_found | tool_not_found | + * dry_run_unknown. */ + error?: { code: string; message: string }; + duration_ms: number; +} + +/** + * A dry-run outcome. `throttled` (HTTP 429) and `unavailable` (HTTP 503) are + * distinct interactive states — surfaced as data, never thrown, and never + * retried (dry-run is process-capped). + */ +export type DryRunToolResult = + | { outcome: "completed"; envelope: DryRunToolEnvelope } + | { outcome: "throttled"; max_concurrent: number } + | { outcome: "unavailable" }; + // `…/revisions/{id}/slack_manifest/` derives the Slack app manifest from the // revision's slack trigger + tools (scopes + event subscriptions computed). diff --git a/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx b/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx index c6f16b17ac..626ed327ee 100644 --- a/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx +++ b/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx @@ -30,6 +30,7 @@ import type { BundleFile, } from "@posthog/shared/agent-platform-types"; import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { AddCustomServerDialog } from "@posthog/ui/features/mcp-server-manager/AddCustomServerDialog"; import { useMcpConnect } from "@posthog/ui/features/mcp-server-manager/useMcpConnect"; import { ToolPermissionList } from "@posthog/ui/features/mcp-servers/components/parts/ToolPermissionList"; @@ -40,6 +41,7 @@ import { CodeBlock } from "@posthog/ui/primitives/CodeBlock"; import { toast } from "@posthog/ui/primitives/toast"; import { Flex, Select, Switch, Text } from "@radix-ui/themes"; import { type ReactNode, useCallback, useMemo, useState } from "react"; +import { AGENT_PLATFORM_FLAG } from "../featureFlag"; import { useAgentApplication } from "../hooks/useAgentApplication"; import { useAgentEnvKeys } from "../hooks/useAgentEnvKeys"; import { useAgentRevision } from "../hooks/useAgentRevision"; @@ -55,6 +57,8 @@ import { CronFireButton } from "./CronFireButton"; import { FileExplorer, type FileTreeNode } from "./FileExplorer"; import { SecretEditor } from "./SecretEditor"; import { SlackSetupCard } from "./SlackSetupCard"; +import { ToolDryRunPanel } from "./ToolDryRunPanel"; +import { ToolSourceEditor } from "./ToolSourceEditor"; // Value readers — spec items are loosely typed on the wire. function rec(v: unknown): Record { @@ -1221,6 +1225,33 @@ function ToolsOverview({ spec, ctx }: { spec: AgentSpec; ctx: Ctx }) { ); } +/** Read `{ description, args_schema }` from a tool's schema.json bundle file, + * falling back to the spec description. v0 tool edits change source only, so we + * round-trip these unchanged on save. */ +function parseToolSchema( + content: string | undefined, + fallbackDescription: string | undefined, +): { description: string; args_schema: Record } { + if (content) { + try { + const parsed = JSON.parse(content) as { + description?: unknown; + args_schema?: unknown; + }; + return { + description: str(parsed.description) ?? fallbackDescription ?? "", + args_schema: + parsed.args_schema && typeof parsed.args_schema === "object" + ? (parsed.args_schema as Record) + : {}, + }; + } catch { + // Malformed schema.json — fall back below rather than block editing. + } + } + return { description: fallbackDescription ?? "", args_schema: {} }; +} + function ToolBody({ tool, files, @@ -1238,6 +1269,18 @@ function ToolBody({ const kind = str(r.kind); const identity = toolRequiresIdentity(tool); const source = byPath(files, `tools/${id}/source.ts`); + const schemaFile = byPath(files, `tools/${id}/schema.json`); + const specDescription = str(r.description); + // Authoring (edit/save/dry-run) lives behind the same flag as the rest of the + // surface; custom tools are the only ones with editable source. + const authoringEnabled = useFeatureFlag(AGENT_PLATFORM_FLAG); + const isCustom = kind === "custom" || !!source; + const isDraft = ctx.revisionState === "draft"; + const canAuthor = authoringEnabled && isCustom && isDraft; + const schema = useMemo( + () => parseToolSchema(schemaFile?.content, specDescription), + [schemaFile?.content, specDescription], + ); return ( @@ -1250,9 +1293,9 @@ function ToolBody({ : "not gated" } /> - {str(r.description) ? ( + {specDescription ? ( - {str(r.description)} + {specDescription} ) : null} {identity ? ( @@ -1261,7 +1304,25 @@ function ToolBody({ {source ? (
source · {source.path} - + {canAuthor ? ( + + ) : ( + + )} + {authoringEnabled && isCustom ? ( + + ) : null}
) : null}
diff --git a/packages/ui/src/features/agent-applications/components/ToolDryRunPanel.tsx b/packages/ui/src/features/agent-applications/components/ToolDryRunPanel.tsx new file mode 100644 index 0000000000..95c862d534 --- /dev/null +++ b/packages/ui/src/features/agent-applications/components/ToolDryRunPanel.tsx @@ -0,0 +1,267 @@ +import { + CheckCircleIcon, + PlayIcon, + PlusIcon, + TrashIcon, + WarningCircleIcon, +} from "@phosphor-icons/react"; +import type { DryRunToolResult } from "@posthog/shared/agent-platform-types"; +import { Button } from "@posthog/ui/primitives/Button"; +import { CodeBlock } from "@posthog/ui/primitives/CodeBlock"; +import { Flex, IconButton, Text, TextArea, TextField } from "@radix-ui/themes"; +import { type ReactNode, useState } from "react"; +import { useDryRunRevisionTool } from "../hooks/useDryRunRevisionTool"; + +interface SecretRow { + name: string; + value: string; +} + +/** + * Minimal dry-run surface for a persisted tool: an args JSON editor, an optional + * mock-secrets key/value editor, and a Test button that runs the tool once in a + * sandbox. The dry-run envelope's `ok` is authoritative (a throwing tool is HTTP + * 200 with ok:false), and throttled (429) / unavailable (503) are handled as + * explicit states — no retries, since dry-run is process-capped. + */ +export function ToolDryRunPanel({ + idOrSlug, + revisionId, + toolId, +}: { + idOrSlug: string; + revisionId: string; + toolId: string; +}) { + const dryRun = useDryRunRevisionTool(idOrSlug, revisionId); + const [argsText, setArgsText] = useState("{}"); + const [argsError, setArgsError] = useState(null); + const [secrets, setSecrets] = useState([]); + const [result, setResult] = useState(null); + + function run() { + let args: unknown; + try { + args = argsText.trim() ? JSON.parse(argsText) : {}; + } catch { + setArgsError("Args must be valid JSON."); + return; + } + setArgsError(null); + setResult(null); + + const mockSecrets: Record = {}; + for (const s of secrets) { + const name = s.name.trim(); + if (name) mockSecrets[name] = s.value; + } + const hasSecrets = Object.keys(mockSecrets).length > 0; + + dryRun.mutate( + { + toolId, + body: { args, ...(hasSecrets ? { mock_secrets: mockSecrets } : {}) }, + }, + { onSuccess: (res) => setResult(res) }, + ); + } + + return ( +
+ + Dry run + + + Runs the saved tool once in a sandbox. Args are passed straight to{" "} + actions.default{" "} + — they aren't validated against the schema. + + + Args (JSON) +