diff --git a/apps/mobile/src/features/mcp/api.ts b/apps/mobile/src/features/mcp/api.ts deleted file mode 100644 index f7fa0a828c..0000000000 --- a/apps/mobile/src/features/mcp/api.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { authedFetch, getBaseUrl, getProjectId } from "@/lib/api"; -import type { - InstallCustomMcpServerOptions, - InstallMcpTemplateOptions, - McpApprovalState, - McpInstallationTool, - McpInstallResponse, - McpOAuthRedirectResponse, - McpRecommendedServer, - McpServerInstallation, - UpdateMcpServerInstallationOptions, -} from "./types"; - -function mcpBaseUrl(): string { - const base = getBaseUrl(); - const projectId = getProjectId(); - return `${base}/api/environments/${projectId}/mcp_server_installations`; -} - -async function readJsonOrThrow( - response: Response, - errorPrefix: string, -): Promise { - if (!response.ok) { - const data = (await response.json().catch(() => ({}))) as { - detail?: string; - }; - throw new Error(data.detail ?? `${errorPrefix}: ${response.statusText}`); - } - return (await response.json()) as T; -} - -/** GET /api/environments/{teamId}/mcp_servers/ — marketplace templates. */ -export async function getMcpRecommendedServers(): Promise< - McpRecommendedServer[] -> { - const base = getBaseUrl(); - const projectId = getProjectId(); - const response = await authedFetch( - `${base}/api/environments/${projectId}/mcp_servers/`, - ); - const data = await readJsonOrThrow< - McpRecommendedServer[] | { results?: McpRecommendedServer[] } - >(response, "Failed to fetch MCP servers"); - return Array.isArray(data) ? data : (data.results ?? []); -} - -/** GET /api/environments/{teamId}/mcp_server_installations/ */ -export async function getMcpServerInstallations(): Promise< - McpServerInstallation[] -> { - const response = await authedFetch(`${mcpBaseUrl()}/`); - const data = await readJsonOrThrow< - McpServerInstallation[] | { results?: McpServerInstallation[] } - >(response, "Failed to fetch MCP server installations"); - return Array.isArray(data) ? data : (data.results ?? []); -} - -/** POST /api/environments/{teamId}/mcp_server_installations/install_custom/ */ -export async function installCustomMcpServer( - options: InstallCustomMcpServerOptions, -): Promise { - const response = await authedFetch(`${mcpBaseUrl()}/install_custom/`, { - method: "POST", - body: JSON.stringify(options), - }); - return readJsonOrThrow( - response, - "Failed to install MCP server", - ); -} - -/** POST /api/environments/{teamId}/mcp_server_installations/install_template/ */ -export async function installMcpTemplate( - options: InstallMcpTemplateOptions, -): Promise { - const response = await authedFetch(`${mcpBaseUrl()}/install_template/`, { - method: "POST", - body: JSON.stringify(options), - }); - return readJsonOrThrow( - response, - "Failed to install MCP template", - ); -} - -/** PATCH /api/environments/{teamId}/mcp_server_installations/{id}/ */ -export async function updateMcpServerInstallation( - installationId: string, - updates: UpdateMcpServerInstallationOptions, -): Promise { - const response = await authedFetch(`${mcpBaseUrl()}/${installationId}/`, { - method: "PATCH", - body: JSON.stringify(updates), - }); - return readJsonOrThrow( - response, - "Failed to update MCP server", - ); -} - -/** DELETE /api/environments/{teamId}/mcp_server_installations/{id}/ */ -export async function uninstallMcpServer( - installationId: string, -): Promise { - const response = await authedFetch(`${mcpBaseUrl()}/${installationId}/`, { - method: "DELETE", - }); - if (!response.ok && response.status !== 204) { - throw new Error(`Failed to uninstall MCP server: ${response.statusText}`); - } -} - -/** GET /api/environments/{teamId}/mcp_server_installations/authorize/?installation_id={id} */ -export async function authorizeMcpInstallation(options: { - installation_id: string; - install_source?: "posthog" | "posthog-code" | "posthog-mobile"; - posthog_code_callback_url?: string; -}): Promise { - const params = new URLSearchParams(); - params.set("installation_id", options.installation_id); - if (options.install_source) { - params.set("install_source", options.install_source); - } - if (options.posthog_code_callback_url) { - params.set("posthog_code_callback_url", options.posthog_code_callback_url); - } - const response = await authedFetch( - `${mcpBaseUrl()}/authorize/?${params.toString()}`, - ); - return readJsonOrThrow( - response, - "Failed to authorize MCP installation", - ); -} - -/** GET /api/environments/{teamId}/mcp_server_installations/{id}/tools/ */ -export async function getMcpInstallationTools( - installationId: string, - options: { includeRemoved?: boolean } = {}, -): Promise { - const params = new URLSearchParams(); - if (options.includeRemoved) params.set("include_removed", "1"); - const query = params.toString(); - const response = await authedFetch( - `${mcpBaseUrl()}/${installationId}/tools/${query ? `?${query}` : ""}`, - ); - const data = await readJsonOrThrow< - McpInstallationTool[] | { results?: McpInstallationTool[] } - >(response, "Failed to fetch MCP installation tools"); - return Array.isArray(data) ? data : (data.results ?? []); -} - -/** PATCH /api/environments/{teamId}/mcp_server_installations/{id}/tools/{name}/ */ -export async function updateMcpToolApproval( - installationId: string, - toolName: string, - approval_state: McpApprovalState, -): Promise { - const response = await authedFetch( - `${mcpBaseUrl()}/${installationId}/tools/${encodeURIComponent(toolName)}/`, - { - method: "PATCH", - body: JSON.stringify({ approval_state }), - }, - ); - return readJsonOrThrow( - response, - "Failed to update tool approval", - ); -} - -/** POST /api/environments/{teamId}/mcp_server_installations/{id}/tools/refresh/ */ -export async function refreshMcpInstallationTools( - installationId: string, -): Promise { - const response = await authedFetch( - `${mcpBaseUrl()}/${installationId}/tools/refresh/`, - { method: "POST" }, - ); - const data = await readJsonOrThrow< - McpInstallationTool[] | { results?: McpInstallationTool[] } - >(response, "Failed to refresh MCP tools"); - return Array.isArray(data) ? data : (data.results ?? []); -} diff --git a/apps/mobile/src/features/mcp/hooks.ts b/apps/mobile/src/features/mcp/hooks.ts index 9c724aea1e..4ecf708d61 100644 --- a/apps/mobile/src/features/mcp/hooks.ts +++ b/apps/mobile/src/features/mcp/hooks.ts @@ -1,16 +1,5 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { - authorizeMcpInstallation, - getMcpInstallationTools, - getMcpRecommendedServers, - getMcpServerInstallations, - installCustomMcpServer, - installMcpTemplate, - refreshMcpInstallationTools, - uninstallMcpServer, - updateMcpServerInstallation, - updateMcpToolApproval, -} from "./api"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; import type { InstallCustomMcpServerOptions, InstallMcpTemplateOptions, @@ -29,7 +18,7 @@ const mcpKeys = { export function useMcpMarketplace() { return useQuery({ queryKey: mcpKeys.marketplace(), - queryFn: getMcpRecommendedServers, + queryFn: () => getPostHogApiClient().getMcpServers(), staleTime: 5 * 60 * 1000, }); } @@ -37,7 +26,7 @@ export function useMcpMarketplace() { export function useMcpInstallations() { return useQuery({ queryKey: mcpKeys.installations(), - queryFn: getMcpServerInstallations, + queryFn: () => getPostHogApiClient().getMcpServerInstallations(), staleTime: 30 * 1000, }); } @@ -45,7 +34,8 @@ export function useMcpInstallations() { export function useMcpInstallationTools(installationId: string | null) { return useQuery({ queryKey: mcpKeys.tools(installationId ?? ""), - queryFn: () => getMcpInstallationTools(installationId as string), + queryFn: () => + getPostHogApiClient().getMcpInstallationTools(installationId as string), enabled: !!installationId, staleTime: 30 * 1000, }); @@ -61,7 +51,7 @@ export function useInstallCustomMcpServer() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (options: InstallCustomMcpServerOptions) => - installCustomMcpServer(options), + getPostHogApiClient().installCustomMcpServer(options), onSuccess: () => invalidateInstallations(queryClient), }); } @@ -70,7 +60,7 @@ export function useInstallMcpTemplate() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (options: InstallMcpTemplateOptions) => - installMcpTemplate(options), + getPostHogApiClient().installMcpTemplate(options), onSuccess: () => invalidateInstallations(queryClient), }); } @@ -84,7 +74,11 @@ export function useUpdateMcpServerInstallation() { }: { installationId: string; updates: UpdateMcpServerInstallationOptions; - }) => updateMcpServerInstallation(installationId, updates), + }) => + getPostHogApiClient().updateMcpServerInstallation( + installationId, + updates, + ), onSuccess: () => invalidateInstallations(queryClient), }); } @@ -92,15 +86,19 @@ export function useUpdateMcpServerInstallation() { export function useUninstallMcpServer() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (installationId: string) => uninstallMcpServer(installationId), + mutationFn: (installationId: string) => + getPostHogApiClient().uninstallMcpServer(installationId), onSuccess: () => invalidateInstallations(queryClient), }); } export function useAuthorizeMcpInstallation() { return useMutation({ - mutationFn: (args: Parameters[0]) => - authorizeMcpInstallation(args), + mutationFn: (args: { + installation_id: string; + install_source?: "posthog" | "posthog-code" | "posthog-mobile"; + posthog_code_callback_url?: string; + }) => getPostHogApiClient().authorizeMcpInstallation(args), }); } @@ -108,7 +106,7 @@ export function useRefreshMcpInstallationTools() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (installationId: string) => - refreshMcpInstallationTools(installationId), + getPostHogApiClient().refreshMcpInstallationTools(installationId), onSuccess: (_, installationId) => { queryClient.invalidateQueries({ queryKey: mcpKeys.tools(installationId), @@ -129,7 +127,12 @@ export function useUpdateMcpToolApproval() { installationId: string; toolName: string; approval_state: McpApprovalState; - }) => updateMcpToolApproval(installationId, toolName, approval_state), + }) => + getPostHogApiClient().updateMcpToolApproval( + installationId, + toolName, + approval_state, + ), onSuccess: (_, { installationId }) => { queryClient.invalidateQueries({ queryKey: mcpKeys.tools(installationId), diff --git a/apps/mobile/src/features/mcp/oauth.ts b/apps/mobile/src/features/mcp/oauth.ts index 190fabd6bf..0c91a6cdf1 100644 --- a/apps/mobile/src/features/mcp/oauth.ts +++ b/apps/mobile/src/features/mcp/oauth.ts @@ -1,10 +1,6 @@ import * as Linking from "expo-linking"; import * as WebBrowser from "expo-web-browser"; -import { - authorizeMcpInstallation, - installCustomMcpServer, - installMcpTemplate, -} from "./api"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; import type { InstallCustomMcpServerOptions, InstallMcpTemplateOptions, @@ -51,11 +47,12 @@ export async function installTemplateWithOAuth( "install_source" | "posthog_code_callback_url" >, ): Promise { - const response: McpInstallResponse = await installMcpTemplate({ - ...options, - install_source: INSTALL_SOURCE, - posthog_code_callback_url: OAUTH_CALLBACK_URL, - }); + const response: McpInstallResponse = + await getPostHogApiClient().installMcpTemplate({ + ...options, + install_source: INSTALL_SOURCE, + posthog_code_callback_url: OAUTH_CALLBACK_URL, + }); if (!isOAuthRedirect(response)) return response; @@ -75,11 +72,12 @@ export async function installCustomWithOAuth( "install_source" | "posthog_code_callback_url" >, ): Promise { - const response: McpInstallResponse = await installCustomMcpServer({ - ...options, - install_source: INSTALL_SOURCE, - posthog_code_callback_url: OAUTH_CALLBACK_URL, - }); + const response: McpInstallResponse = + await getPostHogApiClient().installCustomMcpServer({ + ...options, + install_source: INSTALL_SOURCE, + posthog_code_callback_url: OAUTH_CALLBACK_URL, + }); if (!isOAuthRedirect(response)) return response; const outcome = await waitForOAuthCallback(response.redirect_url); @@ -94,11 +92,13 @@ export async function installCustomWithOAuth( export async function reauthorizeInstallation( installationId: string, ): Promise<"completed" | "cancelled"> { - const { redirect_url } = await authorizeMcpInstallation({ - installation_id: installationId, - install_source: INSTALL_SOURCE, - posthog_code_callback_url: OAUTH_CALLBACK_URL, - }); + const { redirect_url } = await getPostHogApiClient().authorizeMcpInstallation( + { + installation_id: installationId, + install_source: INSTALL_SOURCE, + posthog_code_callback_url: OAUTH_CALLBACK_URL, + }, + ); return waitForOAuthCallback(redirect_url); } diff --git a/apps/mobile/src/features/tasks/components/PlanApprovalCard.tsx b/apps/mobile/src/features/tasks/components/PlanApprovalCard.tsx index 6781c7cdff..a03ff18bea 100644 --- a/apps/mobile/src/features/tasks/components/PlanApprovalCard.tsx +++ b/apps/mobile/src/features/tasks/components/PlanApprovalCard.tsx @@ -1,3 +1,9 @@ +import { + getPermissionOptionMeta, + isPermissionRejection, + permissionOptionUsesCustomInput, +} from "@posthog/core/sessions/permissionResponse"; +import { extractPlanText } from "@posthog/core/sessions/planApprovalPresentation"; import { ArrowsClockwise, ChatCircle, @@ -28,56 +34,6 @@ interface PlanApprovalCardProps { onSendPermissionResponse?: (args: PermissionResponseArgs) => void; } -function optionMeta(option: CloudPendingPermissionRequest["options"][number]) { - return option._meta as - | { - customInput?: boolean; - description?: string; - } - | undefined; -} - -function isRejectOption( - option?: CloudPendingPermissionRequest["options"][number], -) { - if (!option) return false; - return option.kind.startsWith("reject") || option.optionId.includes("reject"); -} - -function extractTextContent(item: unknown): string | null { - if (!item || typeof item !== "object") return null; - - const record = item as Record; - if (typeof record.text === "string") { - return record.text; - } - - if (!record.content || typeof record.content !== "object") { - return null; - } - - const content = record.content as Record; - return typeof content.text === "string" ? content.text : null; -} - -function extractPlanText( - permission?: CloudPendingPermissionRequest, -): string | null { - const rawPlan = permission?.toolCall.rawInput?.plan; - if (typeof rawPlan === "string" && rawPlan.trim().length > 0) { - return rawPlan; - } - - for (const item of permission?.toolCall.content ?? []) { - const text = extractTextContent(item); - if (text?.trim()) { - return text; - } - } - - return null; -} - export function PlanApprovalCard({ toolData, permission, @@ -90,7 +46,10 @@ export function PlanApprovalCard({ const [customInput, setCustomInput] = useState(""); const response = permission?.response; - const planText = useMemo(() => extractPlanText(permission), [permission]); + const planText = useMemo( + () => (permission ? extractPlanText(permission.toolCall) : null), + [permission], + ); const selectedOption = useMemo( () => permission?.options.find( @@ -132,7 +91,9 @@ export function PlanApprovalCard({ selectedOption?.name || response?.displayText || null; - const resolvedAsReject = isRejectOption(selectedOption); + const resolvedAsReject = selectedOption + ? isPermissionRejection(selectedOption) + : false; return ( @@ -196,8 +157,8 @@ export function PlanApprovalCard({ ) : ( {permission.options.map((option) => { - const meta = optionMeta(option); - const usesCustomInput = meta?.customInput === true; + const meta = getPermissionOptionMeta(option); + const usesCustomInput = permissionOptionUsesCustomInput(option); const isCustomSelected = selectedCustomOptionId === option.optionId; return ( @@ -225,7 +186,7 @@ export function PlanApprovalCard({ > {option.name} - {meta?.description && ( + {meta.description && ( {meta.description} diff --git a/apps/mobile/src/features/tasks/skills/api.test.ts b/apps/mobile/src/features/tasks/skills/api.test.ts deleted file mode 100644 index 9c6a13f2a2..0000000000 --- a/apps/mobile/src/features/tasks/skills/api.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { mockFetch } = vi.hoisted(() => ({ - mockFetch: vi.fn(), -})); - -vi.mock("expo/fetch", () => ({ - fetch: mockFetch, -})); - -vi.mock("@/lib/api", () => ({ - getBaseUrl: () => "https://app.posthog.test", - getProjectId: () => 42, - authedFetch: (url: string, init?: RequestInit) => - mockFetch(url, { - ...init, - headers: { - Authorization: "Bearer token", - "Content-Type": "application/json", - ...((init?.headers as Record | undefined) ?? {}), - }, - }), -})); - -import { getSkillStoreSkill, getSkillStoreSkills } from "./api"; - -describe("skill store api", () => { - beforeEach(() => { - mockFetch.mockReset(); - }); - - it("parses paginated skill-list responses", async () => { - mockFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ - results: [ - { - name: "shared-daily-brief", - description: "Shared morning briefing starter", - }, - ], - }), - { status: 200 }, - ), - ); - - const skills = await getSkillStoreSkills(); - - expect(skills).toEqual([ - { - name: "shared-daily-brief", - description: "Shared morning briefing starter", - }, - ]); - expect(mockFetch).toHaveBeenCalledWith( - "https://app.posthog.test/api/environments/42/llm_skills/", - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer token", - }), - }), - ); - }); - - it("encodes skill names for detail requests and returns the full body", async () => { - mockFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ - name: "shared/brief today", - description: "Shared briefing", - body: "Summarize what matters this morning.", - }), - { status: 200 }, - ), - ); - - const skill = await getSkillStoreSkill("shared/brief today"); - - expect(skill).toMatchObject({ - name: "shared/brief today", - body: "Summarize what matters this morning.", - }); - expect(mockFetch).toHaveBeenCalledWith( - "https://app.posthog.test/api/environments/42/llm_skills/name/shared%2Fbrief%20today/", - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer token", - }), - }), - ); - }); -}); diff --git a/apps/mobile/src/features/tasks/skills/api.ts b/apps/mobile/src/features/tasks/skills/api.ts deleted file mode 100644 index 46584a4241..0000000000 --- a/apps/mobile/src/features/tasks/skills/api.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { authedFetch, getBaseUrl, getProjectId } from "@/lib/api"; -import type { SkillStoreListEntry, SkillStoreSkill } from "./types"; - -function skillStoreBaseUrl(): string { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - return `${baseUrl}/api/environments/${projectId}/llm_skills`; -} - -async function readJsonOrThrow( - response: Response, - errorPrefix: string, -): Promise { - if (!response.ok) { - const data = (await response.json().catch(() => ({}))) as { - detail?: string; - }; - throw new Error(data.detail ?? `${errorPrefix}: ${response.statusText}`); - } - - return (await response.json()) as T; -} - -export async function getSkillStoreSkills(): Promise { - const response = await authedFetch(`${skillStoreBaseUrl()}/`); - - const data = await readJsonOrThrow< - SkillStoreListEntry[] | { results?: SkillStoreListEntry[] } - >(response, "Failed to fetch skills"); - - return Array.isArray(data) ? data : (data.results ?? []); -} - -export async function getSkillStoreSkill( - skillName: string, -): Promise { - const response = await authedFetch( - `${skillStoreBaseUrl()}/name/${encodeURIComponent(skillName)}/`, - ); - - return readJsonOrThrow(response, "Failed to fetch skill"); -} diff --git a/apps/mobile/src/features/tasks/skills/hooks.ts b/apps/mobile/src/features/tasks/skills/hooks.ts index c78a85cded..fcba3e5523 100644 --- a/apps/mobile/src/features/tasks/skills/hooks.ts +++ b/apps/mobile/src/features/tasks/skills/hooks.ts @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { getSkillStoreSkill, getSkillStoreSkills } from "./api"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; const skillStoreKeys = { all: ["skill-store"] as const, @@ -13,7 +13,7 @@ const skillStoreKeys = { export function useSkillStoreSkills() { return useQuery({ queryKey: skillStoreKeys.list(), - queryFn: getSkillStoreSkills, + queryFn: async () => (await getPostHogApiClient().listLlmSkills()) ?? [], staleTime: 5 * 60 * 1000, }); } @@ -21,7 +21,7 @@ export function useSkillStoreSkills() { export function useSkillStoreSkill(skillName: string | null) { return useQuery({ queryKey: skillStoreKeys.detail(skillName ?? ""), - queryFn: () => getSkillStoreSkill(skillName as string), + queryFn: () => getPostHogApiClient().getLlmSkillByName(skillName as string), enabled: !!skillName, staleTime: 5 * 60 * 1000, }); diff --git a/apps/mobile/src/features/tasks/stores/pendingPromptRecoveryStore.ts b/apps/mobile/src/features/tasks/stores/pendingPromptRecoveryStore.ts index 4e8894e353..bca210d849 100644 --- a/apps/mobile/src/features/tasks/stores/pendingPromptRecoveryStore.ts +++ b/apps/mobile/src/features/tasks/stores/pendingPromptRecoveryStore.ts @@ -1,3 +1,7 @@ +import { + capPendingPrompts, + listPendingPromptsNewestFirst, +} from "@posthog/core/tasks/pendingPrompts"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; @@ -5,8 +9,6 @@ import { createJSONStorage, persist } from "zustand/middleware"; // Persisted (unlike the transient in-memory echo store, whose entries vanish // the instant the live SSE copy lands) so a prompt survives the app being // killed mid-create and can be recovered on the next launch. -const MAX_RECOVERABLE_PROMPTS = 20; - export interface RecoverablePrompt { promptText: string; createdAt: number; @@ -20,19 +22,6 @@ interface PendingPromptRecoveryState { setHasHydrated: (hydrated: boolean) => void; } -function capToNewest( - byKey: Record, -): Record { - const keys = Object.keys(byKey); - if (keys.length <= MAX_RECOVERABLE_PROMPTS) return byKey; - const kept = keys - .sort((a, b) => byKey[b].createdAt - byKey[a].createdAt) - .slice(0, MAX_RECOVERABLE_PROMPTS); - const trimmed: Record = {}; - for (const key of kept) trimmed[key] = byKey[key]; - return trimmed; -} - export const usePendingPromptRecoveryStore = create()( persist( @@ -41,7 +30,7 @@ export const usePendingPromptRecoveryStore = hasHydrated: false, set: (key, promptText) => set((state) => ({ - byKey: capToNewest({ + byKey: capPendingPrompts({ ...state.byKey, [key]: { promptText, createdAt: Date.now() }, }), @@ -75,9 +64,9 @@ export const pendingPromptRecoveryStoreApi = { usePendingPromptRecoveryStore.getState().clear(key); }, getAllNewestFirst(): { key: string; prompt: RecoverablePrompt }[] { - return Object.entries(usePendingPromptRecoveryStore.getState().byKey) - .map(([key, prompt]) => ({ key, prompt })) - .sort((a, b) => b.prompt.createdAt - a.prompt.createdAt); + return listPendingPromptsNewestFirst( + usePendingPromptRecoveryStore.getState().byKey, + ); }, whenHydrated(): Promise { if (usePendingPromptRecoveryStore.getState().hasHydrated) { diff --git a/apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.ts b/apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.ts index e7face63f3..8d759d0086 100644 --- a/apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.ts +++ b/apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.ts @@ -1,3 +1,4 @@ +import { buildPendingPromptKey } from "@posthog/core/tasks/pendingPrompts"; import { create } from "zustand"; import type { SessionNotificationAttachment } from "../types"; @@ -79,8 +80,9 @@ export function generatePendingTaskKey(): string { typeof globalThis !== "undefined" ? (globalThis as { crypto?: { randomUUID?: () => string } }).crypto : undefined; - if (cryptoObj?.randomUUID) { - return cryptoObj.randomUUID(); - } - return `pending-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + return buildPendingPromptKey( + cryptoObj?.randomUUID?.() ?? null, + Date.now(), + Math.random().toString(36).slice(2, 10), + ); } diff --git a/packages/core/src/sessions/permissionResponse.test.ts b/packages/core/src/sessions/permissionResponse.test.ts index 228a28142d..d467ca6154 100644 --- a/packages/core/src/sessions/permissionResponse.test.ts +++ b/packages/core/src/sessions/permissionResponse.test.ts @@ -2,10 +2,62 @@ import type { PermissionRequest } from "@posthog/shared"; import { describe, expect, it } from "vitest"; import { formatPermissionAnswerPrompt, + getPermissionOptionMeta, isOtherPermissionOption, + isPermissionApproval, + isPermissionRejection, + permissionOptionUsesCustomInput, planPermissionResponse, + resolveInitialPlanApprovalOption, + selectPlanPermissionOptions, } from "./permissionResponse"; +describe("permission option presentation", () => { + const approveOnce = { + optionId: "default", + name: "Approve", + kind: "allow_once" as const, + }; + const approveAuto = { + optionId: "auto", + name: "Approve automatically", + kind: "allow_always" as const, + }; + const reject = { + optionId: "reject_with_feedback", + name: "Reject", + kind: "reject_once" as const, + _meta: { customInput: true, description: "Explain why" }, + }; + + it("classifies approval, rejection, and custom-input options", () => { + expect(isPermissionApproval(approveOnce)).toBe(true); + expect(isPermissionRejection(reject)).toBe(true); + expect(permissionOptionUsesCustomInput(reject)).toBe(true); + expect(getPermissionOptionMeta(reject)).toEqual({ + customInput: true, + description: "Explain why", + }); + }); + + it("selects plan options and prefers a feedback rejection", () => { + expect(selectPlanPermissionOptions([approveOnce, reject])).toEqual({ + approvals: [approveOnce], + rejection: reject, + }); + }); + + it.each([ + ["default", "default"], + [null, "auto"], + ["missing", "auto"], + ])("resolves preferred approval %s", (preferred, expected) => { + expect( + resolveInitialPlanApprovalOption([approveOnce, approveAuto], preferred), + ).toBe(expected); + }); +}); + function makePermission( options: Array<{ optionId: string; diff --git a/packages/core/src/sessions/permissionResponse.ts b/packages/core/src/sessions/permissionResponse.ts index eef81a7f10..5cc4a596b5 100644 --- a/packages/core/src/sessions/permissionResponse.ts +++ b/packages/core/src/sessions/permissionResponse.ts @@ -1,5 +1,72 @@ import type { PermissionRequest } from "@posthog/shared"; +export type PermissionOption = PermissionRequest["options"][number]; + +export function getPermissionOptionMeta(option: PermissionOption): { + customInput: boolean; + description?: string; +} { + const meta = option._meta as + | { customInput?: boolean; description?: string } + | null + | undefined; + return { + customInput: meta?.customInput === true, + ...(meta?.description ? { description: meta.description } : {}), + }; +} + +export function isPermissionApproval(option: PermissionOption): boolean { + return option.kind === "allow_once" || option.kind === "allow_always"; +} + +export function isPermissionRejection(option: PermissionOption): boolean { + return ( + option.kind === "reject_once" || + option.kind === "reject_always" || + option.optionId.includes("reject") + ); +} + +export function permissionOptionUsesCustomInput( + option: PermissionOption, +): boolean { + return ( + isOtherPermissionOption(option.optionId) || + getPermissionOptionMeta(option).customInput + ); +} + +export function selectPlanPermissionOptions(options: PermissionOption[]): { + approvals: PermissionOption[]; + rejection: PermissionOption | null; +} { + const approvals = options.filter(isPermissionApproval); + const rejections = options.filter(isPermissionRejection); + return { + approvals, + rejection: + rejections.find(permissionOptionUsesCustomInput) ?? rejections[0] ?? null, + }; +} + +export function resolveInitialPlanApprovalOption( + approvals: PermissionOption[], + preferredOptionId?: string | null, +): string | undefined { + const has = (optionId: string): boolean => + approvals.some((option) => option.optionId === optionId); + return ( + (preferredOptionId && has(preferredOptionId) + ? preferredOptionId + : undefined) ?? + (has("auto") ? "auto" : undefined) ?? + approvals.find((option) => option.optionId === "default")?.optionId ?? + approvals.find((option) => option.kind === "allow_once")?.optionId ?? + approvals[0]?.optionId + ); +} + const OTHER_OPTION_ID = "_other"; const OTHER_OPTION_ID_ALT = "other"; diff --git a/packages/core/src/sessions/planApprovalPresentation.test.ts b/packages/core/src/sessions/planApprovalPresentation.test.ts new file mode 100644 index 0000000000..90866b3bd5 --- /dev/null +++ b/packages/core/src/sessions/planApprovalPresentation.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { extractPlanText } from "./planApprovalPresentation"; + +describe("extractPlanText", () => { + it.each([ + [{ rawInput: { plan: "Raw plan" } }, "Raw plan"], + [{ content: [{ text: "Direct content" }] }, "Direct content"], + [ + { + content: [ + { type: "content", content: { type: "text", text: "Nested" } }, + ], + }, + "Nested", + ], + [{ rawInput: {}, content: [] }, null], + ])("extracts plan presentation from %o", (toolCall, expected) => { + expect(extractPlanText(toolCall)).toBe(expected); + }); + + it("prefers updated streamed content over the initial raw plan", () => { + expect( + extractPlanText({ + rawInput: { plan: "Initial" }, + content: [{ text: "Updated" }], + }), + ).toBe("Updated"); + }); +}); diff --git a/packages/core/src/sessions/planApprovalPresentation.ts b/packages/core/src/sessions/planApprovalPresentation.ts new file mode 100644 index 0000000000..0817576da7 --- /dev/null +++ b/packages/core/src/sessions/planApprovalPresentation.ts @@ -0,0 +1,23 @@ +function extractTextContent(item: unknown): string | null { + if (!item || typeof item !== "object") return null; + const record = item as Record; + if (typeof record.text === "string") return record.text; + + if (!record.content || typeof record.content !== "object") return null; + const content = record.content as Record; + return typeof content.text === "string" ? content.text : null; +} + +export function extractPlanText(toolCall: { + rawInput?: { plan?: unknown } | null; + content?: readonly unknown[] | null; +}): string | null { + for (const item of toolCall.content ?? []) { + const text = extractTextContent(item); + if (text?.trim()) return text; + } + + const rawPlan = toolCall.rawInput?.plan; + if (typeof rawPlan === "string" && rawPlan.trim()) return rawPlan; + return null; +} diff --git a/packages/core/src/tasks/pendingPrompts.test.ts b/packages/core/src/tasks/pendingPrompts.test.ts new file mode 100644 index 0000000000..28d54e2147 --- /dev/null +++ b/packages/core/src/tasks/pendingPrompts.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { + buildPendingPromptKey, + capPendingPrompts, + listPendingPromptsNewestFirst, +} from "./pendingPrompts"; + +describe("pending prompts", () => { + it("keeps the newest prompts up to the limit", () => { + expect( + capPendingPrompts( + { + old: { createdAt: 1 }, + middle: { createdAt: 2 }, + newest: { createdAt: 3 }, + }, + 2, + ), + ).toEqual({ middle: { createdAt: 2 }, newest: { createdAt: 3 } }); + }); + + it("orders prompts newest first", () => { + const prompts = { old: { createdAt: 1 }, new: { createdAt: 2 } }; + expect( + listPendingPromptsNewestFirst(prompts).map(({ key }) => key), + ).toEqual(["new", "old"]); + }); + + it.each([ + ["uuid", 1, "abc", "uuid"], + [null, 123, "abc", "pending-123-abc"], + ])("builds a portable pending key", (uuid, timestamp, entropy, expected) => { + expect(buildPendingPromptKey(uuid, timestamp, entropy)).toBe(expected); + }); +}); diff --git a/packages/core/src/tasks/pendingPrompts.ts b/packages/core/src/tasks/pendingPrompts.ts new file mode 100644 index 0000000000..97a90c8324 --- /dev/null +++ b/packages/core/src/tasks/pendingPrompts.ts @@ -0,0 +1,41 @@ +export const MAX_RECOVERABLE_PROMPTS = 20; + +export interface TimestampedPendingPrompt { + createdAt: number; +} + +export interface RecoverablePendingPrompt< + TPrompt extends TimestampedPendingPrompt, +> { + key: string; + prompt: TPrompt; +} + +export function capPendingPrompts( + byKey: Record, + limit: number = MAX_RECOVERABLE_PROMPTS, +): Record { + const keys = Object.keys(byKey); + if (keys.length <= limit) return byKey; + + const kept = keys + .sort((left, right) => byKey[right].createdAt - byKey[left].createdAt) + .slice(0, limit); + return Object.fromEntries(kept.map((key) => [key, byKey[key]])); +} + +export function listPendingPromptsNewestFirst< + TPrompt extends TimestampedPendingPrompt, +>(byKey: Record): RecoverablePendingPrompt[] { + return Object.entries(byKey) + .map(([key, prompt]) => ({ key, prompt })) + .sort((left, right) => right.prompt.createdAt - left.prompt.createdAt); +} + +export function buildPendingPromptKey( + randomUuid: string | null, + timestamp: number, + entropy: string, +): string { + return randomUuid ?? `pending-${timestamp}-${entropy}`; +} diff --git a/packages/ui/src/features/permissions/PlanApprovalSelector.tsx b/packages/ui/src/features/permissions/PlanApprovalSelector.tsx index 1cd5e3541f..0f1a25c0d6 100644 --- a/packages/ui/src/features/permissions/PlanApprovalSelector.tsx +++ b/packages/ui/src/features/permissions/PlanApprovalSelector.tsx @@ -1,7 +1,8 @@ -import type { - PermissionOption, - SessionConfigOption, -} from "@agentclientprotocol/sdk"; +import type { SessionConfigOption } from "@agentclientprotocol/sdk"; +import { + resolveInitialPlanApprovalOption, + selectPlanPermissionOptions, +} from "@posthog/core/sessions/permissionResponse"; import type { ExecutionMode } from "@posthog/shared"; import { ModeSelector } from "@posthog/ui/features/message-editor/components/ModeSelector"; import { MODE_LABELS } from "@posthog/ui/features/sessions/modeStyles"; @@ -17,21 +18,6 @@ import { type BasePermissionProps, toSelectorOptions } from "./types"; const TITLE = "Implementation Plan"; const QUESTION = "Approve this plan to proceed?"; -function isApprove(option: PermissionOption): boolean { - return option.kind === "allow_once" || option.kind === "allow_always"; -} - -function isReject(option: PermissionOption): boolean { - return option.kind === "reject_once" || option.kind === "reject_always"; -} - -function hasCustomInput(option: PermissionOption): boolean { - return ( - (option._meta as { customInput?: boolean } | null | undefined) - ?.customInput === true - ); -} - // Don't steal focus from an interactive element in a different grid cell // (multi-task view). Mirrors the guard in useActionSelectorState. function isInteractiveElementInDifferentCell( @@ -65,11 +51,8 @@ export function PlanApprovalSelector({ onSelect, onCancel, }: BasePermissionProps) { - const approveOptions = useMemo(() => options.filter(isApprove), [options]); - const rejectOption = useMemo( - () => - options.find((o) => isReject(o) && hasCustomInput(o)) ?? - options.find(isReject), + const { approvals: approveOptions, rejection: rejectOption } = useMemo( + () => selectPlanPermissionOptions(options), [options], ); @@ -81,16 +64,7 @@ export function PlanApprovalSelector({ // Resolution order: the mode last approved with (remembered preference), // then "auto", then manual-approve, then any single-use mode, then the first. const initialMode = useMemo(() => { - const has = (id: string) => approveOptions.some((o) => o.optionId === id); - return ( - (lastApprovalMode && has(lastApprovalMode) - ? lastApprovalMode - : undefined) ?? - (has("auto") ? "auto" : undefined) ?? - approveOptions.find((o) => o.optionId === "default")?.optionId ?? - approveOptions.find((o) => o.kind === "allow_once")?.optionId ?? - approveOptions[0]?.optionId - ); + return resolveInitialPlanApprovalOption(approveOptions, lastApprovalMode); }, [approveOptions, lastApprovalMode]); const [selectedMode, setSelectedMode] = useState(initialMode); diff --git a/packages/ui/src/features/permissions/types.ts b/packages/ui/src/features/permissions/types.ts index 1505da5062..1794965cda 100644 --- a/packages/ui/src/features/permissions/types.ts +++ b/packages/ui/src/features/permissions/types.ts @@ -3,6 +3,7 @@ import type { RequestPermissionRequest, ToolCallContent, } from "@agentclientprotocol/sdk"; +import { getPermissionOptionMeta } from "@posthog/core/sessions/permissionResponse"; import type { CodeToolKind } from "@posthog/ui/features/sessions/types"; import type { SelectorOption } from "@posthog/ui/primitives/ActionSelector"; @@ -26,14 +27,12 @@ export function toSelectorOptions( options: PermissionOption[], ): SelectorOption[] { return options.map((opt) => { - const meta = opt._meta as - | { description?: string; customInput?: boolean } - | undefined; + const meta = getPermissionOptionMeta(opt); return { id: opt.optionId, label: opt.name, - description: meta?.description, - customInput: meta?.customInput, + description: meta.description, + customInput: meta.customInput, }; }); } diff --git a/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.tsx b/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.tsx index b70c281953..bf609d89d2 100644 --- a/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.tsx @@ -1,4 +1,5 @@ import { CaretDown, CaretRight, CheckCircle } from "@phosphor-icons/react"; +import { extractPlanText } from "@posthog/core/sessions/planApprovalPresentation"; import { Box, Flex, Text } from "@radix-ui/themes"; import { useMemo, useState } from "react"; import { PlanContent } from "../../../permissions/PlanContent"; @@ -32,20 +33,10 @@ export function PlanApprovalView({ | undefined; const isHistoricalPlan = rawInput?.historical === true; - const planText = useMemo(() => { - if (content?.length) { - const textContent = content.find((c) => c.type === "content"); - if (textContent && "content" in textContent) { - const inner = textContent.content as - | { type?: string; text?: string } - | undefined; - if (inner?.type === "text" && inner.text) { - return inner.text; - } - } - } - return rawInput?.plan ?? null; - }, [content, rawInput?.plan]); + const planText = useMemo( + () => extractPlanText({ rawInput, content }), + [content, rawInput], + ); const wasNotApproved = isFailed || wasCancelled; const showResult = isHistoricalPlan || isComplete || wasNotApproved; diff --git a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts index d13a1c445f..9b3abdaae0 100644 --- a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts +++ b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts @@ -31,7 +31,10 @@ import { useConnectivity } from "../../../hooks/useConnectivity"; import { toast } from "../../../primitives/toast"; import { track } from "../../../shell/analytics"; import { logger } from "../../../shell/logger"; -import { pendingTaskPromptStoreApi } from "../../../shell/pendingTaskPromptStore"; +import { + generatePendingTaskKey, + pendingTaskPromptStoreApi, +} from "../../../shell/pendingTaskPromptStore"; import { titleAttachmentStoreApi } from "../../../shell/titleAttachmentStore"; import { useAuthStateValue } from "../../auth/store"; import { assertCloudUsageAvailable } from "../../billing/preflightCloudUsage"; @@ -319,7 +322,7 @@ export function useTaskCreation({ const shouldShowPendingView = !onTaskCreated && !!plainPromptText; const pendingTaskKey = shouldShowPendingView - ? (globalThis.crypto?.randomUUID?.() ?? `pending-${Date.now()}`) + ? generatePendingTaskKey() : null; if (pendingTaskKey) { diff --git a/packages/ui/src/shell/pendingTaskPromptStore.ts b/packages/ui/src/shell/pendingTaskPromptStore.ts index b91fefd3f4..cd227ef24a 100644 --- a/packages/ui/src/shell/pendingTaskPromptStore.ts +++ b/packages/ui/src/shell/pendingTaskPromptStore.ts @@ -1,13 +1,8 @@ import type { UserMessageAttachment } from "@posthog/ui/features/sessions/userMessageTypes"; -import { logger } from "@posthog/ui/shell/logger"; import { electronStorage } from "@posthog/ui/shell/rendererStorage"; import { create } from "zustand"; import { persist } from "zustand/middleware"; -const log = logger.scope("pending-task-prompts"); - -const MAX_PENDING_PROMPTS = 20; - export interface PendingTaskPrompt { promptText: string; attachments: UserMessageAttachment[]; @@ -16,26 +11,6 @@ export interface PendingTaskPrompt { export type PendingTaskPromptInput = Omit; -function capToNewest( - byKey: Record, -): Record { - const keys = Object.keys(byKey); - if (keys.length <= MAX_PENDING_PROMPTS) { - return byKey; - } - const keptKeys = keys - .sort((a, b) => byKey[b].createdAt - byKey[a].createdAt) - .slice(0, MAX_PENDING_PROMPTS); - log.warn("Dropping oldest unrecovered prompts beyond cap", { - dropped: keys.length - keptKeys.length, - }); - const kept: Record = {}; - for (const key of keptKeys) { - kept[key] = byKey[key]; - } - return kept; -} - interface PendingTaskPromptStore { byKey: Record; _hasHydrated: boolean; @@ -54,7 +29,7 @@ export const usePendingTaskPromptStore = create()( setHasHydrated: (hydrated) => set({ _hasHydrated: hydrated }), set: (key, prompt) => set((state) => ({ - byKey: capToNewest({ + byKey: capPendingPrompts({ ...state.byKey, [key]: { ...prompt, createdAt: Date.now() }, }), @@ -110,9 +85,7 @@ export const pendingTaskPromptStoreApi = { usePendingTaskPromptStore.getState().move(fromKey, toKey), clear: (key: string) => usePendingTaskPromptStore.getState().clear(key), getAllNewestFirst: (): RecoverablePendingPrompt[] => - Object.entries(usePendingTaskPromptStore.getState().byKey) - .map(([key, prompt]) => ({ key, prompt })) - .sort((a, b) => b.prompt.createdAt - a.prompt.createdAt), + listPendingPromptsNewestFirst(usePendingTaskPromptStore.getState().byKey), whenHydrated: (): Promise => { if (usePendingTaskPromptStore.getState()._hasHydrated) { return Promise.resolve(); @@ -128,6 +101,14 @@ export const pendingTaskPromptStoreApi = { }, }; +export function generatePendingTaskKey(): string { + return buildPendingPromptKey( + globalThis.crypto?.randomUUID?.() ?? null, + Date.now(), + Math.random().toString(36).slice(2, 10), + ); +} + export function usePendingTaskPrompt( key: string | undefined, ): PendingTaskPrompt | undefined { @@ -135,3 +116,9 @@ export function usePendingTaskPrompt( key ? state.byKey[key] : undefined, ); } + +import { + buildPendingPromptKey, + capPendingPrompts, + listPendingPromptsNewestFirst, +} from "@posthog/core/tasks/pendingPrompts";