feat: add capability discovery route

This commit is contained in:
lda
2026-08-04 21:53:53 +07:00 Verified
parent a458c6c375
commit 40cbac7909
7 changed files with 996 additions and 1 deletions
@@ -0,0 +1,120 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CapabilityDiscoveryController } from "./useCapabilityDiscovery.js";
import { useCapabilityDiscovery } from "./useCapabilityDiscovery.js";
import { DiscoverRoute } from "./DiscoverRoute.js";
vi.mock("./useCapabilityDiscovery.js", () => ({
useCapabilityDiscovery: vi.fn(),
}));
const mockedUseCapabilityDiscovery = vi.mocked(useCapabilityDiscovery);
const summary = {
kind: "node_spec" as const,
name: "local.documents.read",
sourceId: "local.documents",
description: "Read project documents.",
outcomes: ["ok", "error"],
inputFields: ["names"],
outputFields: ["documents"],
};
const controller = (
overrides: Partial<CapabilityDiscoveryController> = {},
): CapabilityDiscoveryController => ({
phase: "ready",
query: "",
sourceId: "",
items: [summary],
selected: null,
nextCursor: null,
message: null,
setQuery: vi.fn(),
setSourceId: vi.fn(),
search: vi.fn(),
loadMore: vi.fn(),
inspect: vi.fn(),
...overrides,
});
beforeEach(() => mockedUseCapabilityDiscovery.mockReturnValue(controller()));
afterEach(() => cleanup());
describe("DiscoverRoute", () => {
it("shows the discovery heading and searchable source-filtered controls", () => {
render(<DiscoverRoute />);
expect(screen.getByRole("heading", { name: "Discover capabilities" })).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Search capabilities" })).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Filter by source" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Search" })).toBeInTheDocument();
});
it("renders compact capability rows with contract summary fields", async () => {
const inspect = vi.fn();
mockedUseCapabilityDiscovery.mockReturnValue(controller({ inspect }));
render(<DiscoverRoute />);
expect(screen.getByText("Node spec")).toBeInTheDocument();
expect(screen.getByText("Source: local.documents")).toBeInTheDocument();
expect(screen.getByText("Inputs: names")).toBeInTheDocument();
expect(screen.getByText("Outputs: documents")).toBeInTheDocument();
expect(screen.getByText("Outcomes: ok, error")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /local\.documents\.read/i }));
expect(inspect).toHaveBeenCalledWith("local.documents.read");
});
it.each([
["disconnected", "Connect a workflow server to discover capabilities."],
["loading", "Loading capabilities..."],
["error", "CapabilityPage is malformed"],
] as const)("renders an explicit %s state", (phase, message) => {
mockedUseCapabilityDiscovery.mockReturnValue(
controller({ phase, message: phase === "error" ? message : null, items: [] }),
);
render(<DiscoverRoute />);
expect(screen.getByText(message)).toBeInTheDocument();
});
it("renders selected input/output schemas and wrapper hints", () => {
mockedUseCapabilityDiscovery.mockReturnValue(
controller({
selected: {
...summary,
isAsync: false,
inputSchema: { type: "object", properties: { names: { type: "array" } } },
outputSchema: { type: "object", properties: { documents: { type: "array" } } },
wrapperHints: { input: "names", output: "documents" },
acceptsContext: true,
},
}),
);
render(<DiscoverRoute />);
expect(screen.getByRole("heading", { name: "Input schema" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Output schema" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Wrapper hints" })).toBeInTheDocument();
expect(screen.getAllByText(/"names"/)).toHaveLength(2);
expect(screen.queryByRole("button", { name: /add to draft/i })).toBeNull();
});
it("shows load more only when the controller has a next cursor", async () => {
const loadMore = vi.fn();
mockedUseCapabilityDiscovery.mockReturnValue(
controller({ nextCursor: "page-2", loadMore }),
);
render(<DiscoverRoute />);
await userEvent.click(screen.getByRole("button", { name: "Load more capabilities" }));
expect(loadMore).toHaveBeenCalledOnce();
cleanup();
mockedUseCapabilityDiscovery.mockReturnValue(controller());
render(<DiscoverRoute />);
expect(screen.queryByRole("button", { name: "Load more capabilities" })).toBeNull();
});
});
@@ -0,0 +1,172 @@
import { Boxes, PackageOpen } from "lucide-react";
import type {
CapabilityDetail,
CapabilitySummary,
} from "../domain/capability-models.js";
import { useCapabilityDiscovery } from "./useCapabilityDiscovery.js";
const formatKind = (kind: CapabilitySummary["kind"]): string =>
kind === "node_spec" ? "Node spec" : "Wrapper artifact";
const KindIcon = ({ kind }: { readonly kind: CapabilitySummary["kind"] }) => {
const Icon = kind === "node_spec" ? Boxes : PackageOpen;
return <Icon aria-hidden="true" size={16} strokeWidth={1.8} />;
};
const SchemaBlock = ({
heading,
value,
}: {
readonly heading: string;
readonly value: Record<string, unknown>;
}) => (
<div className="capability-discovery__schema-block">
<h3>{heading}</h3>
<pre>{JSON.stringify(value, null, 2)}</pre>
</div>
);
const CapabilityRow = ({
item,
selected,
onInspect,
}: {
readonly item: CapabilitySummary;
readonly selected: boolean;
readonly onInspect: (qualifiedName: string) => void;
}) => (
<li>
<button
className="capability-discovery__row"
data-selected={selected}
onClick={() => onInspect(item.name)}
type="button"
>
<span className="capability-discovery__row-heading">
<span className="capability-discovery__kind">
<KindIcon kind={item.kind} />
<span>{formatKind(item.kind)}</span>
</span>
<strong>{item.name}</strong>
</span>
<span className="capability-discovery__row-meta">
<span>Source: {item.sourceId}</span>
<span>Inputs: {item.inputFields.length > 0 ? item.inputFields.join(", ") : "none"}</span>
<span>Outputs: {item.outputFields.length > 0 ? item.outputFields.join(", ") : "none"}</span>
<span>Outcomes: {item.outcomes.length > 0 ? item.outcomes.join(", ") : "none"}</span>
</span>
{item.description && <span className="capability-discovery__description">{item.description}</span>}
</button>
</li>
);
const DetailView = ({ detail }: { readonly detail: CapabilityDetail }) => (
<section aria-labelledby="capability-detail-heading" className="capability-discovery__detail">
<p className="workspace-route-pending__eyebrow">Selected contract</p>
<h2 id="capability-detail-heading">{detail.name}</h2>
<dl className="capability-discovery__facts">
<div><dt>Kind</dt><dd>{formatKind(detail.kind)}</dd></div>
<div><dt>Source</dt><dd>{detail.sourceId}</dd></div>
<div><dt>Async</dt><dd>{detail.isAsync ? "yes" : "no"}</dd></div>
<div><dt>Outcomes</dt><dd>{detail.outcomes.join(", ") || "none"}</dd></div>
</dl>
<div className="capability-discovery__schemas">
<SchemaBlock heading="Input schema" value={detail.inputSchema} />
<SchemaBlock heading="Output schema" value={detail.outputSchema} />
<SchemaBlock heading="Wrapper hints" value={detail.wrapperHints} />
</div>
</section>
);
export const DiscoverRoute = () => {
const discovery = useCapabilityDiscovery();
const isReady = discovery.phase === "ready";
return (
<div className="capability-discovery">
<header className="capability-discovery__header">
<p className="workspace-route-pending__eyebrow">Capability catalog</p>
<h1>Discover capabilities</h1>
<p>Inspect the typed contracts available to workflow authors before drafting.</p>
</header>
<form
className="capability-discovery__filters"
onSubmit={(event) => {
event.preventDefault();
discovery.search();
}}
>
<div>
<label htmlFor="capability-search">Search capabilities</label>
<input
id="capability-search"
onChange={(event) => discovery.setQuery(event.target.value)}
type="text"
value={discovery.query}
/>
</div>
<div>
<label htmlFor="capability-source">Filter by source</label>
<input
id="capability-source"
onChange={(event) => discovery.setSourceId(event.target.value)}
type="text"
value={discovery.sourceId}
/>
</div>
<button type="submit">Search</button>
</form>
<div className="capability-discovery__panes">
<section aria-labelledby="capability-results-heading" className="capability-discovery__results">
<div className="capability-discovery__section-heading">
<div>
<p className="workspace-route-pending__eyebrow">Available interfaces</p>
<h2 id="capability-results-heading">Capabilities</h2>
</div>
{isReady && <span className="capability-discovery__count">{discovery.items.length} shown</span>}
</div>
{discovery.phase === "disconnected" && (
<p role="status">Connect a workflow server to discover capabilities.</p>
)}
{discovery.phase === "loading" && <p role="status">Loading capabilities...</p>}
{discovery.phase === "error" && (
<p role="alert">{discovery.message ?? "Capability discovery failed."}</p>
)}
{isReady && discovery.items.length === 0 && (
<p role="status">No capabilities match the current filters.</p>
)}
{discovery.items.length > 0 && (
<ul className="capability-discovery__list">
{discovery.items.map((item) => (
<CapabilityRow
item={item}
key={item.name}
onInspect={discovery.inspect}
selected={discovery.selected?.name === item.name}
/>
))}
</ul>
)}
{discovery.nextCursor && (
<button onClick={discovery.loadMore} type="button">
Load more capabilities
</button>
)}
</section>
{discovery.selected ? (
<DetailView detail={discovery.selected} />
) : (
<section aria-labelledby="capability-detail-empty-heading" className="capability-discovery__detail capability-discovery__detail--empty">
<p className="workspace-route-pending__eyebrow">Contract detail</p>
<h2 id="capability-detail-empty-heading">Select a capability</h2>
<p>Choose a result to inspect its input, output, and wrapper contract.</p>
</section>
)}
</div>
</div>
);
};
@@ -0,0 +1,246 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectionState } from "../../app/state.js";
import { useConsoleWorkspace } from "../context.js";
import type { CapabilityClient } from "../domain/capability-client.js";
import type {
CapabilityDetail,
CapabilityPage,
CapabilitySummary,
} from "../domain/capability-models.js";
import { createCapabilityClient } from "../domain/capability-client.js";
import type { ConsoleReadExecutor } from "../domain/read-executor.js";
import { useCapabilityDiscovery } from "./useCapabilityDiscovery.js";
vi.mock("../context.js", () => ({
useConsoleWorkspace: vi.fn(),
}));
vi.mock("../domain/capability-client.js", async () => {
const actual = await vi.importActual<typeof import("../domain/capability-client.js")>(
"../domain/capability-client.js",
);
return { ...actual, createCapabilityClient: vi.fn() };
});
const mockedUseConsoleWorkspace = vi.mocked(useConsoleWorkspace);
const mockedCreateCapabilityClient = vi.mocked(createCapabilityClient);
const connectedState = {
phase: "connected",
connectedTarget: "http://workflow.example/rpc",
} as ConnectionState;
const disconnectedState = {
phase: "not_configured",
connectedTarget: null,
} as ConnectionState;
type NodeSpecSummary = Extract<CapabilitySummary, { readonly kind: "node_spec" }>;
const summary = (name: string, sourceId = "local.documents"): NodeSpecSummary => ({
kind: "node_spec",
name,
sourceId,
description: `${name} description`,
outcomes: ["ok", "error"],
inputFields: ["input"],
outputFields: ["output"],
});
const detail = (name: string): CapabilityDetail => ({
...summary(name),
isAsync: false,
inputSchema: { type: "object", properties: { input: { type: "string" } } },
outputSchema: { type: "object", properties: { output: { type: "string" } } },
wrapperHints: { note: "Use the selected input field." },
acceptsContext: true,
});
const page = (
capabilities: ReadonlyArray<CapabilitySummary>,
nextCursor: string | null = null,
): CapabilityPage => ({
capabilities: [...capabilities],
nextCursor,
total: capabilities.length,
});
const deferred = <T,>() => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
};
const client = {
list: vi.fn<CapabilityClient["list"]>(),
inspect: vi.fn<CapabilityClient["inspect"]>(),
} satisfies CapabilityClient;
const readExecutor = {} as ConsoleReadExecutor;
beforeEach(() => {
client.list.mockReset();
client.inspect.mockReset();
mockedCreateCapabilityClient.mockReset();
mockedCreateCapabilityClient.mockReturnValue(client);
mockedUseConsoleWorkspace.mockReturnValue({
connection: connectedState,
connectedTarget: connectedState.connectedTarget,
recordEvidence: vi.fn(),
readExecutor,
});
});
describe("useCapabilityDiscovery", () => {
it("loads the first capability page with the bounded default limit", async () => {
client.list.mockResolvedValue(page([summary("local.documents.read")]));
const { result } = renderHook(() => useCapabilityDiscovery());
await waitFor(() => expect(result.current.phase).toBe("ready"));
expect(client.list).toHaveBeenCalledWith({ limit: 50 });
expect(result.current.items[0]?.name).toBe("local.documents.read");
});
it("searches with the current query and replaces the list", async () => {
client.list
.mockResolvedValueOnce(page([summary("local.documents.read")]))
.mockResolvedValueOnce(page([summary("local.documents.write")]));
const { result } = renderHook(() => useCapabilityDiscovery());
await waitFor(() => expect(result.current.phase).toBe("ready"));
act(() => result.current.setQuery("write"));
act(() => result.current.search());
await waitFor(() => expect(result.current.items[0]?.name).toBe("local.documents.write"));
expect(client.list).toHaveBeenLastCalledWith({ query: "write", limit: 50 });
});
it("uses the source filter when an explicit search starts", async () => {
client.list
.mockResolvedValueOnce(page([summary("local.documents.read")]))
.mockResolvedValueOnce(page([summary("remote.documents.read", "remote.documents")]));
const { result } = renderHook(() => useCapabilityDiscovery());
await waitFor(() => expect(result.current.phase).toBe("ready"));
act(() => result.current.setSourceId("remote.documents"));
act(() => result.current.search());
await waitFor(() => expect(result.current.items[0]?.sourceId).toBe("remote.documents"));
expect(client.list).toHaveBeenLastCalledWith({
sourceId: "remote.documents",
limit: 50,
});
expect(result.current.selected).toBeNull();
});
it("reloads and clears selection when the connected target changes", async () => {
client.list
.mockResolvedValueOnce(page([summary("local.documents.read")]))
.mockResolvedValueOnce(page([summary("remote.documents.read", "remote.documents")]));
client.inspect.mockResolvedValue(detail("local.documents.read"));
const { result, rerender } = renderHook(() => useCapabilityDiscovery());
await waitFor(() => expect(result.current.phase).toBe("ready"));
act(() => result.current.inspect("local.documents.read"));
await waitFor(() => expect(result.current.selected?.name).toBe("local.documents.read"));
mockedUseConsoleWorkspace.mockReturnValue({
connection: connectedState,
connectedTarget: "http://new-workflow.example/rpc",
recordEvidence: vi.fn(),
readExecutor: {} as ConsoleReadExecutor,
});
rerender();
await waitFor(() => expect(result.current.items[0]?.sourceId).toBe("remote.documents"));
expect(result.current.selected).toBeNull();
expect(client.list).toHaveBeenLastCalledWith({ limit: 50 });
});
it("appends the next page without duplicating capability names", async () => {
client.list
.mockResolvedValueOnce(page([summary("local.documents.read")], "page-2"))
.mockResolvedValueOnce(
page([summary("local.documents.read"), summary("local.documents.write")]),
);
const { result } = renderHook(() => useCapabilityDiscovery());
await waitFor(() => expect(result.current.nextCursor).toBe("page-2"));
act(() => result.current.loadMore());
await waitFor(() => expect(result.current.items).toHaveLength(2));
expect(client.list).toHaveBeenLastCalledWith({ cursor: "page-2", limit: 50 });
expect(result.current.items.map((item) => item.name)).toEqual([
"local.documents.read",
"local.documents.write",
]);
});
it("loads the selected capability detail", async () => {
client.list.mockResolvedValue(page([summary("local.documents.read")]));
client.inspect.mockResolvedValue(detail("local.documents.read"));
const { result } = renderHook(() => useCapabilityDiscovery());
await waitFor(() => expect(result.current.phase).toBe("ready"));
act(() => result.current.inspect("local.documents.read"));
await waitFor(() => expect(result.current.selected?.name).toBe("local.documents.read"));
expect(client.inspect).toHaveBeenCalledWith("local.documents.read");
});
it("surfaces malformed capability results as an error phase", async () => {
client.list.mockRejectedValue(new Error("CapabilityPage is malformed"));
const { result } = renderHook(() => useCapabilityDiscovery());
await waitFor(() => expect(result.current.phase).toBe("error"));
expect(result.current.message).toBe("CapabilityPage is malformed");
});
it("does not create a client or request while disconnected", () => {
mockedUseConsoleWorkspace.mockReturnValue({
connection: disconnectedState,
connectedTarget: null,
recordEvidence: vi.fn(),
readExecutor: null,
});
const { result } = renderHook(() => useCapabilityDiscovery());
expect(result.current.phase).toBe("disconnected");
expect(mockedCreateCapabilityClient).not.toHaveBeenCalled();
expect(client.list).not.toHaveBeenCalled();
});
it("ignores stale list and inspect responses after newer requests", async () => {
const firstList = deferred<CapabilityPage>();
const secondList = deferred<CapabilityPage>();
const firstDetail = deferred<CapabilityDetail>();
const secondDetail = deferred<CapabilityDetail>();
client.list.mockReturnValueOnce(firstList.promise).mockReturnValueOnce(secondList.promise);
client.inspect
.mockReturnValueOnce(firstDetail.promise)
.mockReturnValueOnce(secondDetail.promise);
const { result } = renderHook(() => useCapabilityDiscovery());
act(() => result.current.setQuery("newer"));
act(() => result.current.search());
expect(client.list).toHaveBeenCalledTimes(2);
firstList.resolve(page([summary("local.stale.list")]));
secondList.resolve(page([summary("local.current.list")]));
await waitFor(() => expect(result.current.items[0]?.name).toBe("local.current.list"));
act(() => result.current.inspect("local.first"));
act(() => result.current.inspect("local.second"));
firstDetail.resolve(detail("local.first"));
secondDetail.resolve(detail("local.second"));
await waitFor(() => expect(result.current.selected?.name).toBe("local.second"));
});
});
@@ -0,0 +1,179 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useConsoleWorkspace } from "../context.js";
import {
createCapabilityClient,
type CapabilityClient,
} from "../domain/capability-client.js";
import type {
CapabilityDetail,
CapabilitySummary,
} from "../domain/capability-models.js";
const PAGE_LIMIT = 50;
export type CapabilityDiscoveryController = {
readonly phase: "disconnected" | "loading" | "ready" | "error";
readonly query: string;
readonly sourceId: string;
readonly items: ReadonlyArray<CapabilitySummary>;
readonly selected: CapabilityDetail | null;
readonly nextCursor: string | null;
readonly message: string | null;
readonly setQuery: (value: string) => void;
readonly setSourceId: (value: string) => void;
readonly search: () => void;
readonly loadMore: () => void;
readonly inspect: (qualifiedName: string) => void;
};
type DiscoveryState = Omit<CapabilityDiscoveryController, "setQuery" | "setSourceId" | "search" | "loadMore" | "inspect">;
const initialState: DiscoveryState = {
phase: "disconnected",
query: "",
sourceId: "",
items: [],
selected: null,
nextCursor: null,
message: null,
};
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
const requestParams = (
query: string,
sourceId: string,
cursor?: string,
): { readonly query?: string; readonly sourceId?: string; readonly cursor?: string; readonly limit: number } => ({
...(query ? { query } : {}),
...(sourceId ? { sourceId } : {}),
...(cursor ? { cursor } : {}),
limit: PAGE_LIMIT,
});
const appendUnique = (
existing: ReadonlyArray<CapabilitySummary>,
additions: ReadonlyArray<CapabilitySummary>,
): ReadonlyArray<CapabilitySummary> => {
const names = new Set(existing.map((item) => item.name));
return [...existing, ...additions.filter((item) => !names.has(item.name))];
};
export const useCapabilityDiscovery = (): CapabilityDiscoveryController => {
const { connectedTarget, readExecutor } = useConsoleWorkspace();
const client = useMemo<CapabilityClient | null>(
() => (readExecutor ? createCapabilityClient(readExecutor) : null),
[readExecutor],
);
const [state, setState] = useState<DiscoveryState>(initialState);
const listGenerationRef = useRef(0);
const inspectGenerationRef = useRef(0);
const runList = useCallback(
(query: string, sourceId: string, cursor: string | undefined, append: boolean): void => {
if (!client) return;
const generation = ++listGenerationRef.current;
if (append === false) inspectGenerationRef.current++;
setState((current) => ({
...current,
phase: "loading",
items: append ? current.items : [],
selected: append ? current.selected : null,
nextCursor: append ? current.nextCursor : null,
message: null,
}));
void client
.list(requestParams(query, sourceId, cursor))
.then((page) => {
if (generation !== listGenerationRef.current) return;
setState((current) => ({
...current,
phase: "ready",
items: appendUnique(append ? current.items : [], page.capabilities),
nextCursor: page.nextCursor,
message: null,
}));
})
.catch((error: unknown) => {
if (generation !== listGenerationRef.current) return;
setState((current) => ({
...current,
phase: "error",
message: errorMessage(error),
}));
});
},
[client],
);
useEffect(() => {
if (!client || !connectedTarget) {
listGenerationRef.current++;
inspectGenerationRef.current++;
setState((current) => ({
...current,
phase: "disconnected",
items: [],
selected: null,
nextCursor: null,
message: null,
}));
return;
}
runList(state.query, state.sourceId, undefined, false);
// The executor identity changes with the connected target. Query and source
// filters are intentionally retained so reconnecting preserves the view.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [client, connectedTarget, runList]);
const setQuery = useCallback((query: string) => {
setState((current) => ({ ...current, query }));
}, []);
const setSourceId = useCallback(
(sourceId: string) => {
setState((current) => ({ ...current, sourceId }));
},
[],
);
const search = useCallback(() => {
runList(state.query, state.sourceId, undefined, false);
}, [runList, state.query, state.sourceId]);
const loadMore = useCallback(() => {
if (!state.nextCursor) return;
runList(state.query, state.sourceId, state.nextCursor, true);
}, [runList, state.nextCursor, state.query, state.sourceId]);
const inspect = useCallback(
(qualifiedName: string) => {
if (!client) return;
const generation = ++inspectGenerationRef.current;
setState((current) => ({ ...current, phase: "loading", selected: null, message: null }));
void client
.inspect(qualifiedName)
.then((detail) => {
if (generation !== inspectGenerationRef.current) return;
setState((current) => ({ ...current, phase: "ready", selected: detail, message: null }));
})
.catch((error: unknown) => {
if (generation !== inspectGenerationRef.current) return;
setState((current) => ({ ...current, phase: "error", message: errorMessage(error) }));
});
},
[client],
);
return {
...state,
setQuery,
setSourceId,
search,
loadMore,
inspect,
};
};