invariant.spec.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  4. import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
  5. import * as TimeInvariant from '@deepseek-ai/dsh-time-context/invariant'
  6. import InvariantService from '@deepseek-ai/dsh-invariants'
  7. const SECOND = Date.parse('2026-07-14T00:00:00Z')
  8. async function setup(): Promise<Context> {
  9. const ctx = new Context()
  10. await ctx.plugin(SessionStore)
  11. await ctx.plugin(InvariantService, { enabled: true })
  12. await ctx.plugin(TimeInvariant)
  13. return ctx
  14. }
  15. function event(text: string, time = SECOND + 456, content?: unknown[]): SessionEvent {
  16. return {
  17. type: 'user/message',
  18. seq: 0,
  19. time,
  20. data: {
  21. content: (content ?? [{ type: 'text', text }]) as ContentBlock[],
  22. source: { kind: 'plugin', plugin: 'time-context' },
  23. },
  24. }
  25. }
  26. function reading(
  27. turn = '1',
  28. step = '1',
  29. baseline = 'model-visible message',
  30. timestamp = '2026-07-14T00:00:00+00:00[UTC]',
  31. ): string {
  32. return `Time sampled while preparing turn ${turn}, step ${step}: ${timestamp}\n`
  33. + `Elapsed since the preceding ${baseline}: unavailable.`
  34. }
  35. function preparing(turn: number, step: number): Session {
  36. const session = new Session(SessionId(`time-invariant-${turn}-${step}`))
  37. for (let priorTurn = 1; priorTurn < turn; priorTurn += 1) {
  38. session.append('turn/start', { turn: priorTurn, trigger: { kind: 'message', source: { kind: 'user' } } })
  39. session.append('turn/end', { turn: priorTurn, reason: { kind: 'completed' } })
  40. }
  41. session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
  42. session.append('user/message', {
  43. content: [{ type: 'text', text: `turn ${turn}` }],
  44. source: { kind: 'user' },
  45. }, { surfaceOp: 'append' })
  46. for (let priorStep = 1; priorStep < step; priorStep += 1) {
  47. session.append('step/start', { turn, step: priorStep })
  48. session.append('step/end', { turn, step: priorStep })
  49. }
  50. return session
  51. }
  52. function appendReading(session: Session, text: string): void {
  53. session.append('user/message', {
  54. content: [{ type: 'text', text }],
  55. source: { kind: 'plugin', plugin: 'time-context' },
  56. }, { surfaceOp: 'append' })
  57. }
  58. describe('time-context invariants', () => {
  59. it('accepts a reading whose turn, step, baseline, and timestamp agree', async () => {
  60. const ctx = await setup()
  61. const text = 'Time sampled while preparing turn 2, step 3: 2026-07-14T00:00:00+00:00[UTC]\n'
  62. + 'Elapsed since the preceding step context: 4m 2s.'
  63. expect(() => { ctx.emit('session/event', preparing(2, 3), event(text)) }).not.toThrow()
  64. })
  65. it('accepts a reading durably appended after a long process pause', async () => {
  66. const ctx = await setup()
  67. expect(() => {
  68. ctx.emit('session/event', preparing(1, 1), event(reading(), SECOND + 60_000))
  69. }).not.toThrow()
  70. })
  71. it('validates each existing reading against its preceding durable prefix', async () => {
  72. const ctx = new Context()
  73. await ctx.plugin(SessionStore)
  74. const session = ctx.sessions.create(SessionId('time-invariant-late-valid'))
  75. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  76. session.append('user/message', {
  77. content: [{ type: 'text', text: 'prepare' }],
  78. source: { kind: 'user' },
  79. }, { surfaceOp: 'append' })
  80. appendReading(session, reading())
  81. session.append('step/start', { turn: 1, step: 1 })
  82. await ctx.plugin(InvariantService, { enabled: true })
  83. await expect(ctx.plugin(TimeInvariant)).resolves.toBeDefined()
  84. })
  85. it('rejects an invalid existing reading on late registration', async () => {
  86. const ctx = new Context()
  87. await ctx.plugin(SessionStore)
  88. const session = ctx.sessions.create(SessionId('time-invariant-late-invalid'))
  89. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  90. session.append('user/message', {
  91. content: [{ type: 'text', text: 'prepare' }],
  92. source: { kind: 'user' },
  93. }, { surfaceOp: 'append' })
  94. appendReading(session, reading('1', '2', 'step context'))
  95. await ctx.plugin(InvariantService, { enabled: true })
  96. await expect(ctx.plugin(TimeInvariant).then(() => undefined)).rejects.toThrow(/expected turn 1\/step 1/)
  97. })
  98. it.each([
  99. [reading('1', '3', 'step context'), /expected turn 2\/step 3/],
  100. [reading('2', '2', 'step context'), /expected turn 2\/step 3/],
  101. ])('rejects a reading that disagrees with its session position', async (text, message) => {
  102. const ctx = await setup()
  103. expect(() => { ctx.emit('session/event', preparing(2, 3), event(text)) }).toThrow(message)
  104. })
  105. it('rejects a reading after cancellation closes the turn', async () => {
  106. const ctx = await setup()
  107. const session = preparing(1, 2)
  108. session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } })
  109. expect(() => { ctx.emit('session/event', session, event(reading('1', '2', 'step context'))) })
  110. .toThrow(/inside an open turn/)
  111. })
  112. it('rejects a reading after step/start or without any open turn', async () => {
  113. const ctx = await setup()
  114. const started = preparing(1, 1)
  115. started.append('step/start', { turn: 1, step: 1 })
  116. expect(() => { ctx.emit('session/event', started, event(reading())) }).toThrow(/must precede step\/start/)
  117. expect(() => {
  118. ctx.emit('session/event', new Session(SessionId('time-invariant-empty')), event(reading()))
  119. }).toThrow(/inside an open turn/)
  120. })
  121. it.each([
  122. ['not a reading', SECOND, undefined, /durable reading format/],
  123. [reading('0'), SECOND, undefined, /positive safe integers/],
  124. [reading('999999999999999999999'), SECOND, undefined, /positive safe integers/],
  125. [reading('1', '0', 'step context'), SECOND, undefined, /positive safe integers/],
  126. [reading('1', '999999999999999999999', 'step context'), SECOND, undefined, /positive safe integers/],
  127. [reading('1', '1', 'step context'), SECOND, undefined, /wrong elapsed-time baseline/],
  128. [reading('1', '2', 'model-visible message'), SECOND, undefined, /wrong elapsed-time baseline/],
  129. [reading('1', '1', 'model-visible message', '2026-99-99T00:00:00+00:00[UTC]'), SECOND, undefined, /must parse and not postdate/],
  130. [reading(), Number.NaN, undefined, /must parse and not postdate/],
  131. [reading(), SECOND - 1, undefined, /must parse and not postdate/],
  132. ['ignored', SECOND, [], /exactly one text block/],
  133. ['ignored', SECOND, [{ type: 'image', data: 'x', mimeType: 'image/png' }], /exactly one text block/],
  134. ['ignored', SECOND, [{ type: 'text', text: 'one' }, { type: 'text', text: 'two' }], /exactly one text block/],
  135. ] as const)('rejects an incoherent durable reading', async (text, time, content, message) => {
  136. const ctx = await setup()
  137. const preparationStep = text.includes('turn 1, step 2:') ? 2 : 1
  138. expect(() => {
  139. ctx.emit('session/event', preparing(1, preparationStep), event(
  140. text,
  141. time,
  142. content === undefined ? undefined : [...content],
  143. ))
  144. }).toThrow(message)
  145. })
  146. it('ignores context messages owned by another package', async () => {
  147. const ctx = await setup()
  148. const other = event('unrelated') as SessionEvent<'user/message'>
  149. other.data.source = { kind: 'plugin', plugin: 'other' }
  150. expect(() => { ctx.emit('session/event', preparing(1, 1), other) }).not.toThrow()
  151. other.data.source = { kind: 'user' }
  152. expect(() => { ctx.emit('session/event', preparing(1, 1), other) }).not.toThrow()
  153. expect(() => {
  154. ctx.emit('session/event', preparing(1, 1), {
  155. type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
  156. })
  157. ctx.emit('tools/change')
  158. }).not.toThrow()
  159. })
  160. })