service-directory.test.ts 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. import { describe, expect, test } from "bun:test"
  2. import type { McpServer, SessionConfigOption } from "@agentclientprotocol/sdk"
  3. import { makeACPFixture, makeSession, secondModel, testModel } from "./service-fixture"
  4. describe("acp service directory behavior", () => {
  5. test("creates sessions from a catalog shared by concurrent callers in the same cwd", async () => {
  6. let created = 0
  7. await using fixture = makeACPFixture({
  8. fetch(request) {
  9. if (request.method !== "POST" || request.path !== "/api/session") return undefined
  10. created++
  11. return Response.json({
  12. data: makeSession(`ses_${created}`, { cwd: created <= 2 ? "/workspace" : "/other" }),
  13. })
  14. },
  15. })
  16. const first = await Promise.all([
  17. fixture.service.newSession({ cwd: "/workspace", mcpServers: [] }),
  18. fixture.service.newSession({ cwd: "/workspace", mcpServers: [] }),
  19. ])
  20. const other = await fixture.service.newSession({ cwd: "/other", mcpServers: [] })
  21. expect(first.map((session) => session.sessionId).toSorted()).toEqual(["ses_1", "ses_2"])
  22. expect(other.sessionId).toBe("ses_3")
  23. expect(currentValue(first[0], "model")).toBe("test/test-model")
  24. expect(currentValue(first[0], "mode")).toBe("build")
  25. expect(
  26. ["/api/model", "/api/model/default", "/api/agent", "/api/command", "/api/skill"].map((path) =>
  27. fixture.requests
  28. .filter((request) => request.path === path)
  29. .map((request) => request.query["location[directory]"]),
  30. ),
  31. ).toEqual([
  32. ["/workspace", "/other"],
  33. ["/workspace", "/other"],
  34. ["/workspace", "/other"],
  35. ["/workspace", "/other"],
  36. ["/workspace", "/other"],
  37. ])
  38. expect(
  39. fixture.requests
  40. .filter((request) => request.method === "POST" && request.path === "/api/session")
  41. .map((request) => request.body),
  42. ).toEqual([
  43. {
  44. agent: "build",
  45. model: { providerID: "test", id: "test-model", variant: "default" },
  46. location: { directory: "/workspace" },
  47. },
  48. {
  49. agent: "build",
  50. model: { providerID: "test", id: "test-model", variant: "default" },
  51. location: { directory: "/workspace" },
  52. },
  53. {
  54. agent: "build",
  55. model: { providerID: "test", id: "test-model", variant: "default" },
  56. location: { directory: "/other" },
  57. },
  58. ])
  59. expect(
  60. fixture.updates.map((item) =>
  61. item.update.sessionUpdate === "available_commands_update"
  62. ? item.update.availableCommands.map((command) => command.name)
  63. : [],
  64. ),
  65. ).toEqual([
  66. ["review", "verify"],
  67. ["review", "verify"],
  68. ["review", "verify"],
  69. ])
  70. })
  71. test("does not cache a failed catalog load", async () => {
  72. let modelCalls = 0
  73. await using fixture = makeACPFixture({
  74. fetch(request) {
  75. if (request.path === "/api/model") {
  76. modelCalls++
  77. if (modelCalls === 1) {
  78. return Response.json(
  79. { name: "ModelsNotReadyError", data: { message: "catalog is warming" } },
  80. { status: 503 },
  81. )
  82. }
  83. }
  84. if (request.method === "POST" && request.path === "/api/session") {
  85. return Response.json({ data: makeSession("ses_retry") })
  86. }
  87. return undefined
  88. },
  89. })
  90. const failure = await fixture.service
  91. .newSession({ cwd: "/workspace", mcpServers: [] })
  92. .catch((error: unknown) => error)
  93. expect(failure).toMatchObject({ name: "ModelsNotReadyError" })
  94. const retried = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
  95. expect(retried.sessionId).toBe("ses_retry")
  96. expect(modelCalls).toBe(2)
  97. })
  98. test("switches model, effort, and mode against the warm catalog", async () => {
  99. await using fixture = makeACPFixture({
  100. fetch(request) {
  101. if (request.method === "POST" && request.path === "/api/session") {
  102. return Response.json({ data: makeSession("ses_config") })
  103. }
  104. if (
  105. request.method === "POST" &&
  106. (request.path === "/api/session/ses_config/model" || request.path === "/api/session/ses_config/agent")
  107. ) {
  108. return new Response(null, { status: 204 })
  109. }
  110. return undefined
  111. },
  112. })
  113. const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
  114. const selectedModel = await fixture.service.setSessionConfigOption({
  115. sessionId: session.sessionId,
  116. configId: "model",
  117. value: "test/second-model",
  118. })
  119. const selectedEffort = await fixture.service.setSessionConfigOption({
  120. sessionId: session.sessionId,
  121. configId: "effort",
  122. value: "medium",
  123. })
  124. const selectedMode = await fixture.service.setSessionConfigOption({
  125. sessionId: session.sessionId,
  126. configId: "mode",
  127. value: "plan",
  128. })
  129. await fixture.service.setSessionModel({ sessionId: session.sessionId, modelId: "test/test-model/high" })
  130. await fixture.service.setSessionMode({ sessionId: session.sessionId, modeId: "build" })
  131. expect(currentValue(selectedModel, "model")).toBe("test/second-model")
  132. expect(currentValue(selectedModel, "effort")).toBe("low")
  133. expect(currentValue(selectedEffort, "effort")).toBe("medium")
  134. expect(currentValue(selectedMode, "mode")).toBe("plan")
  135. expect(
  136. fixture.requests
  137. .filter((request) => request.path === "/api/session/ses_config/model")
  138. .map((request) => request.body),
  139. ).toEqual([
  140. { model: { providerID: "test", id: secondModel.id } },
  141. { model: { providerID: "test", id: secondModel.id, variant: "medium" } },
  142. { model: { providerID: "test", id: testModel.id, variant: "high" } },
  143. ])
  144. expect(
  145. fixture.requests
  146. .filter((request) => request.path === "/api/session/ses_config/agent")
  147. .map((request) => request.body),
  148. ).toEqual([{ agent: "plan" }, { agent: "build" }])
  149. expect(fixture.requests.filter((request) => request.path === "/api/model")).toHaveLength(1)
  150. const invalidEffort = await fixture.service
  151. .setSessionConfigOption({
  152. sessionId: session.sessionId,
  153. configId: "effort",
  154. value: "maximum",
  155. })
  156. .catch((error: unknown) => error)
  157. const invalidMode = await fixture.service
  158. .setSessionConfigOption({
  159. sessionId: session.sessionId,
  160. configId: "mode",
  161. value: "missing",
  162. })
  163. .catch((error: unknown) => error)
  164. const invalidConfig = await fixture.service
  165. .setSessionConfigOption({
  166. sessionId: session.sessionId,
  167. configId: "missing",
  168. value: "value",
  169. })
  170. .catch((error: unknown) => error)
  171. expect(invalidEffort).toMatchObject({ _tag: "ACPInvalidEffortError" })
  172. expect(invalidMode).toMatchObject({ _tag: "ACPInvalidModeError" })
  173. expect(invalidConfig).toMatchObject({ _tag: "ACPInvalidConfigOptionError" })
  174. })
  175. test("converts MCP configs and deduplicates registrations per session and config", async () => {
  176. const local: McpServer = {
  177. name: "tools",
  178. command: "bun",
  179. args: ["server.ts"],
  180. env: [{ name: "TOKEN", value: "x" }],
  181. }
  182. const changed: McpServer = { ...local, args: ["changed.ts"] }
  183. const remote: McpServer = {
  184. type: "http",
  185. name: "docs",
  186. url: "https://example.com/mcp",
  187. headers: [{ name: "Authorization", value: "Bearer x" }],
  188. }
  189. let created = 0
  190. await using fixture = makeACPFixture({
  191. fetch(request) {
  192. if (request.method === "POST" && request.path === "/api/session") {
  193. created++
  194. return Response.json({ data: makeSession(`ses_${created}`) })
  195. }
  196. if (request.method === "GET" && request.path === "/api/session/ses_1") {
  197. return Response.json({ data: makeSession("ses_1") })
  198. }
  199. if (request.method === "PUT" && request.path.startsWith("/api/mcp/")) {
  200. return new Response(null, { status: 204 })
  201. }
  202. return undefined
  203. },
  204. })
  205. await fixture.service.newSession({ cwd: "/workspace", mcpServers: [local, local, remote] })
  206. await fixture.service.resumeSession({ cwd: "/workspace", sessionId: "ses_1", mcpServers: [local, remote] })
  207. await fixture.service.resumeSession({ cwd: "/workspace", sessionId: "ses_1", mcpServers: [changed] })
  208. await fixture.service.newSession({ cwd: "/workspace", mcpServers: [local] })
  209. const adds = fixture.requests.filter((request) => request.method === "PUT" && request.path.startsWith("/api/mcp/"))
  210. expect(adds).toHaveLength(4)
  211. expect(adds.filter((request) => request.path === "/api/mcp/tools").map((request) => request.body)).toEqual([
  212. {
  213. config: {
  214. type: "local",
  215. command: ["bun", "server.ts"],
  216. environment: { TOKEN: "x" },
  217. },
  218. },
  219. {
  220. config: {
  221. type: "local",
  222. command: ["bun", "changed.ts"],
  223. environment: { TOKEN: "x" },
  224. },
  225. },
  226. {
  227. config: {
  228. type: "local",
  229. command: ["bun", "server.ts"],
  230. environment: { TOKEN: "x" },
  231. },
  232. },
  233. ])
  234. expect(adds.find((request) => request.path === "/api/mcp/docs")?.body).toEqual({
  235. config: {
  236. type: "remote",
  237. url: "https://example.com/mcp",
  238. headers: { Authorization: "Bearer x" },
  239. oauth: false,
  240. },
  241. })
  242. expect(adds.map((request) => request.query)).toEqual([
  243. { "location[directory]": "/workspace" },
  244. { "location[directory]": "/workspace" },
  245. { "location[directory]": "/workspace" },
  246. { "location[directory]": "/workspace" },
  247. ])
  248. })
  249. })
  250. function currentValue(
  251. result: { readonly configOptions?: readonly SessionConfigOption[] | null } | undefined,
  252. id: string,
  253. ) {
  254. return result?.configOptions?.find((option) => option.id === id)?.currentValue
  255. }