request-inspection.spec.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  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('does not promote a truncated resume or change header to the initial prompt', () => {
  85. for (const reason of ['resume', 'change'] as const) {
  86. const snapshot = inspectRequests(entriesOf([
  87. at(10, 'step/start', { turn: 3, step: 1 }),
  88. at(11, 'request/header', {
  89. reason,
  90. header: {
  91. config: { provider: 'fake', model: 'model' },
  92. system: 'tail-window prompt',
  93. },
  94. }),
  95. ]))
  96. expect(snapshot.requests[0]).toMatchObject({
  97. purpose: 'assistant',
  98. prompt: { system: 'tail-window prompt' },
  99. })
  100. expect(snapshot.requests[0]).not.toHaveProperty('promptChange')
  101. }
  102. })
  103. it('classifies a prompt change once the preceding header is loaded', () => {
  104. const snapshot = inspectRequests(entriesOf([
  105. at(0, 'step/start', { turn: 1, step: 1 }),
  106. at(1, 'request/header', {
  107. reason: 'initial',
  108. header: {
  109. config: { provider: 'fake', model: 'model' },
  110. system: 'before',
  111. },
  112. }),
  113. at(2, 'step/start', { turn: 1, step: 2 }),
  114. at(3, 'request/header', {
  115. reason: 'change',
  116. header: {
  117. config: { provider: 'fake', model: 'model' },
  118. system: 'after',
  119. },
  120. }),
  121. ]))
  122. expect(snapshot.requests[1]).toMatchObject({
  123. promptChange: {
  124. seq: 3,
  125. kind: 'system',
  126. previous: { system: 'before' },
  127. },
  128. })
  129. })
  130. it('preserves a standalone compaction owner without widening assistant turns', () => {
  131. const snapshot = inspectRequests(entriesOf([
  132. at(0, 'compact/start', { turn: null }),
  133. at(1, 'compact/summary', {
  134. summary: [{ type: 'text', text: 'standalone summary' }],
  135. provider: 'fake',
  136. model: 'compact-model',
  137. }),
  138. at(2, 'compact/end', { turn: null }),
  139. at(3, 'step/start', { turn: 2, step: 1 }),
  140. ]))
  141. const [compaction, assistant] = snapshot.requests
  142. expect(compaction).toMatchObject({
  143. purpose: 'compaction',
  144. turn: null,
  145. step: 0,
  146. status: 'complete',
  147. })
  148. expect(assistant).toMatchObject({
  149. purpose: 'assistant',
  150. turn: 2,
  151. step: 1,
  152. status: 'running',
  153. })
  154. if (assistant?.purpose === 'assistant') {
  155. const turn: number = assistant.turn
  156. expect(turn).toBe(2)
  157. }
  158. })
  159. it('interrupts an orphaned compaction at end-seed before projecting a new attempt', () => {
  160. const snapshot = inspectRequests(entriesOf([
  161. at(0, 'compact/start', { turn: null }),
  162. at(1, 'session/end-seed', {}),
  163. at(2, 'compact/start', { turn: null }),
  164. at(3, 'compact/summary', {
  165. summary: [{ type: 'text', text: 'replacement summary' }],
  166. provider: 'fake',
  167. model: 'compact-model',
  168. }),
  169. at(4, 'compact/end', { turn: null }),
  170. ]))
  171. expect(snapshot.requests).toMatchObject([
  172. {
  173. purpose: 'compaction',
  174. startSeq: 0,
  175. status: 'error',
  176. completedAt: 1_700_000_000_001,
  177. error: 'Compaction was interrupted before completion.',
  178. },
  179. {
  180. purpose: 'compaction',
  181. startSeq: 2,
  182. status: 'complete',
  183. completedAt: 1_700_000_000_004,
  184. summary: [{ type: 'text', text: 'replacement summary' }],
  185. },
  186. ])
  187. })
  188. it('captures schemas for nested tool dispatches from the active request header', () => {
  189. const snapshot = inspectRequests(entriesOf([
  190. at(0, 'request/header', {
  191. reason: 'initial',
  192. header: {
  193. config: { provider: 'fake', model: 'model' },
  194. tools: [{
  195. name: 'read',
  196. description: 'Read a file.',
  197. parameters: { type: 'object' },
  198. }],
  199. },
  200. }),
  201. at(1, 'tool/code-dispatch-start', {
  202. parentCallId: 'parent',
  203. subCallId: 'nested',
  204. name: 'read',
  205. arguments: {},
  206. }),
  207. ]))
  208. expect(snapshot.callSchemas.get('nested')?.name).toBe('read')
  209. })
  210. it('keeps chunk-reported usage through request failure and prefers it to message fallback', () => {
  211. const chunkUsage = { inputTokens: 21, outputTokens: 3 }
  212. const retryUsage = {
  213. inputTokens: 5,
  214. outputTokens: 2,
  215. cacheReadTokens: 8,
  216. reasoningTokens: 1,
  217. }
  218. const snapshot = inspectRequests(entriesOf([
  219. at(0, 'step/start', { turn: 1, step: 1 }),
  220. at(1, 'assistant/chunk', {
  221. turn: 1,
  222. step: 1,
  223. chunk: { type: 'usage', usage: chunkUsage },
  224. }),
  225. at(2, 'llm/retry', {
  226. turn: 1,
  227. step: 1,
  228. retry: 1,
  229. maxRetries: 2,
  230. delayMs: 100,
  231. failure: { message: 'rate limited' },
  232. }),
  233. at(3, 'assistant/chunk', {
  234. turn: 1,
  235. step: 1,
  236. chunk: { type: 'usage', usage: retryUsage },
  237. }),
  238. at(4, 'assistant/message', {
  239. turn: 1,
  240. step: 1,
  241. message: createAssistantMessage({
  242. content: [{ type: 'text', text: 'recovered' }],
  243. source: { provider: 'fake', model: 'model' },
  244. }),
  245. usage: { inputTokens: 1, outputTokens: 1 },
  246. }),
  247. ]))
  248. expect(snapshot.requests[0]).toMatchObject({
  249. status: 'complete',
  250. usage: {
  251. inputTokens: 26,
  252. outputTokens: 5,
  253. cacheReadTokens: 8,
  254. reasoningTokens: 1,
  255. },
  256. })
  257. })
  258. it('keeps provider credential fragments out of projected request errors', () => {
  259. const snapshot = inspectRequests(entriesOf([
  260. at(0, 'step/start', { turn: 1, step: 1 }),
  261. at(1, 'turn/end', {
  262. turn: 1, reason: { kind: 'error', error: {
  263. code: 'AUTH',
  264. message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
  265. },
  266. },
  267. }),
  268. at(2, 'step/start', { turn: 2, step: 1 }),
  269. at(3, 'turn/end', {
  270. turn: 2, reason: { kind: 'error', error: { message: 'plugin exploded', code: 'UNKNOWN' } },
  271. }),
  272. ]))
  273. expect(snapshot.requests).toMatchObject([
  274. { status: 'error', error: 'API key is invalid' },
  275. { status: 'error', error: 'plugin exploded' },
  276. ])
  277. })
  278. it('treats a scrubbed durable-fixture tool catalog as unavailable', () => {
  279. const snapshot = inspectRequests(entriesOf([
  280. at(0, 'step/start', { turn: 1, step: 1 }),
  281. at(1, 'request/header', {
  282. reason: 'initial',
  283. header: {
  284. config: { provider: 'fake', model: 'model' },
  285. tools: '{{tools}}',
  286. },
  287. }),
  288. at(2, 'tool/call', {
  289. turn: 1,
  290. step: 1,
  291. callId: 'call-1',
  292. name: 'read',
  293. arguments: '{}',
  294. }),
  295. ]))
  296. expect(snapshot.callSchemas).toEqual(new Map())
  297. const [request] = snapshot.requests
  298. expect(request?.purpose === 'assistant' ? request.prompt?.tools : undefined).toEqual([])
  299. })
  300. })