invariant.spec.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  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 = new Session(SessionId(`time-invariant-${turn}-${step}`))
  43. for (let priorTurn = 1; priorTurn < turn; priorTurn += 1) {
  44. session.append('turn/start', { turn: priorTurn, trigger: { kind: 'message', source: { kind: 'user' } } })
  45. session.append('turn/end', { turn: priorTurn, reason: { kind: 'completed' } })
  46. }
  47. session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
  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. return session
  57. }
  58. function appendReading(session: Session, text: string): void {
  59. session.append('user/message', createUserMessage({
  60. content: [{ type: 'text', text }],
  61. source: { kind: 'plugin', plugin: 'time-context' },
  62. }), { surfaceOp: 'append' })
  63. }
  64. describe('time-context invariants', () => {
  65. it('accepts a reading whose turn, step, baseline, and timestamp agree', async () => {
  66. const ctx = await setup()
  67. const text = 'Time sampled while preparing turn 2, step 3: 2026-07-14T00:00:00+00:00[UTC]\n'
  68. + 'Elapsed since the preceding step context: 4m 2s.'
  69. expect(() => { ctx.emit('session/event', preparing(2, 3), event(text)) }).not.toThrow()
  70. })
  71. it('accepts a reading durably appended after a long process pause', async () => {
  72. const ctx = await setup()
  73. expect(() => {
  74. ctx.emit('session/event', preparing(1, 1), event(reading(), SECOND + 60_000))
  75. }).not.toThrow()
  76. })
  77. it('validates each existing reading against its preceding durable prefix', async () => {
  78. const ctx = new Context()
  79. await ctx.plugin(SessionStore)
  80. const session = ctx.sessions.create(SessionId('time-invariant-late-valid'))
  81. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  82. session.append('user/message', createUserMessage({
  83. content: [{ type: 'text', text: 'prepare' }],
  84. source: { kind: 'user' },
  85. }), { surfaceOp: 'append' })
  86. appendReading(session, reading())
  87. session.append('step/start', { turn: 1, step: 1 })
  88. await ctx.plugin(InvariantService, { enabled: true })
  89. await expect(ctx.plugin(TimeInvariant)).resolves.toBeDefined()
  90. })
  91. it('rejects an invalid existing reading on late registration', async () => {
  92. const ctx = new Context()
  93. await ctx.plugin(SessionStore)
  94. const session = ctx.sessions.create(SessionId('time-invariant-late-invalid'))
  95. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  96. session.append('user/message', createUserMessage({
  97. content: [{ type: 'text', text: 'prepare' }],
  98. source: { kind: 'user' },
  99. }), { surfaceOp: 'append' })
  100. appendReading(session, reading('1', '2', 'step context'))
  101. await ctx.plugin(InvariantService, { enabled: true })
  102. await expect(ctx.plugin(TimeInvariant).then(() => undefined)).rejects.toThrow(/expected turn 1\/step 1/)
  103. })
  104. it.each([
  105. [reading('1', '3', 'step context'), /expected turn 2\/step 3/],
  106. [reading('2', '2', 'step context'), /expected turn 2\/step 3/],
  107. ])('rejects a reading that disagrees with its session position', async (text, message) => {
  108. const ctx = await setup()
  109. expect(() => { ctx.emit('session/event', preparing(2, 3), event(text)) }).toThrow(message)
  110. })
  111. it('rejects a reading after cancellation closes the turn', async () => {
  112. const ctx = await setup()
  113. const session = preparing(1, 2)
  114. session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } })
  115. expect(() => { ctx.emit('session/event', session, event(reading('1', '2', 'step context'))) })
  116. .toThrow(/inside an open turn/)
  117. })
  118. it('rejects a reading after step/start or without any open turn', async () => {
  119. const ctx = await setup()
  120. const started = preparing(1, 1)
  121. started.append('step/start', { turn: 1, step: 1 })
  122. expect(() => { ctx.emit('session/event', started, event(reading())) }).toThrow(/must precede step\/start/)
  123. expect(() => {
  124. ctx.emit('session/event', new Session(SessionId('time-invariant-empty')), event(reading()))
  125. }).toThrow(/inside an open turn/)
  126. })
  127. it.each([
  128. ['not a reading', SECOND, undefined, /durable reading format/],
  129. [reading('0'), SECOND, undefined, /positive safe integers/],
  130. [reading('999999999999999999999'), SECOND, undefined, /positive safe integers/],
  131. [reading('1', '0', 'step context'), SECOND, undefined, /positive safe integers/],
  132. [reading('1', '999999999999999999999', 'step context'), SECOND, undefined, /positive safe integers/],
  133. [reading('1', '1', 'step context'), SECOND, undefined, /wrong elapsed-time baseline/],
  134. [reading('1', '2', 'model-visible message'), SECOND, undefined, /wrong elapsed-time baseline/],
  135. [reading('1', '1', 'model-visible message', '2026-99-99T00:00:00+00:00[UTC]'), SECOND, undefined, /must parse and not postdate/],
  136. [reading(), Number.NaN, undefined, /must parse and not postdate/],
  137. [reading(), SECOND - 1, undefined, /must parse and not postdate/],
  138. ['ignored', SECOND, [], /exactly one text block/],
  139. ['ignored', SECOND, [{ type: 'image', data: 'x', mimeType: 'image/png' }], /exactly one text block/],
  140. ['ignored', SECOND, [{ type: 'text', text: 'one' }, { type: 'text', text: 'two' }], /exactly one text block/],
  141. ] as const)('rejects an incoherent durable reading', async (text, time, content, message) => {
  142. const ctx = await setup()
  143. const preparationStep = text.includes('turn 1, step 2:') ? 2 : 1
  144. expect(() => {
  145. ctx.emit('session/event', preparing(1, preparationStep), event(
  146. text,
  147. time,
  148. content === undefined ? undefined : [...content],
  149. ))
  150. }).toThrow(message)
  151. })
  152. it('ignores context messages owned by another package', async () => {
  153. const ctx = await setup()
  154. const other = event('unrelated', SECOND + 456, undefined, 'other')
  155. expect(() => { ctx.emit('session/event', preparing(1, 1), other) }).not.toThrow()
  156. const user: SessionEvent<'user/message'> = {
  157. ...event('unrelated'),
  158. data: createUserMessage({
  159. content: [{ type: 'text', text: 'unrelated' }],
  160. source: { kind: 'user' },
  161. }),
  162. }
  163. expect(() => { ctx.emit('session/event', preparing(1, 1), user) }).not.toThrow()
  164. expect(() => {
  165. ctx.emit('session/event', preparing(1, 1), {
  166. type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
  167. })
  168. ctx.emit('tools/change')
  169. }).not.toThrow()
  170. })
  171. })