session.spec.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import { CallId } from '@deepseek-ai/dsh-llm'
  4. import SessionStore, { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
  5. describe('Session', () => {
  6. it('derives message history from the event log', () => {
  7. const session = new Session(SessionId('s1'))
  8. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  9. session.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } })
  10. session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } })
  11. session.append('assistant/message', {
  12. turn: 1, step: 1,
  13. content: [
  14. { type: 'text', text: 'let me check' },
  15. { type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' },
  16. ],
  17. })
  18. session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false })
  19. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  20. const messages = session.deriveMessages()
  21. expect(messages.map(m => m.role)).toEqual(['user', 'assistant', 'user'])
  22. // raw chunks must NOT appear in derived history
  23. expect(messages[1]!.content).toHaveLength(2)
  24. expect(messages[2]!.content[0]).toMatchObject({ type: 'tool-result', toolCallId: CallId('c1') })
  25. })
  26. it('accepts and round-trips a max-tokens turn/end reason', () => {
  27. // The max-tokens TurnEndReason variant carries no extra data, so it must
  28. // append and persist like any other reason (JSON-serializable, no fields).
  29. const session = new Session(SessionId('s1'))
  30. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  31. session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } })
  32. const turnEnd = session.events.findLast(e => e.type === 'turn/end')!
  33. expect(turnEnd.data.reason).toEqual({ kind: 'max-tokens' })
  34. // survives a structuredClone (the persistence-serialization boundary)
  35. expect(structuredClone(turnEnd.data.reason)).toEqual({ kind: 'max-tokens' })
  36. })
  37. it('renders context and steering messages as tagged synthetic user content', () => {
  38. const session = new Session(SessionId('s2'))
  39. session.append('context/message', {
  40. content: [{ type: 'text', text: 'file changed: a.ts' }],
  41. source: { kind: 'plugin', plugin: 'watcher' },
  42. })
  43. session.append('steering/message', {
  44. turn: 1,
  45. content: [{ type: 'text', text: 'focus on tests' }],
  46. source: { kind: 'user' },
  47. })
  48. const [contextMessage, steeringMessage] = session.deriveMessages()
  49. expect(contextMessage!.role).toBe('user')
  50. expect(contextMessage!.content[0]).toMatchObject({ type: 'text', text: '<context source="plugin">' })
  51. expect(contextMessage!.content.at(-1)).toMatchObject({ type: 'text', text: '</context>' })
  52. expect(steeringMessage!.content[0]).toMatchObject({ type: 'text', text: '<steering source="user">' })
  53. })
  54. it('replays identically from a seeded event log', () => {
  55. const original = new Session(SessionId('s3'))
  56. original.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
  57. original.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] })
  58. const replayed = new Session(SessionId('s3-replay'), [...original.events])
  59. expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
  60. expect(replayed.seq).toBe(original.seq)
  61. })
  62. it('isolates the log from mutation through a derived message (append-only contract)', () => {
  63. const session = new Session(SessionId('s4'))
  64. session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } })
  65. session.append('tool/result', {
  66. turn: 1, step: 1, callId: CallId('c1'),
  67. content: [{ type: 'text', text: 'tool out' }], isError: false,
  68. })
  69. const before = structuredClone(session.events)
  70. // A request middleware / adapter mutates the messages it was handed.
  71. const messages = session.deriveMessages()
  72. const userBlock = messages[0]!.content[0]!
  73. if (userBlock.type === 'text') userBlock.text = 'HACKED'
  74. const toolBlock = messages[1]!.content[0]!
  75. if (toolBlock.type === 'tool-result') {
  76. toolBlock.content.push({ type: 'text', text: 'injected' })
  77. }
  78. messages[0]!.content.push({ type: 'text', text: 'extra' })
  79. // The log is unchanged: deep-equal to the snapshot taken before mutation.
  80. expect(session.events).toEqual(before)
  81. // And a fresh derivation still reflects the original content.
  82. expect(session.deriveMessages()[0]!.content).toEqual([{ type: 'text', text: 'original' }])
  83. })
  84. it('rejects non-JSON-serializable event data at the source (incl. sparse arrays)', () => {
  85. const session = new Session(SessionId('s5'))
  86. const bad = (extra: unknown) => () => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra } as never)
  87. expect(bad(1n)).toThrow(/non-JSON-serializable/)
  88. expect(bad(() => 0)).toThrow(/non-JSON-serializable/)
  89. expect(bad(Symbol('s'))).toThrow(/non-JSON-serializable/)
  90. expect(bad(new Map())).toThrow(/non-JSON-serializable/)
  91. expect(bad(undefined)).toThrow(/non-JSON-serializable/)
  92. expect(bad(Infinity)).toThrow(/non-JSON-serializable/)
  93. // A sparse array: `every` skips the hole but JSON.stringify writes it null.
  94. // Build the hole without a sparse literal or `delete` (both linted).
  95. const sparse: unknown[] = Array(3)
  96. sparse[0] = 1
  97. sparse[2] = 3 // index 1 stays a hole
  98. expect(bad(sparse)).toThrow(/non-JSON-serializable/)
  99. // A DENSE array carrying a non-serializable element is rejected too.
  100. expect(bad([1, 2n, 3])).toThrow(/non-JSON-serializable/)
  101. // A nested non-serializable value (inside a plain object) is rejected.
  102. expect(bad({ nested: { deep: () => 0 } })).toThrow(/non-JSON-serializable/)
  103. // A circular reference is rejected (the seen-set guard, not a stack blow-up).
  104. const cyclic: Record<string, unknown> = { a: 1 }
  105. cyclic['self'] = cyclic
  106. expect(bad(cyclic)).toThrow(/non-JSON-serializable/)
  107. // The rejected appends never entered the log.
  108. expect(session.events).toHaveLength(0)
  109. })
  110. it('accepts dense arrays and nested plain objects', () => {
  111. const session = new Session(SessionId('s6'))
  112. expect(() => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra: [1, 2, [3, { a: null, b: true }]] } as never)).not.toThrow()
  113. expect(session.events).toHaveLength(1)
  114. })
  115. it('validates seed events: rejects a non-JSON-serializable seed', () => {
  116. // A replay/fork seed must satisfy the SAME invariant as Session.append, or
  117. // it builds a live log no backend can persist.
  118. const badSeed = [
  119. { type: 'user/message' as const, seq: 0, time: 1, data: { content: [{ type: 'text' as const, text: 'x' }], source: { kind: 'user' as const }, bad: 1n } },
  120. ] as unknown as SessionEvent[]
  121. expect(() => new Session(SessionId('seed-bad'), badSeed)).toThrow(/non-JSON-serializable/)
  122. })
  123. it('validates seed events: rejects a non-contiguous seq', () => {
  124. const gapSeed = [
  125. { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
  126. { type: 'turn/end' as const, seq: 5, time: 2, data: { turn: 1, reason: { kind: 'completed' as const } } }, // gap: expected seq 1
  127. ] as SessionEvent[]
  128. expect(() => new Session(SessionId('seed-gap'), gapSeed)).toThrow(/contiguous|seq/)
  129. })
  130. it('accepts a well-formed contiguous serializable seed', () => {
  131. const goodSeed = [
  132. { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
  133. { type: 'user/message' as const, seq: 1, time: 2, data: { content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const } } },
  134. { type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
  135. ] as SessionEvent[]
  136. const session = new Session(SessionId('seed-ok'), goodSeed)
  137. expect(session.events).toHaveLength(3)
  138. })
  139. it('snapshots the seed: mutating the original after construction does not affect session.events', () => {
  140. const seed = [
  141. { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
  142. { type: 'user/message' as const, seq: 1, time: 2, data: { content: [{ type: 'text' as const, text: 'original' }], source: { kind: 'user' as const } } },
  143. { type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
  144. ] as SessionEvent[]
  145. const session = new Session(SessionId('seed-snapshot'), seed)
  146. // Mutate the ORIGINAL seed objects after construction: a shared reference
  147. // would let this rewrite the forked log (or reintroduce non-serializable
  148. // data past validation). The snapshot must shield session.events.
  149. const um = seed[1]!
  150. ;(um.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED'
  151. ;(um.data as Record<string, unknown>)['injected'] = 1n // would have failed validation
  152. const logged = session.events[1]!
  153. expect(logged.type === 'user/message' && (logged.data.content[0] as { text: string }).text).toBe('original')
  154. expect((logged.data as Record<string, unknown>)['injected']).toBeUndefined()
  155. })
  156. it('snapshots append data: mutating the passed object after append does not affect session.events', () => {
  157. const session = new Session(SessionId('append-snapshot'))
  158. const data = { content: [{ type: 'text' as const, text: 'original' }], source: { kind: 'user' as const } }
  159. const event = session.append('user/message', data)
  160. // Mutate the caller's object after append returns. A shared reference would
  161. // make session.events diverge from the value that passed validation.
  162. data.content[0]!.text = 'HACKED'
  163. ;(data as Record<string, unknown>)['injected'] = 1n
  164. const logged = session.events[0]!
  165. expect(logged.type === 'user/message' && (logged.data.content[0] as { text: string }).text).toBe('original')
  166. expect((logged.data as Record<string, unknown>)['injected']).toBeUndefined()
  167. // The returned event carries the same snapshot, not the caller's input.
  168. expect((event.data.content[0] as { text: string }).text).toBe('original')
  169. })
  170. })
  171. describe('SessionStore', () => {
  172. it('creates sessions, emits session/created and session/event', async () => {
  173. const ctx = new Context()
  174. await ctx.plugin(SessionStore)
  175. const created: Session[] = []
  176. const events: [Session, SessionEvent][] = []
  177. ctx.on('session/created', session => void created.push(session))
  178. ctx.on('session/event', (session, event) => void events.push([session, event]))
  179. const session = ctx.sessions.create()
  180. expect(created).toEqual([session])
  181. session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } })
  182. expect(events).toHaveLength(1)
  183. expect(events[0]![0]).toBe(session)
  184. expect(events[0]![1].type).toBe('user/message')
  185. expect(ctx.sessions.get(session.id)).toBe(session)
  186. expect(ctx.sessions.list()).toEqual([session])
  187. })
  188. it('rejects duplicate ids and supports seeding', async () => {
  189. const ctx = new Context()
  190. await ctx.plugin(SessionStore)
  191. const a = ctx.sessions.create('fixed')
  192. expect(() => ctx.sessions.create('fixed')).toThrow('already exists')
  193. a.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
  194. const forked = ctx.sessions.create('fork', { seed: [...a.events] })
  195. expect(forked.deriveMessages()).toEqual(a.deriveMessages())
  196. })
  197. it('synthesizes a minimal v1 header for a bare-created session', async () => {
  198. const ctx = new Context()
  199. await ctx.plugin(SessionStore)
  200. const session = ctx.sessions.create('plain')
  201. expect(session.header).toMatchObject({ version: 1, id: 'plain' })
  202. expect(typeof session.header.createdAt).toBe('number')
  203. expect(session.header.cwd).toBeUndefined()
  204. expect(session.header.parentSession).toBeUndefined()
  205. })
  206. it('attaches cwd and parentSession from meta to the header', async () => {
  207. const ctx = new Context()
  208. await ctx.plugin(SessionStore)
  209. const session = ctx.sessions.create('child', {
  210. meta: { cwd: '/work/project', parentSession: SessionId('parent') },
  211. })
  212. expect(session.header).toMatchObject({
  213. version: 1,
  214. id: 'child',
  215. cwd: '/work/project',
  216. parentSession: 'parent',
  217. })
  218. })
  219. it('rejects a non-absolute meta.cwd', async () => {
  220. const ctx = new Context()
  221. await ctx.plugin(SessionStore)
  222. expect(() => ctx.sessions.create('rel', { meta: { cwd: 'relative/path' } }))
  223. .toThrow(/cwd must be an absolute path/)
  224. // the rejected session was not registered
  225. expect(ctx.sessions.get('rel')).toBeUndefined()
  226. })
  227. it('a bare Session() constructed without the store still exposes a v1 header', () => {
  228. const session = new Session(SessionId('bare'))
  229. expect(session.header).toMatchObject({ version: 1, id: 'bare' })
  230. expect(typeof session.header.createdAt).toBe('number')
  231. })
  232. it('detaches sessions when the creating fiber is disposed (HMR safety)', async () => {
  233. const ctx = new Context()
  234. await ctx.plugin(SessionStore)
  235. let session!: Session
  236. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  237. session = inner.sessions.create('scoped')
  238. }, { inject: ['sessions'] }))
  239. expect(ctx.sessions.get('scoped')).toBe(session)
  240. let observed = 0
  241. ctx.on('session/event', () => void observed++)
  242. await fiber.dispose()
  243. expect(ctx.sessions.get('scoped')).toBeUndefined()
  244. session.append('user/message', { content: [{ type: 'text', text: 'late' }], source: { kind: 'user' } })
  245. expect(observed).toBe(0)
  246. })
  247. it('rolls back the session (and onAppend) when a session/created listener throws (P1-1)', async () => {
  248. const ctx = new Context()
  249. await ctx.plugin(SessionStore)
  250. let threw = false
  251. ctx.on('session/created', () => {
  252. if (!threw) { threw = true; throw new Error('boom created listener') }
  253. })
  254. // The throwing emit must roll the store entry back, not leak it.
  255. expect(() => ctx.sessions.create('fixed')).toThrow('boom created listener')
  256. expect(ctx.sessions.get('fixed')).toBeUndefined() // rolled back, not leaked
  257. // A subsequent create of the SAME id succeeds (the already-exists check is
  258. // not wedged) and its onAppend is correctly wired (events observable).
  259. const events: SessionEvent[] = []
  260. ctx.on('session/event', (_session, event) => void events.push(event))
  261. const session = ctx.sessions.create('fixed')
  262. expect(ctx.sessions.get('fixed')).toBe(session)
  263. session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
  264. expect(events).toHaveLength(1)
  265. })
  266. })