diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 79da123e3f..2d6b686807 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -4373,6 +4373,23 @@ export class PostHogAPIClient { return all; } + /** Patch mutable application-level fields (name, description). */ + async updateAgentApplication( + idOrSlug: string, + patch: { name?: string; description?: string }, + ): Promise { + const teamId = await this.getTeamId(); + const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/`; + const url = new URL(`${this.api.baseUrl}${path}`); + const response = await this.api.fetcher.fetch({ + method: "patch", + url, + path, + overrides: { body: JSON.stringify(patch) }, + }); + return (await response.json()) as AgentApplication; + } + /** Fetches a single agent application by UUID or slug; null if not found. */ async getAgentApplication( idOrSlug: string, diff --git a/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderHeaderControls.tsx b/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderHeaderControls.tsx index edf3c9d244..b744e2b821 100644 --- a/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderHeaderControls.tsx +++ b/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderHeaderControls.tsx @@ -8,6 +8,7 @@ import { } from "@posthog/quill"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { Flex } from "@radix-ui/themes"; +import { PublishButton } from "../components/PublishButton"; import { AGENT_PLATFORM_FLAG } from "../featureFlag"; import { headerActionForPage } from "./agentBuilderActions"; import { useAgentBuilderStore } from "./agentBuilderStore"; @@ -40,6 +41,7 @@ export function AgentBuilderHeaderControls() { const action = headerActionForPage(page); const openTip = "Open the agent builder (⌘⇧I)"; + const showPublish = page.kind === "agent"; return ( @@ -48,6 +50,7 @@ export function AgentBuilderHeaderControls() { gap="2" className="absolute top-0 right-0 z-10 shrink-0 px-6 py-2" > + {showPublish ? : null} {action ? (
diff --git a/packages/ui/src/features/agent-applications/agent-builder/agentBuilderActions.ts b/packages/ui/src/features/agent-applications/agent-builder/agentBuilderActions.ts index 3643e8bfab..4bed4ebb99 100644 --- a/packages/ui/src/features/agent-applications/agent-builder/agentBuilderActions.ts +++ b/packages/ui/src/features/agent-applications/agent-builder/agentBuilderActions.ts @@ -26,12 +26,6 @@ export function headerActionForPage( "Help me create a new agent — walk me through what it should do, then set it up.", agentSlug: null, }; - case "agent": - return { - label: "Explain this agent", - prompt: "Explain what this agent does and how it's configured.", - agentSlug: page.slug, - }; case "agent-config": return { label: "Edit configuration", diff --git a/packages/ui/src/features/agent-applications/agent-builder/agentBuilderSuggestions.ts b/packages/ui/src/features/agent-applications/agent-builder/agentBuilderSuggestions.ts index 3b201b3235..fd0ebbc573 100644 --- a/packages/ui/src/features/agent-applications/agent-builder/agentBuilderSuggestions.ts +++ b/packages/ui/src/features/agent-applications/agent-builder/agentBuilderSuggestions.ts @@ -37,10 +37,6 @@ export function suggestionsForPage( ]; case "agent": return [ - { - label: "What does this agent do?", - prompt: "Explain what this agent does and how it's configured.", - }, { label: "Is this agent healthy?", prompt: diff --git a/packages/ui/src/features/agent-applications/agent-builder/useAgentBuilderClientTools.test.tsx b/packages/ui/src/features/agent-applications/agent-builder/useAgentBuilderClientTools.test.tsx new file mode 100644 index 0000000000..f7efa4359c --- /dev/null +++ b/packages/ui/src/features/agent-applications/agent-builder/useAgentBuilderClientTools.test.tsx @@ -0,0 +1,122 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockUpdate = vi.hoisted(() => vi.fn()); +const mockClient = vi.hoisted(() => ({ updateAgentApplication: mockUpdate })); +const mockNavigate = vi.hoisted(() => vi.fn()); +const mockSetPendingSecret = vi.hoisted(() => vi.fn()); + +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => mockNavigate, +})); +vi.mock("@posthog/ui/features/auth/authClient", () => ({ + useAuthenticatedClient: () => mockClient, +})); +vi.mock("../../auth/store", () => ({ + useAuthStateValue: (selector: (s: { currentProjectId: number }) => unknown) => + selector({ currentProjectId: 1 }), +})); +vi.mock("./agentBuilderStore", () => ({ + useAgentBuilderStore: ( + selector: (s: { + followMode: boolean; + setPendingSecret: (...args: unknown[]) => unknown; + page: { kind: string }; + }) => unknown, + ) => + selector({ + followMode: true, + setPendingSecret: mockSetPendingSecret, + page: { kind: "agent-list" }, + }), +})); + +import { useAgentBuilderClientTools } from "./useAgentBuilderClientTools"; + +function wrapper({ children }: { children: ReactNode }) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ( + {children} + ); +} + +function call(toolId: string, args: Record) { + return { call_id: "c1", tool_id: toolId, args }; +} + +describe("useAgentBuilderClientTools — set_application_description", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("calls updateAgentApplication and returns success on the happy path", async () => { + mockUpdate.mockResolvedValue({}); + const { result } = renderHook(() => useAgentBuilderClientTools(), { + wrapper, + }); + const outcome = await result.current( + call("set_application_description", { + agent_slug: "support", + description: " Handles tier-1 support tickets. ", + }), + ); + expect(mockUpdate).toHaveBeenCalledWith("support", { + description: "Handles tier-1 support tickets.", + }); + expect(outcome).toEqual({ result: { success: true } }); + }); + + it("errors when agent_slug is missing", async () => { + const { result } = renderHook(() => useAgentBuilderClientTools(), { + wrapper, + }); + const outcome = await result.current( + call("set_application_description", { description: "ok" }), + ); + expect(mockUpdate).not.toHaveBeenCalled(); + expect(outcome).toEqual({ error: "missing_arg: agent_slug" }); + }); + + it("errors when description is missing", async () => { + const { result } = renderHook(() => useAgentBuilderClientTools(), { + wrapper, + }); + const outcome = await result.current( + call("set_application_description", { agent_slug: "support" }), + ); + expect(mockUpdate).not.toHaveBeenCalled(); + expect(outcome).toEqual({ error: "missing_arg: description" }); + }); + + it("rejects when the trimmed description exceeds the cap", async () => { + const { result } = renderHook(() => useAgentBuilderClientTools(), { + wrapper, + }); + const outcome = await result.current( + call("set_application_description", { + agent_slug: "support", + description: "x".repeat(281), + }), + ); + expect(mockUpdate).not.toHaveBeenCalled(); + expect(outcome).toEqual({ error: "description_too_long: max 280 chars" }); + }); + + it("reports update_failed when the client throws", async () => { + mockUpdate.mockRejectedValue(new Error("boom")); + const { result } = renderHook(() => useAgentBuilderClientTools(), { + wrapper, + }); + const outcome = await result.current( + call("set_application_description", { + agent_slug: "support", + description: "ok", + }), + ); + expect(outcome).toEqual({ error: "update_failed: boom" }); + }); +}); diff --git a/packages/ui/src/features/agent-applications/agent-builder/useAgentBuilderClientTools.ts b/packages/ui/src/features/agent-applications/agent-builder/useAgentBuilderClientTools.ts index aa90a64ddd..f23d833594 100644 --- a/packages/ui/src/features/agent-applications/agent-builder/useAgentBuilderClientTools.ts +++ b/packages/ui/src/features/agent-applications/agent-builder/useAgentBuilderClientTools.ts @@ -1,8 +1,14 @@ +import { useAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { useCallback, useRef } from "react"; +import { useAuthStateValue } from "../../auth/store"; +import { agentApplicationsKeys } from "../hooks/agentApplicationsKeys"; import type { ClientToolHandler } from "../hooks/useAgentChat"; import { useAgentBuilderStore } from "./agentBuilderStore"; +const MAX_DESCRIPTION_CHARS = 280; + /** * The agent builder's UI-driving client tools. The agent calls these to steer the * user's screen (`focus_*`, which navigate code's agent routes and report back @@ -15,6 +21,9 @@ import { useAgentBuilderStore } from "./agentBuilderStore"; */ export function useAgentBuilderClientTools(): ClientToolHandler { const navigate = useNavigate(); + const client = useAuthenticatedClient(); + const queryClient = useQueryClient(); + const projectId = useAuthStateValue((state) => state.currentProjectId); const followMode = useAgentBuilderStore((s) => s.followMode); const setPendingSecret = useAgentBuilderStore((s) => s.setPendingSecret); const page = useAgentBuilderStore((s) => s.page); @@ -26,10 +35,43 @@ export function useAgentBuilderClientTools(): ClientToolHandler { pageRef.current = page; return useCallback( - (data) => { + async (data) => { const args = (data.args ?? {}) as Record; const str = (v: unknown) => (typeof v === "string" ? v : undefined); + // set_application_description — write the agent's short summary. The + // overview surfaces this directly; capping the length keeps it scannable + // and forces the agent to retry shorter on overflow. + if (data.tool_id === "set_application_description") { + const agentSlug = str(args.agent_slug); + const description = str(args.description); + if (!agentSlug) return { error: "missing_arg: agent_slug" }; + if (description === undefined) { + return { error: "missing_arg: description" }; + } + const trimmed = description.trim(); + if (trimmed.length > MAX_DESCRIPTION_CHARS) { + return { + error: `description_too_long: max ${MAX_DESCRIPTION_CHARS} chars`, + }; + } + try { + await client.updateAgentApplication(agentSlug, { + description: trimmed, + }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return { error: `update_failed: ${msg}` }; + } + void queryClient.invalidateQueries({ + queryKey: agentApplicationsKeys.detail(projectId, agentSlug), + }); + void queryClient.invalidateQueries({ + queryKey: agentApplicationsKeys.list(projectId), + }); + return { result: { success: true } }; + } + // set_secret — interactive punch-out. Park the call (defer) and render a // form; the dock PUTs the key and wakes the session on submit. Env keys // are revision-scoped, so resolve the target revision from the tool args, @@ -150,6 +192,6 @@ export function useAgentBuilderClientTools(): ClientToolHandler { return { result: { focused: false, reason: "unknown_focus_target" } }; } }, - [navigate, setPendingSecret], + [navigate, setPendingSecret, client, queryClient, projectId], ); } diff --git a/packages/ui/src/features/agent-applications/components/AgentApplicationDetailView.tsx b/packages/ui/src/features/agent-applications/components/AgentApplicationDetailView.tsx index ea96141cbc..eeac499e91 100644 --- a/packages/ui/src/features/agent-applications/components/AgentApplicationDetailView.tsx +++ b/packages/ui/src/features/agent-applications/components/AgentApplicationDetailView.tsx @@ -1,17 +1,22 @@ +import type { AgentSpec } from "@posthog/shared/agent-platform-types"; import { Flex, Text } from "@radix-ui/themes"; import { Link } from "@tanstack/react-router"; +import { useMemo } from "react"; import { useAgentAnalytics } from "../hooks/useAgentAnalytics"; import { useAgentApplication } from "../hooks/useAgentApplication"; import { useAgentApplicationSessions } from "../hooks/useAgentApplicationSessions"; +import { useAgentRevision } from "../hooks/useAgentRevision"; +import { useAgentRevisions } from "../hooks/useAgentRevisions"; import { AgentAnalyticsKpiStrip } from "./AgentAnalyticsView"; import { AgentDetailEmptyState, AgentDetailLayout } from "./AgentDetailLayout"; import { AgentSessionRow } from "./AgentSessionRow"; /** - * Per-agent Overview pane: the top-level observability KPIs (spend / sessions / - * failure rate / p95 over the last 7 days, with trends + WoW deltas — the same - * metrics as the Observability tab) plus recent sessions. Rendered inside the - * shared {@link AgentDetailLayout} tab shell. + * Per-agent Overview pane: a one-paragraph description + config summary (what + * this agent is wired up to do), then the top-level observability KPIs (spend / + * sessions / failure rate / p95 over the last 7 days, with trends + WoW deltas — + * the same metrics as the Observability tab), then recent sessions. Rendered + * inside the shared {@link AgentDetailLayout} tab shell. */ export function AgentApplicationDetailView({ idOrSlug }: { idOrSlug: string }) { const { data: application } = useAgentApplication(idOrSlug); @@ -28,6 +33,8 @@ export function AgentApplicationDetailView({ idOrSlug }: { idOrSlug: string }) { return ( + +
@@ -86,3 +93,135 @@ export function AgentApplicationDetailView({ idOrSlug }: { idOrSlug: string }) { ); } + +const KNOWN_TRIGGERS = ["cron", "slack", "webhook", "chat", "mcp"] as const; +type KnownTrigger = (typeof KNOWN_TRIGGERS)[number]; + +interface ConfigCounts { + triggersByType: Map; + tools: number; + skills: number; + mcps: number; + identities: number; +} + +function rec(v: unknown): Record { + return v && typeof v === "object" ? (v as Record) : {}; +} +function arr(v: unknown): unknown[] { + return Array.isArray(v) ? v : []; +} +function countsFor(spec: AgentSpec | null | undefined): ConfigCounts { + const triggersByType = new Map(); + if (spec) { + for (const t of arr(spec.triggers)) { + const type = + typeof rec(t).type === "string" ? (rec(t).type as string) : "other"; + triggersByType.set(type, (triggersByType.get(type) ?? 0) + 1); + } + } + return { + triggersByType, + tools: arr(spec?.tools).length, + skills: arr(spec?.skills).length, + mcps: arr(spec?.mcps).length, + identities: arr(spec?.identity_providers).length, + }; +} + +/** + * Top-of-overview summary: a one-liner about what this agent does (sourced from + * `application.description`, which the agent-builder maintains via the + * `set_application_description` client tool) and a compact map of what's wired + * up — triggers by type, tools, skills, MCPs, identities. Counts come from the + * live revision's spec, falling back to the newest revision when nothing is + * live yet. + */ +function OverviewConfigSummary({ idOrSlug }: { idOrSlug: string }) { + const { data: application } = useAgentApplication(idOrSlug); + const { data: revisions } = useAgentRevisions(idOrSlug); + const revisionId = application?.live_revision ?? revisions?.[0]?.id ?? null; + const { data: revision } = useAgentRevision(idOrSlug, revisionId); + const counts = useMemo(() => countsFor(revision?.spec), [revision?.spec]); + + const description = application?.description?.trim(); + const triggerChips = [...counts.triggersByType.entries()].sort((a, b) => { + const aKnown = KNOWN_TRIGGERS.indexOf(a[0] as KnownTrigger); + const bKnown = KNOWN_TRIGGERS.indexOf(b[0] as KnownTrigger); + if (aKnown !== bKnown) { + if (aKnown === -1) return 1; + if (bKnown === -1) return -1; + return aKnown - bKnown; + } + return a[0].localeCompare(b[0]); + }); + + return ( +
+ + About this agent + + + {description ? ( + + {description} + + ) : ( + + No description yet — the agent-builder will write one as it sets + this agent up. + + )} + + a + b, + 0, + )} + detail={ + triggerChips.length + ? triggerChips + .map(([type, n]) => (n > 1 ? `${n} ${type}` : type)) + .join(" · ") + : undefined + } + /> + + + + + + +
+ ); +} + +function SummaryStat({ + label, + value, + detail, +}: { + label: string; + value: number; + detail?: string; +}) { + return ( + + + {label} + + + {value} + + {detail ? ( + + {detail} + + ) : null} + + ); +} diff --git a/packages/ui/src/features/agent-applications/components/PublishButton.test.ts b/packages/ui/src/features/agent-applications/components/PublishButton.test.ts new file mode 100644 index 0000000000..37f47e90d1 --- /dev/null +++ b/packages/ui/src/features/agent-applications/components/PublishButton.test.ts @@ -0,0 +1,49 @@ +import type { AgentRevision } from "@posthog/shared/agent-platform-types"; +import { describe, expect, it } from "vitest"; +import { findLatestDraft } from "./PublishButton"; + +function rev(overrides: Partial): AgentRevision { + return { + id: "r1", + application: "app", + parent_revision: null, + state: "draft", + bundle_sha256: null, + created_by_id: null, + created_by: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + ...overrides, + }; +} + +describe("findLatestDraft", () => { + it("returns null when there are no revisions", () => { + expect(findLatestDraft(undefined)).toBeNull(); + expect(findLatestDraft(null)).toBeNull(); + expect(findLatestDraft([])).toBeNull(); + }); + + it("returns null when there are no draft revisions", () => { + expect( + findLatestDraft([rev({ state: "live" }), rev({ state: "archived" })]), + ).toBeNull(); + }); + + it("returns the newest draft by updated_at", () => { + const drafts = [ + rev({ id: "old", state: "draft", updated_at: "2026-01-01T00:00:00Z" }), + rev({ id: "new", state: "draft", updated_at: "2026-03-01T00:00:00Z" }), + rev({ id: "mid", state: "draft", updated_at: "2026-02-01T00:00:00Z" }), + ]; + expect(findLatestDraft(drafts)?.id).toBe("new"); + }); + + it("ignores non-draft revisions even if newer", () => { + const revisions = [ + rev({ id: "draft", state: "draft", updated_at: "2026-01-01T00:00:00Z" }), + rev({ id: "live", state: "live", updated_at: "2026-06-01T00:00:00Z" }), + ]; + expect(findLatestDraft(revisions)?.id).toBe("draft"); + }); +}); diff --git a/packages/ui/src/features/agent-applications/components/PublishButton.tsx b/packages/ui/src/features/agent-applications/components/PublishButton.tsx new file mode 100644 index 0000000000..a05f33546b --- /dev/null +++ b/packages/ui/src/features/agent-applications/components/PublishButton.tsx @@ -0,0 +1,135 @@ +import { + Button as QuillButton, + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@posthog/quill"; +import type { AgentRevision } from "@posthog/shared/agent-platform-types"; +import { Button } from "@posthog/ui/primitives/Button"; +import { AlertDialog, Flex, Text } from "@radix-ui/themes"; +import { useMemo, useState } from "react"; +import { useAgentApplication } from "../hooks/useAgentApplication"; +import { useAgentRevisionLifecycle } from "../hooks/useAgentRevisionLifecycle"; +import { useAgentRevisions } from "../hooks/useAgentRevisions"; + +/** + * Newest draft revision (by `updated_at`), or null if there is no draft. The + * publish button targets this revision; freezing then promoting it is the + * "ship the latest edits" gesture. + */ +export function findLatestDraft( + revisions: AgentRevision[] | undefined | null, +): AgentRevision | null { + if (!revisions) return null; + return ( + [...revisions] + .filter((r) => r.state === "draft") + .sort( + (a, b) => + new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(), + )[0] ?? null + ); +} + +/** + * One-click publish from the agent header: freezes the latest draft revision + * and promotes it to live in sequence, behind a single confirm. The two-step + * lifecycle still happens server-side; the user just doesn't see it. + * + * Hidden when the agent is archived. Disabled when there is no draft to ship. + */ +export function PublishButton({ idOrSlug }: { idOrSlug: string }) { + const { data: application } = useAgentApplication(idOrSlug); + const { data: revisions } = useAgentRevisions(idOrSlug); + const lifecycle = useAgentRevisionLifecycle(idOrSlug); + const [open, setOpen] = useState(false); + const [error, setError] = useState(null); + + const draft = useMemo(() => findLatestDraft(revisions), [revisions]); + + if (!application || application.archived) return null; + + const hasDraft = !!draft; + const tip = hasDraft + ? "Freeze the draft and promote it to live" + : "No draft to publish"; + + async function handleConfirm() { + if (!draft) return; + setError(null); + try { + await lifecycle.mutateAsync({ revisionId: draft.id, action: "freeze" }); + await lifecycle.mutateAsync({ revisionId: draft.id, action: "promote" }); + setOpen(false); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + } + + return ( + <> + + { + setError(null); + setOpen(true); + }} + > + Publish + + } + /> + {tip} + + { + if (!next && !lifecycle.isPending) { + setOpen(false); + setError(null); + } + }} + > + + + Publish draft revision + + + This freezes the current draft and promotes it to live. Triggers + start serving from it immediately. + + {error ? ( + + {error} + + ) : null} + + + + + + + + ); +}