snapshot-builder.client.spec.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. import { describe, expect, it } from 'vitest'
  2. import type { RequestView } from '@deepseek-ai/dsh-client-ui-conversation/client'
  3. import type {
  4. TrajectoryContribution, TrajectoryConversationViewNode, TrajectoryRequestHeaderState,
  5. } from '../src/client/trajectory-contract.ts'
  6. import { TrajectorySnapshotBuilder } from '../src/client/trajectory-snapshot-builder.ts'
  7. const EMPTY_LOCATION_DATA_SOURCE = { getSnapshot: () => undefined, subscribe: () => () => {} }
  8. const EMPTY_LOCATION_DATA = { get: () => undefined, source: () => EMPTY_LOCATION_DATA_SOURCE }
  9. function assistantRequest(startSeq: number, step: number): Extract<RequestView, { purpose: 'assistant' }> {
  10. return {
  11. purpose: 'assistant',
  12. startSeq,
  13. turn: 1,
  14. step,
  15. startedAt: startSeq,
  16. completedAt: startSeq + 1,
  17. status: 'complete',
  18. }
  19. }
  20. function contribution(
  21. key: string,
  22. anchorSeq: number,
  23. data: TrajectoryContribution,
  24. ): TrajectoryConversationViewNode {
  25. return {
  26. key, kind: key, id: key, target: 'trajectory', anchorSeq,
  27. location: { kind: 'session' },
  28. data,
  29. }
  30. }
  31. function stepLocation(turn: number, step: number): TrajectoryRequestHeaderState['location'] {
  32. const data = EMPTY_LOCATION_DATA
  33. const stepLocation = {
  34. turn,
  35. step,
  36. start: undefined,
  37. end: undefined,
  38. status: 'unknown' as const,
  39. data,
  40. }
  41. const turnLocation = {
  42. turn,
  43. start: undefined,
  44. end: undefined,
  45. status: 'unknown' as const,
  46. steps: [stepLocation],
  47. data,
  48. }
  49. return { kind: 'step', turn: turnLocation, step: stepLocation }
  50. }
  51. function compactionRequest(startSeq: number): Extract<RequestView, { purpose: 'compaction' }> {
  52. return {
  53. purpose: 'compaction',
  54. startSeq,
  55. turn: null,
  56. step: 0,
  57. startedAt: startSeq,
  58. completedAt: null,
  59. status: 'running',
  60. }
  61. }
  62. describe('TrajectorySnapshotBuilder', () => {
  63. it('inherits one request header across requests without repeating its prompt change', () => {
  64. const prompt = {
  65. config: { provider: 'test', model: 'test' },
  66. system: 'one initial prompt',
  67. tools: [],
  68. }
  69. const nodes: TrajectoryConversationViewNode[] = [
  70. {
  71. key: 'header',
  72. kind: 'trajectory-request-header',
  73. id: '2',
  74. target: 'trajectory',
  75. anchorSeq: 2,
  76. location: { kind: 'session' },
  77. data: {
  78. kind: 'request-header',
  79. header: {
  80. seq: 2,
  81. time: 2,
  82. prompt,
  83. change: { seq: 2, time: 2, kind: 'initial' },
  84. location: { kind: 'session' },
  85. },
  86. },
  87. },
  88. ...[assistantRequest(3, 1), assistantRequest(5, 2)].map(request => ({
  89. key: `assistant:${request.step}`,
  90. kind: 'trajectory-assistant-step',
  91. id: `1:${request.step}`,
  92. target: 'trajectory' as const,
  93. anchorSeq: request.startSeq,
  94. location: { kind: 'session' as const },
  95. data: { kind: 'assistant' as const, partial: null, request },
  96. })),
  97. ]
  98. const snapshot = new TrajectorySnapshotBuilder().replace({ nodes })
  99. expect(snapshot.requests.map(request => request.purpose === 'assistant'
  100. ? request.prompt?.system
  101. : undefined)).toEqual(['one initial prompt', 'one initial prompt'])
  102. expect(snapshot.requests.map(request => request.purpose === 'assistant'
  103. ? request.promptChange?.kind
  104. : undefined)).toEqual(['initial', undefined])
  105. })
  106. it('retains a same-step prompt change when a later series header supplies the latest snapshot', () => {
  107. const initial = {
  108. config: { provider: 'test', model: 'test' },
  109. system: 'initial prompt',
  110. tools: [],
  111. }
  112. const changed = { ...initial, system: 'changed prompt' }
  113. const nodes: TrajectoryConversationViewNode[] = [
  114. contribution('header:initial', 2, {
  115. kind: 'request-header',
  116. header: {
  117. seq: 2,
  118. time: 2,
  119. prompt: initial,
  120. change: { seq: 2, time: 2, kind: 'initial' },
  121. location: { kind: 'session' },
  122. },
  123. }),
  124. contribution('assistant:1', 3, {
  125. kind: 'assistant',
  126. partial: null,
  127. request: assistantRequest(3, 1),
  128. }),
  129. contribution('header:change', 5, {
  130. kind: 'request-header',
  131. header: {
  132. seq: 5,
  133. time: 5,
  134. prompt: changed,
  135. change: { seq: 5, time: 5, kind: 'system', previous: initial },
  136. location: stepLocation(1, 2),
  137. },
  138. }),
  139. contribution('header:series', 6, {
  140. kind: 'request-header',
  141. header: {
  142. seq: 6,
  143. time: 6,
  144. prompt: changed,
  145. location: stepLocation(1, 2),
  146. },
  147. }),
  148. contribution('assistant:2', 7, {
  149. kind: 'assistant',
  150. partial: null,
  151. request: assistantRequest(7, 2),
  152. }),
  153. ]
  154. const snapshot = new TrajectorySnapshotBuilder().replace({ nodes })
  155. expect(snapshot.requests.map(request => request.purpose === 'assistant'
  156. ? request.prompt?.system
  157. : undefined)).toEqual(['initial prompt', 'changed prompt'])
  158. expect(snapshot.requests.map(request => request.purpose === 'assistant'
  159. ? request.promptChange?.seq
  160. : undefined)).toEqual([2, 5])
  161. })
  162. it('indexes exact step headers and the active tool schema without backward scans', () => {
  163. const basePrompt = {
  164. config: { provider: 'test', model: 'base' },
  165. system: 'base prompt',
  166. tools: [{ name: 'read', description: 'Read', parameters: { type: 'object' } }],
  167. }
  168. const exactPrompt = {
  169. config: { provider: 'test', model: 'exact' },
  170. system: 'exact prompt',
  171. tools: [{ name: 'edit', description: 'Edit', parameters: { type: 'object' } }],
  172. }
  173. const nodes: TrajectoryConversationViewNode[] = [
  174. contribution('header:base', 2, {
  175. kind: 'request-header',
  176. header: {
  177. seq: 2,
  178. time: 2,
  179. prompt: basePrompt,
  180. change: { seq: 2, time: 2, kind: 'initial' },
  181. location: { kind: 'session' },
  182. },
  183. }),
  184. contribution('assistant:1', 3, {
  185. kind: 'assistant',
  186. partial: null,
  187. request: assistantRequest(3, 1),
  188. }),
  189. contribution('assistant:2', 5, {
  190. kind: 'assistant',
  191. partial: null,
  192. request: assistantRequest(5, 2),
  193. }),
  194. contribution('header:exact', 6, {
  195. kind: 'request-header',
  196. header: {
  197. seq: 6,
  198. time: 6,
  199. prompt: exactPrompt,
  200. change: { seq: 6, time: 6, kind: 'system', previous: basePrompt },
  201. location: stepLocation(1, 2),
  202. },
  203. }),
  204. contribution('tool', 7, {
  205. kind: 'tool',
  206. root: {
  207. callId: 'call-edit',
  208. name: 'edit',
  209. argsRaw: '{}',
  210. turn: 1,
  211. step: 2,
  212. time: 7,
  213. subCalls: [],
  214. },
  215. }),
  216. ]
  217. const snapshot = new TrajectorySnapshotBuilder().replace({ nodes })
  218. expect(snapshot.requests.map(request => request.purpose === 'assistant'
  219. ? request.prompt?.system
  220. : undefined)).toEqual(['base prompt', 'exact prompt'])
  221. expect(snapshot.callSchemas.get('call-edit')).toEqual(exactPrompt.tools[0])
  222. })
  223. it('applies session boundaries and turn errors with linear request indexes', () => {
  224. const nodes: TrajectoryConversationViewNode[] = [
  225. ...[assistantRequest(1, 1), assistantRequest(3, 2)].map(request => contribution(
  226. `assistant:${request.step}`,
  227. request.startSeq,
  228. { kind: 'assistant', partial: null, request },
  229. )),
  230. contribution('turn-end', 5, {
  231. kind: 'turn-end',
  232. turn: 1,
  233. time: 5,
  234. error: 'turn failed',
  235. errorCode: 'AUTH',
  236. }),
  237. contribution('compact:10', 10, {
  238. kind: 'compaction',
  239. request: compactionRequest(10),
  240. }),
  241. contribution('compact:12', 12, {
  242. kind: 'compaction',
  243. request: compactionRequest(12),
  244. }),
  245. contribution('session-end:14', 14, { kind: 'session-end', seq: 14, time: 14 }),
  246. contribution('session-end:16', 16, { kind: 'session-end', seq: 16, time: 16 }),
  247. ]
  248. const snapshot = new TrajectorySnapshotBuilder().replace({ nodes })
  249. expect(snapshot.requests).toMatchObject([
  250. { purpose: 'assistant', step: 1, status: 'complete' },
  251. {
  252. purpose: 'assistant', step: 2, status: 'error', error: 'turn failed', errorCode: 'AUTH',
  253. },
  254. { purpose: 'compaction', startSeq: 10, status: 'error', completedAt: 16 },
  255. { purpose: 'compaction', startSeq: 12, status: 'error', completedAt: 14 },
  256. ])
  257. })
  258. it('keeps cached contribution order across content updates and structural inserts', () => {
  259. const builder = new TrajectorySnapshotBuilder()
  260. const first = contribution('assistant:1', 1, {
  261. kind: 'assistant', partial: null, request: assistantRequest(1, 1),
  262. })
  263. const last = contribution('assistant:3', 5, {
  264. kind: 'assistant', partial: null, request: assistantRequest(5, 3),
  265. })
  266. expect(builder.replace({ nodes: [last, first] }).requests.map(request => request.startSeq))
  267. .toEqual([1, 5])
  268. const updatedLast = contribution('assistant:3', 5, {
  269. kind: 'assistant',
  270. partial: null,
  271. request: { ...assistantRequest(5, 3), status: 'error', error: 'failed' },
  272. })
  273. expect(builder.apply({ upserts: [updatedLast] }).requests.map(request => request.startSeq))
  274. .toEqual([1, 5])
  275. const middle = contribution('assistant:2', 3, {
  276. kind: 'assistant', partial: null, request: assistantRequest(3, 2),
  277. })
  278. expect(builder.apply({ upserts: [middle] }).requests.map(request => request.startSeq))
  279. .toEqual([1, 3, 5])
  280. })
  281. })