layout.spec.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  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 {
  9. ConversationSnapshot, RequestView,
  10. } from '@deepseek-ai/dsh-client-runtime/client'
  11. import { TrajectoryGroupHeader } from '../src/client/TrajectoryGroupHeader.tsx'
  12. import { TrajectoryTurn } from '../src/client/TrajectoryTurn.tsx'
  13. import { TrajectoryTurnHeader } from '../src/client/TrajectoryTurnHeader.tsx'
  14. import {
  15. appendTrajectoryPartialLayout, deriveTrajectoryLayout,
  16. } from '../src/client/layout.ts'
  17. afterEach(cleanup)
  18. describe('TrajectoryTurnHeader', () => {
  19. it('renders Turn N and the four metric column labels', () => {
  20. render(<TrajectoryTurnHeader turn={1} />)
  21. expect(screen.getByText('Turn 1')).toBeTruthy()
  22. expect(screen.getByText('Input')).toBeTruthy()
  23. expect(screen.getByText('Output')).toBeTruthy()
  24. expect(screen.getByText('Think')).toBeTruthy()
  25. expect(screen.getByText('Time')).toBeTruthy()
  26. })
  27. })
  28. describe('TrajectoryGroupHeader', () => {
  29. it('renders title and optional description', () => {
  30. render(<TrajectoryGroupHeader title="Step 1" description="2.2s skill" />)
  31. expect(screen.getByText('Step 1')).toBeTruthy()
  32. expect(screen.getByText('2.2s skill')).toBeTruthy()
  33. })
  34. it('omits the description node when absent', () => {
  35. const { container } = render(<TrajectoryGroupHeader title="Message" />)
  36. expect(screen.getByText('Message')).toBeTruthy()
  37. expect(container.querySelectorAll('span')).toHaveLength(1)
  38. })
  39. })
  40. describe('TrajectoryTurn', () => {
  41. it('wraps a sticky header and body children', () => {
  42. render(
  43. <TrajectoryTurn turn={3}>
  44. <TrajectoryGroupHeader title="Message" description="49s" />
  45. </TrajectoryTurn>,
  46. )
  47. expect(screen.getByText('Turn 3')).toBeTruthy()
  48. expect(screen.getByText('Message')).toBeTruthy()
  49. expect(screen.getByText('49s')).toBeTruthy()
  50. })
  51. })
  52. describe('deriveTrajectoryLayout', () => {
  53. it('expands assistant blocks, hangs usage on Message, and folds call+result into Tool', () => {
  54. const nodes = [
  55. { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hello' }], source: null },
  56. {
  57. kind: 'assistant', seq: 2, time: 6_000, turn: 1, step: 1,
  58. blocks: [
  59. { kind: 'reasoning', text: 'thinking…' },
  60. { kind: 'text', text: 'I will run bash' },
  61. { kind: 'tool-call', callId: 'c1', name: 'bash', argsRaw: '{"command":"ls"}' },
  62. ],
  63. usage: { inputTokens: 10, outputTokens: 20, reasoningTokens: 5 },
  64. },
  65. {
  66. kind: 'tool-result', seq: 3, time: 7_500, callId: 'c1',
  67. call: { name: 'bash', argsRaw: '{"command":"ls"}' }, callTime: 6_200,
  68. content: [{ type: 'text', text: 'a.txt' }], isError: false, callView: null, resultView: null,
  69. },
  70. ] as unknown as ConversationSnapshot['nodes']
  71. const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
  72. expect(turns).toHaveLength(1)
  73. expect(turns[0]?.turn).toBe(1)
  74. const kinds = turns[0]?.groups.flatMap(g => g.cells.map(c => c.kind))
  75. expect(kinds).toEqual(['user', 'message', 'tool'])
  76. const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message')
  77. expect(message).toMatchObject({
  78. input: 10, output: 20, think: 5, timeSeconds: 5,
  79. })
  80. const tool = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'tool')
  81. expect(tool?.text).toBe('bash · {"command":"ls"}')
  82. expect(tool?.timeSeconds).toBe(1.3)
  83. })
  84. it('adds runningCalls not already present and leaves their time blank', () => {
  85. const turns = deriveTrajectoryLayout({
  86. codeDispatches: new Map(),
  87. nodes: [],
  88. partial: null,
  89. runningCalls: [{
  90. callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}',
  91. turn: 1, step: 2, time: 9_000, callView: null,
  92. }],
  93. })
  94. expect(turns[0]?.groups.map(g => g.title)).toEqual(['Step 2'])
  95. expect(turns[0]?.groups[0]?.cells[0]).toMatchObject({
  96. kind: 'tool', text: 'bash · {"command":"pwd"}', timeSeconds: null,
  97. })
  98. })
  99. it('appends a streaming partial without rebuilding unaffected finalized turns', () => {
  100. const nodes = [{
  101. kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
  102. blocks: [{ kind: 'text', text: 'finalized' }],
  103. }] as unknown as ConversationSnapshot['nodes']
  104. const partial = {
  105. turn: 2,
  106. step: 1,
  107. blocks: [{ kind: 'reasoning' as const, text: 'streaming' }],
  108. }
  109. const request = {
  110. purpose: 'assistant', startSeq: 3, turn: 2, step: 1,
  111. startedAt: 3_000, completedAt: null, status: 'running',
  112. } as unknown as RequestView
  113. const base = deriveTrajectoryLayout({
  114. codeDispatches: new Map(),
  115. nodes,
  116. partial: { ...partial, blocks: [] },
  117. requests: [request],
  118. runningCalls: [],
  119. })
  120. expect(base).toHaveLength(1)
  121. const streamed = appendTrajectoryPartialLayout(base, partial, 1)
  122. expect(streamed[0]).toBe(base[0])
  123. expect(streamed).toHaveLength(2)
  124. expect(streamed[1]?.groups[0]?.cells).toMatchObject([{
  125. index: 2,
  126. kind: 'message',
  127. text: 'streaming',
  128. timeSeconds: null,
  129. }])
  130. expect(streamed[1]?.groups[0]?.cells[0]?.requestOnly).toBeUndefined()
  131. })
  132. it('replaces a running-call placeholder with the matching streamed tool call', () => {
  133. const partial = {
  134. turn: 1,
  135. step: 1,
  136. blocks: [{
  137. kind: 'tool-call' as const,
  138. callId: 'c1',
  139. name: 'bash',
  140. argsRaw: '{"command":"pwd"}',
  141. }],
  142. }
  143. const base = deriveTrajectoryLayout({
  144. codeDispatches: new Map(),
  145. nodes: [],
  146. partial: { ...partial, blocks: [] },
  147. runningCalls: [{
  148. callId: 'c1', name: 'bash', argsRaw: '{"command":"pwd"}',
  149. turn: 1, step: 1, time: 9_000, callView: null,
  150. }],
  151. })
  152. const streamed = appendTrajectoryPartialLayout(base, partial, 1)
  153. const cells = streamed[0]?.groups[0]?.cells ?? []
  154. expect(cells.map(cell => cell.kind)).toEqual(['message', 'tool'])
  155. expect(cells.filter(cell => cell.callId === 'c1')).toHaveLength(1)
  156. })
  157. it('omits duration when node times are missing instead of rendering NaN', () => {
  158. const nodes = [
  159. { kind: 'user', seq: 1, content: [{ type: 'text', text: 'hi' }], source: null },
  160. {
  161. kind: 'assistant', seq: 2, turn: 1, step: 1,
  162. blocks: [
  163. { kind: 'reasoning', text: '…' },
  164. { kind: 'text', text: 'ok' },
  165. ],
  166. usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 3 },
  167. },
  168. ] as unknown as ConversationSnapshot['nodes']
  169. const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
  170. const cells = turns[0]?.groups.flatMap(g => g.cells) ?? []
  171. expect(cells.find(c => c.kind === 'message')?.timeSeconds).toBeNull()
  172. expect(turns[0]?.groups.find(g => g.title === 'Step 1')?.description).toBeUndefined()
  173. })
  174. it('builds a wall-span step description with a tool histogram', () => {
  175. const nodes = [
  176. {
  177. kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1,
  178. blocks: [
  179. { kind: 'tool-call', callId: 'a', name: 'bash', argsRaw: '{}' },
  180. { kind: 'tool-call', callId: 'b', name: 'bash', argsRaw: '{}' },
  181. ],
  182. },
  183. {
  184. kind: 'tool-result', seq: 2, time: 2_500, callId: 'a',
  185. call: { name: 'bash', argsRaw: '{}' }, callTime: 1_100,
  186. content: [], isError: false, callView: null, resultView: null,
  187. },
  188. {
  189. kind: 'tool-result', seq: 3, time: 4_000, callId: 'b',
  190. call: { name: 'bash', argsRaw: '{}' }, callTime: 2_600,
  191. content: [], isError: false, callView: null, resultView: null,
  192. },
  193. ] as unknown as ConversationSnapshot['nodes']
  194. const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
  195. expect(turns[0]?.groups[0]?.description).toBe('3,000 ms bash×2')
  196. })
  197. it('assigns each user message to its enclosing turn instead of pooling into Turn 1', () => {
  198. const nodes = [
  199. { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'first' }], source: null },
  200. {
  201. kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 0,
  202. blocks: [{ kind: 'text', text: 'ok1' }],
  203. },
  204. { kind: 'user', seq: 3, time: 3_000, content: [{ type: 'text', text: 'second' }], source: null },
  205. {
  206. kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 0,
  207. blocks: [{ kind: 'text', text: 'ok2' }],
  208. },
  209. ] as unknown as ConversationSnapshot['nodes']
  210. const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
  211. expect(turns.map(t => t.turn)).toEqual([1, 2])
  212. expect(turns[0]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['first', 'ok1'])
  213. expect(turns[1]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['second', 'ok2'])
  214. })
  215. it('places standalone compaction chronologically in its own between-turn section', () => {
  216. const nodes = [
  217. { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'first' }], source: null },
  218. {
  219. kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
  220. blocks: [{ kind: 'text', text: 'before compaction' }],
  221. },
  222. { kind: 'user', seq: 5, time: 5_000, content: [{ type: 'text', text: 'second' }], source: null },
  223. {
  224. kind: 'assistant', seq: 6, time: 6_000, turn: 2, step: 1,
  225. blocks: [{ kind: 'text', text: 'after compaction' }],
  226. },
  227. ] as unknown as ConversationSnapshot['nodes']
  228. const compaction: RequestView = {
  229. purpose: 'compaction',
  230. startSeq: 3,
  231. turn: null,
  232. step: 0,
  233. startedAt: 3_000,
  234. completedAt: 4_000,
  235. status: 'complete',
  236. summary: [{ type: 'text', text: 'standalone summary' }],
  237. }
  238. const turns = deriveTrajectoryLayout({
  239. codeDispatches: new Map(),
  240. nodes,
  241. partial: null,
  242. runningCalls: [],
  243. requests: [compaction],
  244. })
  245. expect(turns.map(turn => turn.turn)).toEqual([1, null, 2])
  246. expect(turns[1]?.groups).toMatchObject([{
  247. title: 'Compaction 3',
  248. cells: [{
  249. kind: 'compacted',
  250. sourceSeq: 3,
  251. text: 'standalone summary',
  252. }],
  253. }])
  254. })
  255. it('keeps usage and a meaningful summary when assistant has no text block', () => {
  256. const nodes = [
  257. {
  258. kind: 'assistant', seq: 1, time: 5_000, turn: 1, step: 0,
  259. blocks: [{ kind: 'reasoning', text: '…' }],
  260. usage: { inputTokens: 11, outputTokens: 22, reasoningTokens: 3 },
  261. },
  262. ] as unknown as ConversationSnapshot['nodes']
  263. const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
  264. const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message')
  265. expect(message).toMatchObject({
  266. text: '…', input: 11, output: 22, think: 3,
  267. })
  268. })
  269. it('bounds a long Markdown-like thinking preview while retaining its full detail', () => {
  270. const thinking = `# Investigation\n\n**NAVIGATION_OK file_path** ${'- repeated detail '.repeat(1_000)}`
  271. const nodes = [{
  272. kind: 'assistant', seq: 1, time: 5_000, turn: 1, step: 0,
  273. blocks: [{ kind: 'reasoning', text: thinking }],
  274. }] as unknown as ConversationSnapshot['nodes']
  275. const turns = deriveTrajectoryLayout({
  276. codeDispatches: new Map(), nodes, partial: null, runningCalls: [],
  277. })
  278. const message = turns[0]?.groups.flatMap(group => group.cells)
  279. .find(cell => cell.kind === 'message')
  280. expect(message?.text.startsWith('Investigation NAVIGATION_OK file_path')).toBe(true)
  281. expect(message?.text.endsWith('…')).toBe(true)
  282. expect(message?.text.length).toBeLessThanOrEqual(513)
  283. expect(message?.thinkingDetail).toBe(thinking)
  284. })
  285. it('advances the duration cursor over context and compaction nodes', () => {
  286. const nodes = [
  287. { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hi' }], source: null },
  288. {
  289. kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
  290. blocks: [{ kind: 'tool-call', callId: 'c1', name: 'bash', argsRaw: '{}' }],
  291. },
  292. {
  293. kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1',
  294. call: { name: 'bash', argsRaw: '{}' }, callTime: 2_100,
  295. content: [], isError: false, callView: null, resultView: null,
  296. },
  297. {
  298. kind: 'context', seq: 4, time: 9_000,
  299. content: [{ type: 'text', text: 'extra' }], source: null,
  300. },
  301. // A landed compaction renders no cell, but is still a real log position,
  302. // so it moves the cursor after the visible context row.
  303. { kind: 'compaction', seq: 5, time: 9_500, summary: 'checkpoint facts' },
  304. {
  305. kind: 'assistant', seq: 6, time: 10_000, turn: 1, step: 0,
  306. blocks: [{ kind: 'text', text: 'done' }],
  307. },
  308. ] as unknown as ConversationSnapshot['nodes']
  309. const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
  310. const cells = turns[0]?.groups.flatMap(g => g.cells) ?? []
  311. const message = cells.find(c => c.kind === 'message' && c.text === 'done')
  312. // From the compaction marker at 9.5s, not from context at 9s or the earlier surfaces.
  313. expect(message?.timeSeconds).toBe(0.5)
  314. // Context remains inspectable in trajectory; the Chat marker is not duplicated.
  315. expect(cells.map(cell => cell.kind)).toEqual(['user', 'message', 'tool', 'context', 'message'])
  316. })
  317. it('uses the recorded step start for assistant duration when timing exists', () => {
  318. const nodes = [
  319. { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hi' }], source: null },
  320. {
  321. kind: 'assistant', seq: 2, time: 4_000, turn: 1, step: 1,
  322. blocks: [{ kind: 'text', text: 'done' }],
  323. timing: { stepStartTime: 3_000, firstTokenTime: 3_500, completedTime: 4_000 },
  324. },
  325. ] as unknown as ConversationSnapshot['nodes']
  326. const turns = deriveTrajectoryLayout({
  327. codeDispatches: new Map(), nodes, partial: null, runningCalls: [],
  328. })
  329. const message = turns[0]?.groups.flatMap(group => group.cells)
  330. .find(cell => cell.kind === 'message')
  331. expect(message).toMatchObject({ startedAt: 3_000, timeSeconds: 1 })
  332. })
  333. })
  334. describe('run_code sub-dispatch cells', () => {
  335. const runCodeNodes = [
  336. {
  337. kind: 'assistant', seq: 2, time: 6_000, turn: 1, step: 1,
  338. blocks: [
  339. { kind: 'tool-call', callId: 'p1', name: 'run_code', argsRaw: '{"code":"…","description":"批量读取"}' },
  340. ],
  341. },
  342. {
  343. kind: 'tool-result', seq: 3, time: 9_000, callId: 'p1',
  344. call: { name: 'run_code', argsRaw: '{"code":"…","description":"批量读取"}' }, callTime: 6_200,
  345. content: [{ type: 'text', text: 'done' }], isError: false, callView: null, resultView: null,
  346. },
  347. ] as unknown as ConversationSnapshot['nodes']
  348. const settledSub = (n: number, name: string, start: number, end: number) => ({
  349. kind: 'tool-result' as const, seq: 100 + n, time: end,
  350. callId: `p1:code:${n}`,
  351. call: { name, argsRaw: '{"x":1}' }, callTime: start,
  352. content: [{ type: 'text' as const, text: 'ok' }], isError: false, callView: null, resultView: null,
  353. })
  354. it('nests settled sub-cells after their parent Tool cell with real durations', () => {
  355. const codeDispatches = new Map([['p1', [
  356. settledSub(1, 'bash', 6_300, 7_300),
  357. settledSub(2, 'read', 7_300, 7_800),
  358. ]]]) as unknown as ConversationSnapshot['codeDispatches']
  359. const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
  360. const cells = turns[0]!.groups.flatMap(g => g.cells)
  361. expect(cells.map(c => c.kind)).toEqual(['message', 'tool', 'subtool', 'subtool'])
  362. expect(cells[0]?.text).toBe('Tool call only')
  363. // Sequential indexes across the interleave; durations from the pair times.
  364. expect(cells.map(c => c.index)).toEqual([1, 2, 3, 4])
  365. expect(cells[2]).toMatchObject({ text: 'bash · {"x":1}', timeSeconds: 1 })
  366. expect(cells[3]).toMatchObject({ timeSeconds: 0.5 })
  367. })
  368. it('a running (unsettled) sub-call renders a subtool cell with blank time', () => {
  369. const running = {
  370. callId: 'p1:code:1', name: 'grep', argsRaw: '{"pattern":"x"}',
  371. turn: 0, step: 0, time: 6_400, callView: null,
  372. }
  373. const codeDispatches = new Map([['p1', [running]]]) as unknown as ConversationSnapshot['codeDispatches']
  374. const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
  375. const sub = turns[0]!.groups.flatMap(g => g.cells).find(c => c.kind === 'subtool')
  376. expect(sub).toMatchObject({ text: 'grep · {"pattern":"x"}', timeSeconds: null })
  377. })
  378. })