service-lifecycle.test.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. import { describe, expect, test } from "bun:test"
  2. import type { SessionConfigOption } from "@agentclientprotocol/sdk"
  3. import { makeACPFixture, makeSession, secondModel } from "./service-fixture"
  4. describe("acp service lifecycle", () => {
  5. test("loads and forks with paginated replay while resume does not replay", async () => {
  6. await using fixture = makeACPFixture({
  7. fetch(request) {
  8. if (request.method === "GET" && request.path === "/api/session/ses_loaded") {
  9. return Response.json({
  10. data: makeSession("ses_loaded", {
  11. cwd: "/workspace",
  12. agent: "plan",
  13. model: { providerID: "test", id: secondModel.id, variant: "medium" },
  14. }),
  15. })
  16. }
  17. if (request.method === "GET" && request.path === "/api/session/ses_resume") {
  18. return Response.json({
  19. data: makeSession("ses_resume", {
  20. cwd: "/workspace",
  21. agent: "plan",
  22. model: { providerID: "test", id: secondModel.id, variant: "low" },
  23. }),
  24. })
  25. }
  26. if (request.method === "POST" && request.path === "/api/session/ses_loaded/fork") {
  27. return Response.json({
  28. data: makeSession("ses_fork", {
  29. cwd: "/workspace",
  30. agent: "plan",
  31. model: { providerID: "test", id: secondModel.id, variant: "medium" },
  32. }),
  33. })
  34. }
  35. if (request.method === "GET" && request.path === "/api/session/ses_loaded/message") {
  36. if (request.query.cursor === "messages-2") {
  37. return Response.json({
  38. data: [
  39. {
  40. id: "msg_assistant",
  41. type: "assistant",
  42. content: [{ type: "text", text: "hi there" }],
  43. },
  44. ],
  45. cursor: {},
  46. })
  47. }
  48. return Response.json({
  49. data: [{ id: "msg_user", type: "user", text: "hello", time: { created: 1 } }],
  50. cursor: { next: "messages-2" },
  51. })
  52. }
  53. if (request.method === "GET" && request.path === "/api/session/ses_fork/message") {
  54. return Response.json({
  55. data: [{ id: "msg_fork", type: "user", text: "forked", time: { created: 2 } }],
  56. cursor: {},
  57. })
  58. }
  59. return undefined
  60. },
  61. })
  62. const loaded = await fixture.service.loadSession({
  63. cwd: "/ignored",
  64. sessionId: "ses_loaded",
  65. mcpServers: [],
  66. })
  67. const resumed = await fixture.service.resumeSession({
  68. cwd: "/ignored",
  69. sessionId: "ses_resume",
  70. mcpServers: [],
  71. })
  72. const forked = await fixture.service.forkSession({
  73. cwd: "/ignored",
  74. sessionId: "ses_loaded",
  75. mcpServers: [],
  76. })
  77. expect(currentValue(loaded, "model")).toBe("test/second-model")
  78. expect(currentValue(loaded, "effort")).toBe("medium")
  79. expect(currentValue(loaded, "mode")).toBe("plan")
  80. expect(currentValue(resumed, "effort")).toBe("low")
  81. expect(forked.sessionId).toBe("ses_fork")
  82. expect(currentValue(forked, "effort")).toBe("medium")
  83. expect(
  84. fixture.updates.filter(
  85. (item) =>
  86. item.update.sessionUpdate === "user_message_chunk" || item.update.sessionUpdate === "agent_message_chunk",
  87. ),
  88. ).toEqual([
  89. {
  90. sessionId: "ses_loaded",
  91. update: {
  92. sessionUpdate: "user_message_chunk",
  93. messageId: "msg_user",
  94. content: { type: "text", text: "hello" },
  95. },
  96. },
  97. {
  98. sessionId: "ses_loaded",
  99. update: {
  100. sessionUpdate: "agent_message_chunk",
  101. messageId: "msg_assistant",
  102. content: { type: "text", text: "hi there" },
  103. },
  104. },
  105. {
  106. sessionId: "ses_fork",
  107. update: {
  108. sessionUpdate: "user_message_chunk",
  109. messageId: "msg_fork",
  110. content: { type: "text", text: "forked" },
  111. },
  112. },
  113. ])
  114. expect(
  115. fixture.requests
  116. .filter((request) => request.path.endsWith("/message"))
  117. .map((request) => ({ path: request.path, query: request.query })),
  118. ).toEqual([
  119. {
  120. path: "/api/session/ses_loaded/message",
  121. query: { limit: "200", order: "asc" },
  122. },
  123. {
  124. path: "/api/session/ses_loaded/message",
  125. query: { limit: "200", cursor: "messages-2" },
  126. },
  127. {
  128. path: "/api/session/ses_fork/message",
  129. query: { limit: "200", order: "asc" },
  130. },
  131. ])
  132. expect(fixture.requests).toContainEqual({
  133. method: "POST",
  134. path: "/api/session/ses_loaded/fork",
  135. query: {},
  136. body: { boundary: { type: "through" } },
  137. })
  138. })
  139. test("lists server-backed pages and forwards cwd and cursor", async () => {
  140. const firstPage = Array.from({ length: 100 }, (_, index) =>
  141. makeSession(`ses_${100 - index}`, {
  142. cwd: "/workspace",
  143. time: { created: index, updated: 100_000 - index },
  144. title: `Session ${100 - index}`,
  145. }),
  146. )
  147. await using fixture = makeACPFixture({
  148. fetch(request) {
  149. if (request.method !== "GET" || request.path !== "/api/session") return undefined
  150. if (request.query.cursor === "page-2") {
  151. return Response.json({
  152. data: [makeSession("ses_0", { cwd: "/workspace", time: { created: 0, updated: 1 } })],
  153. cursor: {},
  154. })
  155. }
  156. return Response.json({ data: firstPage, cursor: { next: "page-2" } })
  157. },
  158. })
  159. const first = await fixture.service.listSessions({ cwd: "/workspace" })
  160. const second = await fixture.service.listSessions({ cwd: "/workspace", cursor: first.nextCursor })
  161. expect(first.sessions).toHaveLength(100)
  162. expect(first.sessions[0]).toEqual({
  163. sessionId: "ses_100",
  164. cwd: "/workspace",
  165. title: "Session 100",
  166. updatedAt: new Date(100_000).toISOString(),
  167. })
  168. expect(first.nextCursor).toBe("page-2")
  169. expect(second.sessions.map((session) => session.sessionId)).toEqual(["ses_0"])
  170. expect(second.nextCursor).toBeUndefined()
  171. expect(
  172. fixture.requests.filter((request) => request.path === "/api/session").map((request) => request.query),
  173. ).toEqual([
  174. { limit: "100", order: "desc", directory: "/workspace" },
  175. { limit: "100", order: "desc", directory: "/workspace", cursor: "page-2" },
  176. ])
  177. })
  178. test("cancel preserves the attachment while close removes it and interrupts best-effort", async () => {
  179. await using fixture = makeACPFixture({
  180. fetch(request) {
  181. if (request.method === "POST" && request.path === "/api/session") {
  182. return Response.json({ data: makeSession("ses_lifecycle") })
  183. }
  184. if (request.method === "POST" && request.path === "/api/session/ses_lifecycle/model") {
  185. return new Response(null, { status: 204 })
  186. }
  187. if (request.method === "POST" && request.path.endsWith("/interrupt")) {
  188. return new Response(null, { status: 500 })
  189. }
  190. return undefined
  191. },
  192. })
  193. const created = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
  194. await fixture.service.cancel({ sessionId: created.sessionId })
  195. const updated = await fixture.service.setSessionConfigOption({
  196. sessionId: created.sessionId,
  197. configId: "effort",
  198. value: "high",
  199. })
  200. expect(currentValue(updated, "effort")).toBe("high")
  201. expect(await fixture.service.closeSession({ sessionId: created.sessionId })).toEqual({})
  202. const missing = await fixture.service
  203. .setSessionConfigOption({
  204. sessionId: created.sessionId,
  205. configId: "effort",
  206. value: "default",
  207. })
  208. .catch((error: unknown) => error)
  209. expect(missing).toMatchObject({ _tag: "ACPSessionNotFoundError", sessionId: created.sessionId })
  210. expect(await fixture.service.closeSession({ sessionId: "missing" })).toEqual({})
  211. expect(
  212. fixture.requests.filter((request) => request.path.endsWith("/interrupt")).map((request) => request.path),
  213. ).toEqual([
  214. "/api/session/ses_lifecycle/interrupt",
  215. "/api/session/ses_lifecycle/interrupt",
  216. "/api/session/missing/interrupt",
  217. ])
  218. })
  219. test("deletes sessions from backing and local storage", async () => {
  220. await using fixture = makeACPFixture({
  221. fetch(request) {
  222. if (request.method === "POST" && request.path === "/api/session") {
  223. return Response.json({ data: makeSession("ses_delete") })
  224. }
  225. if (request.method === "DELETE" && request.path === "/api/session/ses_delete") {
  226. return new Response(null, { status: 204 })
  227. }
  228. return undefined
  229. },
  230. })
  231. const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
  232. expect(await fixture.service.deleteSession({ sessionId: session.sessionId })).toEqual({})
  233. expect(fixture.requests).toContainEqual({
  234. method: "DELETE",
  235. path: "/api/session/ses_delete",
  236. query: {},
  237. body: undefined,
  238. })
  239. const missing = await fixture.service
  240. .setSessionConfigOption({ sessionId: session.sessionId, configId: "effort", value: "high" })
  241. .catch((error: unknown) => error)
  242. expect(missing).toMatchObject({ _tag: "ACPSessionNotFoundError", sessionId: session.sessionId })
  243. })
  244. })
  245. function currentValue(result: { readonly configOptions?: readonly SessionConfigOption[] | null }, id: string) {
  246. return result.configOptions?.find((option) => option.id === id)?.currentValue
  247. }