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 } } };