From 0c0ba88fdb326c12ac89a9a9bf771b3dafa553be Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 26 Aug 2026 23:16:54 -0700 Subject: [PATCH] Allow local agents to run project review checks Signed-off-by: Taylor Ho --- .../projects/ui/ProjectReviewChecks.tsx | 132 ++++++++++-------- desktop/tests/e2e/project-pr-review.spec.ts | 64 +++++++++ 2 files changed, 141 insertions(+), 55 deletions(-) diff --git a/desktop/src/features/projects/ui/ProjectReviewChecks.tsx b/desktop/src/features/projects/ui/ProjectReviewChecks.tsx index c079bb1c7c..77b6b175db 100644 --- a/desktop/src/features/projects/ui/ProjectReviewChecks.tsx +++ b/desktop/src/features/projects/ui/ProjectReviewChecks.tsx @@ -47,6 +47,7 @@ import type { ProjectsConversationOpener } from "@/features/projects/lib/project import { pullRequestShareLink } from "@/features/projects/lib/projectShareLinks"; import type { ProjectPullRequest, Repository } from "@/features/projects/hooks"; import { useIdentityQuery } from "@/shared/api/hooks"; +import { listManagedAgents } from "@/shared/api/tauri"; import { revalidateRelayAgents } from "@/shared/api/tauriRelayAgents"; import { sendChannelMessage } from "@/shared/api/tauriMessages"; import { getProjectRepoFileContent } from "@/shared/api/projectGit"; @@ -81,7 +82,7 @@ type ReviewCheckRun = { type ReviewCheckRuns = Record; -type RelayAssignedAgent = { +type ReviewCheckAgent = { pubkey: string; name: string; isManaged: boolean; @@ -146,47 +147,42 @@ function normalizeStoredRuns(value: Record): ReviewCheckRuns { return runs; } -/** - * A strict relay-backed candidate list. Unlike the general Projects assistant - * picker, this does not add machine-local agents that the active identity - * cannot also resolve through the relay directory. - */ -function useRelayAssignedAgents() { +/** Local managed agents can run on this dev build; relay agents must pass the + * active identity's community-level respond-to policy. */ +function useReviewCheckAgents() { const identityQuery = useIdentityQuery(); const managedAgentsQuery = useManagedAgentsQuery(); const relayAgentsQuery = useRelayAgentsQuery(); const channelsQuery = useChannelsQuery(); const candidates = React.useMemo(() => { + const managedAgents = managedAgentsQuery.data ?? []; const managedByPubkey = new Map( - (managedAgentsQuery.data ?? []).map((agent) => [ - normalizePubkey(agent.pubkey), - agent, - ]), + managedAgents.map((agent) => [normalizePubkey(agent.pubkey), agent]), ); const relayAgents = relayAgentsQuery.data ?? []; const sharedChannelIds = getSharedChannelIds(channelsQuery.data); const allowedPubkeys = getMentionableAgentPubkeys({ currentPubkey: identityQuery.data?.pubkey, eligibilityScope: { type: "community" }, - managedAgentPubkeys: [], + managedAgentPubkeys: managedByPubkey.keys(), relayAgents, sharedChannelIds, }); - const seen = new Set(); - const assigned: RelayAssignedAgent[] = []; + const assigned: ReviewCheckAgent[] = managedAgents.map((agent) => ({ + pubkey: normalizePubkey(agent.pubkey), + name: agent.name, + isManaged: true, + isActive: isManagedAgentActive(agent), + })); for (const relayAgent of relayAgents) { const pubkey = normalizePubkey(relayAgent.pubkey); - if (seen.has(pubkey) || !allowedPubkeys.has(pubkey)) continue; - seen.add(pubkey); - const managedAgent = managedByPubkey.get(pubkey); + if (managedByPubkey.has(pubkey) || !allowedPubkeys.has(pubkey)) continue; assigned.push({ pubkey, name: relayAgent.name, - isManaged: Boolean(managedAgent), - isActive: managedAgent - ? isManagedAgentActive(managedAgent) - : relayAgent.status !== "offline", + isManaged: false, + isActive: relayAgent.status !== "offline", }); } return assigned.sort((left, right) => { @@ -208,7 +204,7 @@ function useRelayAssignedAgents() { managedAgentsQuery.isLoading || relayAgentsQuery.isLoading || channelsQuery.isLoading, - isError: relayAgentsQuery.isError, + isError: managedAgentsQuery.isError || relayAgentsQuery.isError, }; } @@ -363,10 +359,10 @@ function AgentPicker({ onSelect, selected, }: { - candidates: RelayAssignedAgent[]; + candidates: ReviewCheckAgent[]; disabled: boolean; onSelect: (pubkey: string) => void; - selected: RelayAssignedAgent | null; + selected: ReviewCheckAgent | null; }) { return ( @@ -402,7 +398,7 @@ function AgentPicker({ {candidate.isManaged ? candidate.isActive - ? "On this Mac" + ? "Running here" : "Can start here" : candidate.isActive ? "Online on relay" @@ -428,7 +424,7 @@ export function ProjectReviewChecks({ const channelsQuery = useChannelsQuery(); const openDmMutation = useOpenDmMutation(); const startAgentMutation = useStartManagedAgentMutation(); - const relayAssignedAgents = useRelayAssignedAgents(); + const reviewCheckAgents = useReviewCheckAgents(); const checkDefinitions = useProjectReviewCheckDefinitions(project); const relayScope = activeCommunity?.relayUrl ? normalizeRelayUrl(activeCommunity.relayUrl) @@ -516,7 +512,7 @@ export function ProjectReviewChecks({ ); const runCheck = React.useCallback( - async (check: ProjectReviewCheckDefinition, agent: RelayAssignedAgent) => { + async (check: ProjectReviewCheckDefinition, agent: ReviewCheckAgent) => { const expectedStorageKey = storageKey; if (!expectedStorageKey || !relayScope || !signerScope) return; if (!agent.isManaged && !agent.isActive) { @@ -527,35 +523,60 @@ export function ProjectReviewChecks({ } setPendingCheckIds((current) => new Set(current).add(check.id)); try { - if (agent.isManaged && !agent.isActive) { - await startAgentMutation.mutateAsync({ - pubkey: agent.pubkey, - expectedRelayUrl: relayScope, - expectedSignerPubkey: signerScope, - }); + if (agent.isManaged) { + const managedAgent = (await listManagedAgents()).find( + (candidate) => + normalizePubkey(candidate.pubkey) === + normalizePubkey(agent.pubkey), + ); + if (!managedAgent) { + throw new Error( + `${agent.name} is no longer managed by this dev build.`, + ); + } + if (!isManagedAgentActive(managedAgent)) { + await startAgentMutation.mutateAsync({ + pubkey: agent.pubkey, + expectedRelayUrl: relayScope, + expectedSignerPubkey: signerScope, + }); + } } const channel = await openDmMutation.mutateAsync({ pubkeys: [agent.pubkey], expectedRelayUrl: relayScope, expectedSignerPubkey: signerScope, }); - // DMs represent every participant as a regular member, so authorization - // comes from the selected agent's relay-signed assignment elsewhere in - // the community rather than treating this transport channel as proof. - const revalidated = await revalidateRelayAgents( - [agent.pubkey], - undefined, - ); - if ( - !revalidated.some( + if (agent.isManaged) { + const isStillManaged = (await listManagedAgents()).some( (candidate) => normalizePubkey(candidate.pubkey) === normalizePubkey(agent.pubkey), - ) - ) { - throw new Error( - `${agent.name} is no longer authorized for this identity on the relay.`, ); + if (!isStillManaged) { + throw new Error( + `${agent.name} is no longer managed by this dev build.`, + ); + } + } else { + // DMs represent every participant as a regular member, so remote + // authorization comes from the relay directory instead of treating + // this transport channel as proof. + const revalidated = await revalidateRelayAgents( + [agent.pubkey], + undefined, + ); + if ( + !revalidated.some( + (candidate) => + normalizePubkey(candidate.pubkey) === + normalizePubkey(agent.pubkey), + ) + ) { + throw new Error( + `${agent.name} is no longer authorized for this identity on the relay.`, + ); + } } const prompt = buildProjectReviewCheckPrompt({ check, @@ -644,8 +665,9 @@ export function ProjectReviewChecks({ return (
- Agents are limited to identities the relay directory authorizes for your - signed-in identity. Definitions come from the target branch + Choose a local managed agent on this dev build or a relay agent + authorized for your signed-in identity. Definitions come from the target + branch {checkDefinitions.source === "project" ? ` (${PROJECT_REVIEW_CHECKS_CONFIG_PATH})` : checkDefinitions.source === "starter" @@ -660,7 +682,7 @@ export function ProjectReviewChecks({ status: "idle" as const, }; const selectedAgent = - relayAssignedAgents.candidates.find( + reviewCheckAgents.candidates.find( (candidate) => normalizePubkey(candidate.pubkey) === normalizePubkey(run.agentPubkey ?? ""), @@ -699,7 +721,7 @@ export function ProjectReviewChecks({
assignAgent(check.id, pubkey)} selected={selectedAgent} @@ -780,12 +802,12 @@ export function ProjectReviewChecks({ : `Could not load ${PROJECT_REVIEW_CHECKS_CONFIG_PATH}.`}
) : null} - {!relayAssignedAgents.isLoading && - relayAssignedAgents.candidates.length === 0 ? ( + {!reviewCheckAgents.isLoading && + reviewCheckAgents.candidates.length === 0 ? (
- {relayAssignedAgents.isError - ? "The relay agent directory could not be loaded." - : "No agents are currently assigned to this identity by the relay."} + {reviewCheckAgents.isError + ? "Local managed agents or the relay agent directory could not be loaded." + : "No local managed or authorized relay agents are available."}
) : null}
diff --git a/desktop/tests/e2e/project-pr-review.spec.ts b/desktop/tests/e2e/project-pr-review.spec.ts index 0929bef42b..7951f5275f 100644 --- a/desktop/tests/e2e/project-pr-review.spec.ts +++ b/desktop/tests/e2e/project-pr-review.spec.ts @@ -9,6 +9,7 @@ const REVIEWER_AGENT_PUBKEY = "a".repeat(64); const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8); const RELAY_REVIEW_AGENT_PUBKEY = "554cef57437abac34522ac2c9f0490d685b72c80478cf9f7ed6f9570ee8624ea"; +const LOCAL_REVIEW_AGENT_PUBKEY = "c".repeat(64); async function expectSinglePrimaryTextColumn(row: Locator) { const primary = row.locator('[data-projects-text-priority="primary"]'); @@ -173,6 +174,69 @@ test("review checks dispatch to an agent authorized for the relay identity", asy .toBe(true); }); +test("review checks dispatch to a running local managed agent", async ({ + page, +}) => { + await enableProjectsFeature(page); + await installMockBridge(page, { + managedAgents: [ + { + pubkey: LOCAL_REVIEW_AGENT_PUBKEY, + name: "Roof", + status: "running", + // Deliberately exclude the signed-in identity from relay authorization. + // A local managed agent must still be available to this dev build. + respondTo: "allowlist", + respondToAllowlist: [], + }, + ], + }); + await openBuzzProject(page); + + await page.getByRole("tab", { name: "Review" }).click(); + const aliceRow = pullRequestRowByAuthor(page, "alice").first(); + await expect(aliceRow).toBeVisible({ timeout: 10_000 }); + await aliceRow.getByRole("button", { name: /^#/ }).click(); + await page.getByRole("button", { name: "Checks", exact: true }).click(); + + const checks = page.getByTestId("project-review-checks"); + const interfaceCheck = checks.locator("article").first(); + await interfaceCheck.getByRole("button", { name: "Assign an agent" }).click(); + await page + .getByRole("menuitemradio", { name: /Roof.*Running here/i }) + .click(); + const revalidationCountBefore = await page.evaluate( + () => + window.__BUZZ_E2E_COMMANDS__?.filter( + (command) => command === "revalidate_relay_agents", + ).length ?? 0, + ); + await interfaceCheck + .getByRole("button", { name: "Run", exact: true }) + .click(); + + await expect(interfaceCheck).toContainText("Running"); + await expect + .poll(() => + page.evaluate((pubkey) => { + const entry = window.__BUZZ_E2E_COMMAND_PAYLOADS__?.findLast( + (candidate) => candidate.command === "send_channel_message", + ); + return ( + entry?.payload as { mentionPubkeys?: string[] } | undefined + )?.mentionPubkeys?.includes(pubkey); + }, LOCAL_REVIEW_AGENT_PUBKEY), + ) + .toBe(true); + const commands = await page.evaluate( + () => window.__BUZZ_E2E_COMMANDS__ ?? [], + ); + expect( + commands.filter((command) => command === "revalidate_relay_agents"), + ).toHaveLength(revalidationCountBefore); + expect(commands).not.toContain("start_managed_agent"); +}); + test("same-second request changes supersedes approval", async ({ page }) => { await enableProjectsFeature(page); await page.addInitScript(() => {