Move review agent selection to debug harness

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-27 16:21:11 -07:00
parent 2c5c862593
commit e719383a3a
7 changed files with 166 additions and 228 deletions

View File

@@ -48,6 +48,10 @@ import {
} from "./ProjectProfileIdentity";
import { ProjectRichContent } from "./ProjectRichContent";
import { ProjectReviewChecks } from "./ProjectReviewChecks";
import {
ProjectReviewAgentSelectionHarness,
useProjectReviewAgentSelection,
} from "./ProjectReviewAgentSelection";
import { ProjectReviewMemory } from "./ProjectReviewMemory";
import { PullRequestMetaHeader } from "./PullRequestMetaRail";
import { PullRequestReviewCard } from "./PullRequestReviewCard";
@@ -423,6 +427,7 @@ export function ProjectPullRequestDetail({
pullRequest: ProjectPullRequest;
}) {
const identityQuery = useIdentityQuery();
const reviewAgents = useProjectReviewAgentSelection();
const commentMutation = useCreateProjectPullRequestCommentMutation(project);
const [
expandedReviewHistoryPullRequestIds,
@@ -617,14 +622,23 @@ export function ProjectPullRequestDetail({
</div>
</ProjectDetailSection>
<ProjectDetailSection defaultOpen={false} title="Checks">
<ProjectReviewChecks project={project} pullRequest={pullRequest} />
<ProjectReviewChecks
project={project}
pullRequest={pullRequest}
reviewAgents={reviewAgents}
/>
</ProjectDetailSection>
<ProjectDetailSection
defaultOpen={false}
title="Prior art and future vision"
>
<ProjectReviewMemory project={project} pullRequest={pullRequest} />
<ProjectReviewMemory
project={project}
pullRequest={pullRequest}
reviewAgents={reviewAgents}
/>
</ProjectDetailSection>
<ProjectReviewAgentSelectionHarness selection={reviewAgents} />
<ProjectDetailSection defaultOpen title="Activity">
<div className="space-y-3">
<DiscussedInChannels

View File

@@ -0,0 +1,118 @@
import * as React from "react";
import {
useManagedAgentsQuery,
useRelayAgentsQuery,
} from "@/features/agents/hooks";
import {
getMentionableAgentPubkeys,
getSharedChannelIds,
} from "@/features/agents/lib/agentAutocompleteEligibility";
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
import { useChannelsQuery } from "@/features/channels/hooks";
import {
ProjectReviewDebugHarness,
type ReviewAgent,
} from "@/features/projects/ui/ProjectReviewDebugHarness";
import { useIdentityQuery } from "@/shared/api/hooks";
import { normalizePubkey } from "@/shared/lib/pubkey";
export type ProjectReviewAgentSelection = {
candidates: ReviewAgent[];
isError: boolean;
isLoading: boolean;
select: (pubkey: string) => void;
selected: ReviewAgent | null;
};
export function useProjectReviewAgentSelection(): ProjectReviewAgentSelection {
const identityQuery = useIdentityQuery();
const managedAgentsQuery = useManagedAgentsQuery();
const relayAgentsQuery = useRelayAgentsQuery();
const channelsQuery = useChannelsQuery();
const [selectedAgentPubkey, setSelectedAgentPubkey] = React.useState<
string | null
>(null);
const candidates = React.useMemo(() => {
const managedAgents = managedAgentsQuery.data ?? [];
const managedByPubkey = new Map(
managedAgents.map((agent) => [normalizePubkey(agent.pubkey), agent]),
);
const relayAgents = relayAgentsQuery.data ?? [];
const allowedPubkeys = getMentionableAgentPubkeys({
currentPubkey: identityQuery.data?.pubkey,
eligibilityScope: { type: "community" },
managedAgentPubkeys: managedByPubkey.keys(),
relayAgents,
sharedChannelIds: getSharedChannelIds(channelsQuery.data),
});
const available: ReviewAgent[] = 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 (managedByPubkey.has(pubkey) || !allowedPubkeys.has(pubkey)) continue;
available.push({
pubkey,
name: relayAgent.name,
isManaged: false,
isActive: relayAgent.status !== "offline",
});
}
return available.sort((left, right) => {
if (left.isActive !== right.isActive) return left.isActive ? -1 : 1;
if (left.isManaged !== right.isManaged) return left.isManaged ? -1 : 1;
return left.name.localeCompare(right.name);
});
}, [
channelsQuery.data,
identityQuery.data?.pubkey,
managedAgentsQuery.data,
relayAgentsQuery.data,
]);
const activeCandidates = candidates.filter((candidate) => candidate.isActive);
const defaultAgent = activeCandidates[0] ?? null;
const selected = selectedAgentPubkey
? (activeCandidates.find(
(candidate) =>
normalizePubkey(candidate.pubkey) ===
normalizePubkey(selectedAgentPubkey),
) ?? defaultAgent)
: defaultAgent;
const isLoading =
identityQuery.isLoading ||
managedAgentsQuery.isLoading ||
relayAgentsQuery.isLoading ||
channelsQuery.isLoading;
const isError = managedAgentsQuery.isError || relayAgentsQuery.isError;
return React.useMemo(
() => ({
candidates,
isError,
isLoading,
select: setSelectedAgentPubkey,
selected,
}),
[candidates, isError, isLoading, selected],
);
}
export function ProjectReviewAgentSelectionHarness({
selection,
}: {
selection: ProjectReviewAgentSelection;
}) {
return (
<ProjectReviewDebugHarness
candidates={selection.candidates}
hasError={selection.isError}
isLoading={selection.isLoading}
onSelect={selection.select}
selected={selection.selected}
/>
);
}

View File

@@ -9,15 +9,7 @@ import {
import * as React from "react";
import { toast } from "sonner";
import {
useManagedAgentsQuery,
useRelayAgentsQuery,
useStartManagedAgentMutation,
} from "@/features/agents/hooks";
import {
getMentionableAgentPubkeys,
getSharedChannelIds,
} from "@/features/agents/lib/agentAutocompleteEligibility";
import { useStartManagedAgentMutation } from "@/features/agents/hooks";
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
import { useChannelsQuery, useOpenDmMutation } from "@/features/channels/hooks";
import { normalizeRelayUrl } from "@/features/communities/communityStorage";
@@ -45,10 +37,8 @@ import type { ProjectsConversationOpener } from "@/features/projects/lib/project
import { pullRequestShareLink } from "@/features/projects/lib/projectShareLinks";
import type { ProjectPullRequest, Repository } from "@/features/projects/hooks";
import { ProjectReviewCheckResultCard } from "@/features/projects/ui/ProjectReviewCheckResultCard";
import {
ProjectReviewDebugHarness,
type ReviewCheckAgent,
} from "@/features/projects/ui/ProjectReviewDebugHarness";
import type { ProjectReviewAgentSelection } from "@/features/projects/ui/ProjectReviewAgentSelection";
import type { ReviewAgent } from "@/features/projects/ui/ProjectReviewDebugHarness";
import { useIdentityQuery } from "@/shared/api/hooks";
import { listManagedAgents } from "@/shared/api/tauri";
import { revalidateRelayAgents } from "@/shared/api/tauriRelayAgents";
@@ -183,67 +173,6 @@ function normalizeStoredRuns(value: Record<string, unknown>): ReviewCheckRuns {
return runs;
}
/** 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(
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: managedByPubkey.keys(),
relayAgents,
sharedChannelIds,
});
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 (managedByPubkey.has(pubkey) || !allowedPubkeys.has(pubkey)) continue;
assigned.push({
pubkey,
name: relayAgent.name,
isManaged: false,
isActive: relayAgent.status !== "offline",
});
}
return assigned.sort((left, right) => {
if (left.isActive !== right.isActive) return left.isActive ? -1 : 1;
if (left.isManaged !== right.isManaged) return left.isManaged ? -1 : 1;
return left.name.localeCompare(right.name);
});
}, [
channelsQuery.data,
identityQuery.data?.pubkey,
managedAgentsQuery.data,
relayAgentsQuery.data,
]);
return {
candidates,
isLoading:
identityQuery.isLoading ||
managedAgentsQuery.isLoading ||
relayAgentsQuery.isLoading ||
channelsQuery.isLoading,
isError: managedAgentsQuery.isError || relayAgentsQuery.isError,
};
}
function useProjectReviewCheckDefinitions() {
// Future project-owned definitions can be loaded from the target branch here.
// Keep the prototype synchronous and local so opening Checks never clones a repo.
@@ -395,16 +324,17 @@ function CheckStatus({
export function ProjectReviewChecks({
project,
pullRequest,
reviewAgents,
}: {
project: Repository;
pullRequest: ProjectPullRequest;
reviewAgents: ProjectReviewAgentSelection;
}) {
const { activeCommunity } = useCommunities();
const identityQuery = useIdentityQuery();
const channelsQuery = useChannelsQuery();
const openDmMutation = useOpenDmMutation();
const startAgentMutation = useStartManagedAgentMutation();
const reviewCheckAgents = useReviewCheckAgents();
const checkDefinitions = useProjectReviewCheckDefinitions();
const relayScope = activeCommunity?.relayUrl
? normalizeRelayUrl(activeCommunity.relayUrl)
@@ -423,9 +353,6 @@ export function ProjectReviewChecks({
const [pendingCheckIds, setPendingCheckIds] = React.useState<Set<string>>(
() => new Set(),
);
const [selectedAgentPubkey, setSelectedAgentPubkey] = React.useState<
string | null
>(null);
const targetCommit = pullRequest.commit ?? pullRequest.initialCommit;
const diffRepoUrl = React.useMemo(
() =>
@@ -444,7 +371,6 @@ export function ProjectReviewChecks({
);
setRuns(normalizeStoredRuns(stored));
setPendingCheckIds(new Set());
setSelectedAgentPubkey(null);
}, [storageKey]);
const updateRun = React.useCallback(
@@ -516,7 +442,7 @@ export function ProjectReviewChecks({
const runCheck = React.useCallback(
async (
check: ProjectReviewCheckDefinition,
agent: ReviewCheckAgent,
agent: ReviewAgent,
supersededRun?: ReviewCheckRun,
) => {
const expectedStorageKey = storageKey;
@@ -679,17 +605,7 @@ export function ProjectReviewChecks({
),
[channelsQuery.data],
);
const defaultAgent =
reviewCheckAgents.candidates.find((candidate) => candidate.isActive) ??
null;
const selectedAgent = selectedAgentPubkey
? (reviewCheckAgents.candidates.find(
(candidate) =>
candidate.isActive &&
normalizePubkey(candidate.pubkey) ===
normalizePubkey(selectedAgentPubkey),
) ?? defaultAgent)
: defaultAgent;
const selectedAgent = reviewAgents.selected;
return (
<div className="-mx-6" data-testid="project-review-checks">
@@ -705,7 +621,7 @@ export function ProjectReviewChecks({
};
const runningAgent =
run.status === "running" && run.agentPubkey
? (reviewCheckAgents.candidates.find(
? (reviewAgents.candidates.find(
(candidate) =>
normalizePubkey(candidate.pubkey) ===
normalizePubkey(run.agentPubkey ?? ""),
@@ -815,21 +731,13 @@ export function ProjectReviewChecks({
);
})}
</div>
{!reviewCheckAgents.isLoading &&
reviewCheckAgents.candidates.length === 0 ? (
{!reviewAgents.isLoading && reviewAgents.candidates.length === 0 ? (
<div className="border-t border-border/55 px-6 py-3 text-xs text-muted-foreground">
{reviewCheckAgents.isError
{reviewAgents.isError
? "Local managed agents or the relay agent directory could not be loaded."
: "No local managed or authorized relay agents are available."}
</div>
) : null}
<ProjectReviewDebugHarness
candidates={reviewCheckAgents.candidates}
hasError={reviewCheckAgents.isError}
isLoading={reviewCheckAgents.isLoading}
onSelect={setSelectedAgentPubkey}
selected={selectedAgent}
/>
</div>
);
}

View File

@@ -3,14 +3,14 @@ import { Bug } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
export type ReviewCheckAgent = {
export type ReviewAgent = {
pubkey: string;
name: string;
isManaged: boolean;
isActive: boolean;
};
function agentAvailabilityLabel(agent: ReviewCheckAgent) {
function agentAvailabilityLabel(agent: ReviewAgent) {
return agent.isManaged ? "Running here" : "Online on relay";
}
@@ -21,11 +21,11 @@ export function ProjectReviewDebugHarness({
onSelect,
selected,
}: {
candidates: ReviewCheckAgent[];
candidates: ReviewAgent[];
hasError: boolean;
isLoading: boolean;
onSelect: (pubkey: string) => void;
selected: ReviewCheckAgent | null;
selected: ReviewAgent | null;
}) {
const activeCandidates = candidates.filter((candidate) => candidate.isActive);
const offlineAgentCount = candidates.length - activeCandidates.length;
@@ -35,10 +35,10 @@ export function ProjectReviewDebugHarness({
<Popover>
<PopoverTrigger asChild>
<Button
aria-label={`Review check debug harness${selected ? `, agent ${selected.name}` : ""}`}
aria-label={`Review debug harness${selected ? `, agent ${selected.name}` : ""}`}
className="h-12 w-12 rounded-full p-0 shadow-lg ring-1 ring-border/60"
data-testid="project-review-debug-harness-trigger"
title="Review check debug harness"
title="Review debug harness"
type="button"
>
<Bug className="h-5 w-5" />
@@ -54,7 +54,7 @@ export function ProjectReviewDebugHarness({
<div className="space-y-0.5">
<p className="text-sm font-semibold">Review debug harness</p>
<p className="text-xs text-muted-foreground">
Choose the agent used for new checks.
Choose the agent used for checks and context discovery.
</p>
</div>
<label className="block space-y-1.5 text-xs font-medium">

View File

@@ -1,5 +1,4 @@
import {
Bot,
History,
LoaderCircle,
Play,
@@ -10,15 +9,7 @@ import {
import * as React from "react";
import { toast } from "sonner";
import {
useManagedAgentsQuery,
useRelayAgentsQuery,
useStartManagedAgentMutation,
} from "@/features/agents/hooks";
import {
getMentionableAgentPubkeys,
getSharedChannelIds,
} from "@/features/agents/lib/agentAutocompleteEligibility";
import { useStartManagedAgentMutation } from "@/features/agents/hooks";
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
import { useChannelsQuery, useOpenDmMutation } from "@/features/channels/hooks";
import { normalizeRelayUrl } from "@/features/communities/communityStorage";
@@ -43,7 +34,8 @@ import {
} from "@/features/projects/lib/projectReviewMemory";
import { pullRequestShareLink } from "@/features/projects/lib/projectShareLinks";
import type { ProjectPullRequest, Repository } from "@/features/projects/hooks";
import type { ReviewCheckAgent } from "@/features/projects/ui/ProjectReviewDebugHarness";
import type { ProjectReviewAgentSelection } from "@/features/projects/ui/ProjectReviewAgentSelection";
import type { ReviewAgent } from "@/features/projects/ui/ProjectReviewDebugHarness";
import { useIdentityQuery } from "@/shared/api/hooks";
import { listManagedAgents } from "@/shared/api/tauri";
import { revalidateRelayAgents } from "@/shared/api/tauriRelayAgents";
@@ -116,64 +108,6 @@ function normalizeStoredRun(
};
}
function useReviewContextAgents() {
const identityQuery = useIdentityQuery();
const managedAgentsQuery = useManagedAgentsQuery();
const relayAgentsQuery = useRelayAgentsQuery();
const channelsQuery = useChannelsQuery();
const candidates = React.useMemo(() => {
const managedAgents = managedAgentsQuery.data ?? [];
const managedByPubkey = new Map(
managedAgents.map((agent) => [normalizePubkey(agent.pubkey), agent]),
);
const relayAgents = relayAgentsQuery.data ?? [];
const allowedPubkeys = getMentionableAgentPubkeys({
currentPubkey: identityQuery.data?.pubkey,
eligibilityScope: { type: "community" },
managedAgentPubkeys: managedByPubkey.keys(),
relayAgents,
sharedChannelIds: getSharedChannelIds(channelsQuery.data),
});
const available: 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 (managedByPubkey.has(pubkey) || !allowedPubkeys.has(pubkey)) continue;
available.push({
pubkey,
name: relayAgent.name,
isManaged: false,
isActive: relayAgent.status !== "offline",
});
}
return available.sort((left, right) => {
if (left.isActive !== right.isActive) return left.isActive ? -1 : 1;
if (left.isManaged !== right.isManaged) return left.isManaged ? -1 : 1;
return left.name.localeCompare(right.name);
});
}, [
channelsQuery.data,
identityQuery.data?.pubkey,
managedAgentsQuery.data,
relayAgentsQuery.data,
]);
return {
candidates,
isLoading:
identityQuery.isLoading ||
managedAgentsQuery.isLoading ||
relayAgentsQuery.isLoading ||
channelsQuery.isLoading,
isError: managedAgentsQuery.isError || relayAgentsQuery.isError,
};
}
function isAgentMessage(event: RelayEvent, agentPubkey: string) {
return (
(event.kind === KIND_STREAM_MESSAGE ||
@@ -387,16 +321,17 @@ function ReviewContextResultCard({
export function ProjectReviewMemory({
project,
pullRequest,
reviewAgents,
}: {
project: Repository;
pullRequest: ProjectPullRequest;
reviewAgents: ProjectReviewAgentSelection;
}) {
const { activeCommunity } = useCommunities();
const identityQuery = useIdentityQuery();
const channelsQuery = useChannelsQuery();
const openDmMutation = useOpenDmMutation();
const startAgentMutation = useStartManagedAgentMutation();
const reviewContextAgents = useReviewContextAgents();
const relayScope = activeCommunity?.relayUrl
? normalizeRelayUrl(activeCommunity.relayUrl)
: null;
@@ -415,9 +350,6 @@ export function ProjectReviewMemory({
status: "idle",
});
const [isPending, setIsPending] = React.useState(false);
const [selectedAgentPubkey, setSelectedAgentPubkey] = React.useState<
string | null
>(null);
const targetCommit = pullRequest.commit ?? pullRequest.initialCommit;
const repoUrl = React.useMemo(
() =>
@@ -438,7 +370,6 @@ export function ProjectReviewMemory({
);
setRun(stored ?? { agentPubkey: null, status: "idle" });
setIsPending(false);
setSelectedAgentPubkey(null);
}, [storageKey]);
const updateRun = React.useCallback(
@@ -477,7 +408,7 @@ export function ProjectReviewMemory({
);
const runDiscovery = React.useCallback(
async (agent: ReviewCheckAgent, supersededRun?: ReviewContextRun) => {
async (agent: ReviewAgent, supersededRun?: ReviewContextRun) => {
const expectedStorageKey = storageKey;
if (!expectedStorageKey || !relayScope || !signerScope) return;
const requestId = crypto.randomUUID();
@@ -623,20 +554,10 @@ export function ProjectReviewMemory({
),
[channelsQuery.data],
);
const activeCandidates = reviewContextAgents.candidates.filter(
(candidate) => candidate.isActive,
);
const defaultAgent = activeCandidates[0] ?? null;
const selectedAgent = selectedAgentPubkey
? (activeCandidates.find(
(candidate) =>
normalizePubkey(candidate.pubkey) ===
normalizePubkey(selectedAgentPubkey),
) ?? defaultAgent)
: defaultAgent;
const selectedAgent = reviewAgents.selected;
const runningAgent =
run.status === "running" && run.agentPubkey
? (reviewContextAgents.candidates.find(
? (reviewAgents.candidates.find(
(candidate) =>
normalizePubkey(candidate.pubkey) ===
normalizePubkey(run.agentPubkey ?? ""),
@@ -704,33 +625,6 @@ export function ProjectReviewMemory({
{hasRun ? null : "Find context"}
</Button>
</div>
<label className="mt-3 flex max-w-sm items-center gap-2 text-xs font-medium text-muted-foreground">
<Bot className="h-3.5 w-3.5 shrink-0" />
<span className="shrink-0">Agent</span>
<select
aria-label="Context agent"
className="h-8 min-w-0 flex-1 rounded-lg border border-input bg-background px-2.5 text-xs font-normal text-foreground outline-hidden focus:ring-1 focus:ring-ring disabled:opacity-50"
disabled={activeCandidates.length === 0 || run.status === "running"}
onChange={(event) => setSelectedAgentPubkey(event.target.value)}
value={selectedAgent?.pubkey ?? ""}
>
{activeCandidates.length === 0 ? (
<option value="">
{reviewContextAgents.isLoading
? "Loading available agents…"
: reviewContextAgents.isError
? "Agents could not be loaded"
: "No running agents are available"}
</option>
) : null}
{activeCandidates.map((candidate) => (
<option key={candidate.pubkey} value={candidate.pubkey}>
{candidate.name}
{candidate.isManaged ? " · Running here" : " · Online"}
</option>
))}
</select>
</label>
{run.error ? (
<p className="mt-2 text-xs text-destructive">{run.error}</p>
) : null}

View File

@@ -97,8 +97,12 @@ test("review context is discovered by an agent and restored as sourced evidence"
await expect(
memory.getByRole("button", { name: "Record outcome" }),
).toHaveCount(0);
await memory
.getByRole("combobox", { name: "Context agent" })
await expect(
memory.getByRole("combobox", { name: "Context agent" }),
).toHaveCount(0);
await page.getByTestId("project-review-debug-harness-trigger").click();
await page
.getByTestId("project-review-debug-agent-select")
.selectOption(RELAY_REVIEW_AGENT_PUBKEY);
await memory
.getByRole("button", { name: "Find context", exact: true })

View File

@@ -374,7 +374,7 @@ test("review checks dispatch to a running local managed agent", async ({
const debugHarness = page.getByTestId("project-review-debug-harness-trigger");
await expect(debugHarness).toHaveCount(1);
await expect(debugHarness).toHaveAccessibleName(
"Review check debug harness, agent Roof",
"Review debug harness, agent Roof",
);
await debugHarness.click();
await expect(