session.spec.ts 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import SessionStore, { Session, SessionEvent } from '@deepseek-ai/dsh-session'
  4. describe('Session', () => {
  5. it('derives message history from the event log', () => {
  6. const session = new Session('s1')
  7. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  8. session.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } })
  9. session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } })
  10. session.append('assistant/message', {
  11. turn: 1, step: 1,
  12. content: [
  13. { type: 'text', text: 'let me check' },
  14. { type: 'tool-call', id: 'c1', name: 'echo', arguments: '{}' },
  15. ],
  16. })
  17. session.append('tool/result', { turn: 1, step: 1, callId: 'c1', content: [{ type: 'text', text: 'ok' }], isError: false })
  18. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  19. const messages = session.deriveMessages()
  20. expect(messages.map(m => m.role)).toEqual(['user', 'assistant', 'user'])
  21. // raw chunks must NOT appear in derived history
  22. expect(messages[1]!.content).toHaveLength(2)
  23. expect(messages[2]!.content[0]).toMatchObject({ type: 'tool-result', toolCallId: 'c1' })
  24. })
  25. it('renders context and steering messages as tagged synthetic user content', () => {
  26. const session = new Session('s2')
  27. session.append('context/message', {
  28. content: [{ type: 'text', text: 'file changed: a.ts' }],
  29. source: { kind: 'plugin', plugin: 'watcher' },
  30. })
  31. session.append('steering/message', {
  32. turn: 1,
  33. content: [{ type: 'text', text: 'focus on tests' }],
  34. source: { kind: 'user' },
  35. })
  36. const [contextMessage, steeringMessage] = session.deriveMessages()
  37. expect(contextMessage!.role).toBe('user')
  38. expect(contextMessage!.content[0]).toMatchObject({ type: 'text', text: '<context source="plugin">' })
  39. expect(contextMessage!.content.at(-1)).toMatchObject({ type: 'text', text: '</context>' })
  40. expect(steeringMessage!.content[0]).toMatchObject({ type: 'text', text: '<steering source="user">' })
  41. })
  42. it('replays identically from a seeded event log', () => {
  43. const original = new Session('s3')
  44. original.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
  45. original.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] })
  46. const replayed = new Session('s3-replay', [...original.events])
  47. expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
  48. expect(replayed.seq).toBe(original.seq)
  49. })
  50. })
  51. describe('SessionStore', () => {
  52. it('creates sessions, emits session/created and session/event', async () => {
  53. const ctx = new Context()
  54. await ctx.plugin(SessionStore)
  55. const created: Session[] = []
  56. const events: [Session, SessionEvent][] = []
  57. ctx.on('session/created', session => void created.push(session))
  58. ctx.on('session/event', (session, event) => void events.push([session, event]))
  59. const session = ctx.sessions.create()
  60. expect(created).toEqual([session])
  61. session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } })
  62. expect(events).toHaveLength(1)
  63. expect(events[0]![0]).toBe(session)
  64. expect(events[0]![1].type).toBe('user/message')
  65. expect(ctx.sessions.get(session.id)).toBe(session)
  66. expect(ctx.sessions.list()).toEqual([session])
  67. })
  68. it('rejects duplicate ids and supports seeding', async () => {
  69. const ctx = new Context()
  70. await ctx.plugin(SessionStore)
  71. const a = ctx.sessions.create('fixed')
  72. expect(() => ctx.sessions.create('fixed')).toThrow('already exists')
  73. a.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
  74. const forked = ctx.sessions.create('fork', [...a.events])
  75. expect(forked.deriveMessages()).toEqual(a.deriveMessages())
  76. })
  77. it('detaches sessions when the creating fiber is disposed (HMR safety)', async () => {
  78. const ctx = new Context()
  79. await ctx.plugin(SessionStore)
  80. let session!: Session
  81. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  82. session = inner.sessions.create('scoped')
  83. }, { inject: ['sessions'] }))
  84. expect(ctx.sessions.get('scoped')).toBe(session)
  85. let observed = 0
  86. ctx.on('session/event', () => void observed++)
  87. await fiber.dispose()
  88. expect(ctx.sessions.get('scoped')).toBeUndefined()
  89. session.append('user/message', { content: [{ type: 'text', text: 'late' }], source: { kind: 'user' } })
  90. expect(observed).toBe(0)
  91. })
  92. })