invariant.spec.ts 8.0 KB

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