context-breakdown-projection.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. // contextBreakdown projection: heuristic system/tools/message composition,
  2. // plus the shared estimator's pricing branches.
  3. import { describe, expect, it } from 'vitest'
  4. import { Context } from '@deepseek-ai/cordis'
  5. import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
  6. import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
  7. import SessionStore from '@deepseek-ai/dsh-session'
  8. import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
  9. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  10. import TokenMeter from '@deepseek-ai/dsh-token-meter'
  11. import type { ContextBreakdownProjection } from '@deepseek-ai/dsh-token-meter/client'
  12. import { CompactionId } from '@deepseek-ai/dsh-compaction'
  13. import { contextBreakdownProjectionDefinition } from '../src/breakdown-projection.ts'
  14. import {
  15. estimateContent,
  16. estimateHeader,
  17. estimateMessage,
  18. estimateSystemTokens,
  19. estimateToolsTokens,
  20. } from '../src/estimate.ts'
  21. const CONFIG = { provider: 'test', model: 'test-model' }
  22. const TOOLS: ToolSchema[] = [{
  23. name: 'bash',
  24. description: 'run a command',
  25. parameters: { type: 'object', properties: {} },
  26. }]
  27. async function harness(): Promise<{ ctx: Context; session: Session }> {
  28. const ctx = new Context()
  29. await ctx.plugin(SessionStore)
  30. await ctx.plugin(SessionProjectionRegistry)
  31. await ctx.plugin(TokenMeter)
  32. return { ctx, session: ctx.sessions.create() }
  33. }
  34. const projected = (ctx: Context, session: Session): ContextBreakdownProjection => {
  35. const value = ctx.sessionProjections.snapshot(session).values.contextBreakdown
  36. if (value === undefined) throw new Error('contextBreakdown projection is not registered')
  37. return value
  38. }
  39. function appendUser(session: Session, text: string): number {
  40. return session.append('user/message', createUserMessage({
  41. content: [{ type: 'text', text }],
  42. source: { kind: 'user' },
  43. }), { surfaceOp: 'append' }).seq
  44. }
  45. /**
  46. * Meter one upcoming replacement the way compaction-basic does: price the
  47. * replaced span from the measurement service's own nodes and log the
  48. * shadow-price event directly before the replace.
  49. */
  50. function appendSummaryMeter(ctx: Context, session: Session, start: number, end: number): void {
  51. const nodes = ctx.tokenMeter.measure(session).nodes
  52. const startIdx = nodes.findIndex(node => node.seq === start)
  53. const endIdx = nodes.findIndex(node => node.seq === end)
  54. const shadowed = nodes.slice(startIdx, endIdx + 1)
  55. session.append('compaction/summary', {
  56. compactionId: CompactionId('context-breakdown-summary'),
  57. summary: [{ type: 'text', text: 'summary' }],
  58. shadowedRange: { start, end },
  59. shadowedSeqs: shadowed.map(node => node.seq),
  60. shadowedTokenCount: shadowed.reduce((total, node) => total + node.tokens, 0),
  61. provider: 'mock',
  62. model: 'mock',
  63. })
  64. }
  65. describe('contextBreakdown session projection', () => {
  66. it('serves zeros for an empty log', async () => {
  67. const { ctx, session } = await harness()
  68. expect(projected(ctx, session)).toEqual({ systemTokens: 0, toolsTokens: 0, messageTokens: 0 })
  69. })
  70. it('prices the newest envelope last-wins and pushes no change for a restated one', async () => {
  71. const { ctx, session } = await harness()
  72. session.append('request/header', {
  73. header: { config: CONFIG, system: 'You are terse.', tools: TOOLS },
  74. reason: 'initial',
  75. })
  76. expect(projected(ctx, session)).toEqual({
  77. systemTokens: estimateSystemTokens({ config: CONFIG, system: 'You are terse.' }),
  78. toolsTokens: estimateToolsTokens({ config: CONFIG, tools: TOOLS }),
  79. messageTokens: 0,
  80. })
  81. const changed: string[] = []
  82. ctx.sessionProjections.onChanged((_session, key) => { changed.push(key) })
  83. session.append('request/header', {
  84. header: { config: CONFIG, system: 'You are terse.', tools: TOOLS },
  85. reason: 'change',
  86. })
  87. session.append('session/end-seed', {})
  88. expect(changed).not.toContain('contextBreakdown')
  89. // A system-less, tool-less envelope prices back to zero.
  90. session.append('request/header', { header: { config: CONFIG }, reason: 'change' })
  91. expect(projected(ctx, session)).toEqual({ systemTokens: 0, toolsTokens: 0, messageTokens: 0 })
  92. })
  93. it('sums surface appends and skips an empty-content assistant message', async () => {
  94. const { ctx, session } = await harness()
  95. appendUser(session, 'abcd')
  96. session.append('step/start', { turn: 1, step: 1 })
  97. session.append('assistant/message', {
  98. turn: 1,
  99. step: 1,
  100. message: createMessage({
  101. role: 'assistant',
  102. content: [],
  103. source: { kind: 'model', provider: 'mock', model: 'mock' },
  104. }),
  105. usage: { inputTokens: 9, outputTokens: 0 },
  106. }, { surfaceOp: 'append', sourceEventSeqs: [] })
  107. session.append('step/end', { turn: 1, step: 1 })
  108. // 'abcd' prices to 9 (1 text + 4 block + 4 role); the usage-only assistant
  109. // message derives to no transcript entry and adds nothing.
  110. expect(projected(ctx, session).messageTokens).toBe(9)
  111. })
  112. it('shrinks the message figure when a metered replacement compacts the surface', async () => {
  113. const { ctx, session } = await harness()
  114. const first = appendUser(session, 'before compaction, a longer message')
  115. const second = appendUser(session, 'and a second entry')
  116. const summary = createUserMessage({
  117. content: [{ type: 'text', text: 'summary' }],
  118. source: { kind: 'plugin', plugin: 'test' },
  119. })
  120. appendSummaryMeter(ctx, session, first, second)
  121. session.append('user/message', summary, {
  122. surfaceOp: { op: 'replace', start: first, end: second },
  123. sourceEventSeqs: [first, second],
  124. })
  125. expect(projected(ctx, session).messageTokens).toBe(estimateMessage(summary))
  126. })
  127. it('keeps the message figure equal to the service result across appends and a compaction', async () => {
  128. const { ctx, session } = await harness()
  129. // The panel's composition rows and `measure()` answer the same question in
  130. // the same vocabulary; one shared fold is what makes that true.
  131. const agree = (): number => {
  132. const messageTokens = projected(ctx, session).messageTokens
  133. expect(messageTokens).toBe(ctx.tokenMeter.measure(session).surfaceTokens)
  134. return messageTokens
  135. }
  136. session.append('request/header', {
  137. header: { config: CONFIG, system: 'You are terse.', tools: TOOLS },
  138. reason: 'initial',
  139. })
  140. expect(agree()).toBe(0)
  141. const question = appendUser(session, 'a first question, long enough to price above zero')
  142. session.append('step/start', { turn: 1, step: 1 })
  143. const answer = session.append('assistant/message', {
  144. turn: 1,
  145. step: 1,
  146. message: createMessage({
  147. role: 'assistant',
  148. content: [{ type: 'text', text: 'a considered answer' }],
  149. source: { kind: 'model', provider: 'mock', model: 'mock' },
  150. }),
  151. usage: { inputTokens: 40, outputTokens: 7 },
  152. }, { surfaceOp: 'append', sourceEventSeqs: [] }).seq
  153. session.append('step/end', { turn: 1, step: 1 })
  154. const grown = agree()
  155. expect(grown).toBeGreaterThan(0)
  156. appendSummaryMeter(ctx, session, question, answer)
  157. // The armed shadow price must not move the published figure by itself.
  158. expect(agree()).toBe(grown)
  159. session.append('user/message', createUserMessage({
  160. content: [{ type: 'text', text: 'summary' }],
  161. source: { kind: 'plugin', plugin: 'test' },
  162. }), {
  163. surfaceOp: { op: 'replace', start: question, end: answer },
  164. sourceEventSeqs: [question, answer],
  165. })
  166. expect(agree()).toBeLessThan(grown)
  167. })
  168. it('folds a replacement without a claim at zero and fails on a mismatched claim', () => {
  169. const definition = contextBreakdownProjectionDefinition
  170. const replace = (start: number, end: number): SessionEvent => ({
  171. type: 'user/message',
  172. seq: 9,
  173. time: 0,
  174. data: createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }),
  175. surfaceOp: { op: 'replace', start, end },
  176. sourceEventSeqs: [start, end],
  177. } as unknown as SessionEvent)
  178. const append = (seq: number): SessionEvent => ({
  179. type: 'user/message',
  180. seq,
  181. time: 0,
  182. data: createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }),
  183. surfaceOp: 'append',
  184. } as unknown as SessionEvent)
  185. const meter = (start: number, end: number, seq: number): SessionEvent => ({
  186. type: 'compaction/prune',
  187. seq,
  188. time: 0,
  189. data: { shadowedRange: { start, end }, shadowedSeqs: [start, end], shadowedTokenCount: 5 },
  190. } as unknown as SessionEvent)
  191. let state = definition.init()
  192. state = definition.apply(state, append(1))
  193. state = definition.apply(state, append(3))
  194. // No metering event: the replacement contributes zero instead of throwing.
  195. expect(definition.wire.view(definition.apply(state, replace(1, 3))).messageTokens)
  196. .toBe(definition.wire.view(state).messageTokens)
  197. // An adjacent claim for another range contradicts the replacement.
  198. const mismatched = definition.apply(state, meter(1, 1, 8))
  199. expect(() => definition.apply(mismatched, replace(1, 3))).toThrow('no adjacent shadow price')
  200. // A claim expires after one intervening event, so replacement delta is zero.
  201. let expired = definition.apply(state, meter(1, 3, 8))
  202. expired = definition.apply(expired, { type: 'session/end-seed', seq: 9, time: 0, data: {} })
  203. expect(definition.wire.view(definition.apply(expired, replace(1, 3))).messageTokens)
  204. .toBe(definition.wire.view(state).messageTokens)
  205. // The armed claim prices exactly the next event's matching replacement.
  206. const armed = definition.apply(state, meter(1, 3, 8))
  207. expect(definition.wire.view(definition.apply(armed, replace(1, 3))).messageTokens)
  208. .toBe(definition.wire.view(state).messageTokens - 5 + estimateMessage(
  209. createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }),
  210. ))
  211. })
  212. it('keeps the persisted checkpoint O(1) as the surface grows and compacts', async () => {
  213. const { ctx, session } = await harness()
  214. const first = appendUser(session, 'the first of many messages')
  215. for (let index = 0; index < 24; index += 1) appendUser(session, `message number ${index} with some text`)
  216. const last = appendUser(session, 'the last message before compaction')
  217. const stateKeys = (): string[] => {
  218. const row = ctx.sessionProjections.checkpoint(session)['contextBreakdown']
  219. if (row === undefined) throw new Error('contextBreakdown checkpoint row is missing')
  220. return Object.keys(row.val as Record<string, unknown>).sort()
  221. }
  222. // Growth adds no per-node bookkeeping to the durable state.
  223. expect(stateKeys()).toEqual(['messageTokens', 'systemTokens', 'toolsTokens'])
  224. const shadowed = session.surface.nodes.slice(
  225. session.surface.nodes.indexOf(first),
  226. session.surface.nodes.indexOf(last) + 1,
  227. )
  228. appendSummaryMeter(ctx, session, first, last)
  229. session.append('user/message', createUserMessage({
  230. content: [{ type: 'text', text: 'summary' }],
  231. source: { kind: 'plugin', plugin: 'test' },
  232. }), {
  233. surfaceOp: { op: 'replace', start: first, end: last },
  234. sourceEventSeqs: [...shadowed],
  235. })
  236. expect(stateKeys()).toEqual(['messageTokens', 'systemTokens', 'toolsTokens'])
  237. expect(projected(ctx, session).messageTokens)
  238. .toBe(ctx.tokenMeter.measure(session).surfaceTokens)
  239. })
  240. it('restores from a JSON checkpoint and unregisters with the token-meter fiber', async () => {
  241. const ctx = new Context()
  242. await ctx.plugin(SessionStore)
  243. await ctx.plugin(SessionProjectionRegistry)
  244. const meterFiber = await ctx.plugin(TokenMeter)
  245. const session = ctx.sessions.create()
  246. session.append('request/header', {
  247. header: { config: CONFIG, system: 'You are terse.' },
  248. reason: 'initial',
  249. })
  250. appendUser(session, 'abcd')
  251. const checkpoint = JSON.parse(JSON.stringify(
  252. ctx.sessionProjections.checkpoint(session),
  253. )) as ReturnType<typeof ctx.sessionProjections.checkpoint>
  254. await meterFiber.dispose()
  255. expect(ctx.sessionProjections.snapshot(session).values).not.toHaveProperty('contextBreakdown')
  256. await ctx.plugin(TokenMeter)
  257. expect(ctx.sessionProjections.viewCheckpoint(checkpoint).contextBreakdown).toEqual({
  258. systemTokens: estimateSystemTokens({ config: CONFIG, system: 'You are terse.' }),
  259. toolsTokens: 0,
  260. messageTokens: 9,
  261. })
  262. })
  263. })
  264. describe('shared estimator', () => {
  265. it('prices every content-block shape under the fixed heuristic', () => {
  266. expect(estimateContent([{ type: 'text', text: 'abcd' }])).toBe(5)
  267. expect(estimateContent([{ type: 'reasoning', text: 'abcdefgh' }] as ContentBlock[])).toBe(6)
  268. expect(estimateContent([{ type: 'tool-call', id: 'c' as never, name: 'bash', arguments: '{"a":1}' }])).toBe(7)
  269. expect(estimateContent([{
  270. type: 'tool-result', toolCallId: 'c' as never,
  271. content: [{ type: 'text', text: 'abcd' }],
  272. }])).toBe(9)
  273. const unknown = { type: 'mystery', payload: 'abc' } as unknown as ContentBlock
  274. expect(estimateContent([unknown])).toBe(4 + Math.ceil(JSON.stringify(unknown).length / 4))
  275. })
  276. it('prices envelope parts independently and absent parts to zero', () => {
  277. expect(estimateSystemTokens(undefined)).toBe(0)
  278. expect(estimateSystemTokens({ config: CONFIG })).toBe(0)
  279. expect(estimateSystemTokens({ config: CONFIG, system: 'abcdefgh' })).toBe(6)
  280. expect(estimateToolsTokens(undefined)).toBe(0)
  281. expect(estimateToolsTokens({ config: CONFIG, tools: [] })).toBe(0)
  282. expect(estimateToolsTokens({ config: CONFIG, tools: TOOLS }))
  283. .toBe(Math.ceil(JSON.stringify(TOOLS).length / 4) + 4)
  284. expect(estimateHeader(undefined)).toBe(0)
  285. expect(estimateHeader({ config: CONFIG, system: 'abcdefgh', tools: TOOLS }))
  286. .toBe(6 + Math.ceil(JSON.stringify(TOOLS).length / 4) + 4)
  287. })
  288. })