invariant.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  2. import { describe, expect, it, vi } from 'vitest'
  3. import { Context } from '@deepseek-ai/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 InvariantRegistry 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(InvariantRegistry, { 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: plugin === 'time-context'
  29. ? {
  30. kind: 'plugin',
  31. plugin,
  32. form: 'snapshot',
  33. sections: [{ name: plugin, text }],
  34. }
  35. : { kind: 'plugin', plugin },
  36. }),
  37. }
  38. }
  39. function reading(
  40. turn = '1',
  41. step = '1',
  42. baseline = 'model-visible message',
  43. timestamp = '2026-07-14T00:00:00+00:00[UTC]',
  44. browser = 'Browser time zone for this request: unavailable. Ask the user to clarify otherwise-unqualified dates and times.',
  45. ): string {
  46. return `Time sampled while preparing turn ${turn}, step ${step}: ${timestamp}\n`
  47. + `${browser}\n`
  48. + `Elapsed since the preceding ${baseline}: unavailable.`
  49. }
  50. function preparing(turn: number, step: number, clientTimeZone?: string): Session {
  51. const session = Session.create(SessionId(`time-invariant-${turn}-${step}`))
  52. for (let priorTurn = 1; priorTurn < turn; priorTurn += 1) {
  53. session.append('turn/start', { turn: priorTurn })
  54. session.append('turn/end', { turn: priorTurn, reason: { kind: 'completed' } })
  55. }
  56. session.append('turn/start', { turn })
  57. session.append('user/message', createUserMessage({
  58. content: [{ type: 'text', text: `turn ${turn}` }],
  59. source: clientTimeZone === undefined
  60. ? { kind: 'user' }
  61. : { kind: 'user', rpcId: `turn-${String(turn)}`, clientTimeZone } as never,
  62. }), { surfaceOp: 'append' })
  63. for (let priorStep = 1; priorStep < step; priorStep += 1) {
  64. session.append('step/start', { turn, step: priorStep })
  65. session.append('step/end', { turn, step: priorStep })
  66. }
  67. session.append('step/start', { turn, step })
  68. return session
  69. }
  70. function appendReading(session: Session, text: string): void {
  71. session.append('user/message', createUserMessage({
  72. content: [{ type: 'text', text }],
  73. source: {
  74. kind: 'plugin',
  75. plugin: 'time-context',
  76. form: 'snapshot',
  77. sections: [{ name: 'time-context', text }],
  78. },
  79. }), { surfaceOp: 'append' })
  80. }
  81. describe('time-context invariants', () => {
  82. it('accepts a reading whose turn, step, baseline, and timestamp agree', async () => {
  83. const ctx = await setup()
  84. const text = 'Time sampled while preparing turn 2, step 3: 2026-07-14T00:00:00+00:00[UTC]\n'
  85. + 'Browser time zone for this request: unavailable. Ask the user to clarify otherwise-unqualified dates and times.\n'
  86. + 'Elapsed since the preceding step context: 4m 2s.'
  87. expect(() => { ctx.emit('session/event', preparing(2, 3), event(text)) }).not.toThrow()
  88. })
  89. it('accepts a reading durably appended after a long process pause', async () => {
  90. const ctx = await setup()
  91. expect(() => {
  92. ctx.emit('session/event', preparing(1, 1), event(reading(), SECOND + 60_000))
  93. }).not.toThrow()
  94. })
  95. it('requires browser-zone policy and timestamp to match current-turn request provenance', async () => {
  96. const ctx = await setup()
  97. const policy = 'Browser time zone for this request: Asia/Shanghai. '
  98. + 'Interpret otherwise-unqualified dates and times in this zone.'
  99. expect(() => {
  100. ctx.emit('session/event', preparing(1, 1, 'Asia/Shanghai'), event(reading(
  101. '1',
  102. '1',
  103. 'model-visible message',
  104. '2026-07-14T08:00:00+08:00[Asia/Shanghai]',
  105. policy,
  106. ), SECOND + 456))
  107. }).not.toThrow()
  108. expect(() => {
  109. ctx.emit('session/event', preparing(1, 1, 'Asia/Shanghai'), event(reading()))
  110. }).toThrow(/browser-zone text/)
  111. expect(() => {
  112. ctx.emit('session/event', preparing(1, 1, 'Asia/Shanghai'), event(reading(
  113. '1',
  114. '1',
  115. 'model-visible message',
  116. '2026-07-14T00:00:00+00:00[UTC]',
  117. policy,
  118. )))
  119. }).toThrow(/rendered timestamp does not match the unique browser zone/)
  120. })
  121. it('reports browser-zone timestamp formatter failures as invariant violations', async () => {
  122. const ctx = await setup()
  123. const policy = 'Browser time zone for this request: Asia/Shanghai. '
  124. + 'Interpret otherwise-unqualified dates and times in this zone.'
  125. const formatToParts = vi.spyOn(Intl.DateTimeFormat.prototype, 'formatToParts')
  126. .mockImplementationOnce(() => { throw new RangeError('formatter unavailable') })
  127. try {
  128. expect(() => {
  129. ctx.emit('session/event', preparing(1, 1, 'Asia/Shanghai'), event(reading(
  130. '1',
  131. '1',
  132. 'model-visible message',
  133. '2026-07-14T08:00:00+08:00[Asia/Shanghai]',
  134. policy,
  135. )))
  136. }).toThrow(/browser zone cannot format its durable timestamp: RangeError: formatter unavailable/)
  137. } finally {
  138. formatToParts.mockRestore()
  139. }
  140. })
  141. it('rejects invalid browser provenance loaded across the durable boundary', async () => {
  142. const ctx = await setup()
  143. const timeZone = 'Not/A_Real_Zone'
  144. const policy = `Browser time zone for this request: ${timeZone}. `
  145. + 'Interpret otherwise-unqualified dates and times in this zone.'
  146. expect(() => {
  147. ctx.emit('session/event', preparing(1, 1, timeZone), event(reading(
  148. '1',
  149. '1',
  150. 'model-visible message',
  151. `2026-07-14T00:00:00+00:00[${timeZone}]`,
  152. policy,
  153. )))
  154. }).toThrow(/browser time zone is unsupported/)
  155. })
  156. it('rejects one corrupt zone even when another zone would classify the turn as mixed', async () => {
  157. const ctx = await setup()
  158. const session = preparing(1, 1, 'Asia/Shanghai')
  159. session.append('user/message', createUserMessage({
  160. content: [{ type: 'text', text: 'second browser prompt' }],
  161. source: {
  162. kind: 'user',
  163. rpcId: 'turn-1-invalid',
  164. clientTimeZone: 'Not/A_Real_Zone',
  165. } as never,
  166. }), { surfaceOp: 'append' })
  167. expect(() => {
  168. ctx.emit('session/event', session, event(reading(
  169. '1',
  170. '1',
  171. 'model-visible message',
  172. '2026-07-14T00:00:00+00:00[UTC]',
  173. 'Browser time zone for this request: mixed ["Asia/Shanghai","Not/A_Real_Zone"]. '
  174. + 'Ask the user to clarify otherwise-unqualified dates and times.',
  175. )))
  176. }).toThrow(/browser time zone is unsupported/)
  177. })
  178. it('validates each existing reading against its preceding durable prefix', async () => {
  179. const ctx = new Context()
  180. await ctx.plugin(SessionStore)
  181. const session = ctx.sessions.create(SessionId('time-invariant-late-valid'))
  182. session.append('turn/start', { turn: 1 })
  183. session.append('step/start', { turn: 1, step: 1 })
  184. session.append('user/message', createUserMessage({
  185. content: [{ type: 'text', text: 'prepare' }],
  186. source: { kind: 'user' },
  187. }), { surfaceOp: 'append' })
  188. appendReading(session, reading())
  189. await ctx.plugin(InvariantRegistry, { enabled: true })
  190. await expect(ctx.plugin(TimeInvariant)).resolves.toBeDefined()
  191. })
  192. it('rejects an invalid existing reading on late registration', async () => {
  193. const ctx = new Context()
  194. await ctx.plugin(SessionStore)
  195. const session = ctx.sessions.create(SessionId('time-invariant-late-invalid'))
  196. session.append('turn/start', { turn: 1 })
  197. session.append('step/start', { turn: 1, step: 1 })
  198. session.append('user/message', createUserMessage({
  199. content: [{ type: 'text', text: 'prepare' }],
  200. source: { kind: 'user' },
  201. }), { surfaceOp: 'append' })
  202. appendReading(session, reading('1', '2', 'step context'))
  203. await ctx.plugin(InvariantRegistry, { enabled: true })
  204. await expect(ctx.plugin(TimeInvariant).then(() => undefined)).rejects.toThrow(/expected turn 1\/step 1/)
  205. })
  206. it.each([
  207. [reading('1', '3', 'step context'), /expected turn 2\/step 3/],
  208. [reading('2', '2', 'step context'), /expected turn 2\/step 3/],
  209. ])('rejects a reading that disagrees with its session position', async (text, message) => {
  210. const ctx = await setup()
  211. expect(() => { ctx.emit('session/event', preparing(2, 3), event(text)) }).toThrow(message)
  212. })
  213. it('rejects a reading after cancellation closes the turn', async () => {
  214. const ctx = await setup()
  215. const session = preparing(1, 2)
  216. session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } })
  217. expect(() => { ctx.emit('session/event', session, event(reading('1', '2', 'step context'))) })
  218. .toThrow(/inside an open turn/)
  219. })
  220. it('rejects a reading outside prompt assembly', async () => {
  221. const ctx = await setup()
  222. const ended = preparing(1, 1)
  223. ended.append('step/end', { turn: 1, step: 1 })
  224. expect(() => { ctx.emit('session/event', ended, event(reading())) }).toThrow(/follow step\/start/)
  225. const notEntered = Session.create(SessionId('time-invariant-turn-only'))
  226. notEntered.append('turn/start', { turn: 1 })
  227. expect(() => { ctx.emit('session/event', notEntered, event(reading())) }).toThrow(/follow step\/start/)
  228. expect(() => {
  229. ctx.emit('session/event', Session.create(SessionId('time-invariant-empty')), event(reading()))
  230. }).toThrow(/inside an open turn/)
  231. const requested = preparing(1, 1)
  232. requested.append('request/header', {
  233. header: { config: { provider: 'mock', model: 'model' } },
  234. reason: 'initial',
  235. })
  236. expect(() => { ctx.emit('session/event', requested, event(reading())) }).toThrow(/precede request\/header/)
  237. })
  238. it.each([
  239. ['not a reading', SECOND, undefined, /durable reading format/],
  240. [reading('0'), SECOND, undefined, /positive safe integers/],
  241. [reading('999999999999999999999'), SECOND, undefined, /positive safe integers/],
  242. [reading('1', '0', 'step context'), SECOND, undefined, /positive safe integers/],
  243. [reading('1', '999999999999999999999', 'step context'), SECOND, undefined, /positive safe integers/],
  244. [reading('1', '1', 'step context'), SECOND, undefined, /wrong elapsed-time baseline/],
  245. [reading('1', '2', 'model-visible message'), SECOND, undefined, /wrong elapsed-time baseline/],
  246. [reading('1', '1', 'model-visible message', '2026-99-99T00:00:00+00:00[UTC]'), SECOND, undefined, /must parse and not postdate/],
  247. [reading(), Number.NaN, undefined, /must parse and not postdate/],
  248. [reading(), SECOND - 1, undefined, /must parse and not postdate/],
  249. ['ignored', SECOND, [], /exactly one text block/],
  250. ['ignored', SECOND, [{ type: 'image', data: 'x', mimeType: 'image/png' }], /exactly one text block/],
  251. ['ignored', SECOND, [{ type: 'text', text: 'one' }, { type: 'text', text: 'two' }], /exactly one text block/],
  252. [reading(), SECOND, [{ type: 'text', text: reading(), extra: true }], /exactly one text block/],
  253. ] as const)('rejects an incoherent durable reading', async (text, time, content, message) => {
  254. const ctx = await setup()
  255. const preparationStep = text.includes('turn 1, step 2:') ? 2 : 1
  256. expect(() => {
  257. ctx.emit('session/event', preparing(1, preparationStep), event(
  258. text,
  259. time,
  260. content === undefined ? undefined : [...content],
  261. ))
  262. }).toThrow(message)
  263. })
  264. it('requires exact snapshot provenance without copied request authority', async () => {
  265. const ctx = await setup()
  266. const base = event(reading())
  267. for (const source of [
  268. { kind: 'plugin', plugin: 'time-context' },
  269. { ...base.data.source, authority: {} },
  270. {
  271. kind: 'plugin',
  272. plugin: 'time-context',
  273. form: 'snapshot',
  274. sections: [{ name: 'time-context', text: 'different' }],
  275. },
  276. {
  277. kind: 'plugin',
  278. plugin: 'time-context',
  279. form: 'snapshot',
  280. sections: { 0: { name: 'time-context', text: reading() }, length: 1 },
  281. },
  282. {
  283. kind: 'plugin',
  284. plugin: 'time-context',
  285. form: 'snapshot',
  286. sections: [{ name: 'time-context', text: reading(), extra: true }],
  287. },
  288. ]) {
  289. const malformed: SessionEvent<'user/message'> = {
  290. ...base,
  291. data: { ...base.data, source: source as never },
  292. }
  293. expect(() => { ctx.emit('session/event', preparing(1, 1), malformed) })
  294. .toThrow(/must carry only the exact snapshot text/)
  295. }
  296. })
  297. it('validates a seeded Session created after invariant registration', async () => {
  298. const ctx = await setup()
  299. const text = reading('1', '2', 'step context')
  300. expect(() => {
  301. ctx.sessions.create(SessionId('time-invariant-created-invalid'), {
  302. seed: [
  303. { type: 'turn/start', seq: 0, time: SECOND, data: { turn: 1 } },
  304. { type: 'step/start', seq: 1, time: SECOND, data: { turn: 1, step: 1 } },
  305. { ...event(text), seq: 2, surfaceOp: 'append' },
  306. ],
  307. })
  308. }).toThrow(/expected turn 1\/step 1/)
  309. expect(ctx.sessions.get(SessionId('time-invariant-created-invalid'))).toBeUndefined()
  310. })
  311. it('ignores context messages owned by another package', async () => {
  312. const ctx = await setup()
  313. const other = event('unrelated', SECOND + 456, undefined, 'other')
  314. expect(() => { ctx.emit('session/event', preparing(1, 1), other) }).not.toThrow()
  315. const user: SessionEvent<'user/message'> = {
  316. ...event('unrelated'),
  317. data: createUserMessage({
  318. content: [{ type: 'text', text: 'unrelated' }],
  319. source: { kind: 'user' },
  320. }),
  321. }
  322. expect(() => { ctx.emit('session/event', preparing(1, 1), user) }).not.toThrow()
  323. expect(() => {
  324. ctx.emit('session/event', preparing(1, 1), {
  325. type: 'turn/start', seq: 0, time: 0, data: { turn: 1 },
  326. })
  327. ctx.emit('tools/change')
  328. }).not.toThrow()
  329. })
  330. })