session.test.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. import { describe, expect } from "bun:test"
  2. import type { McpServer } from "@agentclientprotocol/sdk"
  3. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  4. import { Effect } from "effect"
  5. import { ProviderV2 } from "@kirincode-ai/core/provider"
  6. import { ModelV2 } from "@kirincode-ai/core/model"
  7. import * as ACPError from "@/acp/error"
  8. import * as ACPSession from "@/acp/session"
  9. import { testEffect } from "../lib/effect"
  10. const sessionTest = testEffect(LayerNode.compile(ACPSession.node))
  11. const model = (providerID: string, modelID: string): ACPSession.SelectedModel => ({
  12. providerID: ProviderV2.ID.make(providerID),
  13. modelID: ModelV2.ID.make(modelID),
  14. })
  15. const mcpServer: McpServer = {
  16. name: "local-tools",
  17. command: "node",
  18. args: ["server.js"],
  19. env: [],
  20. }
  21. describe("acp session state", () => {
  22. sessionTest.effect("creates and retrieves session state", () =>
  23. Effect.gen(function* () {
  24. const createdAt = new Date("2026-05-25T00:00:00.000Z")
  25. const created = yield* ACPSession.Service.use((session) =>
  26. session.create({
  27. id: "ses_1",
  28. cwd: "/workspace",
  29. mcpServers: [mcpServer],
  30. createdAt,
  31. model: model("anthropic", "claude-sonnet"),
  32. variant: "high",
  33. modeId: "build",
  34. }),
  35. )
  36. const loaded = yield* ACPSession.Service.use((session) => session.get("ses_1"))
  37. expect(created).toMatchObject({
  38. id: "ses_1",
  39. cwd: "/workspace",
  40. mcpServers: [mcpServer],
  41. model: model("anthropic", "claude-sonnet"),
  42. variant: "high",
  43. modeId: "build",
  44. })
  45. expect(loaded.createdAt).toEqual(createdAt)
  46. expect(loaded.knownParts.size).toBe(0)
  47. }),
  48. )
  49. sessionTest.effect("fails required lookups with typed SessionNotFound", () =>
  50. Effect.gen(function* () {
  51. const error = yield* ACPSession.Service.use((session) => session.get("ses_missing")).pipe(Effect.flip)
  52. expect(error).toBeInstanceOf(ACPError.SessionNotFoundError)
  53. expect(error.sessionId).toBe("ses_missing")
  54. }),
  55. )
  56. sessionTest.effect("tryGet lets event routing ignore unknown sessions", () =>
  57. Effect.gen(function* () {
  58. const missing = yield* ACPSession.Service.use((session) => session.tryGet("ses_missing"))
  59. const missingPart = yield* ACPSession.Service.use((session) =>
  60. session.tryGetPartMetadata({ sessionId: "ses_missing", messageId: "msg_1", partId: "part_1" }),
  61. )
  62. expect(missing).toBeUndefined()
  63. expect(missingPart).toBeUndefined()
  64. }),
  65. )
  66. sessionTest.effect("updates selected model while preserving session identity and inputs", () =>
  67. Effect.gen(function* () {
  68. yield* ACPSession.Service.use((session) =>
  69. session.create({
  70. id: "ses_model",
  71. cwd: "/workspace",
  72. mcpServers: [mcpServer],
  73. model: model("anthropic", "claude-sonnet"),
  74. variant: "high",
  75. modeId: "build",
  76. }),
  77. )
  78. const updated = yield* ACPSession.Service.use((session) =>
  79. session.setModel("ses_model", model("openai", "gpt-5")),
  80. )
  81. expect(updated.id).toBe("ses_model")
  82. expect(updated.cwd).toBe("/workspace")
  83. expect(updated.mcpServers).toEqual([mcpServer])
  84. expect(updated.model).toEqual(model("openai", "gpt-5"))
  85. expect(updated.variant).toBe("high")
  86. expect(updated.modeId).toBe("build")
  87. }),
  88. )
  89. sessionTest.effect("updates selected variant and mode independently", () =>
  90. Effect.gen(function* () {
  91. yield* ACPSession.Service.use((session) =>
  92. session.load({
  93. id: "ses_config",
  94. cwd: "/workspace",
  95. model: model("anthropic", "claude-sonnet"),
  96. variant: "low",
  97. modeId: "plan",
  98. }),
  99. )
  100. yield* ACPSession.Service.use((session) => session.setVariant("ses_config", "high"))
  101. expect(yield* ACPSession.Service.use((session) => session.getVariant("ses_config"))).toBe("high")
  102. expect(yield* ACPSession.Service.use((session) => session.getMode("ses_config"))).toBe("plan")
  103. yield* ACPSession.Service.use((session) => session.setMode("ses_config", "build"))
  104. expect(yield* ACPSession.Service.use((session) => session.getVariant("ses_config"))).toBe("high")
  105. expect(yield* ACPSession.Service.use((session) => session.getMode("ses_config"))).toBe("build")
  106. }),
  107. )
  108. sessionTest.effect("records known message part metadata for delta routing", () =>
  109. Effect.gen(function* () {
  110. yield* ACPSession.Service.use((session) => session.create({ id: "ses_parts", cwd: "/workspace" }))
  111. const metadata = yield* ACPSession.Service.use((session) =>
  112. session.recordPartMetadata({
  113. sessionId: "ses_parts",
  114. messageId: "msg_1",
  115. partId: "part_1",
  116. toolCallId: "tool_1",
  117. metadata: { output: "first chunk" },
  118. }),
  119. )
  120. const routed = yield* ACPSession.Service.use((session) =>
  121. session.getPartMetadata({ sessionId: "ses_parts", messageId: "msg_1", partId: "part_1" }),
  122. )
  123. expect(metadata).toEqual({
  124. messageId: "msg_1",
  125. partId: "part_1",
  126. toolCallId: "tool_1",
  127. metadata: { output: "first chunk" },
  128. })
  129. expect(routed).toEqual(metadata)
  130. }),
  131. )
  132. sessionTest.effect("keeps repeated part ids distinct across messages", () =>
  133. Effect.gen(function* () {
  134. yield* ACPSession.Service.use((session) => session.create({ id: "ses_duplicate_parts", cwd: "/workspace" }))
  135. yield* ACPSession.Service.use((session) =>
  136. session.recordPartMetadata({
  137. sessionId: "ses_duplicate_parts",
  138. messageId: "msg_1",
  139. partId: "part_1",
  140. metadata: { output: "from first message" },
  141. }),
  142. )
  143. yield* ACPSession.Service.use((session) =>
  144. session.recordPartMetadata({
  145. sessionId: "ses_duplicate_parts",
  146. messageId: "msg_2",
  147. partId: "part_1",
  148. metadata: { output: "from second message" },
  149. }),
  150. )
  151. const first = yield* ACPSession.Service.use((session) =>
  152. session.getPartMetadata({ sessionId: "ses_duplicate_parts", messageId: "msg_1", partId: "part_1" }),
  153. )
  154. const second = yield* ACPSession.Service.use((session) =>
  155. session.getPartMetadata({ sessionId: "ses_duplicate_parts", messageId: "msg_2", partId: "part_1" }),
  156. )
  157. expect(first?.metadata).toEqual({ output: "from first message" })
  158. expect(second?.metadata).toEqual({ output: "from second message" })
  159. }),
  160. )
  161. sessionTest.effect("removing a session clears its known part metadata", () =>
  162. Effect.gen(function* () {
  163. yield* ACPSession.Service.use((session) => session.create({ id: "ses_remove", cwd: "/workspace" }))
  164. yield* ACPSession.Service.use((session) =>
  165. session.recordPartMetadata({ sessionId: "ses_remove", messageId: "msg_1", partId: "part_1" }),
  166. )
  167. const removed = yield* ACPSession.Service.use((session) => session.remove("ses_remove"))
  168. const missing = yield* ACPSession.Service.use((session) => session.tryGet("ses_remove"))
  169. const missingPart = yield* ACPSession.Service.use((session) =>
  170. session.tryGetPartMetadata({ sessionId: "ses_remove", messageId: "msg_1", partId: "part_1" }),
  171. )
  172. expect(removed?.knownParts.size).toBe(1)
  173. expect(missing).toBeUndefined()
  174. expect(missingPart).toBeUndefined()
  175. }),
  176. )
  177. })