layout.client.spec.tsx 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727
  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. ConversationLocation, ConversationNode, RequestView,
  10. } from '@deepseek-ai/dsh-client-ui-conversation/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 as appendTrajectoryPartialLayoutWithLocale,
  16. deriveTrajectoryLayout as deriveTrajectoryLayoutWithLocale,
  17. } from '../src/client/layout.ts'
  18. import { t } from './locale.client.ts'
  19. const deriveTrajectoryLayout = (
  20. input: Parameters<typeof deriveTrajectoryLayoutWithLocale>[0],
  21. ) => deriveTrajectoryLayoutWithLocale(input, t)
  22. const appendTrajectoryPartialLayout = (
  23. turns: Parameters<typeof appendTrajectoryPartialLayoutWithLocale>[0],
  24. partial: Parameters<typeof appendTrajectoryPartialLayoutWithLocale>[1],
  25. lastIndex: number,
  26. ) => appendTrajectoryPartialLayoutWithLocale(turns, partial, lastIndex, t)
  27. interface LegacyConversationSlice {
  28. readonly nodes: readonly ConversationNode[]
  29. }
  30. const EMPTY_LOCATION_DATA_SOURCE = { getSnapshot: () => undefined, subscribe: () => () => {} }
  31. const EMPTY_LOCATION_DATA = { get: () => undefined, source: () => EMPTY_LOCATION_DATA_SOURCE }
  32. afterEach(cleanup)
  33. describe('TrajectoryTurnHeader', () => {
  34. it('renders Turn N and the four metric column labels', () => {
  35. render(<TrajectoryTurnHeader turn={1} t={t} />)
  36. expect(screen.getByText('Turn 1')).toBeTruthy()
  37. expect(screen.getByText('Input')).toBeTruthy()
  38. expect(screen.getByText('Output')).toBeTruthy()
  39. expect(screen.getByText('Think')).toBeTruthy()
  40. expect(screen.getByText('Time')).toBeTruthy()
  41. })
  42. })
  43. describe('TrajectoryGroupHeader', () => {
  44. it('renders title and optional description', () => {
  45. render(<TrajectoryGroupHeader title="Step 1" description="2.2s skill" />)
  46. expect(screen.getByText('Step 1')).toBeTruthy()
  47. expect(screen.getByText('2.2s skill')).toBeTruthy()
  48. })
  49. it('omits the description node when absent', () => {
  50. const { container } = render(<TrajectoryGroupHeader title="Message" />)
  51. expect(screen.getByText('Message')).toBeTruthy()
  52. expect(container.querySelectorAll('span')).toHaveLength(1)
  53. })
  54. })
  55. describe('TrajectoryTurn', () => {
  56. it('wraps a sticky header and body children', () => {
  57. render(
  58. <TrajectoryTurn turn={3} t={t}>
  59. <TrajectoryGroupHeader title="Message" description="49s" />
  60. </TrajectoryTurn>,
  61. )
  62. expect(screen.getByText('Turn 3')).toBeTruthy()
  63. expect(screen.getByText('Message')).toBeTruthy()
  64. expect(screen.getByText('49s')).toBeTruthy()
  65. })
  66. })
  67. describe('deriveTrajectoryLayout', () => {
  68. it('expands assistant blocks, hangs usage on Message, and folds call+result into Tool', () => {
  69. const nodes = [
  70. { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hello' }], source: null },
  71. {
  72. kind: 'assistant', seq: 2, time: 6_000, turn: 1, step: 1,
  73. blocks: [
  74. { kind: 'reasoning', text: 'thinking…' },
  75. { kind: 'text', text: 'I will run bash' },
  76. { kind: 'tool-call', callId: 'c1', name: 'bash', argsRaw: '{"command":"ls"}' },
  77. ],
  78. usage: { inputTokens: 10, outputTokens: 20, reasoningTokens: 5 },
  79. },
  80. {
  81. kind: 'tool-result', seq: 3, time: 7_500, callId: 'c1',
  82. call: { name: 'bash', argsRaw: '{"command":"ls"}' }, callTime: 6_200,
  83. content: [{ type: 'text', text: 'a.txt' }], isError: false,
  84. },
  85. ] as unknown as LegacyConversationSlice['nodes']
  86. const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
  87. expect(turns).toHaveLength(1)
  88. expect(turns[0]?.turn).toBe(1)
  89. const kinds = turns[0]?.groups.flatMap(g => g.cells.map(c => c.kind))
  90. expect(kinds).toEqual(['user', 'message', 'tool'])
  91. const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message')
  92. expect(message).toMatchObject({
  93. input: 10, output: 20, think: 5, timeSeconds: 5,
  94. })
  95. const tool = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'tool')
  96. expect(tool).toMatchObject({
  97. text: 'bash',
  98. previewMarkdown: '{"command":"ls"}',
  99. })
  100. expect(tool?.timeSeconds).toBe(1.3)
  101. })
  102. it('adds runningCalls not already present and leaves their time blank', () => {
  103. const turns = deriveTrajectoryLayout({
  104. nodes: [],
  105. partial: null,
  106. runningCalls: [{
  107. callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}',
  108. turn: 1, step: 2, time: 9_000, subCalls: [],
  109. }],
  110. })
  111. expect(turns[0]?.groups.map(g => g.title)).toEqual(['Step 2'])
  112. expect(turns[0]?.groups[0]?.cells[0]).toMatchObject({
  113. kind: 'tool',
  114. text: 'bash',
  115. previewMarkdown: '{"command":"pwd"}',
  116. timeSeconds: null,
  117. })
  118. })
  119. it('appends a streaming partial without rebuilding unaffected finalized turns', () => {
  120. const nodes = [{
  121. kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
  122. blocks: [{ kind: 'text', text: 'finalized' }],
  123. }] as unknown as LegacyConversationSlice['nodes']
  124. const partial = {
  125. turn: 2,
  126. step: 1,
  127. blocks: [{ kind: 'reasoning' as const, text: 'streaming' }],
  128. }
  129. const request = {
  130. purpose: 'assistant', startSeq: 3, turn: 2, step: 1,
  131. startedAt: 3_000, completedAt: null, status: 'running',
  132. } as unknown as RequestView
  133. const base = deriveTrajectoryLayout({
  134. nodes,
  135. partial: { ...partial, blocks: [] },
  136. requests: [request],
  137. runningCalls: [],
  138. })
  139. expect(base).toHaveLength(1)
  140. const streamed = appendTrajectoryPartialLayout(base, partial, 1)
  141. expect(streamed[0]).toBe(base[0])
  142. expect(streamed).toHaveLength(2)
  143. expect(streamed[1]?.groups[0]?.cells).toMatchObject([{
  144. index: 2,
  145. kind: 'message',
  146. text: '',
  147. previewMarkdown: 'streaming',
  148. timeSeconds: null,
  149. }])
  150. expect(streamed[1]?.groups[0]?.cells[0]?.requestOnly).toBeUndefined()
  151. })
  152. it('replaces a running-call placeholder with the matching streamed tool call', () => {
  153. const partial = {
  154. turn: 1,
  155. step: 1,
  156. blocks: [{
  157. kind: 'tool-call' as const,
  158. callId: 'c1',
  159. name: 'bash',
  160. argsRaw: '{"command":"pwd"}',
  161. }],
  162. }
  163. const base = deriveTrajectoryLayout({
  164. nodes: [],
  165. partial: { ...partial, blocks: [] },
  166. runningCalls: [{
  167. callId: 'c1', name: 'bash', argsRaw: '{"command":"pwd"}',
  168. turn: 1, step: 1, time: 9_000, subCalls: [],
  169. }],
  170. })
  171. const streamed = appendTrajectoryPartialLayout(base, partial, 1)
  172. const cells = streamed[0]?.groups[0]?.cells ?? []
  173. expect(cells.map(cell => cell.kind)).toEqual(['message', 'tool'])
  174. expect(cells.filter(cell => cell.callId === 'c1')).toHaveLength(1)
  175. })
  176. it('omits duration when node times are missing instead of rendering NaN', () => {
  177. const nodes = [
  178. { kind: 'user', seq: 1, content: [{ type: 'text', text: 'hi' }], source: null },
  179. {
  180. kind: 'assistant', seq: 2, turn: 1, step: 1,
  181. blocks: [
  182. { kind: 'reasoning', text: '…' },
  183. { kind: 'text', text: 'ok' },
  184. ],
  185. usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 3 },
  186. },
  187. ] as unknown as LegacyConversationSlice['nodes']
  188. const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
  189. const cells = turns[0]?.groups.flatMap(g => g.cells) ?? []
  190. expect(cells.find(c => c.kind === 'message')?.timeSeconds).toBeNull()
  191. expect(turns[0]?.groups.find(g => g.title === 'Step 1')?.description).toBeUndefined()
  192. })
  193. it('builds a wall-span step description with a tool histogram', () => {
  194. const nodes = [
  195. {
  196. kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1,
  197. blocks: [
  198. { kind: 'tool-call', callId: 'a', name: 'bash', argsRaw: '{}' },
  199. { kind: 'tool-call', callId: 'b', name: 'bash', argsRaw: '{}' },
  200. ],
  201. },
  202. {
  203. kind: 'tool-result', seq: 2, time: 2_500, callId: 'a',
  204. call: { name: 'bash', argsRaw: '{}' }, callTime: 1_100,
  205. content: [], isError: false,
  206. },
  207. {
  208. kind: 'tool-result', seq: 3, time: 4_000, callId: 'b',
  209. call: { name: 'bash', argsRaw: '{}' }, callTime: 2_600,
  210. content: [], isError: false,
  211. },
  212. ] as unknown as LegacyConversationSlice['nodes']
  213. const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
  214. expect(turns[0]?.groups[0]?.description).toBe('3,000 ms bash×2')
  215. })
  216. it('assigns each user message to its enclosing turn instead of pooling into Turn 1', () => {
  217. const nodes = [
  218. { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'first' }], source: null },
  219. {
  220. kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 0,
  221. blocks: [{ kind: 'text', text: 'ok1' }],
  222. },
  223. { kind: 'user', seq: 3, time: 3_000, content: [{ type: 'text', text: 'second' }], source: null },
  224. {
  225. kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 0,
  226. blocks: [{ kind: 'text', text: 'ok2' }],
  227. },
  228. ] as unknown as LegacyConversationSlice['nodes']
  229. const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
  230. expect(turns.map(t => t.turn)).toEqual([1, 2])
  231. expect(turns[0]?.groups.flatMap(g => g.cells.map(c => c.previewMarkdown))).toEqual([
  232. 'first',
  233. 'ok1',
  234. ])
  235. expect(turns[1]?.groups.flatMap(g => g.cells.map(c => c.previewMarkdown))).toEqual([
  236. 'second',
  237. 'ok2',
  238. ])
  239. })
  240. it('places steering in its resolved step instead of the turn-opening Message group', () => {
  241. const nodes = [
  242. { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'start' }], source: null },
  243. {
  244. kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
  245. blocks: [{ kind: 'text', text: 'first step' }],
  246. },
  247. {
  248. kind: 'steering', messageId: 'steer-1', seq: 3, time: 3_000,
  249. content: [{ type: 'text', text: 'change direction' }], source: null,
  250. },
  251. {
  252. kind: 'assistant', seq: 4, time: 4_000, turn: 1, step: 2,
  253. blocks: [{ kind: 'text', text: 'second step' }],
  254. },
  255. ] as unknown as LegacyConversationSlice['nodes']
  256. const data = EMPTY_LOCATION_DATA
  257. const step = { turn: 1, step: 2, start: undefined, end: undefined, status: 'open' as const, data }
  258. const turn = {
  259. turn: 1, start: undefined, end: undefined, status: 'open' as const, steps: [step], data,
  260. }
  261. const eventLocations = new Map<number, ConversationLocation>([[
  262. 3,
  263. { kind: 'step', turn, step },
  264. ]])
  265. const turns = deriveTrajectoryLayout({
  266. nodes,
  267. eventLocations,
  268. partial: null,
  269. runningCalls: [],
  270. })
  271. expect(turns).toHaveLength(1)
  272. expect(turns[0]?.groups.map(group => group.title)).toEqual([
  273. 'Message', 'Step 1', 'Step 2',
  274. ])
  275. expect(turns[0]?.groups[2]?.cells).toMatchObject([
  276. { kind: 'user', previewMarkdown: 'change direction', sourceSeq: 3 },
  277. { kind: 'message', previewMarkdown: 'second step', sourceSeq: 4 },
  278. ])
  279. })
  280. it('keeps a running request boundary after steering input', () => {
  281. const nodes = [{
  282. kind: 'steering', messageId: 'steer-1', seq: 3, time: 3_000,
  283. content: [{ type: 'text', text: 'change direction' }], source: null,
  284. }] as unknown as LegacyConversationSlice['nodes']
  285. const data = EMPTY_LOCATION_DATA
  286. const step = { turn: 1, step: 2, start: undefined, end: undefined, status: 'open' as const, data }
  287. const turn = {
  288. turn: 1, start: undefined, end: undefined, status: 'open' as const, steps: [step], data,
  289. }
  290. const eventLocations = new Map<number, ConversationLocation>([[
  291. 3,
  292. { kind: 'step', turn, step },
  293. ]])
  294. const turns = deriveTrajectoryLayout({
  295. nodes,
  296. eventLocations,
  297. partial: null,
  298. runningCalls: [],
  299. requests: [{
  300. purpose: 'assistant',
  301. startSeq: 2,
  302. turn: 1,
  303. step: 2,
  304. startedAt: 2_000,
  305. completedAt: null,
  306. status: 'running',
  307. }],
  308. })
  309. expect(turns[0]?.groups[0]?.cells).toMatchObject([
  310. { kind: 'user', previewMarkdown: 'change direction', sourceSeq: 3 },
  311. { kind: 'message', requestOnly: true, sourceSeq: 2 },
  312. ])
  313. })
  314. it('uses the following assistant step while a historical window lacks steering Location', () => {
  315. const nodes = [
  316. {
  317. kind: 'steering', messageId: 'steer-1', seq: 3, time: 3_000,
  318. content: [{ type: 'text', text: 'change direction' }], source: null,
  319. },
  320. {
  321. kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 3,
  322. blocks: [{ kind: 'text', text: 'continued' }],
  323. },
  324. ] as unknown as LegacyConversationSlice['nodes']
  325. const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
  326. expect(turns[0]).toMatchObject({
  327. turn: 2,
  328. groups: [{
  329. title: 'Step 3',
  330. cells: [
  331. { kind: 'user', previewMarkdown: 'change direction' },
  332. { kind: 'message', previewMarkdown: 'continued' },
  333. ],
  334. }],
  335. })
  336. })
  337. it('places standalone compaction chronologically in its own between-turn section', () => {
  338. const nodes = [
  339. { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'first' }], source: null },
  340. {
  341. kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
  342. blocks: [{ kind: 'text', text: 'before compaction' }],
  343. },
  344. { kind: 'user', seq: 5, time: 5_000, content: [{ type: 'text', text: 'second' }], source: null },
  345. {
  346. kind: 'assistant', seq: 6, time: 6_000, turn: 2, step: 1,
  347. blocks: [{ kind: 'text', text: 'after compaction' }],
  348. },
  349. ] as unknown as LegacyConversationSlice['nodes']
  350. const compaction: RequestView = {
  351. purpose: 'compaction',
  352. startSeq: 3,
  353. turn: null,
  354. step: 0,
  355. startedAt: 3_000,
  356. completedAt: 4_000,
  357. status: 'complete',
  358. summary: [{ type: 'text', text: 'standalone summary' }],
  359. }
  360. const turns = deriveTrajectoryLayout({
  361. nodes,
  362. partial: null,
  363. runningCalls: [],
  364. requests: [compaction],
  365. })
  366. expect(turns.map(turn => turn.turn)).toEqual([1, null, 2])
  367. expect(turns[1]?.groups).toMatchObject([{
  368. title: 'Compaction 3',
  369. cells: [{
  370. kind: 'compacted',
  371. sourceSeq: 3,
  372. text: '',
  373. previewMarkdown: 'standalone summary',
  374. }],
  375. }])
  376. })
  377. it('keeps usage and a meaningful summary when assistant has no text block', () => {
  378. const nodes = [
  379. {
  380. kind: 'assistant', seq: 1, time: 5_000, turn: 1, step: 0,
  381. blocks: [{ kind: 'reasoning', text: '…' }],
  382. usage: { inputTokens: 11, outputTokens: 22, reasoningTokens: 3 },
  383. },
  384. ] as unknown as LegacyConversationSlice['nodes']
  385. const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
  386. const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message')
  387. expect(message).toMatchObject({
  388. text: '', previewMarkdown: '…', input: 11, output: 22, think: 3,
  389. })
  390. })
  391. it('bounds a long Markdown-like thinking preview while retaining its full detail', () => {
  392. const thinking = `# Investigation\n\n**NAVIGATION_OK file_path** ${'- repeated detail '.repeat(1_000)}`
  393. const nodes = [{
  394. kind: 'assistant', seq: 1, time: 5_000, turn: 1, step: 0,
  395. blocks: [{ kind: 'reasoning', text: thinking }],
  396. }] as unknown as LegacyConversationSlice['nodes']
  397. const turns = deriveTrajectoryLayout({
  398. nodes, partial: null, runningCalls: [],
  399. })
  400. const message = turns[0]?.groups.flatMap(group => group.cells)
  401. .find(cell => cell.kind === 'message')
  402. expect(message?.text).toBe('')
  403. expect(message?.previewMarkdown).toBe(thinking)
  404. expect(message?.thinkingDetail).toBe(thinking)
  405. })
  406. it('advances the duration cursor over context and compaction nodes', () => {
  407. const nodes = [
  408. { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hi' }], source: null },
  409. {
  410. kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
  411. blocks: [{ kind: 'tool-call', callId: 'c1', name: 'bash', argsRaw: '{}' }],
  412. },
  413. {
  414. kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1',
  415. call: { name: 'bash', argsRaw: '{}' }, callTime: 2_100,
  416. content: [], isError: false,
  417. },
  418. {
  419. kind: 'context', seq: 4, time: 9_000,
  420. content: [{ type: 'text', text: 'extra' }], source: null,
  421. },
  422. // A landed compaction renders no cell, but is still a real log position,
  423. // so it moves the cursor after the visible context row.
  424. {
  425. kind: 'compaction', seq: 5, time: 9_500, summary: 'checkpoint facts',
  426. summaryEventSeq: 4, shadowedItemCount: 2, shadowedTokenCount: 100,
  427. },
  428. {
  429. kind: 'assistant', seq: 6, time: 10_000, turn: 1, step: 0,
  430. blocks: [{ kind: 'text', text: 'done' }],
  431. },
  432. ] as unknown as LegacyConversationSlice['nodes']
  433. const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
  434. const cells = turns[0]?.groups.flatMap(g => g.cells) ?? []
  435. const message = cells.find(c => c.kind === 'message' && c.previewMarkdown === 'done')
  436. // From the compaction marker at 9.5s, not from context at 9s or the earlier surfaces.
  437. expect(message?.timeSeconds).toBe(0.5)
  438. // Context remains inspectable in trajectory; the Chat marker is not duplicated.
  439. expect(cells.map(cell => cell.kind)).toEqual(['user', 'message', 'tool', 'context', 'message'])
  440. })
  441. it('uses the recorded step start for assistant duration when timing exists', () => {
  442. const nodes = [
  443. { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hi' }], source: null },
  444. {
  445. kind: 'assistant', seq: 2, time: 4_000, turn: 1, step: 1,
  446. blocks: [{ kind: 'text', text: 'done' }],
  447. timing: { stepStartTime: 3_000, firstTokenTime: 3_500, completedTime: 4_000 },
  448. },
  449. ] as unknown as LegacyConversationSlice['nodes']
  450. const turns = deriveTrajectoryLayout({
  451. nodes, partial: null, runningCalls: [],
  452. })
  453. const message = turns[0]?.groups.flatMap(group => group.cells)
  454. .find(cell => cell.kind === 'message')
  455. expect(message).toMatchObject({ startedAt: 3_000, timeSeconds: 1 })
  456. })
  457. })
  458. describe('run_code sub-dispatch cells', () => {
  459. const runCodeNodes = [
  460. {
  461. kind: 'assistant', seq: 2, time: 6_000, turn: 1, step: 1,
  462. blocks: [
  463. { kind: 'tool-call', callId: 'p1', name: 'run_code', argsRaw: '{"code":"…","description":"批量读取"}' },
  464. ],
  465. },
  466. {
  467. kind: 'tool-result', seq: 3, time: 9_000, callId: 'p1',
  468. call: { name: 'run_code', argsRaw: '{"code":"…","description":"批量读取"}' }, callTime: 6_200,
  469. content: [{ type: 'text', text: 'done' }], isError: false,
  470. subCalls: [],
  471. },
  472. ] as unknown as LegacyConversationSlice['nodes']
  473. const settledSub = (n: number, name: string, start: number, end: number) => ({
  474. kind: 'tool-result' as const, seq: 100 + n, time: end,
  475. callId: `p1:code:${n}`,
  476. call: { name, argsRaw: '{"x":1}' }, callTime: start,
  477. content: [{ type: 'text' as const, text: 'ok' }], isError: false,
  478. subCalls: [],
  479. })
  480. const withSubCalls = (subCalls: readonly ReturnType<typeof settledSub>[] | readonly object[]) =>
  481. runCodeNodes.map(node => node.kind === 'tool-result' ? { ...node, subCalls } : node) as LegacyConversationSlice['nodes']
  482. it('nests settled sub-cells after their parent Tool cell with real durations', () => {
  483. const subCalls = [
  484. settledSub(1, 'bash', 6_300, 7_300),
  485. settledSub(2, 'read', 7_300, 7_800),
  486. ]
  487. const turns = deriveTrajectoryLayout({ nodes: withSubCalls(subCalls), partial: null, runningCalls: [] })
  488. const cells = turns[0]!.groups.flatMap(g => g.cells)
  489. expect(cells.map(c => c.kind)).toEqual(['message', 'tool', 'subtool', 'subtool'])
  490. expect(cells[0]?.text).toBe('Tool call only')
  491. // Sequential indexes across the interleave; durations from the pair times.
  492. expect(cells.map(c => c.index)).toEqual([1, 2, 3, 4])
  493. expect(cells[2]).toMatchObject({
  494. text: 'bash', previewMarkdown: '{"x":1}', timeSeconds: 1,
  495. })
  496. expect(cells[3]).toMatchObject({ timeSeconds: 0.5 })
  497. })
  498. it('a running (unsettled) sub-call renders a subtool cell with blank time', () => {
  499. const running = {
  500. callId: 'p1:code:1', name: 'grep', argsRaw: '{"pattern":"x"}',
  501. turn: 0, step: 0, time: 6_400, subCalls: [],
  502. }
  503. const turns = deriveTrajectoryLayout({ nodes: withSubCalls([running]), partial: null, runningCalls: [] })
  504. const sub = turns[0]!.groups.flatMap(g => g.cells).find(c => c.kind === 'subtool')
  505. expect(sub).toMatchObject({
  506. text: 'grep', previewMarkdown: '{"pattern":"x"}', timeSeconds: null,
  507. })
  508. })
  509. it('recursively flattens nested child calls immediately after their parent', () => {
  510. const leaf = {
  511. ...settledSub(2, 'read', 7_300, 7_800),
  512. callId: 'p1:code:1:code:1',
  513. }
  514. const child = {
  515. ...settledSub(1, 'run_code', 6_300, 8_000),
  516. subCalls: [leaf],
  517. }
  518. const turns = deriveTrajectoryLayout({ nodes: withSubCalls([child]), partial: null, runningCalls: [] })
  519. const cells = turns[0]!.groups.flatMap(group => group.cells)
  520. expect(cells.map(cell => cell.kind)).toEqual(['message', 'tool', 'subtool', 'subtool'])
  521. expect(cells.slice(2).map(cell => cell.callId)).toEqual([
  522. 'p1:code:1',
  523. 'p1:code:1:code:1',
  524. ])
  525. expect(cells.map(cell => cell.index)).toEqual([1, 2, 3, 4])
  526. })
  527. })
  528. describe('durable image attachments', () => {
  529. const attachment = {
  530. attachmentId: `sha256:${'a'.repeat(64)}`,
  531. mediaType: 'image/png',
  532. bytes: 68,
  533. width: 640,
  534. height: 320,
  535. name: 'screenshot.png',
  536. }
  537. it('carries user image refs into sourceBlocks and labels an image-only record', () => {
  538. const nodes = [
  539. {
  540. kind: 'user', seq: 1, time: 1_000, source: null,
  541. content: [{ type: 'image', attachment }, { type: 'image', attachment }],
  542. },
  543. ] as unknown as LegacyConversationSlice['nodes']
  544. const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
  545. const user = turns[0]?.groups[0]?.cells[0]
  546. expect(user?.text).toBe('Images ×2')
  547. expect(user?.previewMarkdown).toBeUndefined()
  548. expect(user?.sourceBlocks).toEqual([
  549. { type: 'image', content: JSON.stringify({ type: 'image', attachment }, null, 2), attachment },
  550. { type: 'image', content: JSON.stringify({ type: 'image', attachment }, null, 2), attachment },
  551. ])
  552. })
  553. it('labels a record whose only text block is empty as image-only', () => {
  554. const nodes = [
  555. {
  556. kind: 'user', seq: 1, time: 1_000, source: null,
  557. content: [{ type: 'text', text: '' }, { type: 'image', attachment }],
  558. },
  559. ] as unknown as LegacyConversationSlice['nodes']
  560. const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
  561. const user = turns[0]?.groups[0]?.cells[0]
  562. expect(user?.text).toBe('Images ×1')
  563. expect(user?.previewMarkdown).toBeUndefined()
  564. })
  565. it('keeps the text preview when a user message mixes text and images', () => {
  566. const nodes = [
  567. {
  568. kind: 'user', seq: 1, time: 1_000, source: null,
  569. content: [{ type: 'text', text: 'look at this' }, { type: 'image', attachment }],
  570. },
  571. ] as unknown as LegacyConversationSlice['nodes']
  572. const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
  573. const user = turns[0]?.groups[0]?.cells[0]
  574. expect(user?.text).toBe('Images ×1')
  575. expect(user?.previewMarkdown).toBe('look at this')
  576. expect(user?.sourceBlocks?.[1]).toEqual({ type: 'image', content: JSON.stringify({ type: 'image', attachment }, null, 2), attachment })
  577. })
  578. it('maps assistant image blocks to attachment source blocks and labels image-only output', () => {
  579. const nodes = [
  580. {
  581. kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 0,
  582. blocks: [{ kind: 'image', attachment }],
  583. },
  584. ] as unknown as LegacyConversationSlice['nodes']
  585. const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
  586. const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message')
  587. expect(message?.text).toBe('Images ×1')
  588. expect(message?.sourceBlocks).toEqual([{ type: 'image', content: JSON.stringify({ type: 'image', attachment }, null, 2), attachment }])
  589. })
  590. it('carries tool-result image refs into outputBlocks and labels the result', () => {
  591. const nodes = [
  592. {
  593. kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1,
  594. blocks: [{ kind: 'tool-call', callId: 'c1', name: 'read_image', argsRaw: '{}' }],
  595. },
  596. {
  597. kind: 'tool-result', seq: 2, time: 2_000, callId: 'c1',
  598. call: { name: 'read_image', argsRaw: '{}' }, callTime: 1_200,
  599. content: [{ type: 'image', attachment }], isError: false,
  600. },
  601. ] as unknown as LegacyConversationSlice['nodes']
  602. const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
  603. const tool = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'tool')
  604. expect(tool?.result).toBe('Images ×1')
  605. expect(tool?.outputDetail).toBe('Images ×1')
  606. expect(tool?.outputBlocks).toEqual([{ type: 'image', content: JSON.stringify({ type: 'image', attachment }, null, 2), attachment }])
  607. })
  608. it('shows wire-shaped blocks without an attachment as JSON, not as an image', () => {
  609. const nodes = [
  610. {
  611. kind: 'user', seq: 1, time: 1_000, source: null,
  612. content: [{ type: 'image', url: 'https://example.com/a.png' }],
  613. },
  614. ] as unknown as LegacyConversationSlice['nodes']
  615. const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
  616. const block = turns[0]?.groups[0]?.cells[0]?.sourceBlocks?.[0]
  617. expect(block?.attachment).toBeUndefined()
  618. expect(block?.content).toContain('https://example.com/a.png')
  619. })
  620. })
  621. describe('durable file attachments', () => {
  622. const attachment = {
  623. attachmentId: `sha256:${'d'.repeat(64)}`,
  624. name: 'notes.pdf',
  625. bytes: 2447 * 1024 * 1024,
  626. }
  627. it('labels file-only and mixed-text user records', () => {
  628. const nodes = [
  629. {
  630. kind: 'user', seq: 1, time: 1_000, source: null,
  631. content: [{ type: 'file', attachment }, { type: 'file', attachment }],
  632. },
  633. {
  634. kind: 'user', seq: 2, time: 2_000, source: null,
  635. content: [{ type: 'text', text: 'review these' }, { type: 'file', attachment }],
  636. },
  637. ] as unknown as LegacyConversationSlice['nodes']
  638. const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
  639. const users = turns.flatMap(turn => turn.groups).flatMap(group => group.cells)
  640. .filter(cell => cell.kind === 'user')
  641. expect(users[0]?.text).toBe('Files ×2')
  642. expect(users[0]?.previewMarkdown).toBeUndefined()
  643. expect(users[1]).toMatchObject({ text: 'Files ×1', previewMarkdown: 'review these' })
  644. })
  645. it.each([undefined, '', 'review these'])('keeps both counts and repeated attachments with text %j', (text) => {
  646. const image = {
  647. attachmentId: `sha256:${'e'.repeat(64)}`,
  648. mediaType: 'image/png',
  649. bytes: 68,
  650. width: 640,
  651. height: 320,
  652. }
  653. const nodes = [{
  654. kind: 'user', seq: 1, time: 1_000, source: null,
  655. content: [
  656. ...(text === undefined ? [] : [{ type: 'text', text }]),
  657. { type: 'image', attachment: image },
  658. { type: 'file', attachment },
  659. { type: 'image', attachment: image },
  660. ],
  661. }] as unknown as LegacyConversationSlice['nodes']
  662. const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
  663. const cell = turns[0]?.groups[0]?.cells[0]
  664. expect(cell?.text).toBe('Images ×2 · Files ×1')
  665. const blocks = cell?.sourceBlocks?.filter(block => block.type !== 'text')
  666. expect(blocks?.map(block => block.attachment ?? block.file)).toEqual([image, attachment, image])
  667. expect(blocks?.map((block): unknown => JSON.parse(block.content))).toEqual([
  668. { type: 'image', attachment: image },
  669. { type: 'file', attachment },
  670. { type: 'image', attachment: image },
  671. ])
  672. })
  673. })