Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AgentApplication> {
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -40,6 +41,7 @@ export function AgentBuilderHeaderControls() {

const action = headerActionForPage(page);
const openTip = "Open the agent builder (⌘⇧I)";
const showPublish = page.kind === "agent";

return (
<TooltipProvider delay={500}>
Expand All @@ -48,6 +50,7 @@ export function AgentBuilderHeaderControls() {
gap="2"
className="absolute top-0 right-0 z-10 shrink-0 px-6 py-2"
>
{showPublish ? <PublishButton idOrSlug={page.slug} /> : null}
{action ? (
<div className="flex items-center">
<Tooltip>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not?

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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

function call(toolId: string, args: Record<string, unknown>) {
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" });
});
});
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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);
Expand All @@ -26,10 +35,43 @@ export function useAgentBuilderClientTools(): ClientToolHandler {
pageRef.current = page;

return useCallback(
(data) => {
async (data) => {
const args = (data.args ?? {}) as Record<string, unknown>;
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, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is bizarre - why are we making the agent only able to do this in the console? This should just be soemthing it knows how to do via the mcp surely?

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,
Expand Down Expand Up @@ -150,6 +192,6 @@ export function useAgentBuilderClientTools(): ClientToolHandler {
return { result: { focused: false, reason: "unknown_focus_target" } };
}
},
[navigate, setPendingSecret],
[navigate, setPendingSecret, client, queryClient, projectId],
);
}
Loading
Loading