diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 40d1e43da8..d5ef273c49 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -25,6 +25,7 @@ import type { AgentMemoryTableHeader, AgentMemoryTableRows, AgentMemoryTreeNode, + AgentPreviewToken, AgentRevision, AgentSessionEvent, AgentSessionLogEntry, @@ -842,6 +843,18 @@ function parseAvailableSuggestedReviewersPayload( }; } +/** + * Wraps the ingress preview token in the `parameters.header` shape the fetcher + * merges into request headers without clobbering the auth bearer. Returns + * `undefined` when there is no token so unmodified ingress calls stay byte-for- + * byte identical to today. + */ +function previewTokenHeader( + token: string | null | undefined, +): { header: { "X-Agent-Preview-Token": string } } | undefined { + return token ? { header: { "X-Agent-Preview-Token": token } } : undefined; +} + export class PostHogAPIClient { private api: ReturnType; private _teamId: number | null = null; @@ -4226,6 +4239,62 @@ export class PostHogAPIClient { } } + /** + * Mint a short-lived preview token (HS256 JWT) for a non-live revision. The + * token is sent to the ingress on /run /send /listen /cancel via + * `X-Agent-Preview-Token` (alongside the usual bearer) and authorizes those + * calls to route against this specific revision instead of `live_revision`. + * The response also self-describes the per-trigger ingress URLs the caller + * should hit (`endpoints`) so the client never has to construct preview URLs + * by string-mangling `ingress_base_url`. + * + * Note the Django route: app-level path with the revision as a query param, + * NOT nested under /revisions/{id}/. + */ + async mintAgentPreviewToken( + idOrSlug: string, + revisionId: string, + ): Promise { + const teamId = await this.getTeamId(); + const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/preview-token/`; + const url = new URL(`${this.api.baseUrl}${path}`); + url.searchParams.set("revision_id", revisionId); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path, + }); + return (await response.json()) as AgentPreviewToken; + } + + /** + * Atomically create a fresh draft revision under this app, seeded with the + * full bundle of `sourceRevisionId`. The standard "edit an immutable + * revision" exit: ready/live/archived bundles are stamped and locked, so + * iterating on them requires forking to a new draft first. Both ids are + * UUIDs; the app's `slug` is not accepted here (the body needs the UUID). + */ + async createAgentDraftRevisionFrom( + applicationId: string, + sourceRevisionId: string, + ): Promise { + const teamId = await this.getTeamId(); + const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(applicationId)}/revisions/new_draft/`; + const url = new URL(`${this.api.baseUrl}${path}`); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path, + overrides: { + body: JSON.stringify({ + application_id: applicationId, + source_revision_id: sourceRevisionId, + }), + }, + }); + return (await response.json()) as AgentRevision; + } + /** Run a revision lifecycle transition: freeze (draft→ready), promote * (ready→live, demoting the old live), or archive. Returns the updated revision. */ async transitionAgentRevision( @@ -4345,10 +4414,17 @@ export class PostHogAPIClient { return (await response.json()) as { session_id: string }; } - /** The names of env keys currently set on an agent (values never returned). */ - async listAgentEnvKeys(idOrSlug: string): Promise { + /** + * The names of env keys currently set on a revision (values never returned). + * Env keys are scoped to a revision, so each revision carries its own secret + * set. + */ + async listAgentEnvKeys( + idOrSlug: string, + revisionId: string, + ): Promise { const teamId = await this.getTeamId(); - const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/env_keys/`; + const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/revisions/${encodeURIComponent(revisionId)}/env_keys/`; const url = new URL(`${this.api.baseUrl}${path}`); const response = await this.api.fetcher.fetch({ method: "get", url, path }); const data = (await response.json()) as { @@ -4358,14 +4434,15 @@ export class PostHogAPIClient { return data.keys ?? data.results ?? []; } - /** Set or rotate one encrypted env key. The value is write-only. */ + /** Set or rotate one encrypted env key on a revision. The value is write-only. */ async setAgentEnvKey( idOrSlug: string, + revisionId: string, key: string, value: string, ): Promise { const teamId = await this.getTeamId(); - const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/env_keys/${encodeURIComponent(key)}/`; + const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/revisions/${encodeURIComponent(revisionId)}/env_keys/${encodeURIComponent(key)}/`; const url = new URL(`${this.api.baseUrl}${path}`); await this.api.fetcher.fetch({ method: "put", @@ -4375,10 +4452,14 @@ export class PostHogAPIClient { }); } - /** Clear one encrypted env key. No-op server-side if it isn't set. */ - async clearAgentEnvKey(idOrSlug: string, key: string): Promise { + /** Clear one encrypted env key on a revision. No-op server-side if it isn't set. */ + async clearAgentEnvKey( + idOrSlug: string, + revisionId: string, + key: string, + ): Promise { const teamId = await this.getTeamId(); - const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/env_keys/${encodeURIComponent(key)}/`; + const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/revisions/${encodeURIComponent(revisionId)}/env_keys/${encodeURIComponent(key)}/`; const url = new URL(`${this.api.baseUrl}${path}`); await this.api.fetcher.fetch({ method: "delete", url, path }); } @@ -4462,17 +4543,24 @@ export class PostHogAPIClient { // attaches the same bearer regardless of host, so no proxy is needed (unlike // the console, which proxied only because browser EventSource can't set // an Authorization header — `fetch` can). + // + // `previewToken`, when present, scopes the call to a non-live revision via + // `X-Agent-Preview-Token`. The fetcher merges `parameters.header` into the + // built headers (so the bearer survives) — never put preview-token into + // `overrides.headers`, which replaces the whole headers object. /** Start a chat session; returns the new session id. */ async runAgentSession( ingressBaseUrl: string, message: string, + previewToken?: string | null, ): Promise<{ session_id: string; resumed?: boolean }> { const url = new URL(`${ingressBaseUrl.replace(/\/$/, "")}/run`); const response = await this.api.fetcher.fetch({ method: "post", url, path: url.pathname, + parameters: previewTokenHeader(previewToken), overrides: { body: JSON.stringify({ message }) }, }); return (await response.json()) as { session_id: string; resumed?: boolean }; @@ -4483,12 +4571,14 @@ export class PostHogAPIClient { ingressBaseUrl: string, sessionId: string, message: string, + previewToken?: string | null, ): Promise { const url = new URL(`${ingressBaseUrl.replace(/\/$/, "")}/send`); await this.api.fetcher.fetch({ method: "post", url, path: url.pathname, + parameters: previewTokenHeader(previewToken), overrides: { body: JSON.stringify({ session_id: sessionId, message }) }, }); } @@ -4499,6 +4589,7 @@ export class PostHogAPIClient { sessionId: string, callId: string, outcome: { result?: unknown; error?: string }, + previewToken?: string | null, ): Promise { const url = new URL( `${ingressBaseUrl.replace(/\/$/, "")}/client_tool_result`, @@ -4507,6 +4598,7 @@ export class PostHogAPIClient { method: "post", url, path: url.pathname, + parameters: previewTokenHeader(previewToken), overrides: { body: JSON.stringify({ session_id: sessionId, @@ -4528,6 +4620,7 @@ export class PostHogAPIClient { sessionId: string, callId: string, outcome: { result: Record } | { error: string }, + previewToken?: string | null, ): Promise { const url = new URL(`${ingressBaseUrl.replace(/\/$/, "")}/send`); const clientToolResult = @@ -4538,6 +4631,7 @@ export class PostHogAPIClient { method: "post", url, path: url.pathname, + parameters: previewTokenHeader(previewToken), overrides: { body: JSON.stringify({ session_id: sessionId, @@ -4551,12 +4645,14 @@ export class PostHogAPIClient { async cancelAgentSession( ingressBaseUrl: string, sessionId: string, + previewToken?: string | null, ): Promise { const url = new URL(`${ingressBaseUrl.replace(/\/$/, "")}/cancel`); await this.api.fetcher.fetch({ method: "post", url, path: url.pathname, + parameters: previewTokenHeader(previewToken), overrides: { body: JSON.stringify({ session_id: sessionId }) }, }); } @@ -4569,17 +4665,20 @@ export class PostHogAPIClient { ingressBaseUrl: string, sessionId: string, signal?: AbortSignal, + previewToken?: string | null, ): AsyncGenerator { const url = new URL(`${ingressBaseUrl.replace(/\/$/, "")}/listen`); url.searchParams.set("session_id", sessionId); // NB: only `signal` in overrides. Passing `headers` here would replace the // fetcher's Authorization header (it spreads overrides over the built - // headers), which 401s the stream. /listen streams SSE without an explicit - // Accept header. + // headers), which 401s the stream. The preview token rides on + // `parameters.header` — merged in, not replacing. /listen streams SSE + // without an explicit Accept header. const response = await this.api.fetcher.fetch({ method: "get", url, path: url.pathname, + parameters: previewTokenHeader(previewToken), overrides: { signal }, }); if (!response.body) return; diff --git a/packages/shared/src/agent-platform-types.ts b/packages/shared/src/agent-platform-types.ts index d987f05a71..dc79f7fc45 100644 --- a/packages/shared/src/agent-platform-types.ts +++ b/packages/shared/src/agent-platform-types.ts @@ -104,6 +104,40 @@ export interface AgentRevision { updated_at: string; } +// --- Preview tokens -------------------------------------------------------- +// `…/agent_applications/{id}/preview-token/?revision_id=` mints a +// short-lived HS256 JWT that authorizes the ingress to route /run /send /listen +// /cancel against a non-live revision. Sent on those calls via the +// `X-Agent-Preview-Token` header (or `?preview_token=` query for EventSource), +// alongside the usual PostHog bearer (which the fetcher attaches regardless +// of host). +// +// The response is self-describing: `endpoints` carries the per-trigger preview +// URLs the caller should hit directly, so the client never has to derive a +// revision-scoped ingress URL by string-mangling `application.ingress_base_url`. + +/** Per-trigger preview URLs, keyed by trigger type → action → absolute URL. */ +export type AgentPreviewEndpoints = Record>; + +export interface AgentPreviewToken { + /** HS256 JWT bound to (app, revision). Short TTL. */ + token: string; + /** Token TTL in seconds from issue; mint a fresh one before this elapses. */ + expires_in: number; + /** `-` — the slug ingress uses in routing. */ + ingress_slug: string; + /** + * Per-trigger ingress URLs derived from this revision's `spec.triggers[]`. + * Empty when no public agent-ingress URL is configured for the active routing mode. + * Shape: `{ chat: { run, send, listen, cancel, client_tool_result }, slack: {...} }`. + */ + endpoints: AgentPreviewEndpoints; + /** Header/query names + per-trigger accepted auth modes. Opaque to most callers. */ + auth: Record; + /** Server-side proxy alternative — opaque shape; preferred path is direct-to-ingress. */ + preview_proxy: Record; +} + // --- Bundle files ---------------------------------------------------------- // `…/revisions/{id}/bundle/` returns a typed bundle ({ agent_md, skills, tools }); // the client flattens it into these per-file rows keyed by canonical path @@ -530,6 +564,19 @@ export type AgentClientToolResultEvent = AgentSessionEventBase & { data: { call_id: string; result?: unknown; error?: string }; }; +/** + * Draft-preview only. The server fires this on `/listen` ~5s before the + * preview token expires (and then closes the stream): the client mints a + * fresh token and reconnects to the same session. The kind alone is the + * signal — `data` is structurally `Record` (matching + * `AgentClosedEvent`) for the discriminated-union shape, but no fields are + * defined or read. + */ +export type AgentPreviewTokenRequiredEvent = AgentSessionEventBase & { + kind: "preview_token_required"; + data: Record; +}; + export type AgentSessionEvent = | AgentSessionStartedEvent | AgentUserMessageEvent @@ -546,7 +593,8 @@ export type AgentSessionEvent = | AgentFailedEvent | AgentClosedEvent | AgentClientToolCallEvent - | AgentClientToolResultEvent; + | AgentClientToolResultEvent + | AgentPreviewTokenRequiredEvent; /** Discriminator values for {@link AgentSessionEvent}. */ export type AgentSessionEventKind = AgentSessionEvent["kind"]; diff --git a/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderDock.tsx b/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderDock.tsx index d7408db794..ed0469ac03 100644 --- a/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderDock.tsx +++ b/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderDock.tsx @@ -131,6 +131,7 @@ export function AgentBuilderDock() { try { await client.setAgentEnvKey( pendingSecret.agentSlug, + pendingSecret.revisionId, pendingSecret.secret, value, ); @@ -190,11 +191,14 @@ export function AgentBuilderDock() { } return ( - + 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 6112ebc01c..bbe49b9e1f 100644 --- a/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderHeaderControls.tsx +++ b/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderHeaderControls.tsx @@ -1,77 +1,116 @@ -import { NavigationArrowIcon, SparkleIcon } from "@phosphor-icons/react"; -import { agentChatStore } from "@posthog/core/agent-chat/agentChatStore"; +import { SidebarSimpleIcon, SparkleIcon } from "@phosphor-icons/react"; +import { + Button, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@posthog/quill"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; -import { Button } from "@posthog/ui/primitives/Button"; -import { Badge, Flex, Tooltip } from "@radix-ui/themes"; -import { useStore } from "zustand"; +import { Flex } from "@radix-ui/themes"; import { AGENT_PLATFORM_FLAG } from "../featureFlag"; import { headerActionForPage } from "./agentBuilderActions"; -import { - AGENT_BUILDER_CHAT_ID, - type AgentBuilderPageContext, - useAgentBuilderStore, -} from "./agentBuilderStore"; -import { EditWithAIButton } from "./EditWithAIButton"; +import { useAgentBuilderStore } from "./agentBuilderStore"; /** - * The agents-header control cluster — identical across every agents view. Driven - * by the view's `context`, it renders: - * - a "Following" indicator while the agent builder is mid-turn and follow mode - * is on (so it's clear the builder is steering navigation), - * - a contextual AI button (New agent / Explain this session / …), - * - a "show" button that opens the dock, ONLY when it's hidden (the inverse of - * the hide button inside the dock header). - * All buttons are small. Renders nothing unless the `agent-platform` flag is on. + * The agents-header control cluster — identical across every agents view. + * + * One split button is the single entry point into the Agent Builder dock: + * - the primary segment is the contextual "edit with AI" action for the view + * you're on (New agent / Edit configuration / Explain this session / …) — it + * opens the dock and seeds the matching prompt, + * - the trailing segment just opens/closes the dock without seeding, so you + * can peek at or dismiss the existing conversation. + * The two were previously near-identical gold buttons; fusing them keeps both + * affordances but with one sparkle (the AI identity) and one neutral toggle. + * Views with no obvious action (Scouts) collapse to the lone open/close toggle. + * Renders nothing unless the `agent-platform` flag is on. */ -export function AgentBuilderHeaderControls({ - context, -}: { - context: AgentBuilderPageContext; -}) { +export function AgentBuilderHeaderControls() { const enabled = useFeatureFlag(AGENT_PLATFORM_FLAG); const visible = useAgentBuilderStore((s) => s.visible); - const setVisible = useAgentBuilderStore((s) => s.setVisible); - const followMode = useAgentBuilderStore((s) => s.followMode); - const status = useStore( - agentChatStore, - (s) => s.chats[AGENT_BUILDER_CHAT_ID]?.status, - ); + const page = useAgentBuilderStore((s) => s.page); + const toggleVisible = useAgentBuilderStore((s) => s.toggleVisible); + const startAgentBuilder = useAgentBuilderStore((s) => s.startAgentBuilder); if (!enabled) return null; - const running = status === "streaming" || status === "starting"; - const action = headerActionForPage(context); + const action = headerActionForPage(page); + const toggleTip = visible + ? "Hide the agent builder (⌘⇧I)" + : "Open the agent builder (⌘⇧I)"; return ( - - {running && followMode ? ( - - - - Following - - - ) : null} - {action ? ( - - ) : null} - {!visible ? ( - - - - ) : null} - + + + {action ? ( +
+ + + startAgentBuilder(action.prompt, action.agentSlug) + } + > + + {action.label} + + } + /> + + Open the agent builder and start here + + + + + + + } + /> + {toggleTip} + +
+ ) : ( + + + + + } + /> + {toggleTip} + + )} +
+
); } diff --git a/packages/ui/src/features/agent-applications/agent-builder/EditWithAIButton.tsx b/packages/ui/src/features/agent-applications/agent-builder/EditWithAIButton.tsx deleted file mode 100644 index a23400bb7a..0000000000 --- a/packages/ui/src/features/agent-applications/agent-builder/EditWithAIButton.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { SparkleIcon } from "@phosphor-icons/react"; -import { Button } from "@posthog/ui/primitives/Button"; -import { useAgentBuilderStore } from "./agentBuilderStore"; - -/** - * Opens the agent builder dock and seeds it with a prompt — the render surfaces' - * hand-off into authoring ("edit with AI"). The agent builder does the actual edits - * server-side via staged draft revisions; this just starts the conversation - * with the right context. - */ -export function EditWithAIButton({ - prompt, - agentSlug, - label = "Ask the agent builder", - variant = "soft", - size = "1", -}: { - prompt: string; - agentSlug?: string | null; - label?: string; - variant?: "soft" | "ghost" | "outline"; - size?: "1" | "2"; -}) { - const startAgentBuilder = useAgentBuilderStore((s) => s.startAgentBuilder); - return ( - - ); -} 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 f9cc0b4d43..3643e8bfab 100644 --- a/packages/ui/src/features/agent-applications/agent-builder/agentBuilderActions.ts +++ b/packages/ui/src/features/agent-applications/agent-builder/agentBuilderActions.ts @@ -28,7 +28,7 @@ export function headerActionForPage( }; case "agent": return { - label: "Ask about this agent", + label: "Explain this agent", prompt: "Explain what this agent does and how it's configured.", agentSlug: page.slug, }; diff --git a/packages/ui/src/features/agent-applications/agent-builder/agentBuilderStore.ts b/packages/ui/src/features/agent-applications/agent-builder/agentBuilderStore.ts index f7ad682ddf..01922f7c5c 100644 --- a/packages/ui/src/features/agent-applications/agent-builder/agentBuilderStore.ts +++ b/packages/ui/src/features/agent-applications/agent-builder/agentBuilderStore.ts @@ -44,6 +44,13 @@ export interface PendingSecret { /** The parked tool call to resolve via `/send`. */ callId: string; agentSlug: string; + /** + * Revision the secret is written to. Env keys are revision-scoped, so the + * `set_secret` punch-out must target a specific revision (the one the agent + * is editing). Sourced from the tool args, falling back to the dock's + * current `agent-config` page context. + */ + revisionId: string; /** Env key name, e.g. "ANTHROPIC_KEY". The value is never seen by the agent. */ secret: string; mode?: "set" | "rotate"; 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 4a43c0433d..caab1e87da 100644 --- a/packages/ui/src/features/agent-applications/agent-builder/useAgentBuilderClientTools.ts +++ b/packages/ui/src/features/agent-applications/agent-builder/useAgentBuilderClientTools.ts @@ -17,8 +17,14 @@ export function useAgentBuilderClientTools(): ClientToolHandler { const navigate = useNavigate(); const followMode = useAgentBuilderStore((s) => s.followMode); const setPendingSecret = useAgentBuilderStore((s) => s.setPendingSecret); + const page = useAgentBuilderStore((s) => s.page); const followRef = useRef(followMode); followRef.current = followMode; + // Latest page context without re-creating the handler each render — used to + // resolve the revision a `set_secret` punch-out targets when the agent + // doesn't name one in the tool args. + const pageRef = useRef(page); + pageRef.current = page; return useCallback( (data) => { @@ -26,16 +32,24 @@ export function useAgentBuilderClientTools(): ClientToolHandler { const str = (v: unknown) => (typeof v === "string" ? v : undefined); // set_secret — interactive punch-out. Park the call (defer) and render a - // form; the dock PUTs the key and wakes the session on submit. + // 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, + // falling back to the revision the user is currently viewing in the + // agent-config page. if (data.tool_id === "set_secret") { const agentSlug = str(args.agent_slug); const secret = str(args.secret); if (!agentSlug) return { error: "missing_arg: agent_slug" }; if (!secret) return { error: "missing_arg: secret" }; + const p = pageRef.current; + const pageRevision = p.kind === "agent-config" ? p.revision : undefined; + const revisionId = str(args.revision_id) ?? pageRevision; + if (!revisionId) return { error: "missing_arg: revision_id" }; const mode = args.mode === "rotate" ? "rotate" : "set"; setPendingSecret({ callId: data.call_id, agentSlug, + revisionId, secret, mode, purpose: str(args.purpose), diff --git a/packages/ui/src/features/agent-applications/chat/chatHistoryStore.ts b/packages/ui/src/features/agent-applications/chat/chatHistoryStore.ts index 3473ef7c6d..5482cd93a9 100644 --- a/packages/ui/src/features/agent-applications/chat/chatHistoryStore.ts +++ b/packages/ui/src/features/agent-applications/chat/chatHistoryStore.ts @@ -15,6 +15,12 @@ export interface PreviewChatEntry { title: string; /** Epoch ms when the chat was started here. */ startedAt: number; + /** + * Revision the chat targets, when the user opened it as a draft preview. + * Undefined for sessions that ran against `live_revision`. Lets the rail + * label drafts and route resumes back to the right preview surface. + */ + revisionId?: string; } interface ChatHistoryState { diff --git a/packages/ui/src/features/agent-applications/chat/sessionEventToAcp.test.ts b/packages/ui/src/features/agent-applications/chat/sessionEventToAcp.test.ts index a4b015d719..3b049fee24 100644 --- a/packages/ui/src/features/agent-applications/chat/sessionEventToAcp.test.ts +++ b/packages/ui/src/features/agent-applications/chat/sessionEventToAcp.test.ts @@ -49,6 +49,38 @@ describe("createAgentChatMapper", () => { expect(mapper.apply(ev("user_message", { text: "" }))).toEqual([]); }); + it.each([ + ["exact match", "hello", "hello"], + ["trailing newline", "hello", "hello\n"], + ["leading spaces", "hello", " hello"], + ])( + "swallows the echo of an optimistically-seeded message (%s)", + (_, seeded, echoed) => { + const mapper = createAgentChatMapper(); + mapper.seedUserMessage(seeded); + expect(mapper.apply(ev("user_message", { text: echoed }))).toEqual([]); + }, + ); + + it("swallows echoes out of order across rapid sends", () => { + const mapper = createAgentChatMapper(); + mapper.seedUserMessage("first"); + mapper.seedUserMessage("second"); + // Echoes arrive in reverse — both must dedup, neither should render. + expect(mapper.apply(ev("user_message", { text: "second" }))).toEqual([]); + expect(mapper.apply(ev("user_message", { text: "first" }))).toEqual([]); + }); + + it("drops a duplicate user_message the runner re-emits", () => { + const mapper = createAgentChatMapper(); + mapper.seedUserMessage("hello"); + expect(mapper.apply(ev("user_message", { text: "hello" }))).toEqual([]); + // The runner re-emits the same user_message later in the stream — there's + // nothing left in `pendingOptimistic`, but we've already rendered this + // text, so it must still be dropped. + expect(mapper.apply(ev("user_message", { text: "hello" }))).toEqual([]); + }); + it("maps text and thinking deltas", () => { const mapper = createAgentChatMapper(); expect( diff --git a/packages/ui/src/features/agent-applications/chat/sessionEventToAcp.ts b/packages/ui/src/features/agent-applications/chat/sessionEventToAcp.ts index d35419033e..9424e4b6a5 100644 --- a/packages/ui/src/features/agent-applications/chat/sessionEventToAcp.ts +++ b/packages/ui/src/features/agent-applications/chat/sessionEventToAcp.ts @@ -71,7 +71,13 @@ export function createAgentChatMapper(): AgentChatMapper { let promptId = 0; const seenToolCalls = new Set(); // Texts shown optimistically and awaiting their echoed `user_message` frame. + // Compared by trimmed form so runner-side whitespace normalization + // (trailing `\n`, padding around envelopes, etc.) doesn't break dedup. const pendingOptimistic: string[] = []; + // Every user text we've rendered this session, normalized. Catches the runner + // re-emitting the same `user_message` event twice (the second arrival has + // nothing left in `pendingOptimistic` to swallow it). + const seenUserTexts = new Set(); return { seedUserMessage(text: string, ts?: number): AcpMessage[] { @@ -80,6 +86,7 @@ export function createAgentChatMapper(): AgentChatMapper { } promptId += 1; pendingOptimistic.push(text); + seenUserTexts.add(text.trim()); return [promptRequestMessage(promptId, text, ts ?? Date.now())]; }, @@ -99,11 +106,25 @@ export function createAgentChatMapper(): AgentChatMapper { // so it never shows in the transcript (and so dedup matches the clean // optimistic text the composer rendered). const text = stripConsoleContext(event.data.text); - // Already rendered optimistically on send — swallow the echo. - if (pendingOptimistic[0] === text) { - pendingOptimistic.shift(); + const normalized = text.trim(); + // Echo of a message we already rendered optimistically. Scan the + // queue (not just `[0]`) so out-of-order echoes from rapid sends + // still match, and compare trimmed forms so trailing/leading + // whitespace from the runner doesn't break the match. + const pendingIdx = pendingOptimistic.findIndex( + (p) => p.trim() === normalized, + ); + if (pendingIdx !== -1) { + pendingOptimistic.splice(pendingIdx, 1); + return []; + } + // The runner re-emitted a `user_message` we've already rendered + // (either as optimistic seed or as a non-dedup'd echo). Drop it so + // the bubble doesn't appear twice. + if (seenUserTexts.has(normalized)) { return []; } + seenUserTexts.add(normalized); promptId += 1; return [promptRequestMessage(promptId, text, ts)]; } diff --git a/packages/ui/src/features/agent-applications/components/AgentApplicationDetailView.tsx b/packages/ui/src/features/agent-applications/components/AgentApplicationDetailView.tsx index 9ca31c8d7f..ea96141cbc 100644 --- a/packages/ui/src/features/agent-applications/components/AgentApplicationDetailView.tsx +++ b/packages/ui/src/features/agent-applications/components/AgentApplicationDetailView.tsx @@ -19,8 +19,11 @@ export function AgentApplicationDetailView({ idOrSlug }: { idOrSlug: string }) { application?.id, "agent", ); - const { data: sessions, isLoading: sessionsLoading } = - useAgentApplicationSessions(idOrSlug, { limit: 25 }); + const { + data: sessions, + isLoading: sessionsLoading, + isError: sessionsError, + } = useAgentApplicationSessions(idOrSlug, { limit: 25 }); return ( @@ -57,6 +60,11 @@ export function AgentApplicationDetailView({ idOrSlug }: { idOrSlug: string }) { /> ))}
+ ) : sessionsError ? ( + ) : !sessions || sessions.results.length === 0 ? ( s.cloudRegion); @@ -44,11 +41,10 @@ export function AgentApplicationsListView() { error, } = useAgentApplications(); const { data: analytics, isLoading: analyticsLoading } = useAgentAnalytics(); - const { data: liveSessions } = useAgentFleetLiveSessions(); const { data: queuedApprovals } = useAgentFleetApprovals({ state: "queued" }); const aiObservabilityUrl = aiObservabilityTracesUrl(region, projectId); - const liveCount = liveSessions?.results.length ?? 0; const pendingCount = queuedApprovals?.length ?? 0; + const hasAnalytics = analytics ? !analytics.empty : false; // Index the per-agent rollups by application id so each row can show its own // sessions / spend / failure rate without a second request. @@ -60,39 +56,22 @@ export function AgentApplicationsListView() { return map; }, [analytics]); + // Split LIVE vs DRAFT so the operational view foregrounds what's serving + // traffic; drafts dim and section below. + const { liveApps, draftApps } = useMemo(() => { + const live: AgentApplication[] = []; + const draft: AgentApplication[] = []; + for (const app of applications ?? []) { + if (app.live_revision != null) live.push(app); + else draft.push(app); + } + return { liveApps: live, draftApps: draft }; + }, [applications]); + return ( - - - +
- - - Activity · last 7 days - - {aiObservabilityUrl ? ( - - ) : null} - - -
- - - - - - Agents - {isLoading ? ( ) : isError ? ( @@ -110,20 +89,95 @@ export function AgentApplicationsListView() { description="Deployed agents on the agent platform will show up here." /> ) : ( - applications.map((app) => ( - + - )) + {draftApps.length > 0 ? ( + + ) : null} + )} -
+ + + + + {hasAnalytics ? ( +
+ + + Activity · last 7 days + + {aiObservabilityUrl ? ( + + ) : null} + + +
+ ) : null} + +
); } +/** A labeled group of agent rows; `dimmed` softens drafts so live agents + * dominate the visual hierarchy. */ +function AgentsSection({ + label, + apps, + statsById, + dimmed, +}: { + label: string; + apps: AgentApplication[]; + statsById: Map; + dimmed?: boolean; +}) { + if (apps.length === 0) return null; + return ( + + + + {label} + + + {apps.length} + + +
+ + {apps.map((app) => ( + + ))} + +
+
+ ); +} + function ApplicationRow({ application, stats, @@ -205,33 +259,24 @@ function RowStat({ } /** - * Operational counts strip — restores the "live now / pending approvals" - * signals the M7 analytics KPIs displaced. Live count anchors the live-now - * panel below; pending links to the fleet approvals queue. + * Operational counts strip — always renders the pending-approvals count as a + * deep link to the fleet approvals queue, and visually emphasizes the row when + * `pendingCount > 0`. */ -function OperationalStrip({ - liveCount, - pendingCount, -}: { - liveCount: number; - pendingCount: number; -}) { +function OperationalStrip({ pendingCount }: { pendingCount: number }) { + const pendingAttention = pendingCount > 0; return ( -
- - - {liveCount} - - live now -
- + 0 ? "text-(--amber-11)" : "text-gray-12"}`} + className={`font-medium tabular-nums ${pendingAttention ? "text-(--amber-11)" : "text-gray-12"}`} > {pendingCount} diff --git a/packages/ui/src/features/agent-applications/components/AgentChatPane.tsx b/packages/ui/src/features/agent-applications/components/AgentChatPane.tsx index 9dd8b07589..78c75d2d2a 100644 --- a/packages/ui/src/features/agent-applications/components/AgentChatPane.tsx +++ b/packages/ui/src/features/agent-applications/components/AgentChatPane.tsx @@ -7,6 +7,8 @@ import { import type { CloudRegion } from "@posthog/shared"; import { Button } from "@posthog/ui/primitives/Button"; import { Flex, Text } from "@radix-ui/themes"; +import { useNavigate } from "@tanstack/react-router"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useAuthStateValue } from "../../auth/store"; import { type PreviewChatEntry, @@ -47,13 +49,38 @@ function relativeTime(ms: number): string { * A left rail lists the preview chats the user started *here* (persisted * locally — never the agent's full server session list, which can include real * customer chats), and a banner makes clear this talks to the deployed revision. + * + * When `revisionId` targets a non-live revision (the "Test draft" affordance + * from the revision bar), the hook mints a short-lived preview token and + * scopes the ingress to that draft; chatId/banner switch accordingly so a + * draft session can't be confused with a live one. */ -export function AgentChatPane({ idOrSlug }: { idOrSlug: string }) { +export function AgentChatPane({ + idOrSlug, + revisionId, + resumeSessionId, +}: { + idOrSlug: string; + revisionId?: string | null; + /** + * Optional session id from the route (`?session=`); when set, the pane + * re-attaches to that session on first mount. Lets rail clicks that cross + * revisions land on the right surface AND immediately resume — without it, + * the new mount would render an empty composer. + */ + resumeSessionId?: string | null; +}) { + const navigate = useNavigate(); const { data: application } = useAgentApplication(idOrSlug); - const { data: revision } = useAgentRevision( - idOrSlug, - application?.live_revision ?? null, - ); + // null/equal-to-live → fall back to the live revision; explicit non-live + // revision id → draft preview mode. + const targetRevisionId = + revisionId && revisionId !== application?.live_revision + ? revisionId + : (application?.live_revision ?? null); + const isDraftPreview = + !!revisionId && revisionId !== application?.live_revision; + const { data: revision } = useAgentRevision(idOrSlug, targetRevisionId); const cloudRegion = useAuthStateValue((s) => s.cloudRegion); const ingressBaseUrl = resolveIngressBaseUrl( application?.ingress_base_url, @@ -63,9 +90,14 @@ export function AgentChatPane({ idOrSlug }: { idOrSlug: string }) { (t) => rec(t).type === "chat", ); const chat = useAgentChat({ - chatId: `preview:${idOrSlug}`, + // Keyed by revision so a draft preview and the live chat coexist in the + // store without trampling each other. + chatId: isDraftPreview + ? `preview:${idOrSlug}:${revisionId}` + : `preview:${idOrSlug}`, agentSlug: idOrSlug, ingressBaseUrl, + revisionId: isDraftPreview ? revisionId : null, recordHistory: true, }); const { data: pendingApproval } = useAgentChatPendingApproval( @@ -75,6 +107,62 @@ export function AgentChatPane({ idOrSlug }: { idOrSlug: string }) { const chats = useChatHistoryStore((s) => s.byAgent[idOrSlug]) ?? EMPTY_CHATS; const removeChat = useChatHistoryStore((s) => s.remove); + // Partition the rail into "matches the current target" vs everything else + // so a live view doesn't drown in old draft previews (and vice-versa). The + // expander reveals the rest on demand. + const currentRev = isDraftPreview ? (revisionId ?? null) : null; + const { matchingChats, otherChats } = useMemo(() => { + const matching: PreviewChatEntry[] = []; + const other: PreviewChatEntry[] = []; + for (const c of chats) { + if ((c.revisionId ?? null) === currentRev) matching.push(c); + else other.push(c); + } + return { matchingChats: matching, otherChats: other }; + }, [chats, currentRev]); + const [showOthers, setShowOthers] = useState(false); + // Reset the expander whenever the target switches so each surface starts + // focused on its own chats. + const lastRevRef = useRef(currentRev); + if (lastRevRef.current !== currentRev) { + lastRevRef.current = currentRev; + if (showOthers) setShowOthers(false); + } + + // Auto-resume the URL-named session exactly once per param value. Tracked by + // a ref so a re-render or chat.resume identity change can't re-fire on the + // same id. After kicking it off we clear the URL hint so an in-pane "New + // chat" can't be silently undone by a refresh. + const resumedRef = useRef(null); + useEffect(() => { + if (!resumeSessionId || resumedRef.current === resumeSessionId) return; + resumedRef.current = resumeSessionId; + chat.resume(resumeSessionId); + // Anchored to this route so TanStack can resolve the search-fn against the + // chat route's typed shape (the no-`to` form widens to `never`). + navigate({ + to: "/code/agents/applications/$idOrSlug/chat", + params: { idOrSlug }, + search: (prev) => ({ ...prev, session: undefined }), + }); + }, [resumeSessionId, chat.resume, navigate, idOrSlug]); + + // The rail mixes chats from every revision; decide per click whether to + // resume inline (same target) or navigate to a different revision's surface. + const handleRailSelect = (entry: PreviewChatEntry) => { + if ((entry.revisionId ?? null) === currentRev) { + chat.resume(entry.sessionId); + return; + } + navigate({ + to: "/code/agents/applications/$idOrSlug/chat", + params: { idOrSlug }, + search: entry.revisionId + ? { revision: entry.revisionId, session: entry.sessionId } + : { session: entry.sessionId }, + }); + }; + return ( {!ingressBaseUrl ? ( @@ -88,21 +176,29 @@ export function AgentChatPane({ idOrSlug }: { idOrSlug: string }) {
) : ( setShowOthers((v) => !v)} activeSessionId={chat.sessionId} onNewChat={chat.newChat} - onSelect={chat.resume} + onSelect={handleRailSelect} onDelete={(sessionId) => removeChat(idOrSlug, sessionId)} /> @@ -136,10 +232,12 @@ export function AgentChatPane({ idOrSlug }: { idOrSlug: string }) { function PreviewBanner({ revisionId, + isDraft, model, region, }: { revisionId: string | null; + isDraft: boolean; model: string | undefined; region: CloudRegion | null; }) { @@ -147,12 +245,15 @@ function PreviewBanner({ - Live preview — messages run against the currently deployed revision. - Only chats you start here appear in the list. + {isDraft + ? "Draft preview — messages run against an unpublished revision. Promote it to make this the live one." + : "Live preview — messages run against the currently deployed revision. Only chats you start here appear in the list."} {model ? ( @@ -176,15 +277,23 @@ function PreviewBanner({ function ChatHistoryRail({ chats, + otherChats, + showOthers, + onToggleShowOthers, activeSessionId, onNewChat, onSelect, onDelete, }: { chats: PreviewChatEntry[]; + /** Chats from other revisions — hidden behind an expander to keep this surface focused. */ + otherChats: PreviewChatEntry[]; + showOthers: boolean; + onToggleShowOthers: () => void; activeSessionId: string | null; onNewChat: () => void; - onSelect: (sessionId: string) => void; + /** Receives the full entry so the parent can route by revision, not just resume. */ + onSelect: (entry: PreviewChatEntry) => void; onDelete: (sessionId: string) => void; }) { return ( @@ -205,50 +314,101 @@ function ChatHistoryRail({
- {chats.length === 0 ? ( + {chats.length === 0 && (otherChats.length === 0 || !showOthers) ? ( Chats you start here will show up in this list. ) : ( - {chats.map((c) => { - const active = c.sessionId === activeSessionId; - return ( -
- - -
- ); - })} + {chats.map((c) => ( + + ))} + {otherChats.length > 0 ? ( + <> + + {showOthers + ? otherChats.map((c) => ( + + )) + : null} + + ) : null}
)}
); } + +function RailEntry({ + entry, + active, + onSelect, + onDelete, +}: { + entry: PreviewChatEntry; + active: boolean; + onSelect: (entry: PreviewChatEntry) => void; + onDelete: (sessionId: string) => void; +}) { + return ( +
+ + +
+ ); +} diff --git a/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx b/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx index c64c87bc6a..e1e43cde88 100644 --- a/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx +++ b/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx @@ -328,7 +328,7 @@ export function AgentConfigurationPane({ const { data: revision, isLoading } = useAgentRevision(idOrSlug, revisionId); const { data: bundle } = useAgentRevisionBundle(idOrSlug, revisionId); - const { data: envKeys } = useAgentEnvKeys(idOrSlug); + const { data: envKeys } = useAgentEnvKeys(idOrSlug, revisionId); const spec = revision?.spec ?? null; const setKeys = useMemo(() => envKeys ?? [], [envKeys]); @@ -598,6 +598,7 @@ function DetailBody({ keyName={id} setKeys={ctx.setKeys} idOrSlug={ctx.idOrSlug} + revisionId={ctx.revisionId} /> ); case "limits": @@ -1220,10 +1221,12 @@ function SecretBody({ keyName, setKeys, idOrSlug, + revisionId, }: { keyName: string; setKeys: string[]; idOrSlug: string; + revisionId: string; }) { const isSet = setKeys.includes(keyName); return ( @@ -1234,7 +1237,12 @@ function SecretBody({ value={isSet ? "set" : "not set"} valueColor={isSet ? "var(--green-11)" : "var(--amber-11)"} /> - +
); } diff --git a/packages/ui/src/features/agent-applications/components/AgentDetailLayout.tsx b/packages/ui/src/features/agent-applications/components/AgentDetailLayout.tsx index 9c902442b7..89d1db64bb 100644 --- a/packages/ui/src/features/agent-applications/components/AgentDetailLayout.tsx +++ b/packages/ui/src/features/agent-applications/components/AgentDetailLayout.tsx @@ -153,7 +153,7 @@ export function AgentDetailLayout({ ) : null} - +
{application?.description?.trim() ? ( diff --git a/packages/ui/src/features/agent-applications/components/AgentRevisionBar.tsx b/packages/ui/src/features/agent-applications/components/AgentRevisionBar.tsx index c4077d50b3..f62ab8cebe 100644 --- a/packages/ui/src/features/agent-applications/components/AgentRevisionBar.tsx +++ b/packages/ui/src/features/agent-applications/components/AgentRevisionBar.tsx @@ -8,8 +8,10 @@ import type { import { Badge } from "@posthog/ui/primitives/Badge"; import { Button } from "@posthog/ui/primitives/Button"; import { AlertDialog, Flex, Popover, Text } from "@radix-ui/themes"; +import { useNavigate } from "@tanstack/react-router"; import { useMemo, useState } from "react"; import { useAgentRevisionLifecycle } from "../hooks/useAgentRevisionLifecycle"; +import { useCreateAgentDraftFromRevision } from "../hooks/useCreateAgentDraftFromRevision"; import { revisionStateColor } from "../utils/format"; type LifecycleAction = "freeze" | "promote" | "archive"; @@ -104,6 +106,8 @@ export function AgentRevisionBar({ onSelectRevision: (id: string) => void; }) { const lifecycle = useAgentRevisionLifecycle(idOrSlug); + const cloneToDraft = useCreateAgentDraftFromRevision(idOrSlug, agent.id); + const navigate = useNavigate(); const [pickerOpen, setPickerOpen] = useState(false); const [filters, setFilters] = useState>( () => new Set(["live", "ready", "draft"]), @@ -183,7 +187,7 @@ export function AgentRevisionBar({ type="button" onClick={() => toggleFilter(f)} aria-pressed={active} - className={`rounded-full border px-2 py-0.5 text-[10.5px] uppercase tracking-wide ${ + className={`rounded-(--radius-2) border px-1.5 py-[3px] text-[10.5px] uppercase leading-none tracking-wide ${ active ? "border-(--accent-7) bg-(--accent-3) text-gray-12" : "border-border text-gray-10 hover:border-(--gray-7)" @@ -212,7 +216,7 @@ export function AgentRevisionBar({ setPickerOpen(false); }} aria-current={r.id === selected.id ? "true" : undefined} - className={`flex w-full items-start gap-2 px-3 py-2 text-left ${ + className={`flex w-full items-center gap-2 px-3 py-2 text-left ${ r.id === selected.id ? "bg-(--accent-3)" : "hover:bg-(--gray-3)" @@ -241,23 +245,68 @@ export function AgentRevisionBar({ - {actions.length > 0 ? ( - - {actions.map((a) => ( - - ))} - - ) : null} + + {/* + * Test — runs this revision through the live ingress with a preview + * token, before it's promoted. The chat tab handles the rest (mint + + * token attach via useAgentChat). Live uses the default Chat tab; + * archived can't be exercised. Label varies by state: "Test draft" + * leans into the unfinished work; for `ready` the bundle is frozen so + * plain "Test" is more accurate. + */} + {selected.state !== "live" && + selected.state !== "archived" && + !isLive ? ( + + ) : null} + {/* + * Clone to draft — fork this revision into a fresh editable draft. + * The standard exit when a ready/live/archived bundle is immutable + * but you want to keep iterating. Pre-selects the new draft so the + * picker lands you in edit mode immediately. + */} + {selected.state !== "draft" ? ( + + ) : null} + {actions.map((a) => ( + + ))} + Session transcript - +
diff --git a/packages/ui/src/features/agent-applications/components/SecretEditor.tsx b/packages/ui/src/features/agent-applications/components/SecretEditor.tsx index 5ebcaa1568..a6802eaf9a 100644 --- a/packages/ui/src/features/agent-applications/components/SecretEditor.tsx +++ b/packages/ui/src/features/agent-applications/components/SecretEditor.tsx @@ -15,14 +15,16 @@ import { useAgentEnvKeyMutations } from "../hooks/useAgentEnvKeyMutations"; */ export function SecretEditor({ idOrSlug, + revisionId, keyName, isSet, }: { idOrSlug: string; + revisionId: string; keyName: string; isSet: boolean; }) { - const { setKey, clearKey } = useAgentEnvKeyMutations(idOrSlug); + const { setKey, clearKey } = useAgentEnvKeyMutations(idOrSlug, revisionId); // For a set secret the input is hidden until the user chooses to rotate. const [editing, setEditing] = useState(false); const [value, setValue] = useState(""); diff --git a/packages/ui/src/features/agent-applications/hooks/agentApplicationsKeys.ts b/packages/ui/src/features/agent-applications/hooks/agentApplicationsKeys.ts index 95893ae3c0..e6ad4ac13d 100644 --- a/packages/ui/src/features/agent-applications/hooks/agentApplicationsKeys.ts +++ b/packages/ui/src/features/agent-applications/hooks/agentApplicationsKeys.ts @@ -57,8 +57,18 @@ export const agentApplicationsKeys = { ] as const, bundle: (projectId: number | null, idOrSlug: string, revisionId: string) => ["agent-applications", "bundle", projectId, idOrSlug, revisionId] as const, - envKeys: (projectId: number | null, idOrSlug: string) => - ["agent-applications", "env-keys", projectId, idOrSlug] as const, + envKeys: ( + projectId: number | null, + idOrSlug: string, + revisionId: string | null, + ) => + [ + "agent-applications", + "env-keys", + projectId, + idOrSlug, + revisionId, + ] as const, slackManifest: ( projectId: number | null, idOrSlug: string, diff --git a/packages/ui/src/features/agent-applications/hooks/useAgentChat.ts b/packages/ui/src/features/agent-applications/hooks/useAgentChat.ts index 34af4dc570..728db215bf 100644 --- a/packages/ui/src/features/agent-applications/hooks/useAgentChat.ts +++ b/packages/ui/src/features/agent-applications/hooks/useAgentChat.ts @@ -51,6 +51,13 @@ export interface UseAgentChatOptions { /** Agent slug the chat targets (drives client-tool context + history). */ agentSlug: string; ingressBaseUrl: string | null; + /** + * When set, this chat targets a specific non-live revision. The hook mints a + * short-lived preview token via the api-client and attaches it on every + * ingress call (run/send/listen/cancel/client_tool_result). Leave null/unset + * to use the agent's currently live revision. + */ + revisionId?: string | null; /** Index started sessions in the local recent-chats rail (preview only). */ recordHistory?: boolean; /** @@ -63,6 +70,23 @@ export interface UseAgentChatOptions { clientTools?: ClientToolHandler; } +/** Reserve a margin so we mint a fresh token before the server rejects the old one. */ +const PREVIEW_TOKEN_EARLY_REFRESH_MS = 30_000; + +interface CachedPreviewToken { + token: string; + expiresAtMs: number; +} + +/** + * Recognize the fetcher's `Failed request: [401] …` shape (the ingress signals + * an expired/missing preview token the same way it signals any other auth + * failure). Anything else falls through to the caller as a normal error. + */ +function isPreviewAuthError(err: unknown): boolean { + return err instanceof Error && /\[401\]/.test(err.message); +} + /** * Drives a live chat against a deployed agent's ingress: starts/sends/cancels * via the api-client, streams SSE through the M3 `createAgentChatMapper`, and @@ -79,6 +103,7 @@ export function useAgentChat({ chatId, agentSlug, ingressBaseUrl, + revisionId = null, recordHistory = false, contextProvider, clientTools, @@ -98,6 +123,65 @@ export function useAgentChat({ // touching the store so a stale loop's terminal/finally can't clobber the new // chat (matters when resuming or starting a new chat mid-stream). const epochRef = useRef(0); + // Cached preview token for a draft-revision session. Lazily minted on the + // first ingress call so chats against the live revision pay nothing. + const previewTokenRef = useRef(null); + // Drop the cached token if the consumer flips revisions (incl. live ↔ draft): + // a token is bound to a specific (app, revision), so a stale one wouldn't + // route to the new target. + const revisionRef = useRef(revisionId); + if (revisionRef.current !== revisionId) { + revisionRef.current = revisionId; + previewTokenRef.current = null; + } + + /** + * Mint a preview token if we don't have one, or refresh it just before + * expiry. `force` skips the cache (used on the post-401 retry path). + * Returns null when the chat targets the live revision. + */ + const getPreviewToken = useCallback( + async (force = false): Promise => { + if (!revisionId) return null; + const cached = previewTokenRef.current; + if ( + !force && + cached && + cached.expiresAtMs - Date.now() > PREVIEW_TOKEN_EARLY_REFRESH_MS + ) { + return cached.token; + } + const minted = await client.mintAgentPreviewToken(agentSlug, revisionId); + previewTokenRef.current = { + token: minted.token, + // Backend returns TTL in seconds; convert to an absolute deadline so + // the early-refresh comparison is straight subtraction. + expiresAtMs: Date.now() + minted.expires_in * 1000, + }; + return minted.token; + }, + [client, agentSlug, revisionId], + ); + + /** + * Run an ingress call with the cached preview token. On the fetcher's + * `[401]` shape, mint a fresh token and retry the call exactly once — covers + * both the silent-expiry case and a server-side rotation we missed. For + * non-preview chats this is just `call(null)`. + */ + const withPreviewToken = useCallback( + async (call: (token: string | null) => Promise): Promise => { + const token = await getPreviewToken(); + try { + return await call(token); + } catch (err) { + if (!revisionId || !isPreviewAuthError(err)) throw err; + const fresh = await getPreviewToken(true); + return call(fresh); + } + }, + [getPreviewToken, revisionId], + ); const dispatchClientTool = useCallback( async ( @@ -121,17 +205,20 @@ export function useAgentChat({ // resolveInteractiveTool once the user submits the form. if (outcome.defer) return; try { - await client.sendAgentClientToolResult( - ingressBaseUrl, - sessionId, - data.call_id, - outcome, + await withPreviewToken((token) => + client.sendAgentClientToolResult( + ingressBaseUrl, + sessionId, + data.call_id, + outcome, + token, + ), ); } catch { // Best-effort — the session will time the call out if this fails. } }, - [client, ingressBaseUrl, agentSlug], + [client, ingressBaseUrl, agentSlug, withPreviewToken], ); const runStream = useCallback( @@ -144,33 +231,95 @@ export function useAgentChat({ abortRef.current = controller; streamingRef.current = true; const store = agentChatStore.getState(); + // Pump the SSE generator with the supplied token. Returns: + // "remint" — server signalled `preview_token_required` and is + // closing the stream; mint fresh and reconnect. + // "auth_failure" — initial fetch 401'd; safety-net retry once. + // "done" — natural exit (session ended, error surfaced, or + // the run was superseded). + const pump = async ( + token: string | null, + ): Promise<"remint" | "auth_failure" | "done"> => { + try { + for await (const event of client.streamAgentSession( + ingressBaseUrl, + sessionId, + controller.signal, + token, + )) { + if (epochRef.current !== epoch) return "done"; + // Control event: don't surface to the user, just request a remint. + if (event.kind === "preview_token_required") return "remint"; + store.appendMessages(chatId, mapperRef.current.apply(event)); + if (event.kind === "client_tool_call") { + void dispatchClientTool(event.data, sessionId); + } else if (event.kind === "completed") { + store.setStatus(chatId, "completed"); + } else if (event.kind === "waiting") { + store.setStatus(chatId, "awaiting_input"); + } else if (event.kind === "failed") { + store.setStatus(chatId, "failed"); + store.setError( + chatId, + event.data?.reason ?? "The agent run failed.", + ); + } + } + return "done"; + } catch (err) { + if ( + revisionId && + !controller.signal.aborted && + isPreviewAuthError(err) + ) { + return "auth_failure"; + } + if (epochRef.current === epoch && !controller.signal.aborted) { + store.setError( + chatId, + err instanceof Error ? err.message : "Stream dropped.", + ); + } + return "done"; + } + }; try { - for await (const event of client.streamAgentSession( - ingressBaseUrl, - sessionId, - controller.signal, - )) { - if (epochRef.current !== epoch) break; - store.appendMessages(chatId, mapperRef.current.apply(event)); - if (event.kind === "client_tool_call") { - void dispatchClientTool(event.data, sessionId); - } else if (event.kind === "completed") { - store.setStatus(chatId, "completed"); - } else if (event.kind === "waiting") { - store.setStatus(chatId, "awaiting_input"); - } else if (event.kind === "failed") { - store.setStatus(chatId, "failed"); + let token = await getPreviewToken(); + // `preview_token_required` is unbounded (one re-mint per ~15 min TTL + // on long author sessions); a true `[401]` only gets one retry as a + // safety net for the initial fetch. + let authRetried = false; + while (true) { + const outcome = await pump(token); + if (epochRef.current !== epoch || controller.signal.aborted) break; + if (outcome === "remint") { + token = await getPreviewToken(true); + continue; + } + if (outcome === "auth_failure" && !authRetried) { + authRetried = true; + token = await getPreviewToken(true); + continue; + } + // outcome === "done", or auth_failure already retried → surface the + // (already-set) error and exit. + if (outcome === "auth_failure") { store.setError( chatId, - event.data?.reason ?? "The agent run failed.", + "Preview session failed to authenticate. Try again.", ); } + break; } } catch (err) { + // A `getPreviewToken` throw (initial mint or re-mint) lands here — + // `pump` already handles its own errors. Without this the rejection + // would slip past `finally` (which only flips status) and the user + // would see the stream quietly stop. if (epochRef.current === epoch && !controller.signal.aborted) { store.setError( chatId, - err instanceof Error ? err.message : "Stream dropped.", + err instanceof Error ? err.message : "Preview session unavailable.", ); } } finally { @@ -184,7 +333,14 @@ export function useAgentChat({ } } }, - [client, ingressBaseUrl, chatId, dispatchClientTool], + [ + client, + ingressBaseUrl, + chatId, + dispatchClientTool, + getPreviewToken, + revisionId, + ], ); const start = useCallback( @@ -201,9 +357,8 @@ export function useAgentChat({ ? `${buildConsoleContextEnvelope(envelope)}\n\n${text}` : text; try { - const { session_id } = await client.runAgentSession( - ingressBaseUrl, - wireText, + const { session_id } = await withPreviewToken((token) => + client.runAgentSession(ingressBaseUrl, wireText, token), ); agentChatStore.getState().setSessionId(chatId, session_id); agentChatStore.getState().setStatus(chatId, "streaming"); @@ -214,6 +369,7 @@ export function useAgentChat({ sessionId: session_id, title: text.slice(0, 120), startedAt: Date.now(), + revisionId: revisionId ?? undefined, }); } void runStream(session_id); @@ -235,6 +391,8 @@ export function useAgentChat({ runStream, recordHistory, recordChat, + revisionId, + withPreviewToken, ], ); @@ -247,7 +405,9 @@ export function useAgentChat({ s.appendMessages(chatId, mapperRef.current.seedUserMessage(text)); s.setStatus(chatId, "streaming"); try { - await client.sendAgentMessage(ingressBaseUrl, sessionId, text); + await withPreviewToken((token) => + client.sendAgentMessage(ingressBaseUrl, sessionId, text, token), + ); if (!streamingRef.current) void runStream(sessionId); } catch (err) { s.setStatus(chatId, "failed"); @@ -257,7 +417,7 @@ export function useAgentChat({ ); } }, - [client, ingressBaseUrl, chatId, start, runStream], + [client, ingressBaseUrl, chatId, start, runStream, withPreviewToken], ); const cancel = useCallback(async () => { @@ -267,12 +427,14 @@ export function useAgentChat({ s.setStatus(chatId, "cancelled"); if (ingressBaseUrl && sessionId) { try { - await client.cancelAgentSession(ingressBaseUrl, sessionId); + await withPreviewToken((token) => + client.cancelAgentSession(ingressBaseUrl, sessionId, token), + ); } catch { // Best-effort. } } - }, [client, ingressBaseUrl, chatId]); + }, [client, ingressBaseUrl, chatId, withPreviewToken]); // Resolve an interactive client tool (set_secret) once the user submits its // form: post the outcome via `/send` (which wakes the parked session) and @@ -287,11 +449,14 @@ export function useAgentChat({ if (!sessionId) return; agentChatStore.getState().setStatus(chatId, "streaming"); try { - await client.sendAgentInteractiveToolResult( - ingressBaseUrl, - sessionId, - callId, - outcome, + await withPreviewToken((token) => + client.sendAgentInteractiveToolResult( + ingressBaseUrl, + sessionId, + callId, + outcome, + token, + ), ); if (!streamingRef.current) void runStream(sessionId); } catch (err) { @@ -304,7 +469,7 @@ export function useAgentChat({ ); } }, - [client, ingressBaseUrl, chatId, runStream], + [client, ingressBaseUrl, chatId, runStream, withPreviewToken], ); // Re-open a past preview chat. `/listen` only tails (it does not replay), so diff --git a/packages/ui/src/features/agent-applications/hooks/useAgentEnvKeyMutations.ts b/packages/ui/src/features/agent-applications/hooks/useAgentEnvKeyMutations.ts index 0348f57ba1..7b238e3b8b 100644 --- a/packages/ui/src/features/agent-applications/hooks/useAgentEnvKeyMutations.ts +++ b/packages/ui/src/features/agent-applications/hooks/useAgentEnvKeyMutations.ts @@ -4,25 +4,27 @@ import { useAuthStateValue } from "../../auth/store"; import { agentApplicationsKeys } from "./agentApplicationsKeys"; /** - * Set/rotate and clear one agent env key. Both invalidate the env-keys list so - * set/not-set status (tree badges, secret detail) reflects the change. + * Set/rotate and clear one revision-scoped env key. Both invalidate the + * env-keys list for the revision so set/not-set status (tree badges, secret + * detail) reflects the change. */ -export function useAgentEnvKeyMutations(idOrSlug: string) { +export function useAgentEnvKeyMutations(idOrSlug: string, revisionId: string) { const client = useAuthenticatedClient(); const queryClient = useQueryClient(); const projectId = useAuthStateValue((state) => state.currentProjectId); const invalidate = () => queryClient.invalidateQueries({ - queryKey: agentApplicationsKeys.envKeys(projectId, idOrSlug), + queryKey: agentApplicationsKeys.envKeys(projectId, idOrSlug, revisionId), }); const setKey = useMutation({ - mutationFn: ({ key, value }) => client.setAgentEnvKey(idOrSlug, key, value), + mutationFn: ({ key, value }) => + client.setAgentEnvKey(idOrSlug, revisionId, key, value), onSuccess: () => void invalidate(), }); const clearKey = useMutation({ - mutationFn: ({ key }) => client.clearAgentEnvKey(idOrSlug, key), + mutationFn: ({ key }) => client.clearAgentEnvKey(idOrSlug, revisionId, key), onSuccess: () => void invalidate(), }); diff --git a/packages/ui/src/features/agent-applications/hooks/useAgentEnvKeys.ts b/packages/ui/src/features/agent-applications/hooks/useAgentEnvKeys.ts index f2b524d281..633a0b7f81 100644 --- a/packages/ui/src/features/agent-applications/hooks/useAgentEnvKeys.ts +++ b/packages/ui/src/features/agent-applications/hooks/useAgentEnvKeys.ts @@ -2,12 +2,18 @@ import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; import { useAuthStateValue } from "../../auth/store"; import { agentApplicationsKeys } from "./agentApplicationsKeys"; -/** Names of env keys currently set on an agent (values are never returned). */ -export function useAgentEnvKeys(idOrSlug: string) { +/** + * Names of env keys currently set on a revision (values are never returned). + * Env keys are revision-scoped, so callers must pass the revision in scope. + */ +export function useAgentEnvKeys(idOrSlug: string, revisionId: string | null) { const projectId = useAuthStateValue((state) => state.currentProjectId); return useAuthenticatedQuery( - agentApplicationsKeys.envKeys(projectId, idOrSlug), - (client) => client.listAgentEnvKeys(idOrSlug), - { enabled: !!projectId && !!idOrSlug, staleTime: 15_000 }, + agentApplicationsKeys.envKeys(projectId, idOrSlug, revisionId), + (client) => client.listAgentEnvKeys(idOrSlug, revisionId as string), + { + enabled: !!projectId && !!idOrSlug && !!revisionId, + staleTime: 15_000, + }, ); } diff --git a/packages/ui/src/features/agent-applications/hooks/useAgentMissingSecrets.ts b/packages/ui/src/features/agent-applications/hooks/useAgentMissingSecrets.ts new file mode 100644 index 0000000000..62c7a1d2da --- /dev/null +++ b/packages/ui/src/features/agent-applications/hooks/useAgentMissingSecrets.ts @@ -0,0 +1,35 @@ +import { useMemo } from "react"; +import { useAgentEnvKeys } from "./useAgentEnvKeys"; +import { useAgentRevision } from "./useAgentRevision"; + +const EMPTY: string[] = []; + +/** + * Names of secrets the given revision declares in its spec but the revision + * doesn't have set yet. Env keys are revision-scoped, so a draft carries its + * own secret set; for any name in this list the runner will fail at use-site + * until the author sets it on this revision. + * + * Returns the empty list when no revision is targeted (live chat doesn't + * surface this — drafts are the only place where unset secrets are an + * authoring blocker). + */ +export function useAgentMissingSecrets( + idOrSlug: string, + revisionId: string | null, +): string[] { + const { data: revision } = useAgentRevision(idOrSlug, revisionId); + const { data: envKeys } = useAgentEnvKeys(idOrSlug, revisionId); + return useMemo(() => { + if (!revisionId) return EMPTY; + const declared = revision?.spec?.secrets ?? []; + if (declared.length === 0) return EMPTY; + const set = new Set(envKeys ?? []); + const missing = declared.filter((name) => !set.has(name)); + return missing.length === declared.length && envKeys == null + ? // env-keys query hasn't loaded yet; avoid flashing the card with the + // full list before we know what's actually set. + EMPTY + : missing; + }, [revisionId, revision, envKeys]); +} diff --git a/packages/ui/src/features/agent-applications/hooks/useCreateAgentDraftFromRevision.ts b/packages/ui/src/features/agent-applications/hooks/useCreateAgentDraftFromRevision.ts new file mode 100644 index 0000000000..52d6de8f45 --- /dev/null +++ b/packages/ui/src/features/agent-applications/hooks/useCreateAgentDraftFromRevision.ts @@ -0,0 +1,42 @@ +import type { AgentRevision } from "@posthog/shared/agent-platform-types"; +import { useAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useAuthStateValue } from "../../auth/store"; +import { agentApplicationsKeys } from "./agentApplicationsKeys"; + +/** + * Fork an existing revision into a fresh editable draft (one round trip via + * `…/revisions/new_draft/`). The standard exit for "I want to keep iterating + * on this ready / live / archived revision" — the source bundle is immutable, + * so we branch off a copy. Returns the new revision so the caller can select + * it in the picker immediately. + * + * The body wants the application's UUID, not its slug, so callers pass + * `application.id` explicitly (the URL slug used elsewhere wouldn't work). + */ +export function useCreateAgentDraftFromRevision( + idOrSlug: string, + applicationId: string | undefined, +) { + const client = useAuthenticatedClient(); + const queryClient = useQueryClient(); + const projectId = useAuthStateValue((state) => state.currentProjectId); + + return useMutation({ + mutationFn: ({ sourceRevisionId }) => { + if (!applicationId) { + throw new Error("Application not loaded yet"); + } + return client.createAgentDraftRevisionFrom( + applicationId, + sourceRevisionId, + ); + }, + onSuccess: () => { + // Refresh the revisions list so the new draft shows up in the picker. + void queryClient.invalidateQueries({ + queryKey: agentApplicationsKeys.revisions(projectId, idOrSlug), + }); + }, + }); +} diff --git a/packages/ui/src/features/agent-applications/hooks/useDecideAgentApproval.ts b/packages/ui/src/features/agent-applications/hooks/useDecideAgentApproval.ts index bba0466149..efbc538b3a 100644 --- a/packages/ui/src/features/agent-applications/hooks/useDecideAgentApproval.ts +++ b/packages/ui/src/features/agent-applications/hooks/useDecideAgentApproval.ts @@ -12,23 +12,61 @@ interface DecideArgs { body: DecideApprovalRequest; } +type ApprovalCacheSnapshot = [readonly unknown[], unknown][]; + /** - * Approve or reject a queued tool-approval request. On success, refetches the - * agent's approval lists (all state filters) so the row reflects its outcome, - * and fires a toast so the caller doesn't have to add post-decide UX. + * Approve or reject a queued tool-approval request. Optimistically clears the + * approval from every cached approvals shape so the in-chat card unmounts + * immediately (no decide-roundtrip → invalidate → list-refetch lag); restores + * on failure. On success, refetches the agent's approval lists so the row + * reflects its outcome, and fires a toast so the caller doesn't have to add + * post-decide UX. */ export function useDecideAgentApproval(idOrSlug: string) { const client = useAuthenticatedClient(); const queryClient = useQueryClient(); const projectId = useAuthStateValue((state) => state.currentProjectId); - return useMutation({ + return useMutation< + AgentApprovalRequest, + Error, + DecideArgs, + { snapshot: ApprovalCacheSnapshot } + >({ mutationFn: ({ approvalId, body }) => client.decideAgentApproval(idOrSlug, approvalId, body), + onMutate: async ({ approvalId }) => { + // Cancel in-flight approvals queries so a slow refetch can't overwrite + // the optimistic clear after the user has already moved on. + const prefix = [ + "agent-applications", + "approvals", + projectId, + idOrSlug, + ] as const; + await queryClient.cancelQueries({ queryKey: prefix }); + const snapshot: ApprovalCacheSnapshot = + queryClient.getQueriesData({ queryKey: prefix }); + // One updater for both shapes: chatPendingApproval stores + // `AgentApprovalRequest | null`; list queries store `AgentApprovalRequest[]`. + queryClient.setQueriesData( + { queryKey: prefix }, + (old: unknown) => { + if (old == null) return old; + if (Array.isArray(old)) { + return (old as AgentApprovalRequest[]).filter( + (r) => r.id !== approvalId, + ); + } + if (typeof old === "object" && old !== null && "id" in old) { + return (old as AgentApprovalRequest).id === approvalId ? null : old; + } + return old; + }, + ); + return { snapshot }; + }, onSuccess: (_data, { body }) => { - void queryClient.invalidateQueries({ - queryKey: ["agent-applications", "approvals", projectId, idOrSlug], - }); if (body.decision === "approve") { toast.success("Approved", { description: @@ -42,10 +80,23 @@ export function useDecideAgentApproval(idOrSlug: string) { }); } }, - onError: (err) => { + onError: (err, _vars, context) => { + // Restore each shape exactly as it was before the optimistic clear. + if (context?.snapshot) { + for (const [key, data] of context.snapshot) { + queryClient.setQueryData(key, data); + } + } toast.error("Decision failed", { description: err instanceof Error ? err.message : undefined, }); }, + onSettled: () => { + // Converge with server truth — handles next-in-line pending approvals + // for the same session, and refreshes any state-filtered list views. + void queryClient.invalidateQueries({ + queryKey: ["agent-applications", "approvals", projectId, idOrSlug], + }); + }, }); } diff --git a/packages/ui/src/features/agents/components/AgentsTabLayout.tsx b/packages/ui/src/features/agents/components/AgentsTabLayout.tsx index bbf98a66a1..5e093097c2 100644 --- a/packages/ui/src/features/agents/components/AgentsTabLayout.tsx +++ b/packages/ui/src/features/agents/components/AgentsTabLayout.tsx @@ -16,7 +16,7 @@ const TAB_DESCRIPTION: Record = { scouts: "Self-driving agents that watch your project and surface work for review — enroll in the canonical fleet or author your own.", applications: - "Your deployed agents — browse their sessions, approvals, configuration and revisions, or build and edit them with the Agent Builder.", + "Deployed agents triggered by chat, Slack, webhooks, or cron — preview, approve, and edit with the Agent Builder.", }; /** @@ -67,7 +67,7 @@ export function AgentsTabLayout({ : "Design, schedule, and deploy the agents that work on your product."} - + , + ): { revision?: string; session?: string } => ({ + revision: typeof search.revision === "string" ? search.revision : undefined, + session: typeof search.session === "string" ? search.session : undefined, + }), component: AgentChatRoute, }); function AgentChatRoute() { const { idOrSlug } = Route.useParams(); - return ; + const { revision, session } = Route.useSearch(); + return ( + + ); }