From 538bbd5478309aa805ea5e664e0d2193a7fbcfc4 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:23:18 -0500 Subject: [PATCH] fix(ui): configure Grok Bot through its external connection (#16338) ## Thinking Path Paperclip coordinates an external Grok Bot through its authenticated agent connection and routine. The shared configuration form only excluded OpenAI Dot from local runtime controls, so Grok Bot inherited a model picker, local paths, an AI account picker, and a generic runtime test. These controls cannot configure the external Bot and the generic test is not evidence of its webhook-to-MCP connection. Extend the existing external-provider presentation gates and retain the dedicated connection verification workflow. Related implementation: #16219 and #16305. Searched open Grok PRs and issues; the remaining matches concern the separate coding runtime or unrelated coverage. ## Linked Issues or Issue Description **What happened?** An existing Grok Bot agent's Runtime page shows local model and runtime configuration alongside its actual external connection controls. **Expected behavior** Identify the harness as Grok Bot, show its connection and routine controls, and use Verify connection for authenticated two-way readiness. Omit inapplicable local-runtime controls and model catalog queries. **Steps to reproduce** Create a Grok Bot through Invite external agent, then open its Runtime settings. Before this correction, the shared form renders a model selector and Test your agent despite the Bot owning its runtime. **Paperclip version/commit** Master after #16219; reproduced against the shared form at 8ffc5748a28b51e9e6af96bd8b10389cb16c87b9. ## What Changed - Treat Grok Bot and Dot as external providers when resolving local configuration and model catalog queries. - Identify Grok Bot directly in the harness field and keep its dedicated connection, routine, and verification controls. - Hide the generic runtime test for Grok Bot, including compact feedback and externally registered test actions. - Add regressions for compact and inline configuration and production ConfigurationTab stories for connected, disconnected, and mobile states. - Isolate generated documentation previews in separate iframes; verified connected and disconnected states remain distinct across polling. - Document the Runtime management path. ## Verification - New regression failed in both variants before the correction; the four focused UI suites pass: 131 tests, including existing native-provider configuration coverage. - Repository typecheck, production build, and token gates pass. Storybook builds successfully. - Used the built production ConfigurationTab in the browser: connected health, credential replacement fields, disconnected reconnect, and a 375-pixel mobile layout with no horizontal overflow. These are synthetic connection fixtures, not live-provider qualification. - A full local database-backed suite is unavailable because this Mac has reached its SysV shared-memory limit. The earlier diagnostic fixture confirmed that limitation; no unrelated databases were stopped. Full repository CI passes on 60119f1ce18fdd546ca32530ecbbdbb7bac34f98: 55 checks successful and two intentional skips. - Fresh Greptile review is 5/5 on the final head, with the preview-isolation finding addressed and its thread resolved. ## Risks Low risk: shared form presentation and unnecessary model queries change; no authorization, secret storage, Runner execution, schema, or transport behavior changes. Existing stored configuration remains intact. Native-provider form regressions run in the focused suite; full repository CI passes on the final head. ## Model Used OpenAI Codex, based on GPT-6, with repository inspection, code editing, shell tests, and browser verification. The host does not expose a more specific serving-model ID or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/grok-bot-agent.md | 5 ++ .../AgentConfigForm.render.test.tsx | 27 +++++++ ui/src/components/AgentConfigForm.tsx | 41 ++++++---- ui/src/pages/AgentDetail.tsx | 2 +- .../agents/grok-bot/configuration.stories.tsx | 76 +++++++++++++++++++ 5 files changed, 135 insertions(+), 16 deletions(-) create mode 100644 ui/storybook/stories/agents/grok-bot/configuration.stories.tsx diff --git a/doc/grok-bot-agent.md b/doc/grok-bot-agent.md index 9e7c8e076c6..dc842362b26 100644 --- a/doc/grok-bot-agent.md +++ b/doc/grok-bot-agent.md @@ -19,6 +19,11 @@ assignment delivery and an authenticated reply before **Done** becomes available Use **Save & exit** to preserve unfinished setup. See the official [routine instructions](https://cursor.com/help/grok-bot/routines). +Manage the Bot from its agent's **Runtime** settings. This shows connection +health, routine configuration, and verification. Model selection, local paths, +and runtime tests are managed outside Paperclip; use **Verify connection** to +check both directions. + Assign ordinary tasks to the agent. Read its progress, answer its question cards, and review its documents and files in the task thread. The Bot can initiate a new task from its Grok conversation through the admitted work-session tools. diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index d880d96d4fd..7bce845abf9 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -73,6 +73,12 @@ vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi, })); +vi.mock("../api/grokBot", () => ({ + grokBotApi: { + connection: async () => ({ enabled: true, canConfigureConnection: true, binding: null }), + }, +})); + vi.mock("../api/adapters", () => ({ adaptersApi: { list: async () => [{ type: "codex_local", supportedRunners: ["legacy", "paperclip"], defaultRunner: "legacy" }] }, })); @@ -312,6 +318,7 @@ async function renderForm( options: { showAdapterTestEnvironmentButton?: boolean; showAdapterTypeField?: boolean; + compactTestFeedback?: boolean; content?: "configuration" | "secrets"; environmentVariablesPlacement?: "configuration" | "secrets"; hideInlineSave?: boolean; @@ -335,6 +342,7 @@ async function renderForm( agent={makeAgent(agentOverrides)} onSave={onSave} hidePromptTemplate + compactTestFeedback={options.compactTestFeedback} content={options.content} environmentVariablesPlacement={options.environmentVariablesPlacement} hideInlineSave={options.hideInlineSave} @@ -841,6 +849,25 @@ describe("AgentConfigForm environment selector", () => { vi.clearAllMocks(); }); + it.each([false, true])("uses Grok connection verification without native controls (compact: %s)", async compactTestFeedback => { + const result = await renderForm([], { + adapterType: "paperclip_runner", + adapterConfig: { provider: "grok_bot", cwd: "/old/local/path", model: "old-model" }, + }, { showAdapterTypeField: true, showAdapterTestEnvironmentButton: true, compactTestFeedback }); + roots.push(result.root); + + expect(result.container.querySelector('[aria-label="Grok Bot connection"]')).not.toBeNull(); + expect(result.container.textContent).toContain("Reconnect agent access"); + expect(result.container.querySelector('[data-config-section="adapter"]')?.textContent).toContain("Grok Bot"); + expect(result.container.querySelector('[aria-label="Model"]')).toBeNull(); + expect(result.container.textContent).not.toContain("AI connection"); + expect(result.container.textContent).not.toContain("Working directory"); + expect(result.container.textContent).not.toContain("Test your agent"); + expect(findButton(result.container, "Test")).toBeUndefined(); + expect(mockAgentsApi.adapterModels).not.toHaveBeenCalled(); + expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); + }); + it("promotes environment drafts through the page Save action and discards them through the page Discard action", async () => { const dirty = vi.fn(); let save: (() => void) | null = null; diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 7b6ef27182a..b595d2e6fab 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -345,9 +345,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const isCreate = mode === "create"; const cards = props.sectionLayout === "cards"; const showAdapterTypeField = props.showAdapterTypeField ?? true; - const showAdapterTestEnvironmentButton = props.showAdapterTestEnvironmentButton ?? true; - const showInlineAdapterTestEnvironmentButton = - showAdapterTestEnvironmentButton && !props.onTestActionChange && !props.compactTestFeedback; const showInlineAdapterTestEnvironmentFeedback = !props.onTestFeedbackChange; const showCreateRunPolicySection = props.showCreateRunPolicySection ?? true; const hideInstructionsFile = props.hideInstructionsFile ?? false; @@ -629,8 +626,17 @@ export function AgentConfigForm(props: AgentConfigFormProps) { : adapterType; const getCapabilities = useAdapterCapabilities(); const adapterCaps = getCapabilities(isCodexHarness ? harnessType : adapterType); - const isDotRunner = adapterType === "paperclip_runner" && (isCreate ? props.values.adapterSchemaValues?.provider : eff("adapterConfig", "provider", config.provider)) === "openai_dot"; - const isLocal = !isDotRunner && (adapterCaps.supportsInstructionsBundle || adapterCaps.supportsSkills || adapterCaps.supportsLocalAgentJwt); + const runnerProvider = adapterType === "paperclip_runner" + ? String(isCreate ? props.values.adapterSchemaValues?.provider ?? "codex" + : eff("adapterConfig", "provider", config.provider === "acpx" && config.acpxAgent === "codex" ? "codex" : config.provider ?? "codex")) + : undefined; + const isDotRunner = runnerProvider === "openai_dot"; + const isGrokBotRunner = runnerProvider === "grok_bot"; + const isExternalBotRunner = isDotRunner || isGrokBotRunner; + const showAdapterTestEnvironmentButton = !isGrokBotRunner && (props.showAdapterTestEnvironmentButton ?? true); + const showInlineAdapterTestEnvironmentButton = + showAdapterTestEnvironmentButton && !props.onTestActionChange && !props.compactTestFeedback; + const isLocal = !isExternalBotRunner && (adapterCaps.supportsInstructionsBundle || adapterCaps.supportsSkills || adapterCaps.supportsLocalAgentJwt); // The legacy working directory is an absolute path on the host, so the // managed-sandbox-only policy hides it. A stored value stays untouched; it is @@ -909,14 +915,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ? "Paperclip Computer" : "Local"; - const runnerProvider = adapterType === "paperclip_runner" - ? String(isCreate ? props.values.adapterSchemaValues?.provider ?? "codex" - : eff("adapterConfig", "provider", config.provider === "acpx" && config.acpxAgent === "codex" ? "codex" : config.provider ?? "codex")) - : undefined; const modelProvider = adapterType === "opencode_local" && aiConnectionBindingSchema.safeParse( (overlay.runtime.runtimeConfig as Record | undefined)?.aiConnection ?? runtimeConfig.aiConnection, ).data?.provider === "openrouter" ? "openrouter" : runnerProvider; - const connectionModels = useConnectionModels(selectedCompanyId, isCreate ? undefined : aiConnectionBindingSchema.safeParse((overlay.runtime.runtimeConfig as Record | undefined)?.aiConnection ?? runtimeConfig.aiConnection).data, aiRoutingHarness(adapterType, runnerProvider, eff("adapterConfig", "acpxAgent", config.acpxAgent))); + const connectionModels = useConnectionModels(isExternalBotRunner ? null : selectedCompanyId, isCreate ? undefined : aiConnectionBindingSchema.safeParse((overlay.runtime.runtimeConfig as Record | undefined)?.aiConnection ?? runtimeConfig.aiConnection).data, aiRoutingHarness(adapterType, runnerProvider, eff("adapterConfig", "acpxAgent", config.acpxAgent))); // Fetch adapter models for the effective provider, including unsaved changes. const modelAdapterType = isCodexHarness ? harnessType : adapterType; const modelQueryKey = selectedCompanyId @@ -932,7 +934,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { environmentId: currentDefaultEnvironmentId || null, provider: modelProvider, }), - enabled: Boolean(selectedCompanyId) && !connectionModels, + enabled: Boolean(selectedCompanyId) && !isExternalBotRunner && !connectionModels, }); const [refreshModelsError, setRefreshModelsError] = useState(null); const [refreshingModels, setRefreshingModels] = useState(false); @@ -983,8 +985,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) { // Resolve the effective instructions-file gate once. The instructions file // is an absolute host path, so the managed-sandbox-only policy hides it for // every adapter without a per-adapter edit. - hideInstructionsFile: hideInstructionsFile || hideHostPaths || isDotRunner, - managedSandboxOnly: hideHostPaths || isDotRunner, + hideInstructionsFile: hideInstructionsFile || hideHostPaths || isExternalBotRunner, + managedSandboxOnly: hideHostPaths || isExternalBotRunner, openAiDotEnabled: experimentalSettings?.enableOpenAiDot === true, }; @@ -1624,7 +1626,16 @@ export function AgentConfigForm(props: AgentConfigFormProps) { )}
- {showAdapterTypeField && ( + {showAdapterTypeField && isGrokBotRunner && ( + +
+ + Grok Bot + +
+
+ )} + {showAdapterTypeField && !isGrokBotRunner && ( )} - {!isDotRunner && !isCreate && selectedCompanyId && | undefined)?.aiConnection ?? runtimeConfig.aiConnection).data} model={String(eff("adapterConfig", "model", config.model) ?? "")} environmentId={currentDefaultEnvironmentId || undefined} legacy onChange={binding => mark("runtime", "runtimeConfig", { ...runtimeConfig, aiConnection: binding })} />} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index dbd5d7fac7d..4fce523e5ad 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -2021,7 +2021,7 @@ export function ConfigurationTab({ ? queryKeys.agents.adapterModels(companyId, agent.adapterType, null, catalogProvider) : ["agents", "none", "adapter-models", agent.adapterType], queryFn: () => agentsApi.adapterModels(companyId!, agent.adapterType, { provider: catalogProvider }), - enabled: Boolean(companyId) && content === "runtime", + enabled: Boolean(companyId) && content === "runtime" && !["openai_dot", "grok_bot"].includes(catalogProvider ?? ""), }); const lowTrustSelected = getTrustPreset(agent.permissions) === "low_trust_review"; diff --git a/ui/storybook/stories/agents/grok-bot/configuration.stories.tsx b/ui/storybook/stories/agents/grok-bot/configuration.stories.tsx new file mode 100644 index 00000000000..70370e74e60 --- /dev/null +++ b/ui/storybook/stories/agents/grok-bot/configuration.stories.tsx @@ -0,0 +1,76 @@ +import { useEffect, useState } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import type { AgentDetail, GrokConnection } from "@paperclipai/shared"; +import { ConfigurationTab } from "@/pages/AgentDetail"; +import { storybookAgents } from "../../../fixtures/paperclipData"; + +const agent: AgentDetail = { + ...storybookAgents[0]!, id: "grok-config-agent", name: "Night Shift", urlKey: "night-shift", + adapterType: "paperclip_runner", adapterConfig: { provider: "grok_bot", allowUnmeteredProvider: true }, + chainOfCommand: [], access: { canAssignTasks: true, taskAssignSource: "explicit_grant", membership: null, grants: [] }, +}; +const bindingId = "11111111-1111-4111-8111-111111111111"; +const at = "2026-10-13T12:00:00Z"; +const connection: GrokConnection = { + enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "idle", + agentLifecycleState: "ready", canConfigureConnection: true, recoveryNeeded: false, + binding: { + id: bindingId, providerKind: "grok_bot", status: "ready", connected: true, subscriptionVerified: false, + hasPendingChallenge: false, pairingExpiresAt: null, challengeExpiresAt: null, wakeVerified: true, + routine: { bindingId, botName: "Night Shift", revision: 1, configured: true, verified: true, + verifiedAt: at, lastContactAt: at, recoveryNeeded: false, lastExchange: { + id: "synthetic-exchange", kind: "verify", status: "acknowledged", httpStatus: 200, + sentAt: at, acknowledgedAt: at, expiresAt: "2099-01-01T00:00:00Z", + } }, + }, +}; +const ignore = () => {}; + +function Configuration({ disconnected = false }: { disconnected?: boolean }) { + const [client] = useState(() => new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } })); + const [ready, setReady] = useState(false); + useEffect(() => { + const previous = window.fetch; + const fixture: typeof fetch = async (input, init) => { + const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, window.location.origin); + if (url.pathname === `/api/companies/${agent.companyId}/agents/${agent.id}/grok-binding`) { + if ((init?.method ?? "GET") !== "GET") return Response.json({ error: "This preview does not authorize connections." }, { status: 405 }); + return Response.json(disconnected ? { ...connection, binding: null } : connection); + } + return previous(input, init); + }; + window.fetch = fixture; + setReady(true); + return () => { if (window.fetch === fixture) window.fetch = previous; client.clear(); }; + }, [client, disconnected]); + return
+

Night Shift ยท Runtime

+ {ready && } +
; +} + +const meta = { + id: "grok-bot-configuration", + title: "Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/Grok Bot/Configuration", + component: Configuration, + tags: ["autodocs"], + parameters: { layout: "fullscreen", docs: { story: { inline: false } }, paperclip: { capabilities: ["agents/grok-bot"], status: "proposed", implementation: "production", boundary: "fixtures", notes: "Production configuration page with synthetic connection observations. No Grok or authorization calls." } }, +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const ConnectedBot: Story = { play: async ({ canvasElement }) => { + const page = within(canvasElement); + await expect(await page.findByText("Agent access connected. Routine round trip verified.")).toBeVisible(); + await expect(page.getByRole("button", { name: "Verify connection" })).toBeVisible(); + await expect(page.queryByRole("button", { name: /^Model$/ })).not.toBeInTheDocument(); + await expect(page.queryByText("Test your agent")).not.toBeInTheDocument(); + await expect(page.queryByText("AI connection", { exact: true })).not.toBeInTheDocument(); +} }; +export const DisconnectedBot: Story = { args: { disconnected: true }, play: async ({ canvasElement }) => { + await expect(await within(canvasElement).findByRole("button", { name: "Reconnect agent access" })).toBeVisible(); +} }; +export const Mobile: Story = { ...ConnectedBot, globals: { viewport: { value: "mobile1", isRotated: false } } };