properties.spec.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. /**
  2. * Property-based tests for the Session event log (the property-testing RFC).
  3. *
  4. * Generates arbitrary event logs and asserts the derivation invariants the
  5. * agent loop and replay depend on: deriveMessages is deterministic and
  6. * replay-from-seed reproduces it; seq is strictly monotonic; non-message
  7. * events never affect derived history.
  8. */
  9. import { describe, expect, it } from 'vitest'
  10. import fc from 'fast-check'
  11. import { CallId } from '@deepseek-ai/dsh-llm'
  12. import { Session, SessionId } from '@deepseek-ai/dsh-session'
  13. import type { SessionEventMap, SessionEventType, SurfaceIntent } from '@deepseek-ai/dsh-session'
  14. // An appendable event: its type/data plus, for surface-eligible types, the
  15. // explicit surface intent the generator declares (mirroring how a real caller
  16. // passes it). The intent is part of the generated fixture, NOT synthesized by
  17. // `build`, so each arbitrary states the marker it produces.
  18. type Appendable = {
  19. [T in SessionEventType]: { type: T; data: SessionEventMap[T]; intent?: SurfaceIntent }
  20. }[SessionEventType]
  21. const textContentArb = fc.array(
  22. fc.record({ type: fc.constant<'text'>('text'), text: fc.string() }),
  23. { maxLength: 3 },
  24. )
  25. // A message-producing event (these DO affect derived history). Each carries an
  26. // explicit `surfaceOp: 'append'` intent — the marker the real loop passes.
  27. const messageEventArb: fc.Arbitrary<Appendable> = fc.oneof(
  28. textContentArb.map((content): Appendable => ({ type: 'user/message', data: { content, source: { kind: 'user' } }, intent: { surfaceOp: 'append' } })),
  29. textContentArb.map((content): Appendable => ({ type: 'assistant/message', data: { turn: 1, step: 1, content }, intent: { surfaceOp: 'append' } })),
  30. textContentArb.map((content): Appendable => ({ type: 'assistant/message', data: { turn: 1, step: 1, content, usage: { inputTokens: 1, outputTokens: 1 } }, intent: { surfaceOp: 'append' } })),
  31. fc.record({ id: fc.string({ minLength: 1 }), content: textContentArb, isError: fc.boolean() })
  32. .map((r): Appendable => ({ type: 'tool/result', data: { turn: 1, step: 1, callId: CallId(r.id), content: r.content, isError: r.isError }, intent: { surfaceOp: 'append' } })),
  33. )
  34. // A non-message event (trace/replay data — must NOT affect derived history).
  35. const nonMessageEventArb: fc.Arbitrary<Appendable> = fc.oneof(
  36. fc.constant<Appendable>({ type: 'turn/start', data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
  37. fc.constant<Appendable>({ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }),
  38. fc.constant<Appendable>({ type: 'step/start', data: { turn: 1, step: 1 } }),
  39. fc.constant<Appendable>({ type: 'step/end', data: { turn: 1, step: 1 } }),
  40. fc.string().map((text): Appendable => ({ type: 'assistant/chunk', data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text } } })),
  41. )
  42. const anyEventArb = fc.oneof(messageEventArb, nonMessageEventArb)
  43. const logArb = fc.array(anyEventArb, { maxLength: 25 })
  44. let counter = 0
  45. function build(events: Appendable[]): Session {
  46. const session = new Session(SessionId(`prop-${counter++}`))
  47. for (const e of events) {
  48. // Forward the generated intent verbatim; non-surface events carry none.
  49. if (e.intent !== undefined) session.append(e.type, e.data, e.intent)
  50. else session.append(e.type, e.data)
  51. }
  52. return session
  53. }
  54. describe('Session properties', () => {
  55. it('deriveMessages is deterministic (same log → identical derivation)', () => {
  56. fc.assert(fc.property(logArb, (events) => {
  57. const a = build(events)
  58. expect(a.deriveMessages()).toEqual(a.deriveMessages())
  59. }))
  60. })
  61. it('seq is strictly monotonic and zero-based contiguous', () => {
  62. fc.assert(fc.property(logArb, (events) => {
  63. const session = build(events)
  64. session.events.forEach((event, i) => { expect(event.seq).toBe(i) })
  65. expect(session.seq).toBe(events.length)
  66. }))
  67. })
  68. it('replay-from-seed reproduces the derivation identically', () => {
  69. fc.assert(fc.property(logArb, (events) => {
  70. const original = build(events)
  71. const replayed = new Session(SessionId(`replay-${counter++}`), [...original.events])
  72. expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
  73. expect(replayed.seq).toBe(original.seq)
  74. }))
  75. })
  76. it('non-message events never affect derived history (any interleaving)', () => {
  77. fc.assert(fc.property(
  78. fc.array(messageEventArb, { maxLength: 12 }),
  79. fc.array(nonMessageEventArb, { maxLength: 12 }),
  80. // An arbitrary merge of the two streams that PRESERVES each stream's
  81. // relative order (a random interleaving, not a fixed alternation).
  82. fc.infiniteStream(fc.boolean()),
  83. (messages, noise, pick) => {
  84. const clean = build(messages).deriveMessages()
  85. const interleaved: Appendable[] = []
  86. let mi = 0
  87. let ni = 0
  88. const picker = pick[Symbol.iterator]()
  89. while (mi < messages.length || ni < noise.length) {
  90. // take from noise when chosen and available, else from messages
  91. const takeNoise = ni < noise.length && (mi >= messages.length || picker.next().value === true)
  92. if (takeNoise) { interleaved.push(noise[ni]!); ni++ }
  93. else { interleaved.push(messages[mi]!); mi++ }
  94. }
  95. const withNoise = build(interleaved).deriveMessages()
  96. expect(withNoise).toEqual(clean)
  97. },
  98. ))
  99. })
  100. it('every derived message has a known role and decoupled content', () => {
  101. fc.assert(fc.property(logArb, (events) => {
  102. const session = build(events)
  103. const messages = session.deriveMessages()
  104. const before = structuredClone(session.events)
  105. for (const m of messages) {
  106. expect(['user', 'assistant', 'system']).toContain(m.role)
  107. // Mutating derived content must not touch the log (append-only).
  108. m.content.push({ type: 'text', text: 'mutation' })
  109. }
  110. expect(session.events).toEqual(before)
  111. }))
  112. })
  113. })