request-header.spec.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. /**
  2. * Request-header utility tests: canonical form, the system line-diff
  3. * (prefix/suffix trim), the name-keyed tools delta, config replacement, the
  4. * round-trip contract (including the reorder case the encoding cannot
  5. * express), and the log fold. These pin the reconstruction algebra: for every
  6. * logged delta, apply(prev, delta) === next, and folding a log prefix yields
  7. * the header its next request was built under.
  8. */
  9. import { describe, expect, it } from 'vitest'
  10. import { Session, SessionId, applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader, headerEquals } from '@deepseek-ai/dsh-session'
  11. import type { EpochHeader, SessionEvent } from '@deepseek-ai/dsh-session'
  12. import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
  13. const CONFIG = { model: 'm' }
  14. function tool(name: string, description = 'd'): ToolSchema {
  15. return { name, description, parameters: { type: 'object' } }
  16. }
  17. function msg(text: string): Message {
  18. return { role: 'user', content: [{ type: 'text', text }] }
  19. }
  20. /** Round-trip helper: diff must reproduce `next` from `prev` exactly. */
  21. function roundTrip(prev: EpochHeader, next: EpochHeader): ReturnType<typeof diffHeader> {
  22. const delta = diffHeader(prev, next)
  23. if (delta !== undefined) {
  24. expect(applyHeaderDelta(prev, delta)).toEqual(canonicalHeader(next))
  25. }
  26. return delta
  27. }
  28. describe('canonicalHeader', () => {
  29. it('normalizes empty system and empty tools to absent fields', () => {
  30. expect(canonicalHeader({ config: CONFIG, system: '', tools: [] })).toEqual({ config: CONFIG })
  31. const full = canonicalHeader({ config: CONFIG, system: 's', tools: [tool('a')] })
  32. expect(full.system).toBe('s')
  33. expect(full.tools).toHaveLength(1)
  34. })
  35. })
  36. describe('diffHeader / applyHeaderDelta', () => {
  37. it('returns undefined for equal headers', () => {
  38. const header = canonicalHeader({ config: CONFIG, system: 'a\nb', tools: [tool('t')] })
  39. expect(diffHeader(header, header)).toBeUndefined()
  40. })
  41. it('encodes a mid-prompt line change as a prefix/suffix trim', () => {
  42. const prev = canonicalHeader({ config: CONFIG, system: 'keep1\nold\nkeep2\nkeep3' })
  43. const next = canonicalHeader({ config: CONFIG, system: 'keep1\nnew A\nnew B\nkeep2\nkeep3' })
  44. const delta = roundTrip(prev, next)
  45. expect(delta?.system).toEqual({ keepStart: 1, keepEnd: 2, insert: ['new A', 'new B'] })
  46. expect(delta?.tools).toBeUndefined()
  47. expect(delta?.config).toBeUndefined()
  48. })
  49. it('degenerates to a full replacement when nothing is shared, and round-trips absence transitions', () => {
  50. const none = canonicalHeader({ config: CONFIG })
  51. const some = canonicalHeader({ config: CONFIG, system: 'x\ny' })
  52. const gained = roundTrip(none, some)
  53. expect(gained?.system).toEqual({ keepStart: 0, keepEnd: 0, insert: ['x', 'y'] })
  54. const lost = roundTrip(some, none)
  55. expect(lost?.system).toEqual({ keepStart: 0, keepEnd: 0, insert: [] })
  56. })
  57. it('does not double-count overlapping prefix and suffix (repeated lines)', () => {
  58. const prev = canonicalHeader({ config: CONFIG, system: 'a\na' })
  59. const next = canonicalHeader({ config: CONFIG, system: 'a\na\na' })
  60. roundTrip(prev, next)
  61. })
  62. it('encodes tool addition, removal, and in-place schema change by name', () => {
  63. const prev = canonicalHeader({ config: CONFIG, tools: [tool('keep'), tool('drop'), tool('edit', 'before')] })
  64. const next = canonicalHeader({ config: CONFIG, tools: [tool('keep'), tool('edit', 'after'), tool('new')] })
  65. const delta = roundTrip(prev, next)
  66. expect(delta?.tools?.added.map(t => t.name)).toEqual(['new'])
  67. expect(delta?.tools?.removed).toEqual(['drop'])
  68. expect(delta?.tools?.changed.map(t => t.name)).toEqual(['edit'])
  69. })
  70. it('round-trips a tool set gained from a tool-less header and lost back to one', () => {
  71. const none = canonicalHeader({ config: CONFIG })
  72. const some = canonicalHeader({ config: CONFIG, tools: [tool('t')] })
  73. const gained = roundTrip(none, some)
  74. expect(gained?.tools?.added.map(t => t.name)).toEqual(['t'])
  75. const lost = roundTrip(some, none)
  76. expect(lost?.tools?.removed).toEqual(['t'])
  77. })
  78. it('cannot express a pure reordering — the writer detects it via the round-trip check', () => {
  79. const prev = canonicalHeader({ config: CONFIG, tools: [tool('a'), tool('b')] })
  80. const next = canonicalHeader({ config: CONFIG, tools: [tool('b'), tool('a')] })
  81. const delta = diffHeader(prev, next)
  82. // A delta IS produced (the lists differ)…
  83. expect(delta).toBeDefined()
  84. // …but applying it cannot reproduce the new order — exactly the case the
  85. // writer's guard turns into a 'fallback' snapshot.
  86. expect(applyHeaderDelta(prev, delta!)).not.toEqual(next)
  87. })
  88. it('replaces the config whole and leaves untouched parts alone', () => {
  89. const prev = canonicalHeader({ config: { model: 'm' }, system: 's', tools: [tool('t')] })
  90. const next = canonicalHeader({ config: { model: 'm2', temperature: 0.1 }, system: 's', tools: [tool('t')] })
  91. const delta = roundTrip(prev, next)
  92. expect(delta).toEqual({ config: { model: 'm2', temperature: 0.1 } })
  93. })
  94. })
  95. describe('the session prefix (messagePrefix)', () => {
  96. it('canonicalHeader normalizes an empty prefix to an absent field', () => {
  97. expect(canonicalHeader({ config: CONFIG, messagePrefix: [] })).toEqual({ config: CONFIG })
  98. const full = canonicalHeader({ config: CONFIG, messagePrefix: [msg('p')] })
  99. expect(full.messagePrefix).toEqual([msg('p')])
  100. })
  101. it('headerEquals treats absence and empty as one representation, content differences as unequal', () => {
  102. expect(headerEquals(canonicalHeader({ config: CONFIG }), { config: CONFIG, messagePrefix: [] })).toBe(true)
  103. expect(headerEquals({ config: CONFIG, messagePrefix: [msg('a')] }, { config: CONFIG, messagePrefix: [msg('b')] })).toBe(false)
  104. expect(headerEquals({ config: CONFIG, messagePrefix: [msg('a')] }, { config: CONFIG })).toBe(false)
  105. })
  106. it('replaces a changed prefix whole and leaves untouched parts alone', () => {
  107. const prev = canonicalHeader({ config: CONFIG, system: 'keep', messagePrefix: [msg('old')] })
  108. const next = canonicalHeader({ config: CONFIG, system: 'keep', messagePrefix: [msg('new'), msg('more')] })
  109. const delta = roundTrip(prev, next)
  110. expect(delta).toEqual({ messagePrefix: [msg('new'), msg('more')] })
  111. })
  112. it('round-trips a prefix gained from a bare header and lost back to one (empty array encodes absence)', () => {
  113. const none = canonicalHeader({ config: CONFIG })
  114. const some = canonicalHeader({ config: CONFIG, messagePrefix: [msg('p')] })
  115. const gained = roundTrip(none, some)
  116. expect(gained).toEqual({ messagePrefix: [msg('p')] })
  117. const lost = roundTrip(some, none)
  118. expect(lost).toEqual({ messagePrefix: [] })
  119. })
  120. it('folds prefix deltas over the log like any other header amendment', () => {
  121. const session = new Session(SessionId('fold-prefix'))
  122. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  123. const first = canonicalHeader({ config: CONFIG, messagePrefix: [msg('catalog v1')] })
  124. session.append('request/header', { header: first, reason: 'initial' })
  125. const second = canonicalHeader({ config: CONFIG, messagePrefix: [msg('catalog v2')] })
  126. session.append('request/header-delta', diffHeader(first, second)!)
  127. expect(foldRequestHeader(session.events)).toEqual(second)
  128. session.append('request/header-delta', diffHeader(second, canonicalHeader({ config: CONFIG }))!)
  129. expect(foldRequestHeader(session.events)).toEqual({ config: CONFIG })
  130. })
  131. })
  132. describe('foldRequestHeader', () => {
  133. function headerEvents(session: Session): readonly SessionEvent[] {
  134. return session.events
  135. }
  136. it('returns undefined on a log with no header events', () => {
  137. const session = new Session(SessionId('fold-none'))
  138. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  139. expect(foldRequestHeader(headerEvents(session))).toBeUndefined()
  140. })
  141. it('folds snapshot then deltas into the header in force, skipping unrelated events', () => {
  142. const session = new Session(SessionId('fold'))
  143. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  144. const first = canonicalHeader({ config: { model: 'm' }, system: 'a\nb', tools: [tool('t')] })
  145. session.append('request/header', { header: first, reason: 'initial' })
  146. session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  147. const second = canonicalHeader({ config: { model: 'm' }, system: 'a\nc', tools: [tool('t')] })
  148. session.append('request/header-delta', diffHeader(first, second)!)
  149. expect(foldRequestHeader(headerEvents(session))).toEqual(second)
  150. // A later snapshot replaces the state wholesale (the 'resume'/'fallback' anchor).
  151. const third = canonicalHeader({ config: { model: 'other' } })
  152. session.append('request/header', { header: third, reason: 'resume' })
  153. expect(foldRequestHeader(headerEvents(session))).toEqual(third)
  154. })
  155. it('throws on a delta before any snapshot (corrupt log)', () => {
  156. const session = new Session(SessionId('fold-corrupt'))
  157. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  158. session.append('request/header-delta', { config: { model: 'x' } })
  159. expect(() => foldRequestHeader(headerEvents(session))).toThrow(/before any request\/header snapshot/)
  160. })
  161. })