properties.spec.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. /**
  2. * Property-based tests for the Session event log (the property-testing Agent Note).
  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. // Each arbitrary supplies its own surface intent; `build` must not synthesize
  15. // one or the property would fail to exercise malformed fixture choices.
  16. type Appendable = {
  17. [T in SessionEventType]: { type: T; data: SessionEventMap[T]; intent?: SurfaceIntent }
  18. }[SessionEventType]
  19. const textContentArb = fc.array(
  20. fc.record({ type: fc.constant<'text'>('text'), text: fc.string() }),
  21. { maxLength: 3 },
  22. )
  23. // A message-producing event (these DO affect derived history). Each carries an
  24. // explicit `surfaceOp: 'append'` intent — the marker the real loop passes.
  25. const messageEventArb: fc.Arbitrary<Appendable> = fc.oneof(
  26. textContentArb.map((content): Appendable => ({ type: 'user/message', data: { content, source: { kind: 'user' } }, intent: { surfaceOp: 'append' } })),
  27. textContentArb.map((content): Appendable => ({ type: 'assistant/message', data: { turn: 1, step: 1, content, provenance: { provider: 'mock', model: 'mock' } }, intent: { surfaceOp: 'append' } })),
  28. textContentArb.map((content): Appendable => ({ type: 'assistant/message', data: { turn: 1, step: 1, content, provenance: { provider: 'mock', model: 'mock' }, usage: { inputTokens: 1, outputTokens: 1 } }, intent: { surfaceOp: 'append' } })),
  29. fc.record({ id: fc.string({ minLength: 1 }), content: textContentArb, isError: fc.boolean() })
  30. .map((r): Appendable => ({ type: 'tool/result', data: { turn: 1, step: 1, callId: CallId(r.id), content: r.content, isError: r.isError }, intent: { surfaceOp: 'append' } })),
  31. )
  32. // A non-message event (trace/replay data — must NOT affect derived history).
  33. const nonMessageEventArb: fc.Arbitrary<Appendable> = fc.oneof(
  34. fc.constant<Appendable>({ type: 'turn/start', data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
  35. fc.constant<Appendable>({ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }),
  36. fc.constant<Appendable>({ type: 'step/start', data: { turn: 1, step: 1 } }),
  37. fc.constant<Appendable>({ type: 'step/end', data: { turn: 1, step: 1 } }),
  38. fc.string().map((text): Appendable => ({ type: 'assistant/chunk', data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text } } })),
  39. )
  40. const anyEventArb = fc.oneof(messageEventArb, nonMessageEventArb)
  41. const logArb = fc.array(anyEventArb, { maxLength: 25 })
  42. let counter = 0
  43. function build(events: Appendable[]): Session {
  44. const session = new Session(SessionId(`prop-${counter++}`))
  45. for (const e of events) {
  46. // Forward the generated intent verbatim; non-surface events carry none.
  47. if (e.intent !== undefined) session.append(e.type, e.data, e.intent)
  48. else session.append(e.type, e.data)
  49. }
  50. return session
  51. }
  52. describe('Session properties', () => {
  53. it('deriveMessages is deterministic (same log → identical derivation)', () => {
  54. fc.assert(fc.property(logArb, (events) => {
  55. const a = build(events)
  56. expect(a.deriveMessages()).toEqual(a.deriveMessages())
  57. }))
  58. })
  59. it('seq is strictly monotonic and zero-based contiguous', () => {
  60. fc.assert(fc.property(logArb, (events) => {
  61. const session = build(events)
  62. session.events.forEach((event, i) => { expect(event.seq).toBe(i) })
  63. expect(session.seq).toBe(events.length)
  64. }))
  65. })
  66. it('replay-from-seed reproduces the derivation identically', () => {
  67. fc.assert(fc.property(logArb, (events) => {
  68. const original = build(events)
  69. const replayed = new Session(SessionId(`replay-${counter++}`), [...original.events])
  70. expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
  71. expect(replayed.seq).toBe(original.seq)
  72. }))
  73. })
  74. it('non-message events never affect derived history (any interleaving)', () => {
  75. fc.assert(fc.property(
  76. fc.array(messageEventArb, { maxLength: 12 }),
  77. fc.array(nonMessageEventArb, { maxLength: 12 }),
  78. // An arbitrary merge of the two streams that PRESERVES each stream's
  79. // relative order (a random interleaving, not a fixed alternation).
  80. fc.infiniteStream(fc.boolean()),
  81. (messages, noise, pick) => {
  82. const clean = build(messages).deriveMessages()
  83. const interleaved: Appendable[] = []
  84. let mi = 0
  85. let ni = 0
  86. const picker = pick[Symbol.iterator]()
  87. while (mi < messages.length || ni < noise.length) {
  88. // take from noise when chosen and available, else from messages
  89. const takeNoise = ni < noise.length && (mi >= messages.length || picker.next().value === true)
  90. if (takeNoise) { interleaved.push(noise[ni]!); ni++ }
  91. else { interleaved.push(messages[mi]!); mi++ }
  92. }
  93. const withNoise = build(interleaved).deriveMessages()
  94. expect(withNoise).toEqual(clean)
  95. },
  96. ))
  97. })
  98. it('every derived message has a known role and is frozen (append-only contract)', () => {
  99. fc.assert(fc.property(logArb, (events) => {
  100. const session = build(events)
  101. const messages = session.deriveMessages()
  102. const before = structuredClone(session.events)
  103. for (const m of messages) {
  104. expect(['user', 'assistant', 'system']).toContain(m.role)
  105. // Derived messages are frozen shared projections: mutation THROWS
  106. // (strict mode) instead of relying on per-call clones for isolation.
  107. expect(Object.isFrozen(m)).toBe(true)
  108. expect(() => { m.content.push({ type: 'text', text: 'mutation' }) }).toThrow(TypeError)
  109. }
  110. expect(session.events).toEqual(before)
  111. }))
  112. })
  113. })