mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
Conflict resolved for this base: its external grants can also be offered rig_team and the inbox tools, so the external request instructions say "Use only the tools this connection offers" without naming which tools are absent, and the base's inbox instructions and instructionsFor stay as they are. (cherry picked from commit a0d68325024b41d6f6d131bd0d66d8e5e78f6de3)
1231 lines
64 KiB
TypeScript
1231 lines
64 KiB
TypeScript
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
import { createMcpServer, MEMBER_WORKFLOW, MEMBER_WORKFLOW_VERSION, type McpAccess } from "../src/mcp-server.js";
|
|
import { DaemonClient, DaemonConnectionError, type DaemonResponse } from "../src/client.js";
|
|
|
|
function mockClient(overrides?: {
|
|
get?: (path: string) => Promise<DaemonResponse>;
|
|
post?: (path: string, body?: unknown, options?: { timeoutMs?: number }) => Promise<DaemonResponse>;
|
|
postText?: (path: string, text: string, contentType?: string, extraHeaders?: Record<string, string>) => Promise<DaemonResponse>;
|
|
}): DaemonClient {
|
|
const client = new DaemonClient("http://127.0.0.1:9999");
|
|
client.get = overrides?.get ?? vi.fn(async () => ({ status: 200, data: { status: "ok" } }));
|
|
client.post = overrides?.post ?? vi.fn(async () => ({ status: 200, data: { status: "ok" } }));
|
|
client.postText = overrides?.postText ?? vi.fn(async () => ({ status: 200, data: { valid: true, errors: [] } }));
|
|
return client;
|
|
}
|
|
|
|
describe("MCP Server", () => {
|
|
let mcpClient: Client;
|
|
let daemonClient: DaemonClient;
|
|
let cleanup: () => Promise<void>;
|
|
|
|
async function setup(clientOverrides?: Parameters<typeof mockClient>[0]) {
|
|
daemonClient = mockClient(clientOverrides);
|
|
const server = createMcpServer(daemonClient);
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
|
|
mcpClient = new Client({ name: "test-client", version: "1.0.0" });
|
|
|
|
await server.connect(serverTransport);
|
|
await mcpClient.connect(clientTransport);
|
|
|
|
cleanup = async () => {
|
|
await mcpClient.close();
|
|
await server.close();
|
|
};
|
|
}
|
|
|
|
afterAll(async () => {
|
|
if (cleanup) await cleanup();
|
|
});
|
|
|
|
// T1: MCP server lists all 37 tools: the original 18, the five queue tools, the telemetry events read, restore status, whoami,
|
|
// the team, slice, mission and two context reads, the artifact read, the decision room and delegated decision, and the
|
|
// worker's handoff and unclaim
|
|
it("lists all 37 tools", async () => {
|
|
await setup();
|
|
const result = await mcpClient.listTools();
|
|
const names = result.tools.map((t) => t.name).sort();
|
|
expect(names).toEqual([
|
|
"rig_add",
|
|
"rig_agent_validate",
|
|
"rig_artifact_read",
|
|
"rig_bind",
|
|
"rig_bundle_inspect",
|
|
"rig_capture",
|
|
"rig_chatroom_send",
|
|
"rig_chatroom_watch",
|
|
"rig_context_get",
|
|
"rig_context_pack",
|
|
"rig_discover",
|
|
"rig_down",
|
|
"rig_environment_export",
|
|
"rig_human_decision_room",
|
|
"rig_mission",
|
|
"rig_ps",
|
|
"rig_queue_claim",
|
|
"rig_queue_create",
|
|
"rig_queue_decide",
|
|
"rig_queue_handoff",
|
|
"rig_queue_list",
|
|
"rig_queue_show",
|
|
"rig_queue_unclaim",
|
|
"rig_queue_update",
|
|
"rig_restore",
|
|
"rig_restore_status",
|
|
"rig_rig_nodes",
|
|
"rig_rig_validate",
|
|
"rig_send",
|
|
"rig_slices",
|
|
"rig_snapshot_create",
|
|
"rig_snapshot_list",
|
|
"rig_status",
|
|
"rig_team",
|
|
"rig_telemetry_events",
|
|
"rig_up",
|
|
"rig_whoami",
|
|
]);
|
|
await cleanup();
|
|
});
|
|
|
|
// rig_add: posts the member fragment to the pod-members route.
|
|
it("rig_add posts to the pod-members route and returns the added node", async () => {
|
|
const postFn = vi.fn(async () => ({
|
|
status: 201,
|
|
data: {
|
|
ok: true,
|
|
result: {
|
|
podId: "pod-1",
|
|
podNamespace: "infra",
|
|
node: { logicalId: "infra.server2", nodeId: "n2", status: "launched", sessionName: "infra-server2@r" },
|
|
warnings: [],
|
|
},
|
|
},
|
|
}));
|
|
await setup({ post: postFn });
|
|
|
|
const member = { id: "server2", runtime: "terminal", agent_ref: "builtin:terminal", profile: "none", cwd: "/tmp" };
|
|
const result = await mcpClient.callTool({
|
|
name: "rig_add",
|
|
arguments: { rigId: "rig-1", podNamespace: "infra", member },
|
|
});
|
|
|
|
expect(postFn).toHaveBeenCalledWith("/api/rigs/rig-1/pods/infra/members", { member });
|
|
const content = result.content as Array<{ type: string; text: string }>;
|
|
const parsed = JSON.parse(content[0].text);
|
|
expect(parsed.ok).toBe(true);
|
|
expect(parsed.result.node.logicalId).toBe("infra.server2");
|
|
expect(result.isError).toBeFalsy();
|
|
await cleanup();
|
|
});
|
|
|
|
it("rig_add forwards optional pod-local edges in the request body", async () => {
|
|
const postFn = vi.fn(async () => ({
|
|
status: 201,
|
|
data: { ok: true, result: { podId: "pod-1", podNamespace: "infra", node: { logicalId: "infra.server2", nodeId: "n2", status: "launched" }, edges: [{ from: "infra.server2", to: "infra.server", kind: "delegates_to" }] } },
|
|
}));
|
|
await setup({ post: postFn });
|
|
|
|
const member = { id: "server2", runtime: "terminal", agent_ref: "builtin:terminal", profile: "none", cwd: "/tmp" };
|
|
const edges = [{ from: "server2", to: "server", kind: "delegates_to" }];
|
|
await mcpClient.callTool({ name: "rig_add", arguments: { rigId: "rig-1", podNamespace: "infra", member, edges } });
|
|
|
|
expect(postFn).toHaveBeenCalledWith("/api/rigs/rig-1/pods/infra/members", { member, edges });
|
|
await cleanup();
|
|
});
|
|
|
|
it("rig_add surfaces a member_conflict (HTTP 409) as isError", async () => {
|
|
const postFn = vi.fn(async () => ({
|
|
status: 409,
|
|
data: { ok: false, code: "member_conflict", message: 'Member "infra.server" already exists' },
|
|
}));
|
|
await setup({ post: postFn });
|
|
|
|
const result = await mcpClient.callTool({
|
|
name: "rig_add",
|
|
arguments: { rigId: "rig-1", podNamespace: "infra", member: { id: "server" } },
|
|
});
|
|
|
|
expect(result.isError).toBe(true);
|
|
const content = result.content as Array<{ type: string; text: string }>;
|
|
expect(JSON.parse(content[0].text).code).toBe("member_conflict");
|
|
await cleanup();
|
|
});
|
|
|
|
it("rig_add flags a persisted-but-not-launched node as isError", async () => {
|
|
const postFn = vi.fn(async () => ({
|
|
status: 201,
|
|
data: {
|
|
ok: true,
|
|
result: {
|
|
podId: "pod-1",
|
|
podNamespace: "infra",
|
|
node: { logicalId: "infra.server2", nodeId: "n2", status: "failed", error: "harness launch failed" },
|
|
},
|
|
},
|
|
}));
|
|
await setup({ post: postFn });
|
|
|
|
const result = await mcpClient.callTool({
|
|
name: "rig_add",
|
|
arguments: { rigId: "rig-1", podNamespace: "infra", member: { id: "server2" } },
|
|
});
|
|
|
|
expect(result.isError).toBe(true);
|
|
await cleanup();
|
|
});
|
|
|
|
// T2: rig_up calls POST /api/up
|
|
it("rig_up calls POST /api/up with correct params", async () => {
|
|
const postFn = vi.fn(async () => ({
|
|
status: 201,
|
|
data: { runId: "run-1", status: "completed", rigId: "rig-1", stages: [], errors: [] },
|
|
}));
|
|
await setup({ post: postFn });
|
|
|
|
const result = await mcpClient.callTool({
|
|
name: "rig_up",
|
|
arguments: { sourceRef: "/tmp/rig.yaml", plan: false, autoApprove: true },
|
|
});
|
|
|
|
expect(postFn).toHaveBeenCalledWith(
|
|
"/api/up",
|
|
{
|
|
sourceRef: "/tmp/rig.yaml",
|
|
plan: false,
|
|
autoApprove: true,
|
|
targetRoot: undefined,
|
|
},
|
|
{ timeoutMs: 120_000 },
|
|
);
|
|
const content = result.content as Array<{ type: string; text: string }>;
|
|
const parsed = JSON.parse(content[0].text);
|
|
expect(parsed.status).toBe("completed");
|
|
expect(result.isError).toBeFalsy();
|
|
await cleanup();
|
|
});
|
|
|
|
// T3: rig_down calls POST /api/down
|
|
it("rig_up passes cwd, env, a rig name and fresh seats, and refuses an env the daemon would drop", async () => {
|
|
const postFn = vi.fn(async () => ({ status: 201, data: { status: "completed" } }));
|
|
await setup({ post: postFn });
|
|
|
|
const ok = await mcpClient.callTool({
|
|
name: "rig_up",
|
|
arguments: { sourceRef: "/work/rig.yaml", cwd: "/work/app", env: { API_MODE: "test" }, rigName: "app-team", fresh: ["dev.impl"] },
|
|
});
|
|
expect(ok.isError).toBeFalsy();
|
|
expect(postFn).toHaveBeenCalledWith("/api/up", expect.objectContaining({
|
|
sourceRef: "/work/rig.yaml", cwdOverride: "/work/app", envOverride: { API_MODE: "test" }, rigName: "app-team", freshLogicalIds: ["dev.impl"],
|
|
}), { timeoutMs: 120_000 });
|
|
|
|
// The daemon ignores an env block with any invalid entry, so the launch would run without it; refuse it before sending.
|
|
for (const env of [{ "lower-case": "x" }, { OPENRIG_HOME: "/tmp" }, { EMPTY: "" }]) {
|
|
const refused = await mcpClient.callTool({ name: "rig_up", arguments: { sourceRef: "/work/rig.yaml", env } });
|
|
expect(refused.isError).toBe(true);
|
|
}
|
|
const relative = await mcpClient.callTool({ name: "rig_up", arguments: { sourceRef: "/work/rig.yaml", cwd: "app" } });
|
|
expect(relative.isError).toBe(true);
|
|
expect(postFn).toHaveBeenCalledTimes(1);
|
|
await cleanup();
|
|
});
|
|
|
|
it("declares read and change hints on every tool, so a client can let the reads through once", async () => {
|
|
await setup();
|
|
const { tools } = await mcpClient.listTools();
|
|
const reads = ["rig_agent_validate", "rig_bundle_inspect", "rig_capture", "rig_chatroom_watch", "rig_ps", "rig_queue_list", "rig_queue_show",
|
|
"rig_restore_status", "rig_rig_nodes", "rig_rig_validate", "rig_snapshot_list", "rig_status", "rig_telemetry_events", "rig_whoami",
|
|
"rig_team", "rig_slices", "rig_mission", "rig_context_pack", "rig_context_get", "rig_artifact_read", "rig_human_decision_room"];
|
|
const additive = ["rig_environment_export", "rig_add", "rig_chatroom_send", "rig_queue_create", "rig_snapshot_create"];
|
|
// Discover refreshes and retires discovered facts; claim ends a blocked row's wait. Both change existing state.
|
|
// A delegated decision closes the person's ask; a handoff closes the item passed on; an unclaim ends a claim.
|
|
const destructive = ["rig_bind", "rig_discover", "rig_down", "rig_queue_claim", "rig_queue_decide", "rig_queue_handoff", "rig_queue_unclaim",
|
|
"rig_queue_update", "rig_restore", "rig_send", "rig_up"];
|
|
const hints = Object.fromEntries(tools.map((tool) => [tool.name, tool.annotations]));
|
|
expect(Object.keys(hints).sort()).toEqual([...reads, ...additive, ...destructive].sort());
|
|
for (const name of reads) expect(hints[name], name).toEqual({ readOnlyHint: true });
|
|
for (const name of additive) expect(hints[name], name).toEqual({ readOnlyHint: false, destructiveHint: false });
|
|
for (const name of destructive) expect(hints[name], name).toEqual({ readOnlyHint: false, destructiveHint: true });
|
|
await cleanup();
|
|
});
|
|
|
|
it("rig_send and rig_capture send the daemon's terminal token when one is configured, as the CLI does", async () => {
|
|
vi.stubEnv("OPENRIG_TERMINAL_BEARER_TOKEN", "terminal-secret");
|
|
try {
|
|
const postFn = vi.fn(async () => ({ status: 200, data: { ok: true } }));
|
|
await setup({ post: postFn });
|
|
await mcpClient.callTool({ name: "rig_send", arguments: { session: "dev-impl@my-rig", text: "hello" } });
|
|
await mcpClient.callTool({ name: "rig_send", arguments: { session: "dev-impl@my-rig", text: "hello", waitForIdleSeconds: 30 } });
|
|
await mcpClient.callTool({ name: "rig_capture", arguments: { session: "dev-impl@my-rig" } });
|
|
const auth = { headers: { Authorization: "Bearer terminal-secret" } };
|
|
expect(postFn.mock.calls.map((call) => (call as unknown[])[2])).toEqual([auth, { timeoutMs: 35000, ...auth }, auth]);
|
|
await cleanup();
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
}
|
|
});
|
|
|
|
it("rig_restore says it only starts the restore, and rig_restore_status follows the attempt", async () => {
|
|
const getFn = vi.fn(async (path: string) => {
|
|
if (path.startsWith("/api/rigs/summary")) return { status: 200, data: [{ id: "rig-1", name: "my-rig" }] };
|
|
if (path === "/api/rigs/rig-1/restore/status/7") return { status: 409, data: { error: "Restore attempt 7 has no terminal completion event", code: "attempt_incomplete" } };
|
|
if (path === "/api/rigs/rig-1/restore/status/8") return { status: 200, data: { ok: true, attemptId: 8, rigResult: "fully_restored" } };
|
|
return { status: 404, data: { error: "unexpected" } };
|
|
});
|
|
await setup({ get: getFn });
|
|
const { tools } = await mcpClient.listTools();
|
|
expect(tools.find((tool) => tool.name === "rig_restore")?.description).toContain("rig_restore_status");
|
|
// No completion event can also mean the attempt stopped, so the description doesn't promise it's still running.
|
|
expect(tools.find((tool) => tool.name === "rig_restore_status")?.description).toContain("stopped before it finished");
|
|
|
|
const incomplete = await mcpClient.callTool({ name: "rig_restore_status", arguments: { rigId: "my-rig", attemptId: 7 } });
|
|
expect(incomplete.isError).toBe(true);
|
|
expect(incomplete.structuredContent).toEqual({ ok: false, code: "attempt_incomplete", message: "Restore attempt 7 has no terminal completion event", status: 409 });
|
|
|
|
const done = await mcpClient.callTool({ name: "rig_restore_status", arguments: { rigId: "my-rig", attemptId: 8 } });
|
|
expect(done.isError).toBeFalsy();
|
|
expect(done.structuredContent).toEqual({ ok: true, attemptId: 8, rigResult: "fully_restored" });
|
|
|
|
// Arguments the input schema refuses never reach the tool: the SDK answers with its own text-only error.
|
|
const invalid = await mcpClient.callTool({ name: "rig_restore_status", arguments: { rigId: "my-rig", attemptId: 0 } });
|
|
expect(invalid.isError).toBe(true);
|
|
expect(invalid.structuredContent).toBeUndefined();
|
|
expect((invalid.content as Array<{ text: string }>)[0]!.text).toContain("Invalid arguments for tool rig_restore_status");
|
|
await cleanup();
|
|
});
|
|
|
|
it("rig_whoami reads the seat this server is configured as, and says plainly when there is none", async () => {
|
|
try {
|
|
vi.stubEnv("OPENRIG_SESSION_NAME", "dev-impl@my-rig");
|
|
vi.stubEnv("RIGGED_SESSION_NAME", "");
|
|
vi.stubEnv("OPENRIG_TARGET_REPO", "");
|
|
vi.stubEnv("RIGGED_TARGET_REPO", "");
|
|
const identity = { identity: { sessionName: "dev-impl@my-rig", rigName: "my-rig" }, peers: [] };
|
|
const getFn = vi.fn(async () => ({ status: 200, data: identity }));
|
|
await setup({ get: getFn });
|
|
const seated = await mcpClient.callTool({ name: "rig_whoami", arguments: {} });
|
|
expect(getFn).toHaveBeenCalledWith("/api/whoami?sessionName=dev-impl%40my-rig&compact=1");
|
|
// A local server's seat is declared (the env), not authenticated, and says so.
|
|
expect(seated.structuredContent).toEqual({ ...identity, caller: { authenticated: false, identity: "declared" } });
|
|
await cleanup();
|
|
|
|
vi.stubEnv("OPENRIG_SESSION_NAME", "");
|
|
const quietGet = vi.fn(async () => ({ status: 200, data: {} }));
|
|
await setup({ get: quietGet });
|
|
const unseated = await mcpClient.callTool({ name: "rig_whoami", arguments: {} });
|
|
expect(unseated.isError).toBeFalsy();
|
|
expect(unseated.structuredContent).toMatchObject({ ok: true, seat: null, caller: { authenticated: false } });
|
|
expect(quietGet).not.toHaveBeenCalled();
|
|
await cleanup();
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
}
|
|
});
|
|
|
|
it("the queue tools return structured content; every error a tool returns carries one shape and keeps its old text", async () => {
|
|
// The queue's missing-item read answers with its code alone, as GET /api/queue/:qitemId does.
|
|
const missing = { error: "qitem_not_found" };
|
|
const getFn = vi.fn(async (path: string) => {
|
|
if (path === "/api/queue/qitem-1") return { status: 200, data: { qitemId: "qitem-1", state: "done" } };
|
|
if (path === "/api/queue/qitem-1/transitions") return { status: 200, data: [{ state: "done" }] };
|
|
if (path === "/api/queue/qitem-x") return { status: 404, data: missing };
|
|
if (path === "/api/queue/qitem-y") return { status: 409, data: { error: "not-found", message: "No pending decision has that id." } };
|
|
if (path.startsWith("/api/queue/list")) return { status: 200, data: [{ qitemId: "qitem-1" }] };
|
|
return { status: 404, data: {} };
|
|
});
|
|
const postFn = vi.fn(async () => { throw new DaemonConnectionError("Cannot reach the OpenRig daemon at http://127.0.0.1:9999"); });
|
|
await setup({ get: getFn, post: postFn });
|
|
|
|
const shown = await mcpClient.callTool({ name: "rig_queue_show", arguments: { qitemId: "qitem-1" } });
|
|
expect(shown.structuredContent).toEqual({ item: { qitemId: "qitem-1", state: "done" }, transitions: [{ state: "done" }] });
|
|
const listed = await mcpClient.callTool({ name: "rig_queue_list", arguments: { destination: "dev-impl@my-rig" } });
|
|
expect(listed.structuredContent).toEqual({ items: [{ qitemId: "qitem-1" }] });
|
|
|
|
const notFound = await mcpClient.callTool({ name: "rig_queue_show", arguments: { qitemId: "qitem-x" } });
|
|
expect(notFound.isError).toBe(true);
|
|
expect((notFound.content as Array<{ text: string }>)[0]!.text).toBe(JSON.stringify(missing));
|
|
expect(notFound.structuredContent).toEqual({ ok: false, code: "qitem_not_found", message: "qitem_not_found", status: 404 });
|
|
// An error beside a message is the route's code, whatever its spelling, and the message stays readable.
|
|
const paired = await mcpClient.callTool({ name: "rig_queue_show", arguments: { qitemId: "qitem-y" } });
|
|
expect(paired.structuredContent).toEqual({ ok: false, code: "not-found", message: "No pending decision has that id.", status: 409 });
|
|
|
|
const unreachable = await mcpClient.callTool({ name: "rig_queue_claim", arguments: { qitemId: "qitem-1" } });
|
|
expect((unreachable.content as Array<{ text: string }>)[0]!.text).toBe("Cannot reach the OpenRig daemon at http://127.0.0.1:9999");
|
|
expect(unreachable.structuredContent).toEqual({ ok: false, code: "daemon_unreachable", message: "Cannot reach the OpenRig daemon at http://127.0.0.1:9999" });
|
|
await cleanup();
|
|
});
|
|
|
|
it("rig_queue_show names where a handed-on item went, and says when it can't tell", async () => {
|
|
const lists: string[] = [];
|
|
const getFn = vi.fn(async (path: string) => {
|
|
if (path === "/api/queue/qitem-remote") return { status: 200, data: { qitemId: "qitem-remote", state: "handed-off", closureReason: "handed_off_to", closureTarget: "qitem-remote-successor@mm2-host" } };
|
|
if (path === "/api/queue/qitem-done") return { status: 200, data: { qitemId: "qitem-done", state: "done", closureReason: "handed_off_to", closureTarget: "qa@team" } };
|
|
if (path === "/api/queue/qitem-old") return { status: 200, data: { qitemId: "qitem-old", state: "handed-off", closureReason: "handed_off_to", closureTarget: "qa@team" } };
|
|
if (path.endsWith("/transitions")) return { status: 200, data: [] };
|
|
if (path.startsWith("/api/queue/list")) {
|
|
lists.push(path);
|
|
// A daemon without the filter answers the unfiltered list; the newer one answers only the successors.
|
|
if (path.includes("handedOffFrom=qitem-old")) return { status: 200, data: [{ qitemId: "qitem-unrelated", handedOffFrom: null }] };
|
|
return { status: 200, data: [
|
|
{ qitemId: "qitem-next", destinationSession: "qa@team", state: "pending", handedOffFrom: "qitem-done" },
|
|
{ qitemId: "qitem-earlier", destinationSession: "dev@team", state: "done", handedOffFrom: "qitem-done" },
|
|
] };
|
|
}
|
|
return { status: 404, data: {} };
|
|
});
|
|
await setup({ get: getFn });
|
|
const successor = async (qitemId: string) => ((await mcpClient.callTool({ name: "rig_queue_show", arguments: { qitemId } })).structuredContent as { successor: unknown }).successor;
|
|
|
|
// A cross-host handoff is named from its closure target, not fetched.
|
|
expect(await successor("qitem-remote")).toEqual({ qitemId: "qitem-remote-successor", host: "mm2-host" });
|
|
expect(lists).toEqual([]);
|
|
// A handoff-and-complete closes the source done, and it still has a successor: the newest, with the others named.
|
|
expect(await successor("qitem-done")).toEqual({ qitemId: "qitem-next", destinationSession: "qa@team", state: "pending", otherSuccessors: ["qitem-earlier"] });
|
|
// A daemon that ignored the filter can't say which item is the successor.
|
|
expect(await successor("qitem-old")).toEqual({ unknown: true, code: "filter_unsupported" });
|
|
|
|
const listed = await mcpClient.callTool({ name: "rig_queue_list", arguments: { handedOffFrom: "qitem-old" } });
|
|
expect(listed.isError).toBe(true);
|
|
expect(listed.structuredContent).toMatchObject({ ok: false, code: "filter_unsupported" });
|
|
// handedOffFrom reads any state and any rig, so neither default narrows it.
|
|
expect(lists.at(-1)).not.toContain("activeOnly");
|
|
expect(lists.at(-1)).not.toContain("rig=");
|
|
await cleanup();
|
|
});
|
|
|
|
it("rig_team says why there's no lead: none authored, or a daemon that doesn't report one", async () => {
|
|
const detail = { rig: { id: "rig-1", name: "my-rig" }, nodes: [{ id: "n1", logicalId: "dev.impl" }], edges: [] };
|
|
let served: Record<string, unknown> = { ...detail, representative: null };
|
|
const getFn = vi.fn(async (path: string) => {
|
|
if (path.startsWith("/api/rigs/summary")) return { status: 200, data: [{ id: "rig-1", name: "my-rig" }] };
|
|
if (path === "/api/rigs/rig-1") return { status: 200, data: served };
|
|
if (path === "/api/rigs/rig-1/nodes") return { status: 200, data: [{ logicalId: "dev.impl", canonicalSessionName: "dev-impl@my-rig", agentActivity: { state: "idle" }, activityState: null }] };
|
|
return { status: 404, data: {} };
|
|
});
|
|
await setup({ get: getFn });
|
|
const team = async () => (await mcpClient.callTool({ name: "rig_team", arguments: { rigId: "my-rig" } })).structuredContent as { lead: unknown; leadNote?: string; seats: Array<{ activity: string }> };
|
|
|
|
const none = await team();
|
|
expect(none.lead).toBeNull();
|
|
expect(none.leadNote).toContain("No single lead");
|
|
expect(none.seats[0]!.activity).toBe("idle");
|
|
served = detail;
|
|
expect((await team()).leadNote).toContain("doesn't report");
|
|
await cleanup();
|
|
});
|
|
|
|
it("rig_team adds the rig's hosted members (marked hosted, on this host, not a node), and says when it can't tell", async () => {
|
|
const folder = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-rosters-"));
|
|
const member = (seat: string, host: string, extra: Record<string, unknown> = {}) =>
|
|
({ seat, host, capabilities: ["web research"], engagement: ["delegate"], use_when: "Current sources", why: "Searches the web", hosted: true, ...extra });
|
|
fs.writeFileSync(path.join(folder, "a.json"), JSON.stringify({ version: 1, id: "research", name: "Research", purpose: "Find things",
|
|
curator: { seat: "dev-impl@my-rig", host: "host-a" }, updated_at: "2026-10-26",
|
|
members: [
|
|
member("grok@my-rig", "host-a", { caveat: "Text only" }),
|
|
member("dev-impl@my-rig", "host-a"), // a node with a session: a seat
|
|
member("dev-qa@my-rig", "host-a"), // a node not launched yet (no session): still a seat
|
|
member("old@my-rig", "host-a", { hosted: undefined }), // a stale entry not marked hosted
|
|
member("bot@my-rig", "host-b"), // another host's
|
|
member("grok@other-rig", "host-a"), // another rig's
|
|
] }));
|
|
fs.writeFileSync(path.join(folder, "b.json"), "{ not json");
|
|
let hostSelfId: string | null = "host-a";
|
|
const getFn = vi.fn(async (p: string) => {
|
|
if (p.startsWith("/api/rigs/summary")) return { status: 200, data: [{ id: "rig-1", name: "my-rig" }] };
|
|
if (p === "/api/rigs/rig-1") return { status: 200, data: { rig: { id: "rig-1", name: "my-rig" }, nodes: [{ id: "n1", logicalId: "dev.impl" }, { id: "n2", logicalId: "dev.qa" }], edges: [], representative: null } };
|
|
if (p === "/api/rigs/rig-1/nodes") return { status: 200, data: [
|
|
{ logicalId: "dev.impl", canonicalSessionName: "dev-impl@my-rig", hostSelfId, agentActivity: { state: "idle" }, activityState: null },
|
|
{ logicalId: "dev.qa", canonicalSessionName: null, hostSelfId, activityState: null },
|
|
] };
|
|
if (p.startsWith("/api/queue/list?") && new URLSearchParams(p.split("?")[1]).get("destinationSession") === "grok@my-rig") return { status: 200, data: [
|
|
{ qitemId: "q-old", destinationSession: "grok@my-rig", lastNudgeAttempt: "2026-10-26T10:00:00.000Z", lastNudgeWireResult: "failed:webhook (GROK_WAKE_URL) answered 500" },
|
|
{ qitemId: "q-new", destinationSession: "grok@my-rig", lastNudgeAttempt: "2026-10-26T11:00:00.000Z", lastNudgeWireResult: "wake-accepted:webhook (GROK_WAKE_URL) answered 202" },
|
|
{ qitemId: "q-unrung", destinationSession: "grok@my-rig", lastNudgeAttempt: null, lastNudgeWireResult: null },
|
|
// A daemon that ignored the filter: another seat's ring doesn't count.
|
|
{ qitemId: "q-other", destinationSession: "lead@my-rig", lastNudgeAttempt: "2026-10-26T12:00:00.000Z", lastNudgeWireResult: "verified" },
|
|
] };
|
|
return { status: 404, data: {} };
|
|
});
|
|
const log = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
const presence = path.join(folder, "presence.json");
|
|
fs.writeFileSync(presence, JSON.stringify({ version: 1, seats: { "grok@my-rig": { grant: "grok", lastCallAt: "2026-10-26T11:05:00.000Z" } } }));
|
|
const connectTo = async (rostersFolder: string) => {
|
|
const server = createMcpServer(mockClient({ get: getFn }), { rostersFolder: () => rostersFolder, presenceFile: () => presence });
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
const client = new Client({ name: "test-client", version: "1.0.0" });
|
|
await server.connect(serverTransport);
|
|
await client.connect(clientTransport);
|
|
return { team: async () => (await client.callTool({ name: "rig_team", arguments: { rigId: "my-rig" } })).structuredContent as { hostedMembers: unknown[]; hostedMembersNote?: string },
|
|
close: async () => { await client.close(); await server.close(); } };
|
|
};
|
|
const blocked = path.join(folder, "a-file");
|
|
fs.writeFileSync(blocked, "not a folder");
|
|
const [here, unreachable] = [await connectTo(folder), await connectTo(path.join(blocked, "rosters"))];
|
|
try {
|
|
const found = await here.team();
|
|
expect(found.hostedMembers).toEqual([{ session: "grok@my-rig", roster: "research", capabilities: ["web research"], useWhen: "Current sources",
|
|
caveat: "Text only", pane: null, activity: "unknown",
|
|
presence: { lastCallAt: "2026-10-26T11:05:00.000Z", lastWakeScope: "the latest ring among its 50 most recent rows",
|
|
lastWake: { at: "2026-10-26T11:00:00.000Z", result: "wake-accepted:webhook (GROK_WAKE_URL) answered 202", qitemId: "q-new" } } }]);
|
|
// A scoped caller gets a generic note; the file names and errors go to this server's log.
|
|
expect(found.hostedMembersNote).toBe("Some rosters couldn't be read, so hosted members may be missing.");
|
|
expect(log.mock.calls.flat().join(" ")).toContain("b.json");
|
|
// A folder that can't be inspected isn't "no rosters".
|
|
expect(await unreachable.team()).toMatchObject({ hostedMembers: [], hostedMembersNote: "Some rosters couldn't be read, so hosted members may be missing." });
|
|
// With this daemon's host id unknown (none on the nodes, none from health), it says so instead of listing none.
|
|
hostSelfId = null;
|
|
const unknown = await here.team();
|
|
expect(unknown.hostedMembers).toEqual([]);
|
|
expect(unknown.hostedMembersNote).toContain("host id isn't known");
|
|
} finally {
|
|
await here.close();
|
|
await unreachable.close();
|
|
log.mockRestore();
|
|
fs.rmSync(folder, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("the context reads return bounded summaries, cut long text and say so, and keep a route's hint as the message", async () => {
|
|
const slice = (name: string, missionId: string) => ({ name, missionId, displayName: name, status: "active", description: null, qitemCount: 2, lastActivityAt: null, slicePath: `/abs/${name}`, readiness: { big: true } });
|
|
const getFn = vi.fn(async (path: string) => {
|
|
if (path === "/api/slices") return { status: 200, data: { slices: [slice("s1", "m1"), slice("s2", "m1"), slice("s3", "m2")], totalCount: 3 } };
|
|
if (path === "/api/slices?filter=done") return { status: 503, data: { error: "slices_root_not_configured", hint: "Run rig config init-workspace" } };
|
|
if (path === "/api/missions/m1") return { status: 200, data: { missionId: "m1", missionPath: "/abs/m1", status: "active", readiness: { big: true }, slices: [slice("s1", "m1")] } };
|
|
if (path.startsWith("/api/context-packs/library/by-ref")) return { status: 200, data: { relativePath: "world", name: "world", version: "1", purpose: "the world", sourcePath: "/abs", files: [{ path: "a.md", role: "atom", summary: null, estimatedTokens: 10, absolutePath: "/abs/a.md" }] } };
|
|
if (path.startsWith("/api/context-packs/library/resolve-address")) return { status: 200, data: { address: "world/a.md", packRef: "world", filePath: "a.md", text: "x".repeat(50_000) } };
|
|
return { status: 404, data: {} };
|
|
});
|
|
await setup({ get: getFn });
|
|
const call = async (name: string, args: Record<string, unknown>) => await mcpClient.callTool({ name, arguments: args });
|
|
|
|
const slices = (await call("rig_slices", { missionId: "m1", limit: 1 })).structuredContent as { slices: Array<Record<string, unknown>>; totalCount: number; truncated: boolean };
|
|
expect(slices).toMatchObject({ totalCount: 2, truncated: true });
|
|
expect(slices.slices).toEqual([{ name: "s1", missionId: "m1", displayName: "s1", status: "active", description: null, qitemCount: 2, lastActivityAt: null }]);
|
|
expect((await call("rig_slices", { filter: "done" })).structuredContent).toEqual({ ok: false, code: "slices_root_not_configured", message: "Run rig config init-workspace", status: 503 });
|
|
|
|
const mission = (await call("rig_mission", { missionId: "m1" })).structuredContent as Record<string, unknown>;
|
|
expect(mission).toEqual({ missionId: "m1", status: "active", slices: [expect.objectContaining({ name: "s1" })] });
|
|
|
|
const pack = (await call("rig_context_pack", { ref: "world" })).structuredContent as Record<string, unknown>;
|
|
expect(pack).toEqual({ ref: "world", name: "world", version: "1", purpose: "the world", files: [{ path: "a.md", role: "atom", summary: null, estimatedTokens: 10 }] });
|
|
|
|
const got = (await call("rig_context_get", { address: "world/a.md" })).structuredContent as { text: string; truncated?: boolean; totalChars?: number };
|
|
expect(got.text.length).toBe(48_000);
|
|
expect(got).toMatchObject({ truncated: true, totalChars: 50_000 });
|
|
await cleanup();
|
|
});
|
|
|
|
it("cuts long context text before an emoji instead of through it", async () => {
|
|
const text = `${"x".repeat(47_999)}🚀${"y".repeat(10)}`;
|
|
await setup({ get: vi.fn(async (path: string) => path.startsWith("/api/context-packs/library/resolve-address")
|
|
? { status: 200, data: { address: "world/a.md", packRef: "world", filePath: "a.md", text } }
|
|
: { status: 404, data: {} }) });
|
|
const got = (await mcpClient.callTool({ name: "rig_context_get", arguments: { address: "world/a.md" } })).structuredContent as { text: string };
|
|
expect(got).toMatchObject({ text: "x".repeat(47_999), truncated: true, totalChars: 48_011 });
|
|
await cleanup();
|
|
});
|
|
|
|
it("gives the client short instructions on the seat, durable delegation and reserved questions", async () => {
|
|
await setup();
|
|
const instructions = mcpClient.getInstructions() ?? "";
|
|
expect(instructions).toContain("OPENRIG_SESSION_NAME");
|
|
expect(instructions).toContain("rig_queue_create");
|
|
expect(instructions).toContain("not idle");
|
|
expect(instructions).toContain("reserved for a person");
|
|
expect(instructions).toContain("beside their answers and never as one");
|
|
await cleanup();
|
|
});
|
|
|
|
it("carries the member workflow as one versioned text, quoted word for word in the hosted-members doc", async () => {
|
|
await setup();
|
|
const instructions = mcpClient.getInstructions() ?? "";
|
|
expect(MEMBER_WORKFLOW).toContain(`(${MEMBER_WORKFLOW_VERSION})`);
|
|
expect(instructions).toContain(MEMBER_WORKFLOW);
|
|
// The inbox, the claim before work, and keeping an item blocked while a person is asked.
|
|
for (const part of ["rig_queue_list with owned set", "rig_queue_claim", "blockedOn that person", "blockedOn that new qitemId"]) expect(MEMBER_WORKFLOW).toContain(part);
|
|
// v2: the closure reason a finished item takes, and scope described from the grant, not the tool list.
|
|
expect(MEMBER_WORKFLOW_VERSION).toBe("member-workflow/v2");
|
|
for (const part of ["closure reason no-follow-on", "a listed tool isn't access", "names the shared roots you may read"]) expect(MEMBER_WORKFLOW).toContain(part);
|
|
const doc = fs.readFileSync(new URL("../../../docs/reference/hosted-members.md", import.meta.url), "utf8");
|
|
expect(doc).toContain(`> ${MEMBER_WORKFLOW}`);
|
|
expect(doc).not.toContain("member-workflow/v1");
|
|
await cleanup();
|
|
});
|
|
|
|
it("names the closure reasons rig_queue_update takes, finished work first", async () => {
|
|
await setup();
|
|
const { tools } = await mcpClient.listTools();
|
|
const update = tools.find((tool) => tool.name === "rig_queue_update");
|
|
const reason = (update?.inputSchema as { properties?: Record<string, { description?: string }> } | undefined)?.properties?.closureReason?.description ?? "";
|
|
expect(reason).toContain("no-follow-on for finished work");
|
|
for (const value of ["handed_off_to", "denied", "canceled", "escalation", "superseded"]) expect(reason).toContain(value);
|
|
expect(reason).not.toContain("completed");
|
|
await cleanup();
|
|
});
|
|
|
|
it("gives an external caller its request workflow without member tools or authority", async () => {
|
|
const server = createMcpServer(mockClient(), { seat: "cus-1-caller@team",
|
|
access: { principal: "cus-1", owner: false, rigs: ["team"], roots: [], workspace: false, external: { objects: { queue: ["create", "show", "list"] } } } });
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
const external = new Client({ name: "external-client", version: "1.0.0" });
|
|
await server.connect(serverTransport);
|
|
await external.connect(clientTransport);
|
|
const instructions = external.getInstructions() ?? "";
|
|
expect(instructions).toContain("reserved for a person");
|
|
expect(instructions).not.toContain(MEMBER_WORKFLOW_VERSION);
|
|
expect(instructions).not.toContain("basically authorized");
|
|
expect(instructions).toContain("You may omit destination");
|
|
expect(instructions).not.toMatch(/rig_whoami|rig_team|rig_queue_handoff|rig_queue_decide/);
|
|
await external.close();
|
|
await server.close();
|
|
});
|
|
|
|
it("rig_down calls POST /api/down with correct params", async () => {
|
|
const postFn = vi.fn(async () => ({
|
|
status: 200,
|
|
data: { rigId: "rig-1", sessionsKilled: 2, deleted: false, deleteBlocked: false, alreadyStopped: false, errors: [] },
|
|
}));
|
|
await setup({ post: postFn });
|
|
|
|
const result = await mcpClient.callTool({
|
|
name: "rig_down",
|
|
arguments: { rigId: "rig-1", delete: true, force: false, snapshot: false },
|
|
});
|
|
|
|
expect(postFn).toHaveBeenCalledWith("/api/down", {
|
|
rigId: "rig-1",
|
|
delete: true,
|
|
force: false,
|
|
snapshot: false,
|
|
});
|
|
const content = result.content as Array<{ type: string; text: string }>;
|
|
const parsed = JSON.parse(content[0].text);
|
|
expect(parsed.sessionsKilled).toBe(2);
|
|
expect(result.isError).toBeFalsy();
|
|
await cleanup();
|
|
});
|
|
|
|
// T4: rig_ps calls GET /api/ps
|
|
it("rig_ps calls GET /api/ps and returns entries", async () => {
|
|
const getFn = vi.fn(async () => ({
|
|
status: 200,
|
|
data: [{ rigId: "rig-1", name: "test", nodeCount: 2, runningCount: 2, status: "running", uptime: "1h", latestSnapshot: null }],
|
|
}));
|
|
await setup({ get: getFn });
|
|
|
|
const result = await mcpClient.callTool({ name: "rig_ps", arguments: {} });
|
|
|
|
expect(getFn).toHaveBeenCalledWith("/api/ps");
|
|
const content = result.content as Array<{ type: string; text: string }>;
|
|
const parsed = JSON.parse(content[0].text);
|
|
expect(Array.isArray(parsed)).toBe(true);
|
|
expect(parsed[0].rigId).toBe("rig-1");
|
|
expect(result.isError).toBeFalsy();
|
|
await cleanup();
|
|
});
|
|
|
|
// T5: Tool schemas have required properties
|
|
it("tool schemas have correct required properties", async () => {
|
|
await setup();
|
|
const result = await mcpClient.listTools();
|
|
|
|
const upTool = result.tools.find((t) => t.name === "rig_up")!;
|
|
expect(upTool.inputSchema.required).toContain("sourceRef");
|
|
expect(upTool.inputSchema.properties).toHaveProperty("plan");
|
|
expect(upTool.inputSchema.properties).toHaveProperty("autoApprove");
|
|
|
|
const downTool = result.tools.find((t) => t.name === "rig_down")!;
|
|
expect(downTool.inputSchema.required).toContain("rigId");
|
|
|
|
const restoreTool = result.tools.find((t) => t.name === "rig_restore")!;
|
|
expect(restoreTool.inputSchema.required).toContain("rigId");
|
|
expect(restoreTool.inputSchema.required).toContain("snapshotId");
|
|
|
|
await cleanup();
|
|
});
|
|
|
|
// T6: HTTP 404 -> isError:true
|
|
it("HTTP error maps to isError:true", async () => {
|
|
const postFn = vi.fn(async () => ({
|
|
status: 404,
|
|
data: { error: "Rig not found" },
|
|
}));
|
|
await setup({ post: postFn });
|
|
|
|
const result = await mcpClient.callTool({
|
|
name: "rig_down",
|
|
arguments: { rigId: "missing" },
|
|
});
|
|
|
|
expect(result.isError).toBe(true);
|
|
const content = result.content as Array<{ type: string; text: string }>;
|
|
const parsed = JSON.parse(content[0].text);
|
|
expect(parsed.error).toBe("Rig not found");
|
|
await cleanup();
|
|
});
|
|
|
|
// T7: In-body errors -> isError:true
|
|
it("in-body errors map to isError:true", async () => {
|
|
const postFn = vi.fn(async () => ({
|
|
status: 200,
|
|
data: { rigId: "rig-1", sessionsKilled: 1, deleted: false, deleteBlocked: false, alreadyStopped: false, errors: ["Kill failed"] },
|
|
}));
|
|
await setup({ post: postFn });
|
|
|
|
const result = await mcpClient.callTool({
|
|
name: "rig_down",
|
|
arguments: { rigId: "rig-1" },
|
|
});
|
|
|
|
expect(result.isError).toBe(true);
|
|
await cleanup();
|
|
});
|
|
|
|
// T8: Transport connect/disconnect lifecycle
|
|
it("transport connects and disconnects cleanly", async () => {
|
|
await setup();
|
|
|
|
// Verify server is responsive
|
|
const result = await mcpClient.listTools();
|
|
expect(result.tools.length).toBe(37);
|
|
|
|
// Clean disconnect
|
|
await cleanup();
|
|
|
|
// Create a fresh pair to verify we can reconnect
|
|
daemonClient = mockClient();
|
|
const server2 = createMcpServer(daemonClient);
|
|
const [ct2, st2] = InMemoryTransport.createLinkedPair();
|
|
const client2 = new Client({ name: "test-client-2", version: "1.0.0" });
|
|
await server2.connect(st2);
|
|
await client2.connect(ct2);
|
|
|
|
const result2 = await client2.listTools();
|
|
expect(result2.tools.length).toBe(37);
|
|
|
|
await client2.close();
|
|
await server2.close();
|
|
});
|
|
|
|
// T9: bundle inspect integrityResult.passed=false -> isError:true
|
|
it("bundle inspect integrity failure maps to isError:true", async () => {
|
|
const postFn = vi.fn(async () => ({
|
|
status: 200,
|
|
data: {
|
|
manifest: { name: "test", version: "1.0.0", packages: [] },
|
|
digestValid: true,
|
|
integrityResult: { passed: false, mismatches: ["pkg-a"], missing: [], extra: [], errors: [] },
|
|
},
|
|
}));
|
|
await setup({ post: postFn });
|
|
|
|
const result = await mcpClient.callTool({
|
|
name: "rig_bundle_inspect",
|
|
arguments: { bundlePath: "/tmp/test.rigbundle" },
|
|
});
|
|
|
|
expect(result.isError).toBe(true);
|
|
const content = result.content as Array<{ type: string; text: string }>;
|
|
const parsed = JSON.parse(content[0].text);
|
|
expect(parsed.integrityResult.passed).toBe(false);
|
|
await cleanup();
|
|
});
|
|
|
|
// T10: MCP server lists rig_agent_validate + rig_rig_validate tools
|
|
it("rig_agent_validate and rig_rig_validate tools are registered", async () => {
|
|
await setup();
|
|
const result = await mcpClient.listTools();
|
|
const names = result.tools.map((t) => t.name);
|
|
expect(names).toContain("rig_agent_validate");
|
|
expect(names).toContain("rig_rig_validate");
|
|
|
|
// Verify schema: both have required "yaml" input
|
|
const agentTool = result.tools.find((t) => t.name === "rig_agent_validate")!;
|
|
expect(agentTool.inputSchema.required).toContain("yaml");
|
|
const rigTool = result.tools.find((t) => t.name === "rig_rig_validate")!;
|
|
expect(rigTool.inputSchema.required).toContain("yaml");
|
|
|
|
await cleanup();
|
|
});
|
|
|
|
// T10b: rig_agent_validate calls postText with correct path
|
|
it("rig_agent_validate calls postText on /api/agents/validate", async () => {
|
|
const postTextFn = vi.fn(async () => ({
|
|
status: 200,
|
|
data: { valid: true, errors: [] },
|
|
}));
|
|
await setup({ postText: postTextFn });
|
|
|
|
const result = await mcpClient.callTool({
|
|
name: "rig_agent_validate",
|
|
arguments: { yaml: "name: my-agent\nversion: 1.0.0\n" },
|
|
});
|
|
|
|
expect(postTextFn).toHaveBeenCalledWith("/api/agents/validate", "name: my-agent\nversion: 1.0.0\n");
|
|
expect(result.isError).toBeFalsy();
|
|
await cleanup();
|
|
});
|
|
|
|
// T11: MCP tools URL-encode path parameters
|
|
it("snapshot_create URL-encodes rigId in path", async () => {
|
|
const postFn = vi.fn(async () => ({
|
|
status: 201,
|
|
data: { id: "snap-1", rigId: "rig/with/slashes", kind: "manual", status: "complete" },
|
|
}));
|
|
await setup({ post: postFn });
|
|
|
|
await mcpClient.callTool({
|
|
name: "rig_snapshot_create",
|
|
arguments: { rigId: "rig/with/slashes" },
|
|
});
|
|
|
|
const calledPath = postFn.mock.calls[0][0] as string;
|
|
expect(calledPath).toBe(`/api/rigs/${encodeURIComponent("rig/with/slashes")}/snapshots`);
|
|
expect(calledPath).toContain("rig%2Fwith%2Fslashes");
|
|
await cleanup();
|
|
});
|
|
|
|
// NS-T09: rig_rig_nodes calls GET /api/rigs/:rigId/nodes
|
|
it("rig_rig_nodes returns node inventory", async () => {
|
|
const getFn = vi.fn(async (path: string) => {
|
|
if (path.includes("/nodes")) {
|
|
return {
|
|
status: 200,
|
|
data: [
|
|
{ rigId: "rig-1", rigName: "test", logicalId: "dev.impl", nodeKind: "agent", runtime: "claude-code", sessionStatus: "running", tmuxAttachCommand: "tmux attach -t dev-impl@test" },
|
|
],
|
|
};
|
|
}
|
|
return { status: 200, data: {} };
|
|
});
|
|
await setup({ get: getFn });
|
|
|
|
const result = await mcpClient.callTool({
|
|
name: "rig_rig_nodes",
|
|
arguments: { rigId: "rig-1" },
|
|
});
|
|
|
|
expect(getFn).toHaveBeenCalled();
|
|
const calledPath = getFn.mock.calls.find((c) => (c[0] as string).includes("/nodes"))?.[0] as string;
|
|
expect(calledPath).toBe("/api/rigs/rig-1/nodes");
|
|
expect(result.isError).toBeUndefined();
|
|
const parsed = JSON.parse((result.content as Array<{ text: string }>)[0]!.text);
|
|
expect(Array.isArray(parsed)).toBe(true);
|
|
expect(parsed[0].nodeKind).toBe("agent");
|
|
await cleanup();
|
|
});
|
|
|
|
// T14: rig_send returns structured result
|
|
it("rig_send returns structured result", async () => {
|
|
const postFn = vi.fn(async () => ({
|
|
status: 200,
|
|
data: { ok: true, sessionName: "dev-impl@my-rig" },
|
|
}));
|
|
await setup({ post: postFn });
|
|
|
|
const result = await mcpClient.callTool({
|
|
name: "rig_send",
|
|
arguments: { session: "dev-impl@my-rig", text: "hello" },
|
|
});
|
|
|
|
expect(postFn).toHaveBeenCalled();
|
|
expect(postFn.mock.calls[0]?.[2]).toBeUndefined();
|
|
expect(result.isError).toBeUndefined();
|
|
const parsed = JSON.parse((result.content as Array<{ text: string }>)[0]!.text);
|
|
expect(parsed.ok).toBe(true);
|
|
expect(parsed.sessionName).toBe("dev-impl@my-rig");
|
|
await cleanup();
|
|
});
|
|
|
|
it("rig_send with waitForIdleSeconds extends request timeout", async () => {
|
|
const postFn = vi.fn(async () => ({
|
|
status: 200,
|
|
data: { ok: true, sessionName: "dev-impl@my-rig" },
|
|
}));
|
|
await setup({ post: postFn });
|
|
|
|
const result = await mcpClient.callTool({
|
|
name: "rig_send",
|
|
arguments: { session: "dev-impl@my-rig", text: "hello", waitForIdleSeconds: 30 },
|
|
});
|
|
|
|
expect(postFn).toHaveBeenCalledWith(
|
|
"/api/transport/send",
|
|
expect.objectContaining({
|
|
session: "dev-impl@my-rig",
|
|
text: "hello",
|
|
waitForIdleMs: 30000,
|
|
}),
|
|
{ timeoutMs: 35000 },
|
|
);
|
|
expect(result.isError).toBeUndefined();
|
|
await cleanup();
|
|
});
|
|
|
|
it("rig_send with a wait longer than 300 s is one request carrying the whole wait", async () => {
|
|
const postFn = vi.fn().mockResolvedValueOnce({ status: 200, data: { ok: true, sessionName: "dev-impl@my-rig" } });
|
|
await setup({ post: postFn });
|
|
|
|
const result = await mcpClient.callTool({
|
|
name: "rig_send",
|
|
arguments: { session: "dev-impl@my-rig", text: "hello", waitForIdleSeconds: 400 },
|
|
});
|
|
|
|
expect(postFn.mock.calls.map((call) => [(call[1] as { waitForIdleMs: number }).waitForIdleMs, call[2]])).toEqual([[400000, { timeoutMs: 405000 }]]);
|
|
expect(result.isError).toBeUndefined();
|
|
await cleanup();
|
|
});
|
|
|
|
it("rig_send rejects waitForIdleSeconds with force before daemon contact", async () => {
|
|
const postFn = vi.fn(async () => ({
|
|
status: 200,
|
|
data: { ok: true },
|
|
}));
|
|
await setup({ post: postFn });
|
|
|
|
const result = await mcpClient.callTool({
|
|
name: "rig_send",
|
|
arguments: { session: "dev-impl@my-rig", text: "hello", waitForIdleSeconds: 30, force: true },
|
|
});
|
|
|
|
expect(result.isError).toBe(true);
|
|
expect(postFn).not.toHaveBeenCalled();
|
|
const parsed = JSON.parse((result.content as Array<{ text: string }>)[0]!.text);
|
|
expect(parsed.error).toContain("cannot be combined");
|
|
await cleanup();
|
|
});
|
|
|
|
// T-chatroom-send: rig_chatroom_send returns result
|
|
it("rig_chatroom_send returns result", async () => {
|
|
const getFn = vi.fn(async () => ({
|
|
status: 200,
|
|
data: [{ id: "rig-1", name: "my-rig", nodeCount: 2 }],
|
|
}));
|
|
const postFn = vi.fn(async () => ({
|
|
status: 201,
|
|
data: { id: "msg-1", rigId: "rig-1", sender: "mcp", kind: "message", body: "hello", topic: null, createdAt: "2026-03-31T10:00:00Z" },
|
|
}));
|
|
await setup({ get: getFn, post: postFn });
|
|
|
|
const result = await mcpClient.callTool({
|
|
name: "rig_chatroom_send",
|
|
arguments: { rigName: "my-rig", body: "hello" },
|
|
});
|
|
|
|
expect(result.isError).toBeFalsy();
|
|
const parsed = JSON.parse((result.content as Array<{ text: string }>)[0]!.text);
|
|
expect(parsed.sender).toBe("mcp");
|
|
expect(parsed.body).toBe("hello");
|
|
await cleanup();
|
|
});
|
|
|
|
// T-chatroom-watch: rig_chatroom_watch returns recent history
|
|
it("rig_chatroom_watch returns recent history", async () => {
|
|
const getFn = vi.fn(async (path: string) => {
|
|
if (path.startsWith("/api/rigs/summary")) {
|
|
return {
|
|
status: 200,
|
|
data: [{ id: "rig-1", name: "my-rig", nodeCount: 2 }],
|
|
};
|
|
}
|
|
if (path.includes("/chat/history")) {
|
|
return {
|
|
status: 200,
|
|
data: [
|
|
{ id: "msg-1", rigId: "rig-1", sender: "alice", kind: "message", body: "hello", topic: null, createdAt: "2026-03-31T10:00:00Z" },
|
|
],
|
|
};
|
|
}
|
|
return { status: 200, data: {} };
|
|
});
|
|
await setup({ get: getFn });
|
|
|
|
const result = await mcpClient.callTool({
|
|
name: "rig_chatroom_watch",
|
|
arguments: { rigName: "my-rig" },
|
|
});
|
|
|
|
expect(result.isError).toBeFalsy();
|
|
expect(getFn).toHaveBeenCalledWith("/api/rigs/rig-1/chat/history?limit=20");
|
|
const parsed = JSON.parse((result.content as Array<{ text: string }>)[0]!.text);
|
|
expect(Array.isArray(parsed)).toBe(true);
|
|
expect(parsed[0].sender).toBe("alice");
|
|
await cleanup();
|
|
});
|
|
|
|
// T-chatroom-ambiguous: MCP rig_chatroom_send with ambiguous rig name -> error
|
|
it("rig_chatroom_send with ambiguous rig name returns error", async () => {
|
|
const getFn = vi.fn(async () => ({
|
|
status: 200,
|
|
data: [
|
|
{ id: "rig-1", name: "my-rig", nodeCount: 2 },
|
|
{ id: "rig-2", name: "my-rig", nodeCount: 1 },
|
|
],
|
|
}));
|
|
const postFn = vi.fn(async () => ({ status: 201, data: {} }));
|
|
await setup({ get: getFn, post: postFn });
|
|
|
|
const result = await mcpClient.callTool({
|
|
name: "rig_chatroom_send",
|
|
arguments: { rigName: "my-rig", body: "hello" },
|
|
});
|
|
|
|
expect(result.isError).toBe(true);
|
|
const parsed = JSON.parse((result.content as Array<{ text: string }>)[0]!.text);
|
|
expect(parsed).toMatchObject({ code: "rig_ambiguous", candidates: ["rig-1", "rig-2"] });
|
|
expect(postFn).not.toHaveBeenCalled();
|
|
await cleanup();
|
|
});
|
|
|
|
it("the chatroom tools take a rig id as well as a name; watch reads one topic, sender and page size; send ignores an old sender argument", async () => {
|
|
const getFn = vi.fn(async (path: string) => {
|
|
if (path.startsWith("/api/rigs/summary")) return { status: 200, data: [{ id: "01RIG", name: "my-rig", nodeCount: 2 }] };
|
|
return { status: 200, data: [] };
|
|
});
|
|
const postFn = vi.fn(async () => ({ status: 201, data: { id: "msg-1", body: "hello" } }));
|
|
await setup({ get: getFn, post: postFn });
|
|
|
|
const sent = await mcpClient.callTool({ name: "rig_chatroom_send", arguments: { rigName: "01RIG", body: "hello", sender: "someone-else" } });
|
|
expect(sent.isError).toBeFalsy();
|
|
expect(postFn).toHaveBeenCalledWith("/api/rigs/01RIG/chat/send", { body: "hello" });
|
|
|
|
const read = await mcpClient.callTool({ name: "rig_chatroom_watch", arguments: { rigName: "my-rig", topic: "release", sender: "dev-impl@my-rig", limit: 5 } });
|
|
expect(read.isError).toBeFalsy();
|
|
expect(getFn).toHaveBeenCalledWith("/api/rigs/01RIG/chat/history?limit=5&topic=release&sender=dev-impl%40my-rig");
|
|
|
|
// A handle no rig has is an error, not an empty room.
|
|
const missing = await mcpClient.callTool({ name: "rig_chatroom_watch", arguments: { rigName: "no-such-rig" } });
|
|
expect(missing.isError).toBe(true);
|
|
expect((missing.content as Array<{ text: string }>)[0]!.text).toContain("not found");
|
|
await cleanup();
|
|
});
|
|
|
|
it("the chatroom tools report a rig list they can't read, instead of an empty room", async () => {
|
|
const getFn = vi.fn(async (path: string) => (path.startsWith("/api/rigs/summary")
|
|
? { status: 500, data: { error: "unavailable" } }
|
|
: { status: 200, data: [] }));
|
|
await setup({ get: getFn });
|
|
const read = await mcpClient.callTool({ name: "rig_chatroom_watch", arguments: { rigName: "my-rig" } });
|
|
expect(read.isError).toBe(true);
|
|
expect((read.content as Array<{ text: string }>)[0]!.text).toContain("could not be checked");
|
|
expect(getFn.mock.calls.some(([path]) => String(path).includes("/chat/history"))).toBe(false);
|
|
await cleanup();
|
|
});
|
|
|
|
// T15: rig_capture asks for the input box and returns structured result
|
|
it("rig_capture asks for the input box and returns structured result", async () => {
|
|
const postFn = vi.fn(async () => ({
|
|
status: 200,
|
|
data: { ok: true, sessionName: "dev-impl@my-rig", content: "pane output", lines: 20 },
|
|
}));
|
|
await setup({ post: postFn });
|
|
|
|
const result = await mcpClient.callTool({
|
|
name: "rig_capture",
|
|
arguments: { session: "dev-impl@my-rig" },
|
|
});
|
|
|
|
expect(postFn).toHaveBeenCalled();
|
|
const [path, body] = postFn.mock.calls[0] as unknown as [string, Record<string, unknown>];
|
|
expect(path).toBe("/api/transport/capture");
|
|
expect(body).toEqual({ session: "dev-impl@my-rig", inputBox: true });
|
|
expect(result.isError).toBeUndefined();
|
|
const parsed = JSON.parse((result.content as Array<{ text: string }>)[0]!.text);
|
|
expect(parsed.ok).toBe(true);
|
|
expect(parsed.content).toBe("pane output");
|
|
await cleanup();
|
|
});
|
|
describe("queue tools", () => {
|
|
const text = (result: Awaited<ReturnType<Client["callTool"]>>) => (result.content as Array<{ text: string }>)[0]!.text;
|
|
|
|
it("rig_queue_create posts the task with a minted request id and no body sender", async () => {
|
|
const postFn = vi.fn(async () => ({ status: 201, data: { qitemId: "qitem-1" } }));
|
|
await setup({ post: postFn });
|
|
const result = await mcpClient.callTool({ name: "rig_queue_create", arguments: { destination: "impl@team", body: "Do X", summary: "X", tags: ["mission:m"] } });
|
|
expect(result.isError).toBeUndefined();
|
|
const [path, body] = postFn.mock.calls[0] as unknown as [string, Record<string, unknown>];
|
|
expect(path).toBe("/api/queue/create");
|
|
expect(body).toMatchObject({ destinationSession: "impl@team", body: "Do X", summary: "X", tags: ["mission:m"] });
|
|
expect(body.qitemId).toMatch(/^qitem-\d{14}-[0-9a-f]{16}$/);
|
|
expect(body).not.toHaveProperty("sourceSession");
|
|
await cleanup();
|
|
});
|
|
|
|
it("rig_queue_create drops OpenRig's provenance tags only from an external caller's create and revision; other callers' pass as sent", async () => {
|
|
const sent = ["mission:m", "project:p", "custom", "workflow", "founder-slack", "inbound", "shared-channel", "participant:acme", "slack-body:0123456789abcdef", "slack-attachments:39", "gate"];
|
|
const tagsSent = async (access?: McpAccess, revises?: string) => {
|
|
const postFn = vi.fn(async () => ({ status: 201, data: { qitemId: "qitem-1" } }));
|
|
const getFn = vi.fn(async () => ({ status: 200, data: { qitemId: "qitem-0", sourceSession: "cus-a1-caller@team", destinationSession: "lead@team" } }));
|
|
const server = createMcpServer(mockClient({ post: postFn, get: getFn }), { seat: "cus-a1-caller@team", access });
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
const client = new Client({ name: "test-client", version: "1.0.0" });
|
|
await server.connect(serverTransport);
|
|
await client.connect(clientTransport);
|
|
const result = await client.callTool({ name: "rig_queue_create", arguments: { destination: "lead@team", body: "Do X", tags: sent, ...(revises ? { revises } : {}) } });
|
|
await client.close();
|
|
await server.close();
|
|
expect(result.isError).toBeUndefined();
|
|
return (postFn.mock.calls[0] as unknown as [string, Record<string, unknown>])[1].tags;
|
|
};
|
|
const work: McpAccess = { principal: "grok", owner: false, rigs: ["team"], roots: [], workspace: false };
|
|
const external: McpAccess = { ...work, principal: "cus_A1", external: { objects: { queue: ["create", "show", "list"] } } };
|
|
expect(await tagsSent()).toEqual(sent);
|
|
expect(await tagsSent(work)).toEqual(sent);
|
|
expect(await tagsSent(external)).toEqual(["mission:m", "project:p", "custom", "workflow"]);
|
|
expect(await tagsSent(external, "qitem-0")).toEqual(["mission:m", "project:p", "custom", "workflow"]);
|
|
});
|
|
|
|
it("rig_queue_create: an external caller hands tasks only to its team, never to a caller-shaped seat (itself included); a self-hosted grant is unchanged", async () => {
|
|
const created = async (access: McpAccess, destination: string, revises?: string) => {
|
|
const postFn = vi.fn(async () => ({ status: 201, data: { qitemId: "qitem-1" } }));
|
|
const getFn = vi.fn(async () => ({ status: 200, data: { qitemId: "qitem-0", sourceSession: "cus-a1-caller@team", destinationSession: "lead@team" } }));
|
|
const server = createMcpServer(mockClient({ post: postFn, get: getFn }), { seat: "cus-a1-caller@team", access });
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
const client = new Client({ name: "test-client", version: "1.0.0" });
|
|
await server.connect(serverTransport);
|
|
await client.connect(clientTransport);
|
|
const result = await client.callTool({ name: "rig_queue_create", arguments: { destination, body: "Do X", ...(revises ? { revises } : {}) } });
|
|
await client.close();
|
|
await server.close();
|
|
return { posted: postFn.mock.calls.length, refused: (result.structuredContent as { code?: string } | undefined)?.code === "out_of_scope" };
|
|
};
|
|
const work: McpAccess = { principal: "grok", owner: false, rigs: ["team"], roots: [], workspace: false };
|
|
const external: McpAccess = { ...work, principal: "cus_A1", external: { objects: { queue: ["create", "show", "list"] } } };
|
|
for (const destination of ["cus-a1-caller@team", "cus-b2-caller@team"]) {
|
|
expect(await created(external, destination)).toEqual({ posted: 0, refused: true });
|
|
expect(await created(external, destination, "qitem-0")).toEqual({ posted: 0, refused: true });
|
|
expect(await created(work, destination)).toEqual({ posted: 1, refused: false });
|
|
}
|
|
expect(await created(external, "lead@team")).toEqual({ posted: 1, refused: false });
|
|
});
|
|
|
|
it("rig_queue_create sends humanAlert only when alert is set, so an MCP-only agent can still raise a sign-in", async () => {
|
|
const postFn = vi.fn(async () => ({ status: 201, data: { qitemId: "qitem-1" } }));
|
|
await setup({ post: postFn });
|
|
await mcpClient.callTool({ name: "rig_queue_create", arguments: { destination: "human-founder@external", body: "Sign in again?", alert: true } });
|
|
await mcpClient.callTool({ name: "rig_queue_create", arguments: { destination: "human-founder@external", body: "Which name?" } });
|
|
const bodies = postFn.mock.calls.map((call) => (call as unknown as [string, Record<string, unknown>])[1]);
|
|
expect(bodies[0]).toMatchObject({ humanAlert: true });
|
|
expect(bodies[1]).not.toHaveProperty("humanAlert");
|
|
await cleanup();
|
|
});
|
|
|
|
it("rig_queue_create reuses a given request id and names it when the outcome is unknown", async () => {
|
|
const postFn = vi.fn(async () => { throw new Error("timed out after 5000ms"); });
|
|
await setup({ post: postFn });
|
|
const result = await mcpClient.callTool({ name: "rig_queue_create", arguments: { destination: "impl@team", body: "Do X", qitemId: "qitem-retry" } });
|
|
expect((postFn.mock.calls[0] as unknown as [string, Record<string, unknown>])[1].qitemId).toBe("qitem-retry");
|
|
expect(result.isError).toBe(true);
|
|
expect(text(result)).toContain("unknown");
|
|
expect(text(result)).toContain("qitem-retry");
|
|
await cleanup();
|
|
});
|
|
|
|
it("rig_queue_show returns the item and its transitions together", async () => {
|
|
const getFn = vi.fn(async (path: string) => path.endsWith("/transitions")
|
|
? { status: 200, data: [{ state: "done", transitionNote: "the answer" }] }
|
|
: { status: 200, data: { qitemId: "qitem 1", state: "done" } });
|
|
await setup({ get: getFn });
|
|
const result = await mcpClient.callTool({ name: "rig_queue_show", arguments: { qitemId: "qitem 1" } });
|
|
expect(getFn.mock.calls.map(c => c[0])).toEqual(["/api/queue/qitem%201", "/api/queue/qitem%201/transitions"]);
|
|
expect(JSON.parse(text(result))).toEqual({ item: { qitemId: "qitem 1", state: "done" }, transitions: [{ state: "done", transitionNote: "the answer" }] });
|
|
await cleanup();
|
|
});
|
|
|
|
it("rig_queue_show surfaces a missing item as an error without reading transitions", async () => {
|
|
const getFn = vi.fn(async () => ({ status: 404, data: { error: "not found" } }));
|
|
await setup({ get: getFn });
|
|
const result = await mcpClient.callTool({ name: "rig_queue_show", arguments: { qitemId: "qitem-x" } });
|
|
expect(result.isError).toBe(true);
|
|
expect(getFn).toHaveBeenCalledTimes(1);
|
|
await cleanup();
|
|
});
|
|
|
|
it("rig_queue_list scopes owned and mine to this server's seat", async () => {
|
|
const getFn = vi.fn(async () => ({ status: 200, data: [] }));
|
|
const prior = process.env.OPENRIG_SESSION_NAME;
|
|
process.env.OPENRIG_SESSION_NAME = "desk@team";
|
|
try {
|
|
await setup({ get: getFn });
|
|
await mcpClient.callTool({ name: "rig_queue_list", arguments: { owned: true } });
|
|
await mcpClient.callTool({ name: "rig_queue_list", arguments: { mine: true, all: true } });
|
|
await mcpClient.callTool({ name: "rig_queue_list", arguments: {} });
|
|
await mcpClient.callTool({ name: "rig_queue_list", arguments: { owned: true, destination: "other@team" } });
|
|
const queries = getFn.mock.calls.map(c => new URLSearchParams(String(c[0]).split("?")[1]));
|
|
expect(queries[3]!.getAll("destinationSession")).toEqual(["desk@team"]);
|
|
expect(queries[0]!.get("destinationSession")).toBe("desk@team");
|
|
expect(queries[0]!.get("activeOnly")).toBe("1");
|
|
expect(queries[1]!.get("as")).toBe("desk@team");
|
|
expect(queries[1]!.has("activeOnly")).toBe(false);
|
|
expect(queries[2]!.get("rig")).toBe("team");
|
|
await cleanup();
|
|
} finally {
|
|
if (prior === undefined) delete process.env.OPENRIG_SESSION_NAME; else process.env.OPENRIG_SESSION_NAME = prior;
|
|
}
|
|
});
|
|
|
|
it("rig_queue_list asks for a seat instead of guessing one", async () => {
|
|
const getFn = vi.fn(async () => ({ status: 200, data: [] }));
|
|
const prior = process.env.OPENRIG_SESSION_NAME;
|
|
const priorLegacy = process.env.RIGGED_SESSION_NAME;
|
|
delete process.env.OPENRIG_SESSION_NAME;
|
|
delete process.env.RIGGED_SESSION_NAME;
|
|
try {
|
|
await setup({ get: getFn });
|
|
const result = await mcpClient.callTool({ name: "rig_queue_list", arguments: { owned: true } });
|
|
expect(result.isError).toBe(true);
|
|
expect(getFn).not.toHaveBeenCalled();
|
|
await cleanup();
|
|
} finally {
|
|
if (prior !== undefined) process.env.OPENRIG_SESSION_NAME = prior;
|
|
if (priorLegacy !== undefined) process.env.RIGGED_SESSION_NAME = priorLegacy;
|
|
}
|
|
});
|
|
|
|
it("rig_queue_claim and rig_queue_update post to the item's routes", async () => {
|
|
const postFn = vi.fn(async () => ({ status: 200, data: { ok: true } }));
|
|
await setup({ post: postFn });
|
|
await mcpClient.callTool({ name: "rig_queue_claim", arguments: { qitemId: "qitem-1" } });
|
|
await mcpClient.callTool({ name: "rig_queue_update", arguments: { qitemId: "qitem-1", state: "done", closureReason: "no-follow-on", note: "the answer" } });
|
|
expect(postFn.mock.calls[0]?.[0]).toBe("/api/queue/qitem-1/claim");
|
|
expect(postFn.mock.calls[1]?.[0]).toBe("/api/queue/qitem-1/update");
|
|
expect(postFn.mock.calls[1]?.[1]).toMatchObject({ state: "done", closureReason: "no-follow-on", transitionNote: "the answer" });
|
|
await cleanup();
|
|
});
|
|
it("rig_queue_handoff and rig_queue_unclaim post to the item's routes, naming the next seat and the reason", async () => {
|
|
const postFn = vi.fn(async () => ({ status: 201, data: { ok: true } }));
|
|
await setup({ post: postFn });
|
|
await mcpClient.callTool({ name: "rig_queue_handoff", arguments: { qitemId: "qitem-1", to: "qa@team", note: "needs a check" } });
|
|
await mcpClient.callTool({ name: "rig_queue_unclaim", arguments: { qitemId: "qitem-2", reason: "no capacity" } });
|
|
expect(postFn.mock.calls[0]?.[0]).toBe("/api/queue/qitem-1/handoff");
|
|
expect(postFn.mock.calls[0]?.[1]).toMatchObject({ toSession: "qa@team", transitionNote: "needs a check" });
|
|
expect(postFn.mock.calls[1]?.[0]).toBe("/api/queue/qitem-2/unclaim");
|
|
expect(postFn.mock.calls[1]?.[1]).toEqual({ reason: "no capacity" });
|
|
await cleanup();
|
|
});
|
|
it("rig_queue_handoff that times out says the outcome is unknown and how to check it before retrying", async () => {
|
|
const postFn = vi.fn(async () => { throw new Error("Request timed out"); });
|
|
await setup({ post: postFn });
|
|
const result = await mcpClient.callTool({ name: "rig_queue_handoff", arguments: { qitemId: "qitem-1", to: "qa@team" } });
|
|
expect(result.isError).toBe(true);
|
|
expect(result.structuredContent).toMatchObject({ ok: false, qitemId: "qitem-1", outcome: "unknown" });
|
|
expect((result.content as Array<{ text: string }>)[0]!.text).toContain("check rig_queue_show with qitemId qitem-1 before retrying");
|
|
await cleanup();
|
|
});
|
|
it("rig_queue_update passes the outcome as resolution and a deliberate reopen", async () => {
|
|
const postFn = vi.fn(async () => ({ status: 200, data: { ok: true } }));
|
|
await setup({ post: postFn });
|
|
await mcpClient.callTool({ name: "rig_queue_update", arguments: { qitemId: "qitem-1", state: "done", closureReason: "no-follow-on", resolution: "the answer" } });
|
|
await mcpClient.callTool({ name: "rig_queue_update", arguments: { qitemId: "qitem-1", state: "pending", reopen: true, note: "one more detail" } });
|
|
expect(postFn.mock.calls[0]?.[1]).toMatchObject({ state: "done", resolution: "the answer" });
|
|
expect(postFn.mock.calls[1]?.[1]).toMatchObject({ state: "pending", reopen: true, transitionNote: "one more detail" });
|
|
await cleanup();
|
|
});
|
|
});
|
|
});
|