request-inspection.spec.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. import { describe, expect, it } from 'vitest'
  2. import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
  3. import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
  4. import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
  5. import { inspectRequests } from '../src/client/sessions/request-inspection.ts'
  6. const at = (seq: number, type: string, data: unknown): SessionEvent =>
  7. ({ seq, time: 1_700_000_000_000 + seq, type, data }) as SessionEvent
  8. const entriesOf = (events: readonly SessionEvent[]): HistoryEntry[] =>
  9. events.map(event => ({ event }))
  10. describe('inspectRequests', () => {
  11. it('projects ordinary and compaction calls into one chronological request stream', () => {
  12. const events = [
  13. at(0, 'step/start', { turn: 1, step: 1 }),
  14. at(1, 'request/header', {
  15. reason: 'initial',
  16. header: {
  17. config: { provider: 'fake', model: 'model' },
  18. system: 'system',
  19. tools: [{
  20. name: 'read',
  21. description: 'Read a file.',
  22. parameters: { type: 'object' },
  23. }],
  24. },
  25. }),
  26. at(2, 'tool/call', {
  27. turn: 1,
  28. step: 1,
  29. callId: 'call-1',
  30. name: 'read',
  31. arguments: '{}',
  32. }),
  33. at(3, 'assistant/message', {
  34. turn: 1,
  35. step: 1,
  36. message: createAssistantMessage({
  37. content: [{ type: 'text', text: 'done' }],
  38. source: { provider: 'fake', model: 'model' },
  39. }),
  40. usage: { inputTokens: 5, outputTokens: 2 },
  41. }),
  42. at(4, 'step/end', { turn: 1, step: 1 }),
  43. at(5, 'compact/start', { turn: 1 }),
  44. at(6, 'compact/summary', {
  45. summary: [{ type: 'text', text: 'summary' }],
  46. rawOutput: [
  47. { type: 'reasoning', text: 'thought' },
  48. { type: 'text', text: 'summary' },
  49. ],
  50. provider: 'fake',
  51. model: 'compact-model',
  52. usage: { inputTokens: 8, outputTokens: 3 },
  53. }),
  54. at(7, 'user/message', createUserMessage({
  55. content: [{ type: 'text', text: 'checkpoint' }],
  56. source: { kind: 'plugin', plugin: 'compact' },
  57. })),
  58. at(8, 'compact/end', { turn: 1 }),
  59. ]
  60. const snapshot = inspectRequests(entriesOf(events))
  61. expect(snapshot.requests).toMatchObject([
  62. {
  63. purpose: 'assistant',
  64. startSeq: 0,
  65. resultSeq: 3,
  66. status: 'complete',
  67. prompt: {
  68. config: { provider: 'fake', model: 'model' },
  69. system: 'system',
  70. },
  71. promptChange: { seq: 1, kind: 'initial' },
  72. },
  73. {
  74. purpose: 'compaction',
  75. startSeq: 5,
  76. resultSeq: 6,
  77. replacementSeq: 7,
  78. status: 'complete',
  79. summary: [{ type: 'text', text: 'summary' }],
  80. },
  81. ])
  82. expect(snapshot.callSchemas.get('call-1')?.name).toBe('read')
  83. })
  84. it('preserves a standalone compaction owner without widening assistant turns', () => {
  85. const snapshot = inspectRequests(entriesOf([
  86. at(0, 'compact/start', { turn: null }),
  87. at(1, 'compact/summary', {
  88. summary: [{ type: 'text', text: 'standalone summary' }],
  89. provider: 'fake',
  90. model: 'compact-model',
  91. }),
  92. at(2, 'compact/end', { turn: null }),
  93. at(3, 'step/start', { turn: 2, step: 1 }),
  94. ]))
  95. const [compaction, assistant] = snapshot.requests
  96. expect(compaction).toMatchObject({
  97. purpose: 'compaction',
  98. turn: null,
  99. step: 0,
  100. status: 'complete',
  101. })
  102. expect(assistant).toMatchObject({
  103. purpose: 'assistant',
  104. turn: 2,
  105. step: 1,
  106. status: 'running',
  107. })
  108. if (assistant?.purpose === 'assistant') {
  109. const turn: number = assistant.turn
  110. expect(turn).toBe(2)
  111. }
  112. })
  113. it('interrupts an orphaned compaction at end-seed before projecting a new attempt', () => {
  114. const snapshot = inspectRequests(entriesOf([
  115. at(0, 'compact/start', { turn: null }),
  116. at(1, 'session/end-seed', {}),
  117. at(2, 'compact/start', { turn: null }),
  118. at(3, 'compact/summary', {
  119. summary: [{ type: 'text', text: 'replacement summary' }],
  120. provider: 'fake',
  121. model: 'compact-model',
  122. }),
  123. at(4, 'compact/end', { turn: null }),
  124. ]))
  125. expect(snapshot.requests).toMatchObject([
  126. {
  127. purpose: 'compaction',
  128. startSeq: 0,
  129. status: 'error',
  130. completedAt: 1_700_000_000_001,
  131. error: 'Compaction was interrupted before completion.',
  132. },
  133. {
  134. purpose: 'compaction',
  135. startSeq: 2,
  136. status: 'complete',
  137. completedAt: 1_700_000_000_004,
  138. summary: [{ type: 'text', text: 'replacement summary' }],
  139. },
  140. ])
  141. })
  142. it('captures schemas for nested tool dispatches from the active request header', () => {
  143. const snapshot = inspectRequests(entriesOf([
  144. at(0, 'request/header', {
  145. reason: 'initial',
  146. header: {
  147. config: { provider: 'fake', model: 'model' },
  148. tools: [{
  149. name: 'read',
  150. description: 'Read a file.',
  151. parameters: { type: 'object' },
  152. }],
  153. },
  154. }),
  155. at(1, 'tool/code-dispatch-start', {
  156. parentCallId: 'parent',
  157. subCallId: 'nested',
  158. name: 'read',
  159. arguments: {},
  160. }),
  161. ]))
  162. expect(snapshot.callSchemas.get('nested')?.name).toBe('read')
  163. })
  164. it('keeps chunk-reported usage through request failure and prefers it to message fallback', () => {
  165. const chunkUsage = { inputTokens: 21, outputTokens: 3 }
  166. const retryUsage = {
  167. inputTokens: 5,
  168. outputTokens: 2,
  169. cacheReadTokens: 8,
  170. reasoningTokens: 1,
  171. }
  172. const snapshot = inspectRequests(entriesOf([
  173. at(0, 'step/start', { turn: 1, step: 1 }),
  174. at(1, 'assistant/chunk', {
  175. turn: 1,
  176. step: 1,
  177. chunk: { type: 'usage', usage: chunkUsage },
  178. }),
  179. at(2, 'llm/retry', {
  180. turn: 1,
  181. step: 1,
  182. retry: 1,
  183. maxRetries: 2,
  184. delayMs: 100,
  185. failure: { message: 'rate limited' },
  186. }),
  187. at(3, 'assistant/chunk', {
  188. turn: 1,
  189. step: 1,
  190. chunk: { type: 'usage', usage: retryUsage },
  191. }),
  192. at(4, 'assistant/message', {
  193. turn: 1,
  194. step: 1,
  195. message: createAssistantMessage({
  196. content: [{ type: 'text', text: 'recovered' }],
  197. source: { provider: 'fake', model: 'model' },
  198. }),
  199. usage: { inputTokens: 1, outputTokens: 1 },
  200. }),
  201. ]))
  202. expect(snapshot.requests[0]).toMatchObject({
  203. status: 'complete',
  204. usage: {
  205. inputTokens: 26,
  206. outputTokens: 5,
  207. cacheReadTokens: 8,
  208. reasoningTokens: 1,
  209. },
  210. })
  211. })
  212. it('keeps provider credential fragments out of projected request errors', () => {
  213. const snapshot = inspectRequests(entriesOf([
  214. at(0, 'step/start', { turn: 1, step: 1 }),
  215. at(1, 'turn/end', {
  216. turn: 1,
  217. reason: {
  218. kind: 'error',
  219. step: 1,
  220. failure: {
  221. code: 'AUTH',
  222. message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
  223. },
  224. },
  225. }),
  226. at(2, 'step/start', { turn: 2, step: 1 }),
  227. at(3, 'turn/end', {
  228. turn: 2,
  229. reason: { kind: 'error', step: 1, message: 'plugin exploded' },
  230. }),
  231. ]))
  232. expect(snapshot.requests).toMatchObject([
  233. { status: 'error', error: 'API key is invalid' },
  234. { status: 'error', error: 'plugin exploded' },
  235. ])
  236. })
  237. it('treats a scrubbed durable-fixture tool catalog as unavailable', () => {
  238. const snapshot = inspectRequests(entriesOf([
  239. at(0, 'step/start', { turn: 1, step: 1 }),
  240. at(1, 'request/header', {
  241. reason: 'initial',
  242. header: {
  243. config: { provider: 'fake', model: 'model' },
  244. tools: '{{tools}}',
  245. },
  246. }),
  247. at(2, 'tool/call', {
  248. turn: 1,
  249. step: 1,
  250. callId: 'call-1',
  251. name: 'read',
  252. arguments: '{}',
  253. }),
  254. ]))
  255. expect(snapshot.callSchemas).toEqual(new Map())
  256. const [request] = snapshot.requests
  257. expect(request?.purpose === 'assistant' ? request.prompt?.tools : undefined).toEqual([])
  258. })
  259. })