context-breakdown-projection.spec.ts 13 KB

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