fold-adapter.spec.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. import { createUserMessage, CallId, createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
  2. /**
  3. * FoldAdapter over the real core SurfaceManager: padding sentinels for paged
  4. * windows, incremental append with node-cache identity, six-variant
  5. * materialization, call-index backfill, and the degraded linear-scan branch.
  6. */
  7. import { describe, expect, it, vi } from 'vitest'
  8. import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
  9. import { FoldAdapter } from '../src/client/sessions/fold-adapter.ts'
  10. import { projectConversationHistory } from '../src/client/session-history/history-fold.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. describe('FoldAdapter', () => {
  15. it('folds a baseSeq>0 window through padding sentinels with correct seqs', () => {
  16. const adapter = new FoldAdapter()
  17. const window = plainTurn(100, 5, '偏移问', '偏移答')
  18. adapter.reset(window, 100)
  19. const { nodes, degraded } = adapter.nodes()
  20. expect(degraded).toBe(false)
  21. expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 101], ['assistant', 103]])
  22. })
  23. it('appends incrementally keeping old node references (cache identity)', () => {
  24. const adapter = new FoldAdapter()
  25. adapter.reset(plainTurn(0, 0, 'a', 'b'), 0)
  26. const first = adapter.nodes()
  27. expect(adapter.nodes()).toBe(first)
  28. adapter.append(ev.user(6, '追加'))
  29. const second = adapter.nodes()
  30. expect(second.nodes).toHaveLength(3)
  31. expect(second.nodes[0]).toBe(first.nodes[0])
  32. expect(second.nodes[1]).toBe(first.nodes[1])
  33. expect(second.nodes).not.toBe(first.nodes) // array itself fresh per call
  34. })
  35. it('projects frozen surface generations without widening the core live surface', () => {
  36. const events = [
  37. ev.user(0, 'a'),
  38. ev.user(1, 'b'),
  39. at(2, {
  40. type: 'assistant/message',
  41. surfaceOp: { op: 'replace', start: 0, end: 0 },
  42. sourceEventSeqs: [0],
  43. data: {
  44. turn: 1,
  45. step: 1,
  46. message: createMessage({
  47. role: 'assistant',
  48. content: [{ type: 'text', text: 'summary' }],
  49. source: { kind: 'model', provider: 'fake', model: 'fake' },
  50. }),
  51. },
  52. }),
  53. at(3, {
  54. type: 'assistant/message',
  55. surfaceOp: { op: 'replace', start: 2, end: 1 },
  56. sourceEventSeqs: [2, 1],
  57. data: {
  58. turn: 1,
  59. step: 2,
  60. message: createMessage({
  61. role: 'assistant',
  62. content: [{ type: 'text', text: 'summary 2' }],
  63. source: { kind: 'model', provider: 'fake', model: 'fake' },
  64. }),
  65. },
  66. }),
  67. ]
  68. expect(projectConversationHistory(events.map(event => ({ event }))).contexts.map(context => ({
  69. id: context.id,
  70. parentId: context.parentId,
  71. originSeq: context.originSeq,
  72. nodes: context.nodes.map(node => node.seq),
  73. }))).toEqual([
  74. { id: 0, parentId: undefined, originSeq: undefined, nodes: [0, 1] },
  75. { id: 1, parentId: 0, originSeq: 2, nodes: [2, 1] },
  76. { id: 2, parentId: 1, originSeq: 3, nodes: [3] },
  77. ])
  78. })
  79. it('materializes all six node variants with field mapping', () => {
  80. const adapter = new FoldAdapter()
  81. const events = [
  82. ev.user(0, '用户'),
  83. ev.assistant(1, 0, '助手'),
  84. at(2, { type: 'steering/message', surfaceOp: 'append', data: {
  85. turn: 0,
  86. message: createUserMessage({
  87. content: [{ type: 'text', text: '插话' }],
  88. source: { kind: 'user' },
  89. }),
  90. } }),
  91. at(3, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
  92. content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' },
  93. }) }),
  94. ev.toolCall(4, 0, 'c1', 'echo', '{"x":1}'),
  95. ev.toolResult(5, 0, 'c1', '结果'),
  96. ]
  97. adapter.reset(events, 0)
  98. const { nodes } = adapter.nodes()
  99. const kinds = nodes.map(n => n.kind)
  100. expect(kinds).toContain('user')
  101. expect(kinds).toContain('assistant')
  102. expect(kinds).toContain('steering')
  103. expect(kinds).toContain('context')
  104. const result = nodes.find(n => n.kind === 'tool-result')
  105. expect(result).toMatchObject({ callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false })
  106. })
  107. it('returns call:null for a tool-result whose call fell outside the window', () => {
  108. const adapter = new FoldAdapter()
  109. adapter.reset([ev.toolResult(50, 3, 'outside-call', '孤儿结果')], 50)
  110. const { nodes } = adapter.nodes()
  111. expect(nodes[0]).toMatchObject({ kind: 'tool-result', callId: 'outside-call', call: null })
  112. })
  113. it('materializes surface-eligible types it does not know as unknown nodes', () => {
  114. const adapter = new FoldAdapter()
  115. adapter.reset([at(0, { type: 'notice/message', surfaceOp: 'append', data: { note: 1 } })], 0)
  116. const { nodes } = adapter.nodes()
  117. // Either the fold surfaces it (unknown node) or skips it as non-eligible — both are valid
  118. // shapes; what matters is no throw and no misclassification into a known kind.
  119. for (const node of nodes) expect(node.kind).toBe('unknown')
  120. })
  121. it('degrades to the lenient linear scan when the fold throws, and stays degraded', () => {
  122. const adapter = new FoldAdapter()
  123. // An invalid surfaceOp on a surface-eligible event deterministically throws in the core fold.
  124. const window = [
  125. ev.user(10, '正常'),
  126. at(11, { type: 'assistant/message', surfaceOp: 'bogus-op', data: {
  127. turn: 0, step: 0,
  128. message: createMessage({
  129. role: 'assistant',
  130. content: [{ type: 'text', text: '坏 op' }],
  131. source: {
  132. kind: 'model',
  133. ...{ provider: 'x', model: 'y' },
  134. },
  135. }),
  136. } }),
  137. ]
  138. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  139. try {
  140. adapter.reset(window, 10)
  141. const first = adapter.nodes()
  142. expect(first.degraded).toBe(true)
  143. expect(errorSpy).toHaveBeenCalled()
  144. expect(first.nodes.map(n => n.seq)).toEqual([10, 11]) // linear scan: append order, bad op ignored
  145. adapter.append(ev.user(12, '降级后追加')) // bump rev so the cached result is not reused
  146. const second = adapter.nodes()
  147. expect(second.degraded).toBe(true) // sticky: no re-throw loop, straight to the linear scan
  148. expect(second.nodes[0]).toBe(first.nodes[0]) // cache still serves node identity
  149. expect(second.nodes.map(n => n.seq)).toEqual([10, 11, 12])
  150. } finally {
  151. errorSpy.mockRestore()
  152. }
  153. })
  154. it('silently degrades when a replacement needs an earlier history page', () => {
  155. const adapter = new FoldAdapter()
  156. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  157. try {
  158. adapter.reset([
  159. at(10, {
  160. type: 'assistant/message',
  161. surfaceOp: { op: 'replace', start: 1, end: 3 },
  162. sourceEventSeqs: [1, 3],
  163. data: {
  164. turn: 1,
  165. step: 1,
  166. message: createMessage({
  167. role: 'assistant',
  168. content: [{ type: 'text', text: 'partial summary' }],
  169. source: { kind: 'model', provider: 'fake', model: 'fake' },
  170. }),
  171. },
  172. }),
  173. ev.user(11, 'newer message'),
  174. ], 10)
  175. expect(adapter.nodes()).toMatchObject({
  176. degraded: true,
  177. nodes: [{ seq: 10 }, { seq: 11 }],
  178. })
  179. expect(errorSpy).not.toHaveBeenCalled()
  180. } finally {
  181. errorSpy.mockRestore()
  182. }
  183. })
  184. it('silently degrades when a live replacement needs an earlier history page', () => {
  185. const adapter = new FoldAdapter()
  186. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  187. try {
  188. adapter.reset([ev.user(10, 'window head')], 10)
  189. adapter.append(at(11, {
  190. type: 'assistant/message',
  191. surfaceOp: { op: 'replace', start: 1, end: 1 },
  192. sourceEventSeqs: [1],
  193. data: {
  194. turn: 1,
  195. step: 1,
  196. message: createMessage({
  197. role: 'assistant',
  198. content: [{ type: 'text', text: 'live summary' }],
  199. source: { kind: 'model', provider: 'fake', model: 'fake' },
  200. }),
  201. },
  202. }))
  203. expect(adapter.nodes()).toMatchObject({
  204. degraded: true,
  205. nodes: [{ seq: 10 }, { seq: 11 }],
  206. })
  207. expect(errorSpy).not.toHaveBeenCalled()
  208. } finally {
  209. errorSpy.mockRestore()
  210. }
  211. })
  212. it('materializes a tool-result error field when present', () => {
  213. const adapter = new FoldAdapter()
  214. adapter.reset([
  215. at(0, { type: 'tool/result', surfaceOp: 'append', data: {
  216. turn: 0, step: 0,
  217. message: createToolResultMessage({
  218. callId: CallId('c1'),
  219. content: [],
  220. isError: true,
  221. }),
  222. error: { name: 'Boom', code: 'boom' },
  223. } }),
  224. ], 0)
  225. expect(adapter.nodes().nodes[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } })
  226. })
  227. it('projects assistant timing and the active request header from history', () => {
  228. const projection = projectConversationHistory([
  229. ev.stepStart(0, 1, 2),
  230. at(1, { type: 'request/header', data: {
  231. reason: 'initial',
  232. header: {
  233. config: { provider: 'fake', model: 'first' },
  234. tools: [],
  235. },
  236. } }),
  237. ev.chunkStart(2, 1, 2),
  238. ev.chunkText(3, 1, 'token', 2),
  239. ev.assistant(4, 1, 'done', 2),
  240. ev.stepStart(5, 2, 1),
  241. ev.chunkText(6, 2, 'next', 1),
  242. ev.assistant(7, 2, 'next done', 1),
  243. ].map(event => ({ event })))
  244. expect(projection.eventNodes[0]).toMatchObject({
  245. kind: 'assistant',
  246. timing: {
  247. stepStartTime: 1_700_000_000_000,
  248. firstTokenTime: 1_700_000_000_003,
  249. completedTime: 1_700_000_000_004,
  250. },
  251. requestConfig: { provider: 'fake', model: 'first' },
  252. })
  253. expect(projection.eventNodes.at(-1)).toMatchObject({
  254. timing: {
  255. stepStartTime: 1_700_000_000_005,
  256. firstTokenTime: 1_700_000_000_006,
  257. completedTime: 1_700_000_000_007,
  258. },
  259. requestConfig: { provider: 'fake', model: 'first' },
  260. })
  261. })
  262. it('exposes the in-window call index for runningCalls material', () => {
  263. const adapter = new FoldAdapter()
  264. adapter.reset([ev.toolCall(0, 1, 'c9', 'slow', '{}')], 0)
  265. expect(adapter.callIndex.get('c9')).toMatchObject({ name: 'slow', turn: 1 })
  266. adapter.append(ev.toolCall(1, 1, 'c10', 'fast', '{}'))
  267. expect(adapter.callIndex.size).toBe(2)
  268. })
  269. it('attaches wire views: callView into the call index, resultView onto the node by seq', () => {
  270. const adapter = new FoldAdapter()
  271. const events = [
  272. ev.toolCall(0, 1, 'c1', 'bash', '{"cmd":"ls"}'),
  273. ev.toolResult(1, 1, 'c1', 'listing'),
  274. ]
  275. const callView = { for: 'call' as const, view: { card: 'terminal' as const, command: 'ls' } }
  276. const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '完成' } }
  277. adapter.reset(events, 0, [callView, resultView] as never)
  278. expect(adapter.callIndex.get('c1')).toMatchObject({ callView: { card: 'terminal' } })
  279. const node = adapter.nodes().nodes.find(n => n.kind === 'tool-result')
  280. expect(node).toMatchObject({ callView: { card: 'terminal' }, resultView: { card: 'generic', title: '完成' } })
  281. })
  282. it('attaches views on the live append path and defaults to null without views', () => {
  283. const adapter = new FoldAdapter()
  284. adapter.reset(plainTurn(0, 0, 'a', 'b'), 0) // no views argument: legacy-shaped call
  285. adapter.append(ev.toolCall(6, 1, 'c2', 'echo', '{}'), { for: 'call', view: { card: 'generic', title: '回声' } } as never)
  286. adapter.append(ev.toolResult(7, 1, 'c2', 'ok')) // no view on the result
  287. expect(adapter.callIndex.get('c2')).toMatchObject({ callView: { title: '回声' } })
  288. const node = adapter.nodes().nodes.find(n => n.kind === 'tool-result')
  289. expect(node).toMatchObject({ callView: { title: '回声' }, resultView: null })
  290. })
  291. it('leaves callView null when the paired call fell outside the window (cross-page break)', () => {
  292. const adapter = new FoldAdapter()
  293. const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '孤儿' } }
  294. adapter.reset([ev.toolResult(50, 3, 'outside', '窗外配对')], 50, [resultView] as never)
  295. const node = adapter.nodes().nodes[0]
  296. expect(node).toMatchObject({ kind: 'tool-result', call: null, callView: null, resultView: { title: '孤儿' } })
  297. })
  298. describe('command lifecycle nodes', () => {
  299. it('folds a run/done pair into one settled node merged into flow order by seq', () => {
  300. const adapter = new FoldAdapter()
  301. adapter.reset([
  302. ev.user(0, '先说话'),
  303. ev.commandRun(1, 'cmd-1', 'plan'),
  304. ev.commandDone(2, 'cmd-1', 'success', '已进入 plan mode'),
  305. ev.assistant(3, 0, '然后回答'),
  306. ], 0)
  307. const { nodes } = adapter.nodes()
  308. expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['assistant', 3]])
  309. expect(nodes[1]).toMatchObject({
  310. kind: 'command', commandId: 'cmd-1', name: 'plan', args: '',
  311. outcome: { kind: 'success', text: '已进入 plan mode' },
  312. })
  313. })
  314. it('renders a run with no done as still executing (outcome null)', () => {
  315. const adapter = new FoldAdapter()
  316. adapter.reset([ev.commandRun(0, 'cmd-2', 'goal', ' ship it')], 0)
  317. expect(adapter.nodes().nodes[0]).toMatchObject({
  318. kind: 'command', name: 'goal', args: ' ship it', outcome: null,
  319. })
  320. })
  321. it('soft-falls a done-only window into a node built from the done (cross-window cut)', () => {
  322. const adapter = new FoldAdapter()
  323. adapter.reset([ev.commandDone(80, 'cmd-3', 'error', '失败了')], 80)
  324. expect(adapter.nodes().nodes[0]).toMatchObject({
  325. kind: 'command', seq: 80, commandId: 'cmd-3', name: null, args: null,
  326. outcome: { kind: 'error', text: '失败了' },
  327. })
  328. })
  329. it('settles a live-appended done in place, keeping the node at the run seq', () => {
  330. const adapter = new FoldAdapter()
  331. adapter.reset(plainTurn(0, 0, 'q', 'a'), 0)
  332. adapter.append(ev.commandRun(6, 'cmd-4', 'clear'))
  333. const running = adapter.nodes().nodes.find(n => n.kind === 'command')
  334. expect(running).toMatchObject({ outcome: null })
  335. adapter.append(ev.commandDone(7, 'cmd-4'))
  336. const settled = adapter.nodes().nodes.find(n => n.kind === 'command')
  337. expect(settled).toMatchObject({ seq: 6, outcome: { kind: 'success' } })
  338. // Settlement replaced the node object rather than mutating the published one.
  339. expect(settled).not.toBe(running)
  340. })
  341. it('tails command nodes whose seq is past every surface node', () => {
  342. const adapter = new FoldAdapter()
  343. adapter.reset([ev.user(0, '问'), ev.commandRun(1, 'cmd-tail', 'plan')], 0)
  344. expect(adapter.nodes().nodes.map(n => n.kind)).toEqual(['user', 'command'])
  345. })
  346. it('command nodes survive the degraded linear-scan branch', () => {
  347. const adapter = new FoldAdapter()
  348. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  349. try {
  350. adapter.reset([
  351. ev.commandRun(0, 'cmd-5', 'plan'),
  352. ev.commandDone(1, 'cmd-5'),
  353. at(2, { type: 'assistant/message', surfaceOp: 'bogus-op', data: {
  354. turn: 0,
  355. step: 0,
  356. message: createMessage({
  357. role: 'assistant',
  358. content: [{ type: 'text', text: '坏 op' }],
  359. source: { kind: 'model', provider: 'x', model: 'y' },
  360. }),
  361. } }),
  362. ], 0)
  363. const { nodes, degraded } = adapter.nodes()
  364. expect(degraded).toBe(true)
  365. expect(nodes.some(n => n.kind === 'command')).toBe(true)
  366. } finally {
  367. errorSpy.mockRestore()
  368. }
  369. })
  370. })
  371. })