tools.test.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. import assert from "node:assert/strict";
  2. import test from "node:test";
  3. import { Client } from "@modelcontextprotocol/sdk/client/index.js";
  4. import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
  5. import { AppServerClient } from "../src/app-server-client.js";
  6. import { normalizeThread, type ContextBounds, type ThreadInspection } from "../src/domain.js";
  7. import { createMcpServer, SERVER_INSTRUCTIONS } from "../src/mcp-server.js";
  8. import { ThreadMonitor } from "../src/monitor.js";
  9. import { MonitorStore, type WaitResult } from "../src/store.js";
  10. import { ChiefOfStaffTools, type MonitorFacade } from "../src/tools.js";
  11. import { FakeAppServer, rawThread, successfulHandshake } from "./fake-app-server.js";
  12. class StoreFacade implements MonitorFacade {
  13. readonly store: MonitorStore;
  14. constructor(store: MonitorStore) {
  15. this.store = store;
  16. }
  17. snapshot() { return this.store.snapshot(); }
  18. ensureFresh() { return Promise.resolve(); }
  19. inspectThread(_threadId: string, _bounds: ContextBounds): Promise<ThreadInspection> {
  20. return Promise.reject(new Error("inspection not configured"));
  21. }
  22. waitForChange(
  23. afterRevision: number,
  24. timeoutMs: number,
  25. options: { signal?: AbortSignal; maxChanges?: number } = {},
  26. ): Promise<WaitResult> {
  27. return this.store.waitForChange(afterRevision, timeoutMs, options);
  28. }
  29. }
  30. function storeWith(rawThreads: Record<string, unknown>[]): MonitorStore {
  31. const store = new MonitorStore();
  32. store.replaceSnapshot(rawThreads.map(normalizeThread), new Set());
  33. return store;
  34. }
  35. async function connectMcp(tools: ChiefOfStaffTools) {
  36. const server = createMcpServer(tools);
  37. const client = new Client({ name: "test-client", version: "1.0.0" });
  38. const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
  39. await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
  40. return { server, client };
  41. }
  42. test("overview is recency ordered and excludes exec, subagent, guardian, and message bodies", async () => {
  43. const store = storeWith([
  44. rawThread("interactive-old", { updatedAt: 10, recencyAt: 10, source: "cli" }),
  45. rawThread("interactive-new", { updatedAt: 50, recencyAt: 50, source: "vscode" }),
  46. rawThread("exec", { updatedAt: 60, recencyAt: 60, source: "exec" }),
  47. rawThread("sub", { updatedAt: 70, recencyAt: 70, source: { subAgent: { type: "thread_spawn" } }, parentThreadId: "parent" }),
  48. rawThread("guardian", { updatedAt: 80, recencyAt: 80, source: "cli", agentRole: "guardian" }),
  49. ]);
  50. const result = await new ChiefOfStaffTools(new StoreFacade(store)).overview({});
  51. const threads = result.threads as Array<Record<string, unknown>>;
  52. assert.deepEqual(threads.map((thread) => thread.id), ["interactive-new", "interactive-old"]);
  53. assert.equal(JSON.stringify(result).includes("turns"), false);
  54. assert.equal((result.coverage as Record<string, unknown>).freshness, "current");
  55. });
  56. test("list_threads covers cwd roots, descendants, non-matches, filters, pagination, and truncation", async () => {
  57. const nowSeconds = 2_000;
  58. const store = storeWith([
  59. rawThread("root", { cwd: "/Users/dnabracz/Work", recencyAt: 1_990, updatedAt: 1_990, status: { type: "active", activeFlags: [] } }),
  60. rawThread("descendant", { cwd: "/Users/dnabracz/Work/scm/repo", recencyAt: 1_980, updatedAt: 1_980, status: { type: "idle" } }),
  61. rawThread("similar", { cwd: "/Users/dnabracz/Workspace", recencyAt: 1_970, updatedAt: 1_970 }),
  62. rawThread("old", { cwd: "/Users/dnabracz/Work/old", recencyAt: 100, updatedAt: 100 }),
  63. ]);
  64. const tools = new ChiefOfStaffTools(new StoreFacade(store), () => new Date(nowSeconds * 1_000));
  65. const first = await tools.listThreads({ cwdPrefix: "/Users/dnabracz/Work", recentSeconds: 100, limit: 1 });
  66. assert.deepEqual((first.threads as Array<Record<string, unknown>>).map((thread) => thread.id), ["root"]);
  67. assert.equal(first.truncated, true);
  68. assert.equal(first.nextCursor, "1");
  69. const second = await tools.listThreads({ cwdPrefix: "/Users/dnabracz/Work", recentSeconds: 100, limit: 1, cursor: "1" });
  70. assert.deepEqual((second.threads as Array<Record<string, unknown>>).map((thread) => thread.id), ["descendant"]);
  71. assert.equal(second.nextCursor, null);
  72. const active = await tools.listThreads({ statuses: ["active"] });
  73. assert.deepEqual((active.threads as Array<Record<string, unknown>>).map((thread) => thread.id), ["root"]);
  74. });
  75. test("MCP client lists exactly four observational tools", async () => {
  76. const { client, server } = await connectMcp(new ChiefOfStaffTools(new StoreFacade(storeWith([]))));
  77. assert.equal(client.getInstructions(), SERVER_INSTRUCTIONS);
  78. assert.match(client.getInstructions() ?? "", /call overview first/i);
  79. assert.match(client.getInstructions() ?? "", /Do not browse the web/i);
  80. const listed = await client.listTools();
  81. assert.deepEqual(listed.tools.map((tool) => tool.name), [
  82. "overview",
  83. "list_threads",
  84. "inspect_thread",
  85. "wait_for_change",
  86. ]);
  87. assert.match(listed.tools[0]?.description ?? "", /Use first for Chief-of-Staff requests/i);
  88. await Promise.all([client.close(), server.close()]);
  89. });
  90. test("wait_for_change MCP integration covers status, connection, and timeout", async () => {
  91. const store = storeWith([rawThread("a")]);
  92. const { client, server } = await connectMcp(new ChiefOfStaffTools(new StoreFacade(store)));
  93. let revision = store.revision;
  94. queueMicrotask(() => store.updateStatus("a", { type: "active", activeFlags: [] }));
  95. const status = await client.callTool({ name: "wait_for_change", arguments: { afterRevision: revision, timeoutMs: 100 } });
  96. assert.equal((status.structuredContent as Record<string, unknown>).timedOut, false);
  97. revision = store.revision;
  98. queueMicrotask(() => store.setConnection("disconnected", "test disconnect"));
  99. const connection = await client.callTool({ name: "wait_for_change", arguments: { afterRevision: revision, timeoutMs: 100 } });
  100. const changes = (connection.structuredContent as Record<string, unknown>).changes as Array<Record<string, unknown>>;
  101. assert.equal(changes[0]?.kind, "connection");
  102. const timedOut = await client.callTool({ name: "wait_for_change", arguments: { afterRevision: store.revision, timeoutMs: 5 } });
  103. assert.equal((timedOut.structuredContent as Record<string, unknown>).timedOut, true);
  104. await Promise.all([client.close(), server.close()]);
  105. });
  106. test("every published tool keeps upstream traffic inside the read-only allowlist", async () => {
  107. const fake = new FakeAppServer(successfulHandshake((request, server) => {
  108. if (request.method === "thread/list") {
  109. server.respond(request, { data: [rawThread("a")], nextCursor: null, backwardsCursor: null });
  110. } else if (request.method === "thread/loaded/list") {
  111. server.respond(request, { data: [], nextCursor: null });
  112. } else if (request.method === "thread/read") {
  113. server.respond(request, { thread: rawThread("a") });
  114. }
  115. }));
  116. const monitor = new ThreadMonitor({
  117. clientFactory: () => new AppServerClient({
  118. url: "ws://127.0.0.1:4500",
  119. webSocketFactory: fake.factory,
  120. requestTimeoutMs: 50,
  121. connectTimeoutMs: 50,
  122. }),
  123. waitRefreshIntervalMs: 10,
  124. });
  125. monitor.start();
  126. await monitor.waitUntilSynchronized();
  127. const { client, server } = await connectMcp(new ChiefOfStaffTools(monitor));
  128. await client.callTool({ name: "overview", arguments: {} });
  129. await client.callTool({ name: "list_threads", arguments: { includeSubagents: true } });
  130. const inspection = await client.callTool({ name: "inspect_thread", arguments: { threadId: "a" } });
  131. assert.equal(inspection.isError, undefined);
  132. await client.callTool({ name: "wait_for_change", arguments: { afterRevision: monitor.snapshot().revision, timeoutMs: 1 } });
  133. const methods = fake.frames.map((frame) => frame.method as string);
  134. const permitted = new Set(["initialize", "initialized", "thread/list", "thread/loaded/list", "thread/read"]);
  135. assert.deepEqual(methods.filter((method) => !permitted.has(method)), []);
  136. assert.equal(methods.includes("thread/read"), true);
  137. await Promise.all([client.close(), server.close()]);
  138. await monitor.stop();
  139. });
  140. test("inspect_thread returns structured upstream errors without resuming or subscribing", async () => {
  141. const fake = new FakeAppServer(successfulHandshake((request, server) => {
  142. if (request.method === "thread/list") server.respond(request, { data: [], nextCursor: null, backwardsCursor: null });
  143. else if (request.method === "thread/loaded/list") server.respond(request, { data: [], nextCursor: null });
  144. else if (request.method === "thread/read") server.fail(request, -32602, "thread not found");
  145. }));
  146. const monitor = new ThreadMonitor({ clientFactory: () => new AppServerClient({
  147. url: "ws://127.0.0.1:4500",
  148. webSocketFactory: fake.factory,
  149. requestTimeoutMs: 50,
  150. }) });
  151. monitor.start();
  152. await monitor.waitUntilSynchronized();
  153. const result = await new ChiefOfStaffTools(monitor).call("inspect_thread", { threadId: "missing" });
  154. assert.equal(result.isError, true);
  155. assert.equal(((result.structuredContent.error as Record<string, unknown>).code), "not_found");
  156. const methods = fake.frames.map((frame) => frame.method);
  157. assert.equal(methods.includes("thread/resume"), false);
  158. assert.equal(methods.includes("thread/unsubscribe"), false);
  159. await monitor.stop();
  160. });