layout.client.spec.tsx 26 KB

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