schema-decoding.test.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. import { describe, expect, test } from "bun:test"
  2. import { Schema } from "effect"
  3. import { Session } from "@/session/session"
  4. import { SessionPrompt } from "../../src/session/prompt"
  5. import { SessionRevert } from "../../src/session/revert"
  6. import { SessionStatus } from "../../src/session/status"
  7. import { SessionSummary } from "../../src/session/summary"
  8. import { Todo } from "../../src/session/todo"
  9. import { SessionID, MessageID, PartID } from "../../src/session/schema"
  10. import { ProjectV2 } from "@kirincode-ai/core/project"
  11. import { WorkspaceV2 } from "@kirincode-ai/core/workspace"
  12. // Covers the session-domain Effect Schema migration. For each migrated
  13. // schema we assert:
  14. // 1. The Effect decoder (`Schema.decodeUnknownSync`) accepts valid input.
  15. // 2. Clearly-invalid input is rejected.
  16. // Representative valid IDs — the branded schemas require the right prefix
  17. // (see src/id/id.ts).
  18. const sessionID = Schema.decodeUnknownSync(SessionID)("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2K")
  19. const sessionIDChild = Schema.decodeUnknownSync(SessionID)("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2L")
  20. const messageID = Schema.decodeUnknownSync(MessageID)("msg_01J5Y5H0AH4Q4NXJ6P4C3P5V2M")
  21. const partID = Schema.decodeUnknownSync(PartID)("prt_01J5Y5H0AH4Q4NXJ6P4C3P5V2N")
  22. const projectID = ProjectV2.ID.make("proj-alpha")
  23. const workspaceID = Schema.decodeUnknownSync(WorkspaceV2.ID)("wrk-primary")
  24. function decodeUnknown<S extends Schema.Top>(schema: S) {
  25. const decode = Schema.decodeUnknownSync(schema as any)
  26. return (input: unknown): Schema.Schema.Type<S> => decode(input) as Schema.Schema.Type<S>
  27. }
  28. describe("Session.Info", () => {
  29. const decode = decodeUnknown(Session.Info)
  30. test("accepts minimal session", () => {
  31. const input = {
  32. id: sessionID,
  33. slug: "hello",
  34. projectID,
  35. directory: "/tmp/proj",
  36. title: "First session",
  37. version: "0.1.0",
  38. time: { created: 1, updated: 2 },
  39. }
  40. expect(decode(input)).toEqual(input)
  41. })
  42. test("round-trips every optional field", () => {
  43. const input = {
  44. id: sessionID,
  45. slug: "fullshape",
  46. projectID,
  47. workspaceID,
  48. directory: "/tmp/proj",
  49. path: "packages/opencode",
  50. parentID: sessionIDChild,
  51. summary: {
  52. additions: 10,
  53. deletions: 5,
  54. files: 2,
  55. diffs: [{ additions: 1, deletions: 0, file: "a.ts", patch: "--- a/a.ts" }],
  56. },
  57. share: { url: "https://share.example.com/s/1" },
  58. title: "Full session",
  59. version: "1.0.0",
  60. metadata: { source: "test" },
  61. time: { created: 100, updated: 200, compacting: 150, archived: 300 },
  62. permission: [{ action: "allow" as const, pattern: "*", permission: "read" }],
  63. revert: {
  64. messageID,
  65. partID,
  66. snapshot: "snap-1",
  67. diff: "diff-1",
  68. },
  69. }
  70. expect(decode(input)).toEqual(input)
  71. })
  72. test("accepts migrated summary diffs without file details", () => {
  73. const input = {
  74. id: sessionID,
  75. slug: "legacy-diff",
  76. projectID,
  77. directory: "/tmp/proj",
  78. title: "Legacy diff",
  79. version: "0.1.0",
  80. summary: {
  81. additions: 1,
  82. deletions: 0,
  83. files: 1,
  84. diffs: [{ additions: 1, deletions: 0 }],
  85. },
  86. time: { created: 1, updated: 2 },
  87. }
  88. expect(decode(input)).toEqual(input)
  89. })
  90. test("rejects unbranded session id", () => {
  91. const bad = { id: "not-a-session-id" } as unknown
  92. expect(() => decode(bad)).toThrow()
  93. })
  94. test("rejects missing required fields", () => {
  95. const bad = { id: sessionID } as unknown
  96. expect(() => decode(bad)).toThrow()
  97. })
  98. })
  99. describe("Session.ProjectInfo", () => {
  100. const decode = decodeUnknown(Session.ProjectInfo)
  101. test("accepts with and without optional name", () => {
  102. const noName = { id: projectID, worktree: "/tmp/wt" }
  103. const withName = { ...noName, name: "alpha" }
  104. expect(decode(noName)).toEqual(noName)
  105. expect(decode(withName)).toEqual(withName)
  106. })
  107. })
  108. describe("Session.GlobalInfo", () => {
  109. const decode = decodeUnknown(Session.GlobalInfo)
  110. test("accepts null project", () => {
  111. const input = {
  112. id: sessionID,
  113. slug: "global",
  114. projectID,
  115. directory: "/tmp/proj",
  116. title: "global",
  117. version: "0",
  118. time: { created: 0, updated: 0 },
  119. project: null,
  120. }
  121. expect(decode(input)).toEqual(input)
  122. })
  123. test("accepts populated project", () => {
  124. const input = {
  125. id: sessionID,
  126. slug: "global",
  127. projectID,
  128. directory: "/tmp/proj",
  129. title: "global",
  130. version: "0",
  131. time: { created: 0, updated: 0 },
  132. project: { id: projectID, worktree: "/tmp/wt", name: "alpha" },
  133. }
  134. expect(decode(input)).toEqual(input)
  135. })
  136. })
  137. describe("Session input schemas", () => {
  138. test("CreateInput accepts undefined and populated forms", () => {
  139. const decode = decodeUnknown(Session.CreateInput)
  140. expect(decode(undefined)).toBeUndefined()
  141. const populated = {
  142. parentID: sessionID,
  143. title: "child",
  144. metadata: { source: "test" },
  145. permission: [{ action: "ask" as const, pattern: "*", permission: "bash" }],
  146. workspaceID,
  147. }
  148. expect(decode(populated)).toEqual(populated)
  149. })
  150. test("ForkInput round-trips", () => {
  151. const decode = decodeUnknown(Session.ForkInput)
  152. const input = { sessionID, messageID }
  153. expect(decode(input)).toEqual(input)
  154. // messageID is optional
  155. const bare = { sessionID }
  156. expect(decode(bare)).toEqual(bare)
  157. })
  158. test("SetTitleInput rejects missing title", () => {
  159. expect(() => decodeUnknown(Session.SetTitleInput)({ sessionID })).toThrow()
  160. })
  161. test("SetArchivedInput accepts both with and without time", () => {
  162. const decode = decodeUnknown(Session.SetArchivedInput)
  163. expect(decode({ sessionID })).toEqual({ sessionID })
  164. expect(decode({ sessionID, time: 123 })).toEqual({ sessionID, time: 123 })
  165. })
  166. test("SetPermissionInput requires a ruleset", () => {
  167. const decode = decodeUnknown(Session.SetPermissionInput)
  168. const input = { sessionID, permission: [{ action: "deny" as const, pattern: "*", permission: "write" }] }
  169. expect(decode(input)).toEqual(input)
  170. expect(() => decode({ sessionID })).toThrow()
  171. })
  172. test("MessagesInput accepts optional limit", () => {
  173. const decode = decodeUnknown(Session.MessagesInput)
  174. expect(decode({ sessionID })).toEqual({ sessionID })
  175. expect(decode({ sessionID, limit: 50 })).toEqual({ sessionID, limit: 50 })
  176. })
  177. })
  178. describe("SessionRevert.RevertInput", () => {
  179. const decode = decodeUnknown(SessionRevert.RevertInput)
  180. test("messageID is required, partID is optional", () => {
  181. const withPart = { sessionID, messageID, partID }
  182. expect(decode(withPart)).toEqual(withPart)
  183. const noPart = { sessionID, messageID }
  184. expect(decode(noPart)).toEqual(noPart)
  185. expect(() => decode({ sessionID })).toThrow()
  186. })
  187. })
  188. describe("SessionSummary.DiffInput", () => {
  189. const decode = decodeUnknown(SessionSummary.DiffInput)
  190. test("messageID optional", () => {
  191. expect(decode({ sessionID })).toEqual({ sessionID })
  192. expect(decode({ sessionID, messageID })).toEqual({ sessionID, messageID })
  193. })
  194. })
  195. describe("SessionStatus.Info", () => {
  196. const decode = decodeUnknown(SessionStatus.Info)
  197. test("idle / busy discriminators", () => {
  198. expect(decode({ type: "idle" })).toEqual({ type: "idle" })
  199. expect(decode({ type: "busy" })).toEqual({ type: "busy" })
  200. })
  201. test("retry carries attempt/message/action/next", () => {
  202. const input = {
  203. type: "retry" as const,
  204. attempt: 1,
  205. message: "transient",
  206. action: {
  207. reason: "free_tier_limit",
  208. provider: "kirincode",
  209. title: "Free limit reached",
  210. message: "Subscribe to KirinCode Go.",
  211. label: "subscribe",
  212. link: "https://kirincode.ai/go",
  213. },
  214. next: 500,
  215. }
  216. expect(decode(input)).toEqual(input)
  217. })
  218. test("rejects unknown type", () => {
  219. expect(() => decode({ type: "bogus" })).toThrow()
  220. })
  221. })
  222. describe("Todo.Info", () => {
  223. const decode = decodeUnknown(Todo.Info)
  224. test("three-field round-trip", () => {
  225. const input = Todo.Info.make({ content: "do a thing", status: "pending", priority: "high" })
  226. expect(decode(input)).toEqual(input)
  227. })
  228. })
  229. describe("SessionPrompt input schemas", () => {
  230. test("LoopInput is just sessionID", () => {
  231. const decode = decodeUnknown(SessionPrompt.LoopInput)
  232. expect(decode({ sessionID })).toEqual({ sessionID })
  233. })
  234. test("ShellInput requires agent + command", () => {
  235. const decode = decodeUnknown(SessionPrompt.ShellInput)
  236. const expected = { sessionID, agent: "build", command: "echo hi" }
  237. const input: unknown = expected
  238. expect(decode(input)).toEqual(expected)
  239. expect(() => decode({ sessionID })).toThrow()
  240. })
  241. test("PromptInput accepts a text part and a file part", () => {
  242. const decode = decodeUnknown(SessionPrompt.PromptInput)
  243. const expected = {
  244. sessionID,
  245. parts: [
  246. { type: "text" as const, text: "hello" },
  247. { type: "file" as const, mime: "image/png", url: "data:image/png;base64,AAAA" },
  248. ],
  249. }
  250. const input: unknown = expected
  251. const decoded = decode(input)
  252. expect(decoded.parts).toHaveLength(2)
  253. expect(decoded.parts[0]).toMatchObject({ type: "text", text: "hello" })
  254. expect(decoded.parts[1]).toMatchObject({ type: "file", mime: "image/png" })
  255. })
  256. test("PromptInput rejects unknown part type", () => {
  257. const decode = decodeUnknown(SessionPrompt.PromptInput)
  258. const bad = {
  259. sessionID,
  260. parts: [{ type: "nonsense", payload: 42 }],
  261. }
  262. expect(() => decode(bad)).toThrow()
  263. })
  264. test("CommandInput round-trips core fields", () => {
  265. const decode = decodeUnknown(SessionPrompt.CommandInput)
  266. const expected = {
  267. sessionID,
  268. arguments: "--flag",
  269. command: "deploy",
  270. }
  271. const input: unknown = expected
  272. expect(decode(input)).toEqual(expected)
  273. })
  274. })