mirror of https://github.com/openclaw/openclaw.git
233 lines
7.2 KiB
TypeScript
233 lines
7.2 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { __resetMeshRunsForTest, meshHandlers } from "./mesh.js";
|
|
import type { GatewayRequestContext } from "./types.js";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
agent: vi.fn(),
|
|
agentWait: vi.fn(),
|
|
agentCommand: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("./agent.js", () => ({
|
|
agentHandlers: {
|
|
agent: (...args: unknown[]) => mocks.agent(...args),
|
|
"agent.wait": (...args: unknown[]) => mocks.agentWait(...args),
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../commands/agent.js", () => ({
|
|
agentCommand: (...args: unknown[]) => mocks.agentCommand(...args),
|
|
}));
|
|
|
|
const makeContext = (): GatewayRequestContext =>
|
|
({
|
|
dedupe: new Map(),
|
|
addChatRun: vi.fn(),
|
|
logGateway: { info: vi.fn(), error: vi.fn() },
|
|
}) as unknown as GatewayRequestContext;
|
|
|
|
async function callMesh(method: keyof typeof meshHandlers, params: Record<string, unknown>) {
|
|
return await new Promise<{ ok: boolean; payload?: unknown; error?: unknown }>((resolve) => {
|
|
void meshHandlers[method]({
|
|
req: { type: "req", id: `test-${method}`, method },
|
|
params,
|
|
respond: (ok, payload, error) => resolve({ ok, payload, error }),
|
|
context: makeContext(),
|
|
client: null,
|
|
isWebchatConnect: () => false,
|
|
});
|
|
});
|
|
}
|
|
|
|
afterEach(() => {
|
|
__resetMeshRunsForTest();
|
|
mocks.agent.mockReset();
|
|
mocks.agentWait.mockReset();
|
|
mocks.agentCommand.mockReset();
|
|
});
|
|
|
|
describe("mesh handlers", () => {
|
|
it("builds a default single-step plan", async () => {
|
|
const res = await callMesh("mesh.plan", { goal: "Write release notes" });
|
|
expect(res.ok).toBe(true);
|
|
const payload = res.payload as { plan: { goal: string; steps: Array<{ id: string }> } };
|
|
expect(payload.plan.goal).toBe("Write release notes");
|
|
expect(payload.plan.steps).toHaveLength(1);
|
|
expect(payload.plan.steps[0]?.id).toBe("step-1");
|
|
});
|
|
|
|
it("rejects cyclic plans", async () => {
|
|
const cyclePlan = {
|
|
planId: "mesh-plan-1",
|
|
goal: "cycle",
|
|
createdAt: Date.now(),
|
|
steps: [
|
|
{ id: "a", prompt: "a", dependsOn: ["b"] },
|
|
{ id: "b", prompt: "b", dependsOn: ["a"] },
|
|
],
|
|
};
|
|
const res = await callMesh("mesh.run", { plan: cyclePlan });
|
|
expect(res.ok).toBe(false);
|
|
});
|
|
|
|
it("runs steps in DAG order and supports retrying failed steps", async () => {
|
|
const runState = new Map<string, "ok" | "error">();
|
|
mocks.agent.mockImplementation(
|
|
(opts: {
|
|
params: { idempotencyKey: string };
|
|
respond: (ok: boolean, payload?: unknown) => void;
|
|
}) => {
|
|
const agentRunId = `agent-${opts.params.idempotencyKey}`;
|
|
runState.set(agentRunId, "ok");
|
|
if (opts.params.idempotencyKey.includes(":review:1")) {
|
|
runState.set(agentRunId, "error");
|
|
}
|
|
opts.respond(true, { runId: agentRunId, status: "accepted" });
|
|
},
|
|
);
|
|
mocks.agentWait.mockImplementation(
|
|
(opts: { params: { runId: string }; respond: (ok: boolean, payload?: unknown) => void }) => {
|
|
const status = runState.get(opts.params.runId) ?? "error";
|
|
if (status === "ok") {
|
|
opts.respond(true, { runId: opts.params.runId, status: "ok" });
|
|
return;
|
|
}
|
|
opts.respond(true, {
|
|
runId: opts.params.runId,
|
|
status: "error",
|
|
error: "simulated failure",
|
|
});
|
|
},
|
|
);
|
|
|
|
const plan = {
|
|
planId: "mesh-plan-2",
|
|
goal: "Ship patch",
|
|
createdAt: Date.now(),
|
|
steps: [
|
|
{ id: "research", prompt: "Research requirements" },
|
|
{ id: "build", prompt: "Build feature", dependsOn: ["research"] },
|
|
{ id: "review", prompt: "Review result", dependsOn: ["build"] },
|
|
],
|
|
};
|
|
|
|
const runRes = await callMesh("mesh.run", { plan });
|
|
expect(runRes.ok).toBe(true);
|
|
const runPayload = runRes.payload as {
|
|
runId: string;
|
|
status: string;
|
|
stats: { failed: number };
|
|
};
|
|
expect(runPayload.status).toBe("failed");
|
|
expect(runPayload.stats.failed).toBe(1);
|
|
|
|
// Make subsequent retries succeed
|
|
mocks.agent.mockImplementation(
|
|
(opts: {
|
|
params: { idempotencyKey: string };
|
|
respond: (ok: boolean, payload?: unknown) => void;
|
|
}) => {
|
|
const agentRunId = `agent-${opts.params.idempotencyKey}`;
|
|
runState.set(agentRunId, "ok");
|
|
opts.respond(true, { runId: agentRunId, status: "accepted" });
|
|
},
|
|
);
|
|
|
|
const retryRes = await callMesh("mesh.retry", {
|
|
runId: runPayload.runId,
|
|
stepIds: ["review"],
|
|
});
|
|
expect(retryRes.ok).toBe(true);
|
|
const retryPayload = retryRes.payload as { status: string; stats: { failed: number } };
|
|
expect(retryPayload.status).toBe("completed");
|
|
expect(retryPayload.stats.failed).toBe(0);
|
|
|
|
const statusRes = await callMesh("mesh.status", { runId: runPayload.runId });
|
|
expect(statusRes.ok).toBe(true);
|
|
const statusPayload = statusRes.payload as { status: string };
|
|
expect(statusPayload.status).toBe("completed");
|
|
});
|
|
|
|
it("auto planner creates multiple steps from llm json output", async () => {
|
|
mocks.agentCommand.mockResolvedValue({
|
|
payloads: [
|
|
{
|
|
text: JSON.stringify({
|
|
steps: [
|
|
{ id: "analyze", prompt: "Analyze requirements" },
|
|
{ id: "build", prompt: "Build implementation", dependsOn: ["analyze"] },
|
|
],
|
|
}),
|
|
},
|
|
],
|
|
meta: {},
|
|
});
|
|
|
|
const res = await callMesh("mesh.plan.auto", {
|
|
goal: "Create dashboard with auth",
|
|
maxSteps: 4,
|
|
});
|
|
expect(res.ok).toBe(true);
|
|
const payload = res.payload as {
|
|
source: string;
|
|
plan: { steps: Array<{ id: string }> };
|
|
order: string[];
|
|
};
|
|
expect(payload.source).toBe("llm");
|
|
expect(payload.plan.steps.map((s) => s.id)).toEqual(["analyze", "build"]);
|
|
expect(payload.order).toEqual(["analyze", "build"]);
|
|
expect(mocks.agentCommand).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
agentId: "main",
|
|
sessionKey: "agent:main:mesh-planner",
|
|
}),
|
|
expect.any(Object),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("auto planner falls back to single-step plan when llm output is invalid", async () => {
|
|
mocks.agentCommand.mockResolvedValue({
|
|
payloads: [{ text: "not valid json" }],
|
|
meta: {},
|
|
});
|
|
const res = await callMesh("mesh.plan.auto", {
|
|
goal: "Do a thing",
|
|
});
|
|
expect(res.ok).toBe(true);
|
|
const payload = res.payload as {
|
|
source: string;
|
|
plan: { steps: Array<{ id: string; prompt: string }> };
|
|
};
|
|
expect(payload.source).toBe("fallback");
|
|
expect(payload.plan.steps).toHaveLength(1);
|
|
expect(payload.plan.steps[0]?.prompt).toBe("Do a thing");
|
|
});
|
|
|
|
it("auto planner respects caller-provided planner session key", async () => {
|
|
mocks.agentCommand.mockResolvedValue({
|
|
payloads: [
|
|
{
|
|
text: JSON.stringify({
|
|
steps: [{ id: "one", prompt: "One" }],
|
|
}),
|
|
},
|
|
],
|
|
meta: {},
|
|
});
|
|
|
|
const res = await callMesh("mesh.plan.auto", {
|
|
goal: "Do a thing",
|
|
sessionKey: "agent:main:custom-planner",
|
|
});
|
|
expect(res.ok).toBe(true);
|
|
expect(mocks.agentCommand).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
sessionKey: "agent:main:custom-planner",
|
|
}),
|
|
expect.any(Object),
|
|
undefined,
|
|
);
|
|
});
|
|
});
|