conversation-definitions.client.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. import type { Context } from '@deepseek-ai/cordis'
  2. import { describe, expect, it } from 'vitest'
  3. import type {
  4. ConversationEventInput, ConversationNodeDefinition, ConversationViewDefinition,
  5. } from '@deepseek-ai/dsh-client-ui-conversation/client'
  6. import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client'
  7. import { registerTrajectoryAssistantDefinition } from '../src/client/trajectory-assistant-definition.ts'
  8. import { registerTrajectoryCompactionDefinitions } from '../src/client/trajectory-compaction-definition.ts'
  9. import type { TrajectorySnapshot } from '../src/client/trajectory-contract.ts'
  10. import { registerTrajectoryMessageDefinitions } from '../src/client/trajectory-message-definitions.ts'
  11. import { registerTrajectoryRequestHeaderDefinition } from '../src/client/trajectory-request-header-definition.ts'
  12. import { trajectoryViewDefinition } from '../src/client/trajectory-snapshot-builder.ts'
  13. import { registerTrajectoryToolDefinition } from '../src/client/trajectory-tool-definition.ts'
  14. const DEFINITIONS: ConversationNodeDefinition[] = []
  15. const registrationContext = {
  16. uiConversation: {
  17. events: {
  18. register: (definition: ConversationNodeDefinition) => {
  19. DEFINITIONS.push(definition)
  20. return () => {}
  21. },
  22. },
  23. },
  24. } as unknown as Context
  25. registerTrajectoryMessageDefinitions(registrationContext)
  26. registerTrajectoryRequestHeaderDefinition(registrationContext)
  27. registerTrajectoryAssistantDefinition(registrationContext)
  28. registerTrajectoryToolDefinition(registrationContext)
  29. registerTrajectoryCompactionDefinitions(registrationContext)
  30. class TestEventDefinitions {
  31. entries(): readonly ConversationNodeDefinition[] {
  32. return DEFINITIONS
  33. }
  34. fallbackEntry(): undefined {
  35. return undefined
  36. }
  37. }
  38. class TestViewDefinitions {
  39. entries(): readonly ConversationViewDefinition[] {
  40. return [trajectoryViewDefinition]
  41. }
  42. }
  43. function at(
  44. seq: number,
  45. type: string,
  46. data: unknown,
  47. extra: Record<string, unknown> = {},
  48. ): ConversationEventInput {
  49. return {
  50. event: {
  51. seq,
  52. time: 1_700_000_000_000 + seq,
  53. type,
  54. data,
  55. ...extra,
  56. } as unknown as ConversationEventInput['event'],
  57. }
  58. }
  59. function assembler(events: readonly ConversationEventInput[]): ConversationNodeAssembler {
  60. const value = new ConversationNodeAssembler(
  61. new TestEventDefinitions(),
  62. new TestViewDefinitions(),
  63. )
  64. value.replaceWindow(events, false)
  65. value.flush()
  66. return value
  67. }
  68. function snapshot(value: ConversationNodeAssembler): TrajectorySnapshot {
  69. const current = value.get('trajectory')
  70. if (current === undefined) throw new Error('trajectory view was not registered')
  71. return current
  72. }
  73. function assistantMessage(id: string, text: string) {
  74. return {
  75. id,
  76. role: 'assistant',
  77. content: [{ type: 'text', text }],
  78. source: { kind: 'model', provider: 'test', model: 'test' },
  79. }
  80. }
  81. describe('Trajectory conversation Definitions', () => {
  82. it('assembles streaming usage, preserves retry facts, and materializes interruption', () => {
  83. const value = assembler([
  84. at(1, 'turn/start', { turn: 1 }),
  85. at(2, 'step/start', { turn: 1, step: 1 }),
  86. at(3, 'assistant/chunk', {
  87. turn: 1,
  88. step: 1,
  89. chunk: { type: 'text-delta', index: 0, text: 'first attempt' },
  90. }),
  91. at(4, 'assistant/chunk', {
  92. turn: 1,
  93. step: 1,
  94. chunk: { type: 'usage', usage: { inputTokens: 10, outputTokens: 3 } },
  95. }),
  96. ])
  97. expect(snapshot(value).partial?.blocks).toEqual([{ kind: 'text', text: 'first attempt' }])
  98. expect(snapshot(value).requests).toMatchObject([{
  99. purpose: 'assistant',
  100. status: 'running',
  101. usage: { inputTokens: 10, outputTokens: 3 },
  102. }])
  103. value.append(at(5, 'llm/retry', {
  104. retryId: 'retry-1',
  105. turn: 1,
  106. step: 1,
  107. provider: 'test',
  108. mode: 'normal',
  109. policyKey: 'test-normal',
  110. retry: 1,
  111. maxRetries: 2,
  112. delayMs: 25,
  113. failure: { code: 'TRANSPORT', message: 'temporary failure' },
  114. }))
  115. value.append(at(6, 'assistant/chunk', {
  116. turn: 1,
  117. step: 1,
  118. chunk: { type: 'text-delta', index: 0, text: 'second attempt' },
  119. }))
  120. value.append(at(7, 'step/end', { turn: 1, step: 1 }))
  121. value.flush()
  122. const settled = snapshot(value)
  123. expect(settled.partial).toBeNull()
  124. expect(settled.eventNodes).toMatchObject([{
  125. kind: 'assistant',
  126. seq: 6.1,
  127. interrupted: true,
  128. blocks: [{ kind: 'text', text: 'second attempt' }],
  129. }])
  130. expect(settled.requests).toMatchObject([{
  131. purpose: 'assistant',
  132. status: 'error',
  133. error: 'temporary failure',
  134. errorCode: 'TRANSPORT',
  135. retry: 1,
  136. maxRetries: 2,
  137. retryDelayMs: 25,
  138. usage: { inputTokens: 10, outputTokens: 3 },
  139. }])
  140. })
  141. it('classifies a cancellation-finalized prefix as an interrupted request result', () => {
  142. const current = snapshot(assembler([
  143. at(1, 'turn/start', { turn: 1 }),
  144. at(2, 'step/start', { turn: 1, step: 1 }),
  145. at(3, 'assistant/message', {
  146. turn: 1,
  147. step: 1,
  148. message: assistantMessage('interrupted-message', 'cut short'),
  149. interrupted: true,
  150. }),
  151. at(4, 'step/end', { turn: 1, step: 1 }),
  152. at(5, 'turn/end', {
  153. turn: 1,
  154. reason: { kind: 'aborted', reason: { kind: 'user' } },
  155. }),
  156. ]))
  157. expect(current.eventNodes).toMatchObject([{
  158. kind: 'assistant',
  159. seq: 3,
  160. messageId: 'interrupted-message',
  161. interrupted: true,
  162. blocks: [{ kind: 'text', text: 'cut short' }],
  163. }])
  164. expect(current.requests).toMatchObject([{
  165. purpose: 'assistant',
  166. resultSeq: 3,
  167. status: 'error',
  168. provenance: { provider: 'test', model: 'test' },
  169. }])
  170. })
  171. it('keeps parallel roots, raw Tool facts, and nested Code Dispatch results', () => {
  172. const current = snapshot(assembler([
  173. at(1, 'turn/start', { turn: 1 }),
  174. at(2, 'step/start', { turn: 1, step: 1 }),
  175. at(3, 'tool/call', {
  176. turn: 1, step: 1, callId: 'root-a', name: 'code', arguments: '{}',
  177. }),
  178. at(4, 'tool/call', {
  179. turn: 1, step: 1, callId: 'root-b', name: 'parallel', arguments: '{}',
  180. }),
  181. at(5, 'tool/code-dispatch-start', {
  182. rootCallId: 'root-a',
  183. parentCallId: 'root-a',
  184. subCallId: 'child',
  185. name: 'read',
  186. arguments: { path: 'README.md' },
  187. }),
  188. at(6, 'tool/code-dispatch', {
  189. rootCallId: 'root-a',
  190. parentCallId: 'root-a',
  191. subCallId: 'child',
  192. name: 'read',
  193. arguments: { path: 'README.md' },
  194. content: [{ type: 'text', text: 'contents' }],
  195. }),
  196. at(7, 'tool/result', {
  197. turn: 1,
  198. step: 1,
  199. message: {
  200. id: 'result-root-a',
  201. role: 'user',
  202. source: { kind: 'tool', callId: 'root-a' },
  203. content: [{
  204. type: 'tool-result',
  205. toolCallId: 'root-a',
  206. content: [{ type: 'text', text: 'root failed' }],
  207. isError: true,
  208. }],
  209. },
  210. error: { name: 'ToolError', code: 'failed' },
  211. meta: { presentation: 'raw' },
  212. }, { surfaceOp: 'append' }),
  213. at(8, 'step/end', { turn: 1, step: 1 }),
  214. ]))
  215. const tools = current.eventNodes.filter(node => node.kind === 'tool-result')
  216. expect(tools.map(node => node.callId).sort()).toEqual(['root-a', 'root-b'])
  217. expect(tools.find(node => node.callId === 'root-a')).toMatchObject({
  218. kind: 'tool-result',
  219. callId: 'root-a',
  220. call: { name: 'code', argsRaw: '{}' },
  221. content: [{ type: 'text', text: 'root failed' }],
  222. isError: true,
  223. error: { name: 'ToolError', code: 'failed' },
  224. meta: { presentation: 'raw' },
  225. subCalls: [{
  226. kind: 'tool-result', callId: 'child', parentCallId: 'root-a', call: { name: 'read' },
  227. }],
  228. })
  229. expect(tools.find(node => node.callId === 'root-b')).toMatchObject({
  230. isError: true,
  231. error: { name: 'Interrupted', code: 'interrupted' },
  232. })
  233. })
  234. it('assembles compaction lifecycle, checkpoint replacement, and orphan interruption', () => {
  235. const current = snapshot(assembler([
  236. at(1, 'compaction/start', { compactionId: 'complete', turn: null }),
  237. at(2, 'compaction/summary', {
  238. compactionId: 'complete',
  239. turn: null,
  240. summary: 'summary',
  241. provider: 'test',
  242. model: 'test',
  243. maxTokens: 100,
  244. usage: { inputTokens: 20, outputTokens: 5 },
  245. }),
  246. at(3, 'user/message', {
  247. id: 'checkpoint',
  248. role: 'user',
  249. content: [{ type: 'text', text: 'summary checkpoint' }],
  250. source: { kind: 'plugin', plugin: 'compact', compactionId: 'complete' },
  251. }),
  252. at(4, 'compaction/end', { compactionId: 'complete', turn: null }),
  253. at(5, 'compaction/start', { compactionId: 'orphan', turn: null }),
  254. at(6, 'session/end-seed', {}),
  255. ]))
  256. expect(current.requests).toMatchObject([
  257. {
  258. purpose: 'compaction',
  259. startSeq: 1,
  260. status: 'complete',
  261. resultSeq: 2,
  262. replacementSeq: 3,
  263. summary: 'summary',
  264. },
  265. {
  266. purpose: 'compaction',
  267. startSeq: 5,
  268. status: 'error',
  269. completedAt: 1_700_000_000_006,
  270. },
  271. ])
  272. })
  273. it('classifies claimed inbox input as steering and consumes one inherited prompt change', () => {
  274. const value = assembler([
  275. at(1, 'turn/start', { turn: 1 }),
  276. at(2, 'request/header', {
  277. reason: 'initial',
  278. header: {
  279. config: { provider: 'test', model: 'test' },
  280. system: 'system prompt',
  281. tools: [],
  282. },
  283. }),
  284. at(3, 'step/start', { turn: 1, step: 1 }),
  285. at(4, 'assistant/message', {
  286. turn: 1,
  287. step: 1,
  288. message: assistantMessage('assistant-1', 'first'),
  289. }),
  290. at(5, 'step/end', { turn: 1, step: 1 }),
  291. at(6, 'agent/inbox/spliced', {
  292. target: 'next-step', start: 0, removedCount: 0, inserted: [{ id: 'm1' }],
  293. }),
  294. at(7, 'agent/inbox/spliced', {
  295. target: 'next-step', start: 0, removedCount: 1, inserted: [],
  296. }),
  297. at(8, 'step/start', { turn: 1, step: 2 }),
  298. ])
  299. value.append(at(9, 'user/message', {
  300. id: 'm1',
  301. role: 'user',
  302. content: [{ type: 'text', text: 'steer here' }],
  303. source: { kind: 'user' },
  304. }))
  305. value.flush()
  306. const steering = snapshot(value)
  307. expect(steering.eventNodes.find(node => node.seq === 9)?.kind).toBe('steering')
  308. expect(steering.eventLocations.get(9)).toMatchObject({
  309. kind: 'step',
  310. turn: { turn: 1 },
  311. step: { step: 2 },
  312. })
  313. value.append(at(10, 'assistant/message', {
  314. turn: 1,
  315. step: 2,
  316. message: assistantMessage('assistant-2', 'second'),
  317. }))
  318. value.flush()
  319. const current = snapshot(value)
  320. expect(current.requests.map(request => request.purpose === 'assistant'
  321. ? request.prompt?.system
  322. : undefined)).toEqual(['system prompt', 'system prompt'])
  323. expect(current.requests.map(request => request.purpose === 'assistant'
  324. ? request.promptChange?.kind
  325. : undefined)).toEqual(['initial', undefined])
  326. })
  327. })