layout.spec.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. // @vitest-environment jsdom
  2. /**
  3. * Trajectory turn chrome and layout fold: expand blocks, usage on Message,
  4. * tool own-duration, group wall-span descriptions, in-flight rows.
  5. */
  6. import { afterEach, describe, expect, it } from 'vitest'
  7. import { cleanup, render, screen } from '@testing-library/react'
  8. import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
  9. import { TrajectoryGroupHeader } from '../src/client/TrajectoryGroupHeader.tsx'
  10. import { TrajectoryTurn } from '../src/client/TrajectoryTurn.tsx'
  11. import { TrajectoryTurnHeader } from '../src/client/TrajectoryTurnHeader.tsx'
  12. import { deriveTrajectoryLayout } from '../src/client/layout.ts'
  13. afterEach(cleanup)
  14. describe('TrajectoryTurnHeader', () => {
  15. it('renders Turn N and the four metric column labels', () => {
  16. render(<TrajectoryTurnHeader turn={1} />)
  17. expect(screen.getByText('Turn 1')).toBeTruthy()
  18. expect(screen.getByText('Input')).toBeTruthy()
  19. expect(screen.getByText('Output')).toBeTruthy()
  20. expect(screen.getByText('Think')).toBeTruthy()
  21. expect(screen.getByText('Time')).toBeTruthy()
  22. })
  23. })
  24. describe('TrajectoryGroupHeader', () => {
  25. it('renders title and optional description', () => {
  26. render(<TrajectoryGroupHeader title="Step 1" description="2.2s skill" />)
  27. expect(screen.getByText('Step 1')).toBeTruthy()
  28. expect(screen.getByText('2.2s skill')).toBeTruthy()
  29. })
  30. it('omits the description node when absent', () => {
  31. const { container } = render(<TrajectoryGroupHeader title="Message" />)
  32. expect(screen.getByText('Message')).toBeTruthy()
  33. expect(container.querySelectorAll('span')).toHaveLength(1)
  34. })
  35. })
  36. describe('TrajectoryTurn', () => {
  37. it('wraps a sticky header and body children', () => {
  38. render(
  39. <TrajectoryTurn turn={3}>
  40. <TrajectoryGroupHeader title="Message" description="49s" />
  41. </TrajectoryTurn>,
  42. )
  43. expect(screen.getByText('Turn 3')).toBeTruthy()
  44. expect(screen.getByText('Message')).toBeTruthy()
  45. expect(screen.getByText('49s')).toBeTruthy()
  46. })
  47. })
  48. describe('deriveTrajectoryLayout', () => {
  49. it('expands assistant blocks, hangs usage on Message, and folds call+result into Tool', () => {
  50. const nodes = [
  51. { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hello' }], source: null },
  52. {
  53. kind: 'assistant', seq: 2, time: 6_000, turn: 1, step: 1,
  54. blocks: [
  55. { kind: 'reasoning', text: 'thinking…' },
  56. { kind: 'text', text: 'I will run bash' },
  57. { kind: 'tool-call', callId: 'c1', name: 'bash', argsRaw: '{"command":"ls"}' },
  58. ],
  59. usage: { inputTokens: 10, outputTokens: 20, reasoningTokens: 5 },
  60. },
  61. {
  62. kind: 'tool-result', seq: 3, time: 7_500, callId: 'c1',
  63. call: { name: 'bash', argsRaw: '{"command":"ls"}' }, callTime: 6_200,
  64. content: [{ type: 'text', text: 'a.txt' }], isError: false, callView: null, resultView: null,
  65. },
  66. ] as unknown as ConversationSnapshot['nodes']
  67. const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
  68. expect(turns).toHaveLength(1)
  69. expect(turns[0]?.turn).toBe(1)
  70. const kinds = turns[0]?.groups.flatMap(g => g.cells.map(c => c.kind))
  71. expect(kinds).toEqual(['user', 'message', 'tool'])
  72. const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message')
  73. expect(message).toMatchObject({
  74. input: 10, output: 20, think: 5, timeSeconds: 5,
  75. })
  76. const tool = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'tool')
  77. expect(tool?.text).toBe('bash · {"command":"ls"}')
  78. expect(tool?.timeSeconds).toBe(1.3)
  79. })
  80. it('adds runningCalls not already present and leaves their time blank', () => {
  81. const turns = deriveTrajectoryLayout({
  82. codeDispatches: new Map(),
  83. nodes: [],
  84. partial: null,
  85. runningCalls: [{
  86. callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}',
  87. turn: 1, step: 2, time: 9_000, callView: null,
  88. }],
  89. })
  90. expect(turns[0]?.groups.map(g => g.title)).toEqual(['Step 2'])
  91. expect(turns[0]?.groups[0]?.cells[0]).toMatchObject({
  92. kind: 'tool', text: 'bash · {"command":"pwd"}', timeSeconds: null,
  93. })
  94. })
  95. it('omits duration when node times are missing instead of rendering NaN', () => {
  96. const nodes = [
  97. { kind: 'user', seq: 1, content: [{ type: 'text', text: 'hi' }], source: null },
  98. {
  99. kind: 'assistant', seq: 2, turn: 1, step: 1,
  100. blocks: [
  101. { kind: 'reasoning', text: '…' },
  102. { kind: 'text', text: 'ok' },
  103. ],
  104. usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 3 },
  105. },
  106. ] as unknown as ConversationSnapshot['nodes']
  107. const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
  108. const cells = turns[0]?.groups.flatMap(g => g.cells) ?? []
  109. expect(cells.find(c => c.kind === 'message')?.timeSeconds).toBeNull()
  110. expect(turns[0]?.groups.find(g => g.title === 'Step 1')?.description).toBeUndefined()
  111. })
  112. it('builds a wall-span step description with a tool histogram', () => {
  113. const nodes = [
  114. {
  115. kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1,
  116. blocks: [
  117. { kind: 'tool-call', callId: 'a', name: 'bash', argsRaw: '{}' },
  118. { kind: 'tool-call', callId: 'b', name: 'bash', argsRaw: '{}' },
  119. ],
  120. },
  121. {
  122. kind: 'tool-result', seq: 2, time: 2_500, callId: 'a',
  123. call: { name: 'bash', argsRaw: '{}' }, callTime: 1_100,
  124. content: [], isError: false, callView: null, resultView: null,
  125. },
  126. {
  127. kind: 'tool-result', seq: 3, time: 4_000, callId: 'b',
  128. call: { name: 'bash', argsRaw: '{}' }, callTime: 2_600,
  129. content: [], isError: false, callView: null, resultView: null,
  130. },
  131. ] as unknown as ConversationSnapshot['nodes']
  132. const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
  133. expect(turns[0]?.groups[0]?.description).toBe('3 s bash×2')
  134. })
  135. it('assigns each user message to its enclosing turn instead of pooling into Turn 1', () => {
  136. const nodes = [
  137. { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'first' }], source: null },
  138. {
  139. kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 0,
  140. blocks: [{ kind: 'text', text: 'ok1' }],
  141. },
  142. { kind: 'user', seq: 3, time: 3_000, content: [{ type: 'text', text: 'second' }], source: null },
  143. {
  144. kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 0,
  145. blocks: [{ kind: 'text', text: 'ok2' }],
  146. },
  147. ] as unknown as ConversationSnapshot['nodes']
  148. const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
  149. expect(turns.map(t => t.turn)).toEqual([1, 2])
  150. expect(turns[0]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['first', 'ok1'])
  151. expect(turns[1]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['second', 'ok2'])
  152. })
  153. it('keeps usage and a meaningful summary when assistant has no text block', () => {
  154. const nodes = [
  155. {
  156. kind: 'assistant', seq: 1, time: 5_000, turn: 1, step: 0,
  157. blocks: [{ kind: 'reasoning', text: '…' }],
  158. usage: { inputTokens: 11, outputTokens: 22, reasoningTokens: 3 },
  159. },
  160. ] as unknown as ConversationSnapshot['nodes']
  161. const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
  162. const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message')
  163. expect(message).toMatchObject({
  164. text: '…', input: 11, output: 22, think: 3,
  165. })
  166. })
  167. it('bounds a long Markdown-like thinking preview while retaining its full detail', () => {
  168. const thinking = `# Investigation\n\n**NAVIGATION_OK file_path** ${'- repeated detail '.repeat(1_000)}`
  169. const nodes = [{
  170. kind: 'assistant', seq: 1, time: 5_000, turn: 1, step: 0,
  171. blocks: [{ kind: 'reasoning', text: thinking }],
  172. }] as unknown as ConversationSnapshot['nodes']
  173. const turns = deriveTrajectoryLayout({
  174. codeDispatches: new Map(), nodes, partial: null, runningCalls: [],
  175. })
  176. const message = turns[0]?.groups.flatMap(group => group.cells)
  177. .find(cell => cell.kind === 'message')
  178. expect(message?.text.startsWith('Investigation NAVIGATION_OK file_path')).toBe(true)
  179. expect(message?.text.endsWith('…')).toBe(true)
  180. expect(message?.text.length).toBeLessThanOrEqual(513)
  181. expect(message?.thinkingDetail).toBe(thinking)
  182. })
  183. it('advances the duration cursor over context nodes', () => {
  184. const nodes = [
  185. { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hi' }], source: null },
  186. {
  187. kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
  188. blocks: [{ kind: 'tool-call', callId: 'c1', name: 'bash', argsRaw: '{}' }],
  189. },
  190. {
  191. kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1',
  192. call: { name: 'bash', argsRaw: '{}' }, callTime: 2_100,
  193. content: [], isError: false, callView: null, resultView: null,
  194. },
  195. {
  196. kind: 'context', seq: 4, time: 9_000,
  197. content: [{ type: 'text', text: 'extra' }], source: null,
  198. },
  199. {
  200. kind: 'assistant', seq: 5, time: 10_000, turn: 1, step: 0,
  201. blocks: [{ kind: 'text', text: 'done' }],
  202. },
  203. ] as unknown as ConversationSnapshot['nodes']
  204. const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
  205. const message = turns[0]?.groups
  206. .flatMap(g => g.cells)
  207. .find(c => c.kind === 'message' && c.text === 'done')
  208. // From context at 9s, not from the earlier user/tool surfaces.
  209. expect(message?.timeSeconds).toBe(1)
  210. })
  211. it('uses the recorded step start for assistant duration when timing exists', () => {
  212. const nodes = [
  213. { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hi' }], source: null },
  214. {
  215. kind: 'assistant', seq: 2, time: 4_000, turn: 1, step: 1,
  216. blocks: [{ kind: 'text', text: 'done' }],
  217. timing: { stepStartTime: 3_000, firstTokenTime: 3_500, completedTime: 4_000 },
  218. },
  219. ] as unknown as ConversationSnapshot['nodes']
  220. const turns = deriveTrajectoryLayout({
  221. codeDispatches: new Map(), nodes, partial: null, runningCalls: [],
  222. })
  223. const message = turns[0]?.groups.flatMap(group => group.cells)
  224. .find(cell => cell.kind === 'message')
  225. expect(message).toMatchObject({ startedAt: 3_000, timeSeconds: 1 })
  226. })
  227. })
  228. describe('run_code sub-dispatch cells', () => {
  229. const runCodeNodes = [
  230. {
  231. kind: 'assistant', seq: 2, time: 6_000, turn: 1, step: 1,
  232. blocks: [
  233. { kind: 'tool-call', callId: 'p1', name: 'run_code', argsRaw: '{"code":"…","description":"批量读取"}' },
  234. ],
  235. },
  236. {
  237. kind: 'tool-result', seq: 3, time: 9_000, callId: 'p1',
  238. call: { name: 'run_code', argsRaw: '{"code":"…","description":"批量读取"}' }, callTime: 6_200,
  239. content: [{ type: 'text', text: 'done' }], isError: false, callView: null, resultView: null,
  240. },
  241. ] as unknown as ConversationSnapshot['nodes']
  242. const settledSub = (n: number, name: string, start: number, end: number) => ({
  243. kind: 'tool-result' as const, seq: 100 + n, time: end,
  244. callId: `p1:code:${n}`,
  245. call: { name, argsRaw: '{"x":1}' }, callTime: start,
  246. content: [{ type: 'text' as const, text: 'ok' }], isError: false, callView: null, resultView: null,
  247. })
  248. it('nests settled sub-cells after their parent Tool cell with real durations', () => {
  249. const codeDispatches = new Map([['p1', [
  250. settledSub(1, 'bash', 6_300, 7_300),
  251. settledSub(2, 'read', 7_300, 7_800),
  252. ]]]) as unknown as ConversationSnapshot['codeDispatches']
  253. const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
  254. const cells = turns[0]!.groups.flatMap(g => g.cells)
  255. expect(cells.map(c => c.kind)).toEqual(['message', 'tool', 'subtool', 'subtool'])
  256. expect(cells[0]?.text).toBe('Tool call only')
  257. // Sequential indexes across the interleave; durations from the pair times.
  258. expect(cells.map(c => c.index)).toEqual([1, 2, 3, 4])
  259. expect(cells[2]).toMatchObject({ text: 'bash · {"x":1}', timeSeconds: 1 })
  260. expect(cells[3]).toMatchObject({ timeSeconds: 0.5 })
  261. })
  262. it('a running (unsettled) sub-call renders a subtool cell with blank time', () => {
  263. const running = {
  264. callId: 'p1:code:1', name: 'grep', argsRaw: '{"pattern":"x"}',
  265. turn: 0, step: 0, time: 6_400, callView: null,
  266. }
  267. const codeDispatches = new Map([['p1', [running]]]) as unknown as ConversationSnapshot['codeDispatches']
  268. const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
  269. const sub = turns[0]!.groups.flatMap(g => g.cells).find(c => c.kind === 'subtool')
  270. expect(sub).toMatchObject({ text: 'grep · {"pattern":"x"}', timeSeconds: null })
  271. })
  272. })