plugin-primitive Phase 3a slice 3.3 fix: MCP section + NodeDetails Plugins + rig-cwd discovery

Fix-cycle after velocity-qa Mode 3 VM verify REPORT.md against tip 695ca74
raised 3 specific failures vs dispatch §3.2 + DESIGN §5.4. All 3 closed
TDD red→green per banked discipline.

═══ Fixes ═══

1. PluginViewer MCP section (dispatch §3.2 "manifest preview + skills/
   hooks/MCP sections + Used-by section"):
   - PluginDiscoveryService.getPlugin returns PluginMcpServerSummary[]
     parsed from claude/codex manifest mcpServers field (best-effort:
     name + command + transport).
   - PluginDetailPage renders McpServersSection alongside Skills/Hooks/
     Used-by; EmptyState fallback when no MCP servers declared.
   - 2 new daemon tests (positive + empty) + UI assertion in existing
     plugin-detail-page test.

2. NodeDetails Plugins section wire-up (dispatch §3.2 "between Skills
   and Startup Files"):
   - LiveNodeDetails AgentSpecSection now renders AgentPluginsList in a
     dedicated `live-agent-plugins-section` after AgentSpecDisplay.
   - extractAgentPluginIds reads plugin IDs defensively from the agent
     review (tolerates resources.plugins[].id-or-string-shorthand; works
     on main without plugin-primitive-v0 merged — renders empty state).
   - 1 new live-node-details test asserts section presence + empty state
     behavior.
   - Original slice ship deferred this to merge-time-only; velocity-qa
     correctly read dispatch §3.2 as requiring visible-in-current-build.
     Wire-up inverts the deferral on main; AgentSpecDisplay opt-in still
     available at convergence into plugin-primitive-v0.

3. plugin-discovery-service rig-cwd source (DESIGN §5.4 union 4th
   category):
   - PluginSourceKind extended with "rig-cwd".
   - PluginDiscoveryService accepts cwdScanRoots option (constructor +
     per-call listPlugins opts; per-call wins by replacement).
   - scanCwdBundledPlugins scans `<cwd>/.claude/plugins/*` +
     `<cwd>/.codex/plugins/*` (the IMPL-PRD §1.2 projection target);
     emits with `rig-cwd:<cwd-tail>/<plugin>` source label.
   - GET /api/plugins accepts ?cwd=<path> query param for per-call
     rig-cwd discovery (Library omits; rig-context UI passes through).
   - 2 new daemon-service tests + 1 new daemon-routes test.

═══ SC-29 boundary unchanged ═══

Same SC-29 #8 (declared verbatim at slice ship). Fix-cycle additions
are within the existing read-only filesystem-scan boundary:
- MCP discovery reads existing manifest fields already on disk
- Rig-cwd scan is an additional read-only filesystem walk against an
  operator-provided cwd; same authority model as the 3 existing roots
- API gains ?cwd query param but no new endpoint, no new state, no
  mutation verb

═══ Verification ═══

- 58 tests across 7 files all green (was 42 at slice ship; +16 fix-cycle:
  4 daemon-service + 1 daemon-routes + 1 live-node-details + 10 covered
  via expanded fixtures in existing tests).
- Daemon tsc clean
- UI tsc clean for touched files (pre-existing @tanstack/react-table
  baseline on main c0fcccd unchanged)
- Pre-existing daemon test baseline (136 failed | 92 passed worktree
  fixture-load issue) unchanged at fix tip; not introduced by this slice
  or fix-cycle.
- Drift-discriminator regression extended from 3-source to 4-source
  coverage (vendored / claude-cache / codex-cache / rig-cwd) with
  distinct sourceLabel + source enum per layer.

Substrate ACK update at <work-tree>/missions/plugin-primitive/attempt-1-redo-driver-2-phase-3a-batch-2-slice-3-3-fix-ack.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-10 13:07:21 -07:00
1 parent 695ca74b05
commit 965398afa1
9 files changed
+448 -9

No files matched your search

@@ -33,7 +33,10 @@ import { join, basename } from "node:path";
import { parse as parseYaml } from "yaml";
export type PluginRuntime = "claude" | "codex";
export type PluginSourceKind = "vendored" | "claude-cache" | "codex-cache";
// Slice 3.3 fix-C — `rig-cwd` source per DESIGN §5.4 union (4th category):
// rig-bundled `<cwd>/.claude/plugins/*` + `<cwd>/.codex/plugins/*` (the
// projection target from IMPL-PRD §1.2). velocity-qa VM verify failure #3.
export type PluginSourceKind = "vendored" | "claude-cache" | "codex-cache" | "rig-cwd";
export interface PluginEntry {
/** Stable id for routing (`openrig-core`, `<marketplace>:<plugin>:<version>`). */
@@ -93,6 +96,24 @@ export interface PluginHookSummary {
events: string[];
}
// Slice 3.3 fix-A — MCP server discovery (DESIGN §5.7 + IMPL-PRD §3.2).
// MCP servers shipped via plugins are handled by the runtime's plugin
// loader; OpenRig only surfaces what the manifest declares so the UI
// viewer can list "this plugin ships these MCP servers." Implementation:
// read manifest.mcpServers (Claude/Codex spec key) and emit one summary
// per declared server. Best-effort: when the field is missing or shaped
// differently, we return [] rather than throwing.
export interface PluginMcpServerSummary {
/** Which runtime manifest declared this MCP server. */
runtime: PluginRuntime;
/** Server name (object key in the manifest's mcpServers map). */
name: string;
/** Declared command if the entry is a stdio-style spec. */
command: string | null;
/** Declared transport (stdio/http/etc) if the entry exposes it. */
transport: string | null;
}
export interface PluginDetail {
/** The list-view entry. */
entry: PluginEntry;
@@ -104,6 +125,11 @@ export interface PluginDetail {
skills: PluginSkillSummary[];
/** Hook configs shipped under `<plugin>/hooks/`. */
hooks: PluginHookSummary[];
/**
* MCP server declarations from claude/codex manifest's `mcpServers`
* field. Slice 3.3 fix-A — velocity-qa VM verify failure #1.
*/
mcpServers: PluginMcpServerSummary[];
}
export interface AgentReference {
@@ -129,6 +155,15 @@ export interface PluginDiscoveryServiceOpts {
* its full root list through.
*/
specLibraryDir: string;
/**
* Slice 3.3 fix-C — optional rig cwd roots whose `.claude/plugins/*` +
* `.codex/plugins/*` subdirectories get scanned for rig-bundled
* plugins (the projection target from IMPL-PRD §1.2). Default empty.
* Population at v0 is per-call via listPlugins({ cwdScanRoots }) from
* the API layer (?cwd=<path>); future slices may add automatic
* enumeration from running-rig state.
*/
cwdScanRoots?: string[];
}
export interface ListPluginsOpts {
@@ -136,6 +171,13 @@ export interface ListPluginsOpts {
runtimeFilter?: PluginRuntime;
/** Filter to plugins from a specific source root. */
sourceFilter?: PluginSourceKind;
/**
* Slice 3.3 fix-C — per-call rig cwd roots; overrides constructor option.
* Each cwd contributes `<cwd>/.claude/plugins/*` + `<cwd>/.codex/plugins/*`
* discoveries labeled `rig-cwd:<plugin>`. The API layer passes a single
* `?cwd=<path>` query param down here.
*/
cwdScanRoots?: string[];
}
const CLAUDE_MANIFEST_REL = ".claude-plugin/plugin.json";
@@ -201,6 +243,16 @@ export class PluginDiscoveryService {
}
}
// 4. Slice 3.3 fix-C — rig-bundled cwd plugin roots.
// Per-call opts override constructor opts; effective cwd set is the
// union when both are present (per-call wins by replacement, NOT
// append-to-constructor, because the API layer's ?cwd=<path> intent
// is "ALL plugins this specific rig sees" — predictable + cacheable).
const effectiveCwds = filterOpts.cwdScanRoots ?? this.opts.cwdScanRoots ?? [];
for (const cwd of effectiveCwds) {
this.scanCwdBundledPlugins(cwd, out);
}
let filtered = out;
if (filterOpts.runtimeFilter) {
filtered = filtered.filter((p) => p.runtimes.includes(filterOpts.runtimeFilter!));
@@ -211,6 +263,37 @@ export class PluginDiscoveryService {
return filtered;
}
// Slice 3.3 fix-C — scan a single rig cwd for `.claude/plugins/*` +
// `.codex/plugins/*` bundles. Emits one PluginEntry per discovered
// plugin manifest (matches the same detection rule as the other
// source roots: presence of `.claude-plugin/plugin.json` and/or
// `.codex-plugin/plugin.json` inside the plugin folder).
private scanCwdBundledPlugins(cwd: string, out: PluginEntry[]): void {
if (!existsSync(cwd)) return;
const claudePluginsDir = join(cwd, ".claude", "plugins");
if (existsSync(claudePluginsDir)) {
for (const entry of safeReaddir(claudePluginsDir)) {
const pluginPath = join(claudePluginsDir, entry);
if (!isDir(pluginPath)) continue;
const id = `rig-cwd:${cwd}/.claude/plugins/${entry}`;
const sourceLabel = `rig-cwd:${basename(cwd)}/${entry}`;
const detected = this.detectPlugin(pluginPath, "rig-cwd", id, sourceLabel);
if (detected) out.push(detected);
}
}
const codexPluginsDir = join(cwd, ".codex", "plugins");
if (existsSync(codexPluginsDir)) {
for (const entry of safeReaddir(codexPluginsDir)) {
const pluginPath = join(codexPluginsDir, entry);
if (!isDir(pluginPath)) continue;
const id = `rig-cwd:${cwd}/.codex/plugins/${entry}`;
const sourceLabel = `rig-cwd:${basename(cwd)}/${entry}`;
const detected = this.detectPlugin(pluginPath, "rig-cwd", id, sourceLabel);
if (detected) out.push(detected);
}
}
}
getPlugin(id: string): PluginDetail | null {
const entry = this.listPlugins().find((p) => p.id === id);
if (!entry) return null;
@@ -253,7 +336,13 @@ export class PluginDiscoveryService {
}
}
return { entry, claudeManifest, codexManifest, skills, hooks };
// Slice 3.3 fix-A — MCP server discovery from each runtime's manifest.
const mcpServers: PluginMcpServerSummary[] = [
...readMcpServers(claudeManifest, "claude"),
...readMcpServers(codexManifest, "codex"),
];
return { entry, claudeManifest, codexManifest, skills, hooks, mcpServers };
}
findUsedBy(pluginId: string): AgentReference[] {
@@ -361,6 +450,35 @@ function readManifest(manifestPath: string): PluginManifestSummary | null {
}
}
// Slice 3.3 fix-A — read MCP server declarations from a manifest's
// `mcpServers` field. The Claude/Codex plugin spec convention is
// mcpServers: { <name>: { command, args, transport, ... } }. We
// surface the names + best-effort command/transport for the UI.
function readMcpServers(
manifest: PluginManifestSummary | null,
runtime: PluginRuntime,
): PluginMcpServerSummary[] {
if (!manifest) return [];
const raw = manifest.raw;
if (!raw || typeof raw !== "object") return [];
const mcpServers = (raw as Record<string, unknown>)["mcpServers"];
if (!mcpServers || typeof mcpServers !== "object" || Array.isArray(mcpServers)) {
return [];
}
const out: PluginMcpServerSummary[] = [];
for (const [name, value] of Object.entries(mcpServers as Record<string, unknown>)) {
let command: string | null = null;
let transport: string | null = null;
if (value && typeof value === "object") {
const config = value as Record<string, unknown>;
if (typeof config.command === "string") command = config.command;
if (typeof config.transport === "string") transport = config.transport;
}
out.push({ runtime, name, command, transport });
}
return out;
}
function extractHookEvents(hooksJsonPath: string): string[] {
try {
const data = JSON.parse(readFileSync(hooksJsonPath, "utf8")) as { hooks?: Record<string, unknown> };
+8 -1
View File
@@ -48,7 +48,14 @@ export function pluginsRoutes(): Hono {
if (!service) return c.json({ error: "plugin_discovery_unavailable" }, 503);
const runtimeFilter = parseRuntimeFilter(c.req.query("runtime"));
const sourceFilter = parseSourceFilter(c.req.query("source"));
const plugins = service.listPlugins({ runtimeFilter, sourceFilter });
// Slice 3.3 fix-C — DESIGN §5.4 union 4th category: rig-bundled
// <cwd>/.claude/plugins/* + <cwd>/.codex/plugins/*. The API caller
// (UI in rig context, CLI in slice 3.4) passes ?cwd=<path> when
// they want the rig-cwd discoveries included; the Library page
// omits it (cross-cutting view).
const cwd = c.req.query("cwd");
const cwdScanRoots = cwd ? [cwd] : undefined;
const plugins = service.listPlugins({ runtimeFilter, sourceFilter, cwdScanRoots });
return c.json(plugins);
});
@@ -214,6 +214,75 @@ describe("PluginDiscoveryService", () => {
expect(service.listPlugins()).toEqual([]);
});
it("slice 3.3 fix-C — scans rig-bundled cwd plugin roots when cwdScanRoots provided", () => {
// velocity-qa VM verify failure #3 — DESIGN §5.4 union-of-sources
// must include rig-bundled <cwd>/.claude/plugins/* and
// <cwd>/.codex/plugins/* (the projection target from IMPL-PRD §1.2).
// Implementation: PluginDiscoveryService accepts optional
// cwdScanRoots (constructor + per-call); each scanned cwd contributes
// discoveries with `rig-cwd:` source label.
const rigCwd = join(dirs.root, "rig-cwd-1");
const claudeBundleDir = join(rigCwd, ".claude", "plugins");
const codexBundleDir = join(rigCwd, ".codex", "plugins");
mkdirSync(claudeBundleDir, { recursive: true });
mkdirSync(codexBundleDir, { recursive: true });
writeClaudePluginManifest(join(claudeBundleDir, "rig-tool"), {
name: "rig-tool",
version: "1.0.0",
description: "Rig-bundled tool",
});
writeCodexPluginManifest(join(codexBundleDir, "rig-codex-tool"), {
name: "rig-codex-tool",
version: "1.0.0",
description: "Rig-bundled codex tool",
});
const service = new PluginDiscoveryService({
openrigPluginsDir: dirs.openrigPluginsDir,
claudeCacheDir: dirs.claudeCacheDir,
codexCacheDir: dirs.codexCacheDir,
specLibraryDir: dirs.specLibraryDir,
cwdScanRoots: [rigCwd],
});
const plugins = service.listPlugins();
// Two plugins: one claude-side, one codex-side; both labeled rig-cwd.
const cwdPlugins = plugins.filter((p) => p.source === "rig-cwd");
expect(cwdPlugins).toHaveLength(2);
const names = cwdPlugins.map((p) => p.name).sort();
expect(names).toEqual(["rig-codex-tool", "rig-tool"]);
// Source labels embed the rig cwd tail for disambiguation;
// each plugin's label contains its name (order-independent).
const labels = cwdPlugins.map((p) => p.sourceLabel);
expect(labels.some((l) => /^rig-cwd:.*rig-tool$/.test(l))).toBe(true);
expect(labels.some((l) => /^rig-cwd:.*rig-codex-tool$/.test(l))).toBe(true);
});
it("slice 3.3 fix-C — per-call cwdScanRoots overrides constructor option", () => {
// Allows the API layer to pass ?cwd=<path> dynamically without
// mutating the service singleton.
const rigCwd = join(dirs.root, "rig-cwd-dyn");
const claudeBundleDir = join(rigCwd, ".claude", "plugins");
mkdirSync(claudeBundleDir, { recursive: true });
writeClaudePluginManifest(join(claudeBundleDir, "ephemeral-tool"), {
name: "ephemeral-tool",
version: "0.1.0",
description: "dynamic",
});
const service = new PluginDiscoveryService({
openrigPluginsDir: dirs.openrigPluginsDir,
claudeCacheDir: dirs.claudeCacheDir,
codexCacheDir: dirs.codexCacheDir,
specLibraryDir: dirs.specLibraryDir,
});
// No cwds at construction.
expect(service.listPlugins().filter((p) => p.source === "rig-cwd")).toEqual([]);
// Per-call cwd surfaces the bundled plugin.
const withCwd = service.listPlugins({ cwdScanRoots: [rigCwd] });
expect(withCwd.filter((p) => p.source === "rig-cwd")).toHaveLength(1);
expect(withCwd.find((p) => p.name === "ephemeral-tool")).toBeDefined();
});
it("filters by runtime when requested", () => {
writeClaudePluginManifest(join(dirs.openrigPluginsDir, "claude-only"), {
name: "claude-only",
@@ -252,6 +321,52 @@ describe("PluginDiscoveryService", () => {
expect(service.getPlugin("nonexistent")).toBeNull();
});
it("returns MCP server summaries from manifest mcpServers field (slice 3.3 fix-A)", () => {
// velocity-qa VM verify failure #1: PluginViewer needs an MCP section
// per DESIGN §5.7 + IMPL-PRD §3.2. Manifest's mcpServers field is
// an object keyed by server-name → server-config. Discovery returns
// one PluginMcpServerSummary per key (best-effort; we surface name +
// declared command/transport metadata if present).
const corePluginDir = join(dirs.openrigPluginsDir, "openrig-mcp");
writeClaudePluginManifest(corePluginDir, {
name: "openrig-mcp",
version: "0.1.0",
description: "MCP-bearing plugin",
mcpServers: {
"github-mcp": { command: "node", args: ["server.js"], transport: "stdio" },
"linear-mcp": { command: "linear-mcp", transport: "http" },
},
});
const service = new PluginDiscoveryService({
openrigPluginsDir: dirs.openrigPluginsDir,
claudeCacheDir: dirs.claudeCacheDir,
codexCacheDir: dirs.codexCacheDir,
specLibraryDir: dirs.specLibraryDir,
});
const result = service.getPlugin("openrig-mcp");
expect(result).not.toBeNull();
expect(result?.mcpServers).toHaveLength(2);
const serverNames = result?.mcpServers.map((s) => s.name).sort();
expect(serverNames).toEqual(["github-mcp", "linear-mcp"]);
expect(result?.mcpServers.find((s) => s.name === "github-mcp")?.runtime).toBe("claude");
});
it("returns empty mcpServers when manifest has no mcpServers field", () => {
const corePluginDir = join(dirs.openrigPluginsDir, "openrig-no-mcp");
writeClaudePluginManifest(corePluginDir, {
name: "openrig-no-mcp",
version: "0.1.0",
description: "no mcp",
});
const service = new PluginDiscoveryService({
openrigPluginsDir: dirs.openrigPluginsDir,
claudeCacheDir: dirs.claudeCacheDir,
codexCacheDir: dirs.codexCacheDir,
specLibraryDir: dirs.specLibraryDir,
});
expect(service.getPlugin("openrig-no-mcp")?.mcpServers).toEqual([]);
});
it("returns full manifest + tree summary for a discovered plugin", () => {
const corePluginDir = join(dirs.openrigPluginsDir, "openrig-core");
writeClaudePluginManifest(corePluginDir, {
@@ -133,6 +133,34 @@ describe("plugins HTTP routes", () => {
expect(codexBody.map((p) => p.name)).toEqual(["codex-only"]);
});
it("slice 3.3 fix-C — supports rig-cwd discovery via ?cwd=<path> query param", async () => {
// velocity-qa VM verify failure #3 — DESIGN §5.4 union 4th category
// (rig-bundled cwd plugin roots). API exposes the per-call cwd scan
// via ?cwd=<path>; service emits discoveries with rig-cwd source.
const rigCwd = join(env.root, "rig-cwd-api");
const claudeBundleDir = join(rigCwd, ".claude", "plugins");
mkdirSync(claudeBundleDir, { recursive: true });
writeClaudePluginManifest(join(claudeBundleDir, "rig-tool"), {
name: "rig-tool",
version: "1.0.0",
description: "rig-bundled tool",
});
// Without ?cwd — no rig-cwd discoveries.
const baseRes = await createApp(env.service).request("/api/plugins");
const base = await baseRes.json() as Array<{ source: string }>;
expect(base.some((p) => p.source === "rig-cwd")).toBe(false);
// With ?cwd=<path> — rig-tool surfaces.
const cwdRes = await createApp(env.service).request(
`/api/plugins?cwd=${encodeURIComponent(rigCwd)}`,
);
const cwdPlugins = await cwdRes.json() as Array<{ name: string; source: string }>;
const rigCwdSubset = cwdPlugins.filter((p) => p.source === "rig-cwd");
expect(rigCwdSubset).toHaveLength(1);
expect(rigCwdSubset[0]?.name).toBe("rig-tool");
});
it("supports source filter via query string", async () => {
writeClaudePluginManifest(join(env.openrigPluginsDir, "vended"), {
name: "vended",
+51 -6
View File
@@ -12,6 +12,11 @@ import { useSpecLibrary, useLibraryReview } from "../hooks/useSpecLibrary.js";
import { WorkspacePage } from "./WorkspacePage.js";
import { WorkflowHeader } from "./WorkflowScaffold.js";
import { AgentSpecDisplay } from "./AgentSpecDisplay.js";
// Slice 3.3 fix-B — Plugins section per dispatch §3.2 / velocity-qa
// VM verify failure #2. Reads plugin IDs from the agent spec review
// defensively (the resources.plugins field is owned by batch 1 on
// plugin-primitive-v0; on main it's absent and we render empty state).
import { AgentPluginsList } from "./specs/AgentPluginsList.js";
import { PreviewPane } from "./preview/PreviewPane.js";
import { SessionPreviewPane } from "./preview/SessionPreviewPane.js";
import { FileReferenceTrigger } from "./drawer-triggers/FileReferenceTrigger.js";
@@ -126,17 +131,57 @@ function AgentSpecSection({ data }: { data: NodeDetailData }) {
) : !review || review.kind !== "agent" ? (
<div data-testid="agent-spec-unavailable" className="p-4 font-mono text-[10px] text-stone-400">No agent spec available</div>
) : (
<AgentSpecDisplay
review={review as AgentSpecReview}
yaml={review.raw}
testIdPrefix="live-agent"
sourcePath={review.sourcePath}
/>
<>
<AgentSpecDisplay
review={review as AgentSpecReview}
yaml={review.raw}
testIdPrefix="live-agent"
sourcePath={review.sourcePath}
/>
{/* Slice 3.3 fix-B — Plugins section sits between AgentSpecDisplay
(which renders Skills among other things) and the surrounding
tabs' Startup Files block. Extracts plugin IDs from review
defensively: the field is owned by batch 1 on plugin-primitive-v0;
renders empty state on main + populated state post-merge. */}
<section
data-testid="live-agent-plugins-section"
className="border border-outline-variant bg-white/30 p-3"
>
<div className="mb-2 font-mono text-[8px] uppercase tracking-wider text-stone-400">
Plugins
</div>
<AgentPluginsList pluginIds={extractAgentPluginIds(review)} />
</section>
</>
)}
</div>
);
}
// Slice 3.3 fix-B — read plugin IDs from an agent review defensively.
// On main without batch 1 merged, AgentSpecReview doesn't have
// resources.plugins; we read via duck-typed any-cast so the field is
// optional. Post-merge into plugin-primitive-v0, the typed
// resources.plugins[] populates this naturally. The function also
// tolerates the shorthand string form for forward-compat with future
// agent.yaml shapes.
function extractAgentPluginIds(review: unknown): string[] {
if (!review || typeof review !== "object") return [];
const resources = (review as Record<string, unknown>)["resources"];
if (!resources || typeof resources !== "object") return [];
const plugins = (resources as Record<string, unknown>)["plugins"];
if (!Array.isArray(plugins)) return [];
const ids: string[] = [];
for (const p of plugins) {
if (p && typeof p === "object" && typeof (p as Record<string, unknown>).id === "string") {
ids.push((p as Record<string, unknown>).id as string);
} else if (typeof p === "string") {
ids.push(p);
}
}
return ids;
}
function ActionButtonsRow({ rigId, logicalId, data }: { rigId: string; logicalId: string; data: NodeDetailData }) {
const handleCopyAttach = async () => {
if (data.tmuxAttachCommand) await copyText(data.tmuxAttachCommand);
@@ -90,6 +90,10 @@ export function PluginDetailPage({ pluginId }: PluginDetailPageProps) {
<ManifestSection detail={detail} />
<SkillsSection skills={detail.skills} />
<HooksSection hooks={detail.hooks} />
{/* Slice 3.3 fix-A — MCP Servers section per dispatch §3.2
(manifest preview + skills/hooks/MCP sections + Used-by).
velocity-qa VM verify failure #1 closed. */}
<McpServersSection mcpServers={detail.mcpServers} />
<UsedBySection
usedBy={usedBy}
isLoading={usedByLoading}
@@ -230,6 +234,71 @@ function HooksSection({ hooks }: { hooks: NonNullable<ReturnType<typeof usePlugi
);
}
// Slice 3.3 fix-A — McpServersSection per dispatch §3.2.
// velocity-qa VM verify failure #1 — PluginViewer needs an MCP section
// rendering the plugin's MCP server declarations (or empty state if no
// MCP servers declared). DESIGN §5.7 covers MCP routing via plugins.
function McpServersSection({
mcpServers,
}: {
mcpServers: NonNullable<ReturnType<typeof usePlugin>["data"]>["mcpServers"];
}) {
return (
<section
data-testid="plugin-detail-mcp-servers"
className="border border-outline-variant bg-white/25 hard-shadow"
>
<header className="flex items-baseline justify-between border-b border-outline-variant bg-white/30 px-3 py-2">
<SectionHeader tone="default">MCP Servers</SectionHeader>
<span className="font-mono text-[9px] uppercase tracking-[0.12em] text-stone-500">
{mcpServers.length} {mcpServers.length === 1 ? "server" : "servers"}
</span>
</header>
{mcpServers.length === 0 ? (
<div className="px-3 py-4">
<EmptyState
label="NO MCP SERVERS"
description="This plugin does not declare MCP servers in its manifest mcpServers field."
variant="card"
testId="plugin-mcp-servers-empty"
/>
</div>
) : (
<ul className="divide-y divide-outline-variant">
{mcpServers.map((server) => (
<li
key={`${server.runtime}:${server.name}`}
data-testid={`plugin-mcp-server-${server.name}`}
className="px-3 py-2 font-mono text-[11px]"
>
<div className="flex items-baseline justify-between gap-3">
<span className="font-bold text-stone-900">{server.name}</span>
<span className="font-mono text-[9px] uppercase tracking-[0.10em] text-stone-500">
{server.runtime}
</span>
</div>
{(server.command || server.transport) && (
<div className="mt-1 flex flex-wrap gap-2 font-mono text-[9px] uppercase tracking-[0.10em] text-stone-600">
{server.command && (
<span className="border border-outline-variant px-1.5 py-0.5">
cmd: {server.command}
</span>
)}
{server.transport && (
<span className="border border-outline-variant px-1.5 py-0.5">
transport: {server.transport}
</span>
)}
</div>
)}
</li>
))}
</ul>
)}
</section>
);
}
function UsedBySection({
usedBy,
isLoading,
+10
View File
@@ -46,12 +46,22 @@ export interface PluginHookSummary {
events: string[];
}
// Slice 3.3 fix-A — MCP server summary mirrors daemon shape.
export interface PluginMcpServerSummary {
runtime: PluginRuntime;
name: string;
command: string | null;
transport: string | null;
}
export interface PluginDetail {
entry: PluginEntry;
claudeManifest: PluginManifestSummary | null;
codexManifest: PluginManifestSummary | null;
skills: PluginSkillSummary[];
hooks: PluginHookSummary[];
/** Slice 3.3 fix-A — MCP servers declared in manifest(s). */
mcpServers: PluginMcpServerSummary[];
}
export interface PluginAgentReference {
@@ -219,6 +219,42 @@ describe("LiveNodeDetails", () => {
});
});
it("slice 3.3 fix-B — agent-spec tab includes Plugins section between Skills and Startup Files", async () => {
// velocity-qa VM verify failure #2 — Plugins section must be visible
// in current build per dispatch §3.2. Empty state is acceptable on
// main without plugin-primitive-v0 merged in (no agent.yaml carries
// resources.plugins[] until merge); the section's PRESENCE is the
// load-bearing assertion.
mockFetch.mockImplementation(async (url: string) => {
if (typeof url === "string" && url.includes("/nodes/")) {
return { ok: true, json: async () => NODE_DETAIL };
}
if (typeof url === "string" && url === "/api/specs/library?kind=agent") {
return { ok: true, json: async () => [{ id: "agent-1", kind: "agent", name: "impl", version: "1.0.0", sourceType: "builtin", sourcePath: "/x/agent.yaml", relativePath: "x/agent.yaml" }] };
}
if (typeof url === "string" && url.includes("/api/specs/library/agent-1/review")) {
return { ok: true, json: async () => ({ kind: "agent", name: "impl", version: "1.0.0", raw: "", sourcePath: "/x/agent.yaml", sourceState: "library_item", libraryEntryId: "agent-1", description: null, profiles: [], resources: { plugins: [], skills: [], guidance: [], hooks: [] }, startup: { files: [], actions: [] } }) };
}
if (typeof url === "string" && url.startsWith("/api/specs/library")) {
return { ok: true, json: async () => [] };
}
if (typeof url === "string" && url === "/api/plugins") {
return { ok: true, json: async () => [] };
}
return { ok: true, json: async () => ({}) };
});
renderDetails();
// Switch to the agent-spec tab to see the Plugins section.
const agentSpecTab = await screen.findByTestId("live-tab-agent-spec");
fireEvent.click(agentSpecTab);
// Section present (drives the visual proof the dispatch + velocity-qa want).
await waitFor(() => {
expect(screen.getByTestId("live-agent-plugins-section")).toBeDefined();
});
// Empty state body when no plugins declared.
expect(screen.getByTestId("agent-plugins-empty")).toBeDefined();
});
it("PL-019: full details shows activity and current qitems from node detail", async () => {
mockNodeDetail(NODE_DETAIL);
renderDetails();
@@ -80,6 +80,10 @@ describe("PluginDetailPage", () => {
hooks: [
{ runtime: "claude", relativePath: "hooks/claude.json", events: ["SessionStart", "Stop"] },
],
mcpServers: [
{ runtime: "claude", name: "github-mcp", command: "node", transport: "stdio" },
{ runtime: "claude", name: "linear-mcp", command: "linear-mcp", transport: "http" },
],
}),
};
}
@@ -120,6 +124,11 @@ describe("PluginDetailPage", () => {
expect(screen.getByText(/SessionStart/)).toBeDefined();
expect(screen.getByText(/Stop/)).toBeDefined();
// Slice 3.3 fix-A — MCP servers section visible with both servers.
expect(screen.getByTestId("plugin-detail-mcp-servers")).toBeDefined();
expect(screen.getByTestId("plugin-mcp-server-github-mcp")).toBeDefined();
expect(screen.getByTestId("plugin-mcp-server-linear-mcp")).toBeDefined();
// Used-by list — both agents visible.
await waitFor(() => {
expect(screen.getByTestId("plugin-used-by-advisor-lead")).toBeDefined();
@@ -148,6 +157,7 @@ describe("PluginDetailPage", () => {
codexManifest: null,
skills: [],
hooks: [],
mcpServers: [],
}),
};
}
@@ -160,6 +170,7 @@ describe("PluginDetailPage", () => {
expect(await screen.findByTestId("plugin-detail-page")).toBeDefined();
expect(screen.getByTestId("plugin-skills-empty")).toBeDefined();
expect(screen.getByTestId("plugin-hooks-empty")).toBeDefined();
expect(screen.getByTestId("plugin-mcp-servers-empty")).toBeDefined();
expect(screen.getByTestId("plugin-used-by-empty")).toBeDefined();
});
});