session.spec.ts 19 KB

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