feat: add capability playground controller

This commit is contained in:
lda
2026-08-11 20:09:06 +07:00 Verified
parent 45ba905724
commit ed2b65b4c4
7 changed files with 622 additions and 0 deletions
@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from "vitest";
import {
decodeCapabilityCallResult,
type CapabilityCallResult,
} from "./capability-models.js";
import {
callCapability,
type CapabilityCallRequest,
} from "./capability-call-client.js";
import type { ConsoleExecutor } from "./executor-protocol.js";
const result: CapabilityCallResult = {
qualifiedName: "local.docs.read_documents",
sourceId: "local.docs",
kind: "node_spec",
deploymentId: null,
outcome: "ok",
output: { documents: [] },
diagnostics: [],
};
describe("callCapability", () => {
it("lowers the request to the executor operation payload", async () => {
const executor = {
run: vi.fn().mockResolvedValue(result),
} as unknown as ConsoleExecutor;
const request: CapabilityCallRequest = {
qualifiedName: "local.docs.read_documents",
payload: { names: ["README.md"] },
deploymentId: "docs.default",
};
await callCapability(executor, request);
expect(executor.run).toHaveBeenCalledWith(
"workflow.capabilities.call",
{
qualified_name: "local.docs.read_documents",
payload: { names: ["README.md"] },
deployment_id: "docs.default",
},
decodeCapabilityCallResult,
);
});
it("omits a blank deployment ID", async () => {
const executor = {
run: vi.fn().mockResolvedValue(result),
} as unknown as ConsoleExecutor;
await callCapability(executor, {
qualifiedName: "local.docs.read_documents",
payload: {},
deploymentId: " ",
});
expect(executor.run).toHaveBeenCalledWith(
"workflow.capabilities.call",
{
qualified_name: "local.docs.read_documents",
payload: {},
},
decodeCapabilityCallResult,
);
});
});
@@ -0,0 +1,27 @@
import type { ConsoleExecutor } from "./executor-protocol.js";
import {
decodeCapabilityCallResult,
type CapabilityCallResult,
} from "./capability-models.js";
export type CapabilityCallRequest = {
readonly qualifiedName: string;
readonly payload: Record<string, unknown>;
readonly deploymentId?: string;
};
export const callCapability = (
executor: ConsoleExecutor,
request: CapabilityCallRequest,
): Promise<CapabilityCallResult> => {
const deploymentId = request.deploymentId?.trim();
return executor.run(
"workflow.capabilities.call",
{
qualified_name: request.qualifiedName,
payload: request.payload,
...(deploymentId ? { deployment_id: deploymentId } : {}),
},
decodeCapabilityCallResult,
);
};
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
decodeCapabilityCallResult,
decodeCapabilityDetail,
decodeCapabilityPage,
} from "./capability-models.js";
@@ -81,4 +82,52 @@ describe("capability models", () => {
expect(wrapper.version).toBe(2);
}
});
it("decodes a successful capability call with dependency diagnostics", () => {
const result = decodeCapabilityCallResult({
qualifiedName: "local.docs.read_documents",
sourceId: "local.docs",
kind: "node_spec",
deploymentId: null,
outcome: "ok",
output: { documents: [{ name: "README.md" }] },
diagnostics: [
{
boundSource: "local.docs",
code: "source_missing",
logicalRef: "local.docs",
message: "The source is not configured.",
repairHint: "Configure the source before calling.",
severity: "error",
},
],
});
expect(result.qualifiedName).toBe("local.docs.read_documents");
expect(result.output).toEqual({ documents: [{ name: "README.md" }] });
expect(result.diagnostics[0]?.repairHint).toBe(
"Configure the source before calling.",
);
});
it("decodes runtime_error as a normal completed capability result", () => {
const result = decodeCapabilityCallResult({
qualifiedName: "local.docs.read_documents",
sourceId: "local.docs",
kind: "wrapper_artifact",
deploymentId: "docs.default",
outcome: "runtime_error",
output: null,
diagnostics: [],
});
expect(result.outcome).toBe("runtime_error");
expect(result.output).toBeNull();
});
it("rejects malformed capability call results", () => {
expect(() => decodeCapabilityCallResult({ outcome: "ok" })).toThrow(
"CapabilityCallResult is malformed",
);
});
});
@@ -15,6 +15,25 @@ const decode = <T>(
const JsonObjectSchema = v.record(v.string(), v.unknown());
const WrapperHintsSchema = v.record(v.string(), v.unknown());
const DependencyDiagnosticSchema = v.object({
boundSource: v.nullable(v.string()),
code: v.string(),
logicalRef: v.string(),
message: v.string(),
repairHint: v.nullable(v.string()),
severity: v.string(),
});
const CapabilityCallResultSchema = v.object({
qualifiedName: v.string(),
sourceId: v.string(),
kind: v.union([v.literal("node_spec"), v.literal("wrapper_artifact")]),
deploymentId: v.nullable(v.string()),
outcome: v.string(),
output: v.nullable(JsonObjectSchema),
diagnostics: v.array(DependencyDiagnosticSchema),
});
const CapabilitySummarySchema = v.variant("kind", [
v.object({
kind: v.literal("node_spec"),
@@ -75,9 +94,17 @@ const CapabilityDetailSchema = v.variant("kind", [
export type CapabilitySummary = v.InferOutput<typeof CapabilitySummarySchema>;
export type CapabilityPage = v.InferOutput<typeof CapabilityPageSchema>;
export type CapabilityDetail = v.InferOutput<typeof CapabilityDetailSchema>;
export type CapabilityCallResult = v.InferOutput<
typeof CapabilityCallResultSchema
>;
export const decodeCapabilityPage = (value: unknown): CapabilityPage =>
decode("CapabilityPage", CapabilityPageSchema, value);
export const decodeCapabilityDetail = (value: unknown): CapabilityDetail =>
decode("CapabilityDetail", CapabilityDetailSchema, value);
export const decodeCapabilityCallResult = (
value: unknown,
): CapabilityCallResult =>
decode("CapabilityCallResult", CapabilityCallResultSchema, value);
@@ -0,0 +1,215 @@
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 {
callCapability,
type CapabilityCallRequest,
} from "../domain/capability-call-client.js";
import type { CapabilityCallResult } from "../domain/capability-models.js";
import type { ConsoleWriteExecutor } from "../domain/write-executor.js";
import { useCapabilityPlayground } from "./useCapabilityPlayground.js";
vi.mock("../context.js", () => ({
useConsoleWorkspace: vi.fn(),
}));
vi.mock("../domain/capability-call-client.js", async () => {
const actual = await vi.importActual<
typeof import("../domain/capability-call-client.js")
>("../domain/capability-call-client.js");
return { ...actual, callCapability: vi.fn() };
});
const mockedUseConsoleWorkspace = vi.mocked(useConsoleWorkspace);
const mockedCallCapability = vi.mocked(callCapability);
const connectedState = {
phase: "connected",
connectedTarget: "http://workflow.example/rpc",
} as ConnectionState;
const disconnectedState = {
phase: "not_configured",
connectedTarget: null,
} as ConnectionState;
const writeExecutor = {} as ConsoleWriteExecutor;
const result = (outcome = "ok"): CapabilityCallResult => ({
qualifiedName: "local.docs.read_documents",
sourceId: "local.docs",
kind: "node_spec",
deploymentId: null,
outcome,
output: outcome === "runtime_error" ? null : { documents: [] },
diagnostics: [],
});
const deferred = <T,>() => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
};
beforeEach(() => {
mockedCallCapability.mockReset();
mockedUseConsoleWorkspace.mockReturnValue({
connection: connectedState,
connectedTarget: connectedState.connectedTarget,
recordEvidence: vi.fn(),
readExecutor: null,
writeExecutor,
});
});
describe("useCapabilityPlayground", () => {
it("is disconnected without a connected write target", () => {
mockedUseConsoleWorkspace.mockReturnValue({
connection: disconnectedState,
connectedTarget: null,
recordEvidence: vi.fn(),
readExecutor: null,
writeExecutor: null,
});
const { result: hook } = renderHook(() =>
useCapabilityPlayground("local.docs.read_documents"),
);
expect(hook.current.phase).toBe("disconnected");
expect(hook.current.result).toBeNull();
});
it("starts connected in the idle phase", async () => {
const { result: hook } = renderHook(() =>
useCapabilityPlayground("local.docs.read_documents"),
);
await waitFor(() => expect(hook.current.phase).toBe("idle"));
expect(hook.current.acknowledged).toBe(false);
expect(hook.current.deploymentId).toBe("");
});
it("reports calling and then a successful result", async () => {
mockedCallCapability.mockResolvedValue(result());
const { result: hook } = renderHook(() =>
useCapabilityPlayground("local.docs.read_documents"),
);
act(() => hook.current.call({ names: ["README.md"] }));
expect(hook.current.phase).toBe("calling");
await waitFor(() => expect(hook.current.phase).toBe("result"));
expect(hook.current.result?.outcome).toBe("ok");
});
it("keeps runtime_error in the completed result phase", async () => {
mockedCallCapability.mockResolvedValue(result("runtime_error"));
const { result: hook } = renderHook(() =>
useCapabilityPlayground("local.docs.read_documents"),
);
act(() => hook.current.call({}));
await waitFor(() => expect(hook.current.phase).toBe("result"));
expect(hook.current.result?.outcome).toBe("runtime_error");
expect(hook.current.message).toBeNull();
});
it("reports transport and protocol failures as an error phase", async () => {
mockedCallCapability.mockRejectedValue(new Error("upstream unavailable"));
const { result: hook } = renderHook(() =>
useCapabilityPlayground("local.docs.read_documents"),
);
act(() => hook.current.call({}));
await waitFor(() => expect(hook.current.phase).toBe("error"));
expect(hook.current.message).toBe("upstream unavailable");
expect(hook.current.result).toBeNull();
});
it("suppresses a second submit while a call is pending", () => {
const pending = deferred<CapabilityCallResult>();
mockedCallCapability.mockReturnValue(pending.promise);
const { result: hook } = renderHook(() =>
useCapabilityPlayground("local.docs.read_documents"),
);
act(() => {
hook.current.call({ first: true });
hook.current.call({ second: true });
});
expect(mockedCallCapability).toHaveBeenCalledTimes(1);
});
it("resets acknowledgement, deployment, and result when capability changes", async () => {
mockedCallCapability.mockResolvedValue(result());
const { result: hook, rerender } = renderHook(
({ qualifiedName }) => useCapabilityPlayground(qualifiedName),
{ initialProps: { qualifiedName: "local.docs.read_documents" } },
);
act(() => {
hook.current.setAcknowledged(true);
hook.current.setDeploymentId("docs.default");
hook.current.call({});
});
await waitFor(() => expect(hook.current.phase).toBe("result"));
rerender({ qualifiedName: "local.docs.write_documents" });
await waitFor(() => expect(hook.current.phase).toBe("idle"));
expect(hook.current.acknowledged).toBe(false);
expect(hook.current.deploymentId).toBe("");
expect(hook.current.result).toBeNull();
});
it("ignores a stale completion after the selected capability changes", async () => {
const first = deferred<CapabilityCallResult>();
const second = deferred<CapabilityCallResult>();
mockedCallCapability
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
const { result: hook, rerender } = renderHook(
({ qualifiedName }) => useCapabilityPlayground(qualifiedName),
{ initialProps: { qualifiedName: "local.docs.first" } },
);
act(() => hook.current.call({ value: "first" }));
rerender({ qualifiedName: "local.docs.second" });
act(() => hook.current.call({ value: "second" }));
first.resolve({ ...result(), qualifiedName: "local.docs.first" });
await waitFor(() => expect(hook.current.phase).toBe("calling"));
second.resolve({ ...result(), qualifiedName: "local.docs.second" });
await waitFor(() =>
expect(hook.current.result?.qualifiedName).toBe("local.docs.second"),
);
});
it("passes the selected capability, payload, and deployment to the client", async () => {
mockedCallCapability.mockResolvedValue(result());
const { result: hook } = renderHook(() =>
useCapabilityPlayground("local.docs.read_documents"),
);
act(() => {
hook.current.setDeploymentId("docs.default");
hook.current.call({ names: ["README.md"] });
});
await waitFor(() => expect(hook.current.phase).toBe("result"));
expect(mockedCallCapability).toHaveBeenCalledWith(writeExecutor, {
qualifiedName: "local.docs.read_documents",
payload: { names: ["README.md"] },
deploymentId: "docs.default",
} satisfies CapabilityCallRequest);
});
});
@@ -0,0 +1,193 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useConsoleWorkspace } from "../context.js";
import {
callCapability,
type CapabilityCallRequest,
} from "../domain/capability-call-client.js";
import type { CapabilityCallResult } from "../domain/capability-models.js";
import type { ConsoleWriteExecutor } from "../domain/write-executor.js";
export type CapabilityPlaygroundPhase =
| "disconnected"
| "idle"
| "calling"
| "result"
| "error";
export type CapabilityPlaygroundController = {
readonly phase: CapabilityPlaygroundPhase;
readonly result: CapabilityCallResult | null;
readonly message: string | null;
readonly acknowledged: boolean;
readonly deploymentId: string;
readonly setAcknowledged: (value: boolean) => void;
readonly setDeploymentId: (value: string) => void;
readonly call: (payload: Record<string, unknown>) => void;
readonly reset: () => void;
};
type PlaygroundState = Omit<
CapabilityPlaygroundController,
"setAcknowledged" | "setDeploymentId" | "call" | "reset"
>;
type SelectionIdentity = {
readonly writeExecutor: ConsoleWriteExecutor | null;
readonly connectedTarget: string | null;
readonly qualifiedName: string | null;
};
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
const isSameSelection = (
left: SelectionIdentity | null,
right: SelectionIdentity,
): boolean =>
left !== null &&
left.writeExecutor === right.writeExecutor &&
left.connectedTarget === right.connectedTarget &&
left.qualifiedName === right.qualifiedName;
export const useCapabilityPlayground = (
qualifiedName: string | null,
): CapabilityPlaygroundController => {
const { connectedTarget, writeExecutor } = useConsoleWorkspace();
const isConnected = writeExecutor !== null && connectedTarget !== null;
const selection: SelectionIdentity = {
writeExecutor,
connectedTarget,
qualifiedName,
};
const selectionRef = useRef<SelectionIdentity>(selection);
const committedSelectionRef = useRef<SelectionIdentity | null>(null);
const generationRef = useRef(0);
const pendingRef = useRef(false);
const deploymentIdRef = useRef("");
const [state, setState] = useState<PlaygroundState>(() => ({
phase: isConnected ? "idle" : "disconnected",
result: null,
message: null,
acknowledged: false,
deploymentId: "",
}));
if (!isSameSelection(selectionRef.current, selection)) {
// Invalidate an in-flight call during render so a promise resolving before
// the reset effect cannot publish data for the previous selection.
selectionRef.current = selection;
generationRef.current += 1;
pendingRef.current = false;
}
const resetState = useCallback((): void => {
deploymentIdRef.current = "";
setState({
phase: isConnected ? "idle" : "disconnected",
result: null,
message: null,
acknowledged: false,
deploymentId: "",
});
}, [isConnected]);
useEffect(() => {
committedSelectionRef.current = selectionRef.current;
resetState();
}, [connectedTarget, qualifiedName, resetState, writeExecutor]);
const setAcknowledged = useCallback((value: boolean): void => {
setState((current) => ({ ...current, acknowledged: value }));
}, []);
const setDeploymentId = useCallback((value: string): void => {
deploymentIdRef.current = value;
setState((current) => ({ ...current, deploymentId: value }));
}, []);
const reset = useCallback((): void => {
generationRef.current += 1;
pendingRef.current = false;
resetState();
}, [resetState]);
const call = useCallback(
(payload: Record<string, unknown>): void => {
if (
writeExecutor === null ||
connectedTarget === null ||
qualifiedName === null ||
!qualifiedName.trim() ||
pendingRef.current ||
!isSameSelection(committedSelectionRef.current, selectionRef.current)
) return;
const generation = ++generationRef.current;
const requestSelection = selectionRef.current;
pendingRef.current = true;
setState((current) => ({
...current,
phase: "calling",
result: null,
message: null,
}));
const request: CapabilityCallRequest = {
qualifiedName,
payload,
deploymentId: deploymentIdRef.current,
};
void callCapability(writeExecutor, request).then(
(result) => {
if (
generation !== generationRef.current ||
!isSameSelection(committedSelectionRef.current, requestSelection)
) return;
pendingRef.current = false;
setState((current) => ({
...current,
phase: "result",
result,
message: null,
}));
},
(error: unknown) => {
if (
generation !== generationRef.current ||
!isSameSelection(committedSelectionRef.current, requestSelection)
) return;
pendingRef.current = false;
setState((current) => ({
...current,
phase: "error",
result: null,
message: errorMessage(error),
}));
},
);
},
[connectedTarget, qualifiedName, writeExecutor],
);
const hasCurrentSelection = isSameSelection(
committedSelectionRef.current,
selection,
);
const visibleState: PlaygroundState = hasCurrentSelection
? state
: {
phase: isConnected ? "idle" : "disconnected",
result: null,
message: null,
acknowledged: false,
deploymentId: "",
};
return {
...visibleState,
setAcknowledged,
setDeploymentId,
call,
reset,
};
};