transcript-adapter.spec.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  1. /**
  2. * TranscriptAdapter over the raw append-only window: log-ordered projection of
  3. * append-origin events, one marker per landed compaction, replacement copies
  4. * hidden, command-lifecycle folding, node/array identity, call pairing, and
  5. * host-provided wire views.
  6. */
  7. import { createUserMessage, CallId, createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
  8. import { describe, expect, it } from 'vitest'
  9. import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
  10. import { TranscriptAdapter } from '../src/client/sessions/transcript-adapter.ts'
  11. import { ev, plainTurn } from './event-script.ts'
  12. const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
  13. ({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent
  14. /** A `compact/summary` event (log-only, no surfaceOp). */
  15. function compactSummary(seq: number, summary: unknown = [{ type: 'text', text: '# 摘要\n\n保留事实' }]): SessionEvent {
  16. return at(seq, {
  17. type: 'compact/summary',
  18. data: {
  19. summary,
  20. shadowedRange: { start: 1, end: 3 },
  21. shadowedSeqs: [1, 3],
  22. shadowedTokenCount: 100,
  23. provider: 'fake',
  24. model: 'compact-1',
  25. },
  26. })
  27. }
  28. /** The replacement user message a compaction backend lands (the checkpoint). */
  29. function checkpoint(
  30. seq: number,
  31. summarySeq: number,
  32. { start = 1, end = 3, sourceEventSeqs = [summarySeq, start, end] }: {
  33. start?: number
  34. end?: number
  35. sourceEventSeqs?: number[]
  36. } = {},
  37. ): SessionEvent {
  38. return at(seq, {
  39. type: 'user/message',
  40. surfaceOp: { op: 'replace', start, end },
  41. sourceEventSeqs,
  42. data: createUserMessage({
  43. content: [{ type: 'text', text: '<context_checkpoint>model only</context_checkpoint>' }],
  44. source: { kind: 'plugin', plugin: 'compact' },
  45. }),
  46. })
  47. }
  48. describe('TranscriptAdapter', () => {
  49. it('projects a window starting past seq 0 at its own log positions', () => {
  50. const adapter = new TranscriptAdapter()
  51. adapter.reset(plainTurn(100, 5, '偏移问', '偏移答'))
  52. expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 101], ['assistant', 103]])
  53. })
  54. it('appends incrementally keeping old node references (materialize-once identity)', () => {
  55. const adapter = new TranscriptAdapter()
  56. adapter.reset(plainTurn(0, 0, 'a', 'b'))
  57. const first = adapter.nodes()
  58. adapter.append(ev.user(6, '追加'))
  59. const second = adapter.nodes()
  60. expect(second).toHaveLength(3)
  61. expect(second[0]).toBe(first[0])
  62. expect(second[1]).toBe(first[1])
  63. expect(second).not.toBe(first) // a real change swaps the array
  64. })
  65. it('keeps the array reference across a chunk storm and swaps it when a node lands', () => {
  66. const adapter = new TranscriptAdapter()
  67. adapter.reset(plainTurn(0, 0, 'a', 'b'))
  68. const settled = adapter.nodes()
  69. adapter.append(ev.chunkStart(6, 1))
  70. expect(adapter.nodes()).toBe(settled)
  71. adapter.append(ev.chunkText(7, 1, '流式'))
  72. expect(adapter.nodes()).toBe(settled)
  73. adapter.append(ev.assistant(8, 1, '流式完成'))
  74. const finalized = adapter.nodes()
  75. expect(finalized).not.toBe(settled)
  76. expect(finalized.at(-1)).toMatchObject({ kind: 'assistant', seq: 8 })
  77. })
  78. it('materializes every append-origin variant with field mapping', () => {
  79. const adapter = new TranscriptAdapter()
  80. const steering = createUserMessage({
  81. content: [{ type: 'text', text: '插话' }],
  82. source: { kind: 'user' },
  83. })
  84. adapter.reset([
  85. ev.user(0, '用户'),
  86. ev.assistant(1, 0, '助手'),
  87. at(2, { type: 'agent/inbox/spliced', data: {
  88. target: 'next-step', start: 0, inserted: [steering],
  89. } }),
  90. at(3, { type: 'agent/inbox/spliced', data: {
  91. target: 'next-step', start: 0, removedCount: 1, inserted: [],
  92. } }),
  93. at(4, { type: 'user/message', surfaceOp: 'append', data: steering }),
  94. at(5, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
  95. content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' },
  96. }) }),
  97. ev.toolCall(6, 0, 'c1', 'echo', '{"x":1}'),
  98. ev.toolResult(7, 0, 'c1', '结果'),
  99. ])
  100. const nodes = adapter.nodes()
  101. expect(nodes.map(n => n.kind)).toEqual(['user', 'assistant', 'steering', 'context', 'tool-result'])
  102. expect(nodes.find(n => n.kind === 'steering')).toMatchObject({ messageId: steering.id })
  103. expect(nodes.find(n => n.kind === 'tool-result')).toMatchObject({
  104. callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false,
  105. })
  106. })
  107. it('identifies steering on the live append path', () => {
  108. const adapter = new TranscriptAdapter()
  109. const steering = createUserMessage({
  110. content: [{ type: 'text', text: 'live steer' }],
  111. source: { kind: 'user' },
  112. })
  113. adapter.reset([])
  114. adapter.append(at(0, { type: 'agent/inbox/spliced', data: {
  115. target: 'next-step', start: 0, inserted: [steering],
  116. } }))
  117. adapter.append(at(1, { type: 'agent/inbox/spliced', data: {
  118. target: 'next-step', start: 0, removedCount: 1, inserted: [],
  119. } }))
  120. adapter.append(at(2, { type: 'user/message', surfaceOp: 'append', data: steering }))
  121. expect(adapter.nodes()).toMatchObject([{ kind: 'steering', messageId: steering.id }])
  122. })
  123. it('does not mark queued, canceled, or non-user next-step messages as steering', () => {
  124. const adapter = new TranscriptAdapter()
  125. const queued = createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } })
  126. const canceled = createUserMessage({ content: [{ type: 'text', text: 'canceled' }], source: { kind: 'user' } })
  127. const context = createUserMessage({
  128. content: [{ type: 'text', text: 'context' }],
  129. source: { kind: 'plugin', plugin: 'test' },
  130. })
  131. adapter.reset([
  132. at(0, { type: 'agent/inbox/spliced', data: {
  133. target: 'next-turn', start: 0, inserted: [queued],
  134. } }),
  135. at(1, { type: 'agent/inbox/spliced', data: {
  136. target: 'next-turn', start: 0, removedCount: 1, inserted: [],
  137. } }),
  138. at(2, { type: 'user/message', surfaceOp: 'append', data: queued }),
  139. at(3, { type: 'agent/inbox/spliced', data: {
  140. target: 'next-step', start: 0, inserted: [canceled],
  141. } }),
  142. at(4, { type: 'agent/inbox/spliced', data: {
  143. target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled',
  144. } }),
  145. at(5, { type: 'user/message', surfaceOp: 'append', data: canceled }),
  146. at(6, { type: 'agent/inbox/spliced', data: {
  147. target: 'next-step', start: 0, inserted: [context],
  148. } }),
  149. at(7, { type: 'agent/inbox/spliced', data: {
  150. target: 'next-step', start: 0, removedCount: 1, inserted: [],
  151. } }),
  152. at(8, { type: 'user/message', surfaceOp: 'append', data: context }),
  153. ])
  154. expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context'])
  155. })
  156. it('materializes a skill-invocation injection as a named instructions context', () => {
  157. const adapter = new TranscriptAdapter()
  158. adapter.reset([
  159. at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
  160. content: [{ type: 'text', text: '/hidden-demo check the fixture' }],
  161. source: { kind: 'user' },
  162. }) }),
  163. at(1, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
  164. content: [{ type: 'text', text: '<skill_content name="hidden-demo">body</skill_content>' }],
  165. source: { kind: 'skill-invocation', name: 'hidden-demo', form: 'instructions' } as never,
  166. }) }),
  167. ])
  168. const nodes = adapter.nodes()
  169. // The gesture stays a user bubble; the injected body folds to a context
  170. // row named after the skill, presented as instructions.
  171. expect(nodes.map(node => node.kind)).toEqual(['user', 'context'])
  172. expect(nodes[1]).toMatchObject({
  173. provenance: { role: 'inject', label: 'hidden-demo' },
  174. form: 'instructions',
  175. })
  176. })
  177. it('skips events core does not call surface-eligible, marker or not', () => {
  178. // The transcript is the append-origin surface, so log-only events (a chunk,
  179. // a turn boundary, a `compact/*` record) and a future type core
  180. // has not admitted contribute no node.
  181. const adapter = new TranscriptAdapter()
  182. adapter.reset([
  183. ev.turnStart(0, 1),
  184. at(1, { type: 'notice/message', surfaceOp: 'append', data: { note: 1 } }),
  185. compactSummary(2),
  186. ev.user(3, '唯一的一条'),
  187. ev.turnEnd(4, 1),
  188. ])
  189. expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 3]])
  190. })
  191. describe('compaction markers', () => {
  192. it('keeps the original messages and full tool output, hiding replacement copies', () => {
  193. const adapter = new TranscriptAdapter()
  194. adapter.reset([
  195. ev.user(0, '原始问题'),
  196. ev.assistant(1, 0, '原始回答'),
  197. ev.toolCall(4, 0, 'c1', 'echo', '{}'),
  198. ev.toolResult(5, 0, 'c1', '完整工具输出'),
  199. // A pruned tool/result copy: rewrites one node for the model, marks nothing.
  200. at(6, { type: 'tool/result', surfaceOp: { op: 'replace', start: 5, end: 5 }, sourceEventSeqs: [5], data: {
  201. turn: 0, step: 0,
  202. message: createToolResultMessage({ callId: CallId('c1'), content: [{ type: 'text', text: '已裁剪' }], isError: false }),
  203. } }),
  204. compactSummary(7),
  205. checkpoint(8, 7, { start: 1, end: 5, sourceEventSeqs: [7, 1, 5] }),
  206. // A regenerated assistant/message: also a silent model-only rewrite.
  207. at(9, { type: 'assistant/message', surfaceOp: { op: 'replace', start: 8, end: 8 }, sourceEventSeqs: [8], data: {
  208. turn: 0, step: 0,
  209. message: createMessage({
  210. role: 'assistant',
  211. content: [{ type: 'text', text: '通用 replacement 副本' }],
  212. source: { kind: 'model', ...{ provider: 'x', model: 'copy' } },
  213. }),
  214. } }),
  215. ])
  216. const nodes = adapter.nodes()
  217. expect(nodes.map(n => [n.kind, n.seq])).toEqual([
  218. ['user', 0], ['assistant', 1], ['tool-result', 5], ['compaction', 8],
  219. ])
  220. expect(nodes[2]).toMatchObject({ kind: 'tool-result', content: [{ type: 'text', text: '完整工具输出' }] })
  221. expect(nodes[3]).toMatchObject({ kind: 'compaction', summary: '# 摘要\n\n保留事实' })
  222. })
  223. it('adds one marker per landed compaction, in log order', () => {
  224. const adapter = new TranscriptAdapter()
  225. adapter.reset([
  226. ev.user(0, 'a'),
  227. compactSummary(1, [{ type: 'text', text: 'first' }]),
  228. checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }),
  229. ev.user(3, 'b'),
  230. compactSummary(4, [{ type: 'text', text: 'second' }]),
  231. checkpoint(5, 4, { start: 2, end: 3, sourceEventSeqs: [4, 2, 3] }),
  232. ])
  233. expect(adapter.nodes().filter(n => n.kind === 'compaction')).toEqual([
  234. {
  235. kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: 'first',
  236. summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100,
  237. },
  238. {
  239. kind: 'compaction', seq: 5, time: 1_700_000_000_005, summary: 'second',
  240. summaryEventSeq: 4, shadowedItemCount: 2, shadowedTokenCount: 100,
  241. },
  242. ])
  243. })
  244. it('renders the marker when the shadowed range is outside the window and logs nothing', () => {
  245. // The pagination hole A1 left open: quota is no longer spent on
  246. // replacement copies, so a page can carry a checkpoint whose
  247. // surfaceOp.start lies below the window head. The old surface fold threw
  248. // on the missing range and degraded with a console error; a log-ordered
  249. // projection has no range to resolve.
  250. const adapter = new TranscriptAdapter()
  251. const noise = { error: console.error, warn: console.warn }
  252. const logged: unknown[] = []
  253. console.error = (...args: unknown[]) => logged.push(args)
  254. console.warn = (...args: unknown[]) => logged.push(args)
  255. try {
  256. adapter.reset([
  257. compactSummary(80, [{ type: 'text', text: '窗外范围' }]),
  258. checkpoint(81, 80, { start: 3, end: 40, sourceEventSeqs: [80, 3, 40] }),
  259. ev.user(82, '压缩后的新问题'),
  260. ])
  261. expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['compaction', 81], ['user', 82]])
  262. expect(adapter.nodes()[0]).toMatchObject({ summary: '窗外范围' })
  263. } finally {
  264. console.error = noise.error
  265. console.warn = noise.warn
  266. }
  267. expect(logged).toEqual([])
  268. })
  269. it('treats an APPENDING plugin-sourced user/message as injected context, not a compaction', () => {
  270. // A session-reference card carries the same plugin source shape; only the
  271. // replacement marker makes an event a checkpoint.
  272. const adapter = new TranscriptAdapter()
  273. adapter.reset([
  274. at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
  275. content: [{ type: 'text', text: '注入的上下文' }],
  276. source: { kind: 'plugin', plugin: 'compact', form: 'instructions' },
  277. }) }),
  278. ])
  279. expect(adapter.nodes()).toMatchObject([{
  280. kind: 'context',
  281. seq: 0,
  282. provenance: { role: 'inject', label: 'compact' },
  283. form: 'instructions',
  284. }])
  285. })
  286. it('ignores a foreign plugin s replacement user/message', () => {
  287. const adapter = new TranscriptAdapter()
  288. adapter.reset([
  289. ev.user(0, '保留'),
  290. at(1, { type: 'user/message', surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0], data: createUserMessage({
  291. content: [{ type: 'text', text: '别的插件重写' }],
  292. source: { kind: 'plugin', plugin: 'not-compact' },
  293. }) }),
  294. ])
  295. expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 0]])
  296. })
  297. it.each([
  298. ['absent summary event', undefined],
  299. ['text-less summary blocks', compactSummary(1, [{ type: 'image', data: 'nope' }])],
  300. ['a whitespace-only summary', compactSummary(1, [{ type: 'text', text: ' ' }])],
  301. ['an empty summary array', compactSummary(1, [])],
  302. ['a non-array summary', compactSummary(1, 'plain string')],
  303. ])('degrades %s to a non-expandable marker', (_label, summary) => {
  304. const adapter = new TranscriptAdapter()
  305. adapter.reset([
  306. ...(summary === undefined ? [] : [summary]),
  307. checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }),
  308. ])
  309. expect(adapter.nodes()).toMatchObject([
  310. { kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null },
  311. ])
  312. })
  313. it('keeps the text of a mixed-block summary, skipping the blocks it cannot render', () => {
  314. // ContentBlock is merge-extensible and the payload type is ContentBlock[],
  315. // so a non-text block must not discard recoverable text beside it.
  316. const adapter = new TranscriptAdapter()
  317. adapter.reset([
  318. compactSummary(1, [{ type: 'text', text: '可用摘要' }, { type: 'image', data: 'nope' }]),
  319. checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }),
  320. ])
  321. expect(adapter.nodes()).toEqual([
  322. {
  323. kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: '可用摘要',
  324. summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100,
  325. },
  326. ])
  327. })
  328. it('leaves the summary null when the checkpoint cites no source events', () => {
  329. const adapter = new TranscriptAdapter()
  330. adapter.reset([at(2, {
  331. type: 'user/message',
  332. surfaceOp: { op: 'replace', start: 0, end: 0 },
  333. data: createUserMessage({
  334. content: [{ type: 'text', text: '<context_checkpoint>x</context_checkpoint>' }],
  335. source: { kind: 'plugin', plugin: 'compact' },
  336. }),
  337. })])
  338. expect(adapter.nodes()).toEqual([{
  339. kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null,
  340. summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null,
  341. }])
  342. })
  343. it('skips a cited non-summary seq before reaching the summary event', () => {
  344. const adapter = new TranscriptAdapter()
  345. adapter.reset([
  346. ev.user(0, '被压缩的问题'),
  347. at(1, { type: 'compact/start', data: { turn: 0 } }),
  348. compactSummary(2, [{ type: 'text', text: '第三个来源才是摘要' }]),
  349. checkpoint(3, 2, { start: 0, end: 0, sourceEventSeqs: [1, 2, 0] }),
  350. ])
  351. expect(adapter.nodes().at(-1)).toMatchObject({ kind: 'compaction', summary: '第三个来源才是摘要' })
  352. })
  353. it('resolves the summary once an older page supplies the cited summary event', () => {
  354. const adapter = new TranscriptAdapter()
  355. const landed = checkpoint(8, 7, { start: 0, end: 0, sourceEventSeqs: [7, 0] })
  356. adapter.reset([landed])
  357. expect(adapter.nodes()[0]).toMatchObject({ kind: 'compaction', summary: null })
  358. adapter.reset([compactSummary(7, [{ type: 'text', text: '分页补齐的摘要' }]), landed])
  359. expect(adapter.nodes()[0]).toMatchObject({ kind: 'compaction', summary: '分页补齐的摘要' })
  360. })
  361. it('creates the marker on the live append path', () => {
  362. const adapter = new TranscriptAdapter()
  363. adapter.reset(plainTurn(0, 0, 'a', 'b'))
  364. adapter.append(compactSummary(6, [{ type: 'text', text: '直播摘要' }]))
  365. adapter.append(checkpoint(7, 6, { start: 1, end: 3, sourceEventSeqs: [6, 1, 3] }))
  366. const nodes = adapter.nodes()
  367. // The compacted history is still there; the marker is one more row after it.
  368. expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 1], ['assistant', 3], ['compaction', 7]])
  369. expect(nodes.at(-1)).toMatchObject({ kind: 'compaction', seq: 7, summary: '直播摘要' })
  370. })
  371. })
  372. it('returns call:null for a tool-result whose call fell outside the window', () => {
  373. const adapter = new TranscriptAdapter()
  374. adapter.reset([ev.toolResult(50, 3, 'outside-call', '孤儿结果')])
  375. expect(adapter.nodes()[0]).toMatchObject({ kind: 'tool-result', callId: 'outside-call', call: null })
  376. })
  377. it('materializes a tool-result error field when present', () => {
  378. const adapter = new TranscriptAdapter()
  379. adapter.reset([
  380. at(0, { type: 'tool/result', surfaceOp: 'append', data: {
  381. turn: 0, step: 0,
  382. message: createToolResultMessage({ callId: CallId('c1'), content: [], isError: true }),
  383. error: { name: 'Boom', code: 'boom' },
  384. } }),
  385. ])
  386. expect(adapter.nodes()[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } })
  387. })
  388. it('attaches wire views to the materialized result node', () => {
  389. const adapter = new TranscriptAdapter()
  390. const callView = { for: 'call' as const, view: { card: 'terminal' as const, command: 'ls' } }
  391. const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '完成' } }
  392. adapter.reset([
  393. ev.toolCall(0, 1, 'c1', 'bash', '{"cmd":"ls"}'),
  394. ev.toolResult(1, 1, 'c1', 'listing'),
  395. ], [callView, resultView] as never)
  396. expect(adapter.nodes().find(n => n.kind === 'tool-result')).toMatchObject({
  397. callView: { card: 'terminal' }, resultView: { card: 'generic', title: '完成' },
  398. })
  399. })
  400. it('attaches views on the live append path and defaults to null without views', () => {
  401. const adapter = new TranscriptAdapter()
  402. adapter.reset(plainTurn(0, 0, 'a', 'b')) // no views argument
  403. adapter.append(ev.toolCall(6, 1, 'c2', 'echo', '{}'), { for: 'call', view: { card: 'generic', title: '回声' } } as never)
  404. adapter.append(ev.toolResult(7, 1, 'c2', 'ok')) // no view on the result
  405. expect(adapter.nodes().find(n => n.kind === 'tool-result')).toMatchObject({
  406. callView: { title: '回声' }, resultView: null,
  407. })
  408. })
  409. it('leaves callView null when the paired call fell outside the window (cross-page break)', () => {
  410. const adapter = new TranscriptAdapter()
  411. const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '孤儿' } }
  412. adapter.reset([ev.toolResult(50, 3, 'outside', '窗外配对')], [resultView] as never)
  413. expect(adapter.nodes()[0]).toMatchObject({
  414. kind: 'tool-result', call: null, callView: null, resultView: { title: '孤儿' },
  415. })
  416. })
  417. describe('command lifecycle nodes', () => {
  418. it('folds a run/done pair into one settled node merged into flow order by seq', () => {
  419. const adapter = new TranscriptAdapter()
  420. adapter.reset([
  421. ev.user(0, '先说话'),
  422. ev.commandRun(1, 'cmd-1', 'plan'),
  423. ev.commandDone(2, 'cmd-1', 'success', '已进入 plan mode'),
  424. ev.assistant(3, 0, '然后回答'),
  425. ])
  426. const nodes = adapter.nodes()
  427. expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['assistant', 3]])
  428. expect(nodes[1]).toMatchObject({
  429. kind: 'command', commandId: 'cmd-1', name: 'plan', args: '',
  430. outcome: { kind: 'success', text: '已进入 plan mode' },
  431. })
  432. })
  433. it('renders a run with no done as still executing (outcome null)', () => {
  434. const adapter = new TranscriptAdapter()
  435. adapter.reset([ev.commandRun(0, 'cmd-2', 'goal', ' ship it')])
  436. expect(adapter.nodes()[0]).toMatchObject({ kind: 'command', name: 'goal', args: ' ship it', outcome: null })
  437. })
  438. it('represents command input omitted by the host as null', () => {
  439. const adapter = new TranscriptAdapter()
  440. adapter.reset([ev.commandRunWithoutInput(0, 'cmd-private', 'feedback')])
  441. expect(adapter.nodes()[0]).toMatchObject({
  442. kind: 'command', name: 'feedback', args: null, outcome: null,
  443. })
  444. })
  445. it('soft-falls a done-only window into a node built from the done (cross-window cut)', () => {
  446. const adapter = new TranscriptAdapter()
  447. adapter.reset([ev.commandDone(80, 'cmd-3', 'error', '失败了')])
  448. expect(adapter.nodes()[0]).toMatchObject({
  449. kind: 'command', seq: 80, commandId: 'cmd-3', name: null, args: null,
  450. outcome: { kind: 'error', text: '失败了' },
  451. })
  452. })
  453. it('settles a live-appended done in place, keeping the node at the run seq', () => {
  454. const adapter = new TranscriptAdapter()
  455. adapter.reset(plainTurn(0, 0, 'q', 'a'))
  456. adapter.append(ev.commandRun(6, 'cmd-4', 'clear'))
  457. const running = adapter.nodes().find(n => n.kind === 'command')
  458. expect(running).toMatchObject({ outcome: null })
  459. adapter.append(ev.commandDone(7, 'cmd-4'))
  460. const settled = adapter.nodes().find(n => n.kind === 'command')
  461. expect(settled).toMatchObject({ seq: 6, outcome: { kind: 'success' } })
  462. // Settlement replaced the node object rather than mutating the published one.
  463. expect(settled).not.toBe(running)
  464. })
  465. it('tails command nodes whose seq is past every transcript node', () => {
  466. const adapter = new TranscriptAdapter()
  467. adapter.reset([ev.user(0, '问'), ev.commandRun(1, 'cmd-tail', 'plan')])
  468. expect(adapter.nodes().map(n => n.kind)).toEqual(['user', 'command'])
  469. })
  470. it('preserves the domain-event link for the UI to fold a /compact row into its marker', () => {
  471. const adapter = new TranscriptAdapter()
  472. adapter.reset([
  473. ev.user(0, '压缩前的问题'),
  474. ev.commandRun(1, 'cmd-compact', 'compact'),
  475. compactSummary(2, [{ type: 'text', text: '手动压缩摘要' }]),
  476. checkpoint(3, 2, { start: 0, end: 0, sourceEventSeqs: [2, 0] }),
  477. ev.commandDone(4, 'cmd-compact', 'success', '已压缩', 2),
  478. ])
  479. const nodes = adapter.nodes()
  480. expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['compaction', 3]])
  481. expect(nodes[1]).toMatchObject({
  482. name: 'compact',
  483. outcome: { kind: 'success', text: '已压缩', sourceEventSeq: 2 },
  484. })
  485. expect(nodes[2]).toMatchObject({ kind: 'compaction', summaryEventSeq: 2 })
  486. })
  487. })
  488. describe('assistant timing', () => {
  489. const base = 1_700_000_000_000
  490. it('derives step timing across a window rebuild (start + first token + completion)', () => {
  491. const adapter = new TranscriptAdapter()
  492. adapter.reset([
  493. ev.turnStart(0, 0),
  494. ev.user(1, '问'),
  495. ev.stepStart(2, 0),
  496. ev.chunkStart(3, 0),
  497. ev.chunkText(4, 0, '答'),
  498. ev.chunkText(5, 0, '案'),
  499. ev.assistant(6, 0, '答案'),
  500. ev.turnEnd(7, 0),
  501. ])
  502. const assistant = adapter.nodes().find(n => n.kind === 'assistant')
  503. expect(assistant).toMatchObject({
  504. timing: { stepStartTime: base + 2, firstTokenTime: base + 4, completedTime: base + 6 },
  505. })
  506. })
  507. it('derives the same timing on the live append path, first token winning once', () => {
  508. const adapter = new TranscriptAdapter()
  509. adapter.reset([ev.user(0, '问')])
  510. adapter.append(ev.stepStart(1, 0))
  511. adapter.append(ev.chunkText(2, 0, '首'))
  512. adapter.append(ev.chunkText(3, 0, '次'))
  513. adapter.append(ev.assistant(4, 0, '首次'))
  514. const assistant = adapter.nodes().find(n => n.kind === 'assistant')
  515. expect(assistant).toMatchObject({
  516. timing: { stepStartTime: base + 1, firstTokenTime: base + 2, completedTime: base + 4 },
  517. })
  518. })
  519. it('soft-falls to null boundaries when the step opening fell outside the window', () => {
  520. const adapter = new TranscriptAdapter()
  521. adapter.reset([ev.assistant(100, 0, '被切窗的答案')])
  522. const assistant = adapter.nodes().find(n => n.kind === 'assistant')
  523. expect(assistant).toMatchObject({
  524. timing: { stepStartTime: null, firstTokenTime: null, completedTime: base + 100 },
  525. })
  526. })
  527. })
  528. })