service-directory.test.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. import { describe, expect, test } from "bun:test"
  2. import type { McpServer, SessionConfigOption } from "@agentclientprotocol/sdk"
  3. import { makeACPFixture, makeSession, secondModel } 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.setSessionMode({ sessionId: session.sessionId, modeId: "build" })
  130. expect(currentValue(selectedModel, "model")).toBe("test/second-model")
  131. expect(currentValue(selectedModel, "effort")).toBe("low")
  132. expect(currentValue(selectedEffort, "effort")).toBe("medium")
  133. expect(currentValue(selectedMode, "mode")).toBe("plan")
  134. expect(
  135. fixture.requests
  136. .filter((request) => request.path === "/api/session/ses_config/model")
  137. .map((request) => request.body),
  138. ).toEqual([
  139. { model: { providerID: "test", id: secondModel.id } },
  140. { model: { providerID: "test", id: secondModel.id, variant: "medium" } },
  141. ])
  142. expect(
  143. fixture.requests
  144. .filter((request) => request.path === "/api/session/ses_config/agent")
  145. .map((request) => request.body),
  146. ).toEqual([{ agent: "plan" }, { agent: "build" }])
  147. expect(fixture.requests.filter((request) => request.path === "/api/model")).toHaveLength(1)
  148. const invalidEffort = await fixture.service
  149. .setSessionConfigOption({
  150. sessionId: session.sessionId,
  151. configId: "effort",
  152. value: "maximum",
  153. })
  154. .catch((error: unknown) => error)
  155. const invalidMode = await fixture.service
  156. .setSessionConfigOption({
  157. sessionId: session.sessionId,
  158. configId: "mode",
  159. value: "missing",
  160. })
  161. .catch((error: unknown) => error)
  162. const invalidConfig = await fixture.service
  163. .setSessionConfigOption({
  164. sessionId: session.sessionId,
  165. configId: "missing",
  166. value: "value",
  167. })
  168. .catch((error: unknown) => error)
  169. expect(invalidEffort).toMatchObject({ _tag: "ACPInvalidEffortError" })
  170. expect(invalidMode).toMatchObject({ _tag: "ACPInvalidModeError" })
  171. expect(invalidConfig).toMatchObject({ _tag: "ACPInvalidConfigOptionError" })
  172. })
  173. test("converts MCP configs and deduplicates registrations per session and config", async () => {
  174. const local: McpServer = {
  175. name: "tools",
  176. command: "bun",
  177. args: ["server.ts"],
  178. env: [{ name: "TOKEN", value: "x" }],
  179. }
  180. const changed: McpServer = { ...local, args: ["changed.ts"] }
  181. const remote: McpServer = {
  182. type: "http",
  183. name: "docs",
  184. url: "https://example.com/mcp",
  185. headers: [{ name: "Authorization", value: "Bearer x" }],
  186. }
  187. let created = 0
  188. await using fixture = makeACPFixture({
  189. fetch(request) {
  190. if (request.method === "POST" && request.path === "/api/session") {
  191. created++
  192. return Response.json({ data: makeSession(`ses_${created}`) })
  193. }
  194. if (request.method === "GET" && request.path === "/api/session/ses_1") {
  195. return Response.json({ data: makeSession("ses_1") })
  196. }
  197. if (request.method === "PUT" && request.path.startsWith("/api/mcp/")) {
  198. return new Response(null, { status: 204 })
  199. }
  200. return undefined
  201. },
  202. })
  203. await fixture.service.newSession({ cwd: "/workspace", mcpServers: [local, local, remote] })
  204. await fixture.service.resumeSession({ cwd: "/workspace", sessionId: "ses_1", mcpServers: [local, remote] })
  205. await fixture.service.resumeSession({ cwd: "/workspace", sessionId: "ses_1", mcpServers: [changed] })
  206. await fixture.service.newSession({ cwd: "/workspace", mcpServers: [local] })
  207. const adds = fixture.requests.filter((request) => request.method === "PUT" && request.path.startsWith("/api/mcp/"))
  208. expect(adds).toHaveLength(4)
  209. expect(adds.filter((request) => request.path === "/api/mcp/tools").map((request) => request.body)).toEqual([
  210. {
  211. config: {
  212. type: "local",
  213. command: ["bun", "server.ts"],
  214. environment: { TOKEN: "x" },
  215. },
  216. },
  217. {
  218. config: {
  219. type: "local",
  220. command: ["bun", "changed.ts"],
  221. environment: { TOKEN: "x" },
  222. },
  223. },
  224. {
  225. config: {
  226. type: "local",
  227. command: ["bun", "server.ts"],
  228. environment: { TOKEN: "x" },
  229. },
  230. },
  231. ])
  232. expect(adds.find((request) => request.path === "/api/mcp/docs")?.body).toEqual({
  233. config: {
  234. type: "remote",
  235. url: "https://example.com/mcp",
  236. headers: { Authorization: "Bearer x" },
  237. oauth: false,
  238. },
  239. })
  240. expect(adds.map((request) => request.query)).toEqual([
  241. { "location[directory]": "/workspace" },
  242. { "location[directory]": "/workspace" },
  243. { "location[directory]": "/workspace" },
  244. { "location[directory]": "/workspace" },
  245. ])
  246. })
  247. })
  248. function currentValue(
  249. result: { readonly configOptions?: readonly SessionConfigOption[] | null } | undefined,
  250. id: string,
  251. ) {
  252. return result?.configOptions?.find((option) => option.id === id)?.currentValue
  253. }