fold-adapter.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  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 { ev, plainTurn } from './event-script.ts'
  11. const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
  12. ({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent
  13. describe('FoldAdapter', () => {
  14. it('folds a baseSeq>0 window through padding sentinels with correct seqs', () => {
  15. const adapter = new FoldAdapter()
  16. const window = plainTurn(100, 5, '偏移问', '偏移答')
  17. adapter.reset(window, 100)
  18. const { nodes, degraded } = adapter.nodes()
  19. expect(degraded).toBe(false)
  20. expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 101], ['assistant', 103]])
  21. })
  22. it('appends incrementally keeping old node references (cache identity)', () => {
  23. const adapter = new FoldAdapter()
  24. adapter.reset(plainTurn(0, 0, 'a', 'b'), 0)
  25. const first = adapter.nodes()
  26. adapter.append(ev.user(6, '追加'))
  27. const second = adapter.nodes()
  28. expect(second.nodes).toHaveLength(3)
  29. expect(second.nodes[0]).toBe(first.nodes[0])
  30. expect(second.nodes[1]).toBe(first.nodes[1])
  31. expect(second.nodes).not.toBe(first.nodes) // array itself fresh per call
  32. })
  33. it('materializes all six node variants with field mapping', () => {
  34. const adapter = new FoldAdapter()
  35. const events = [
  36. ev.user(0, '用户'),
  37. ev.assistant(1, 0, '助手'),
  38. at(2, { type: 'steering/message', surfaceOp: 'append', data: {
  39. turn: 0,
  40. message: createUserMessage({
  41. content: [{ type: 'text', text: '插话' }],
  42. source: { kind: 'user' },
  43. }),
  44. } }),
  45. at(3, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
  46. content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' },
  47. }) }),
  48. ev.toolCall(4, 0, 'c1', 'echo', '{"x":1}'),
  49. ev.toolResult(5, 0, 'c1', '结果'),
  50. ]
  51. adapter.reset(events, 0)
  52. const { nodes } = adapter.nodes()
  53. const kinds = nodes.map(n => n.kind)
  54. expect(kinds).toContain('user')
  55. expect(kinds).toContain('assistant')
  56. expect(kinds).toContain('steering')
  57. expect(kinds).toContain('context')
  58. const result = nodes.find(n => n.kind === 'tool-result')
  59. expect(result).toMatchObject({ callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false })
  60. })
  61. it('returns call:null for a tool-result whose call fell outside the window', () => {
  62. const adapter = new FoldAdapter()
  63. adapter.reset([ev.toolResult(50, 3, 'outside-call', '孤儿结果')], 50)
  64. const { nodes } = adapter.nodes()
  65. expect(nodes[0]).toMatchObject({ kind: 'tool-result', callId: 'outside-call', call: null })
  66. })
  67. it('materializes surface-eligible types it does not know as unknown nodes', () => {
  68. const adapter = new FoldAdapter()
  69. adapter.reset([at(0, { type: 'notice/message', surfaceOp: 'append', data: { note: 1 } })], 0)
  70. const { nodes } = adapter.nodes()
  71. // Either the fold surfaces it (unknown node) or skips it as non-eligible — both are valid
  72. // shapes; what matters is no throw and no misclassification into a known kind.
  73. for (const node of nodes) expect(node.kind).toBe('unknown')
  74. })
  75. it('degrades to the lenient linear scan when the fold throws, and stays degraded', () => {
  76. const adapter = new FoldAdapter()
  77. // An invalid surfaceOp on a surface-eligible event deterministically throws in the core fold.
  78. const window = [
  79. ev.user(10, '正常'),
  80. at(11, { type: 'assistant/message', surfaceOp: 'bogus-op', data: {
  81. turn: 0, step: 0,
  82. message: createMessage({
  83. role: 'assistant',
  84. content: [{ type: 'text', text: '坏 op' }],
  85. source: {
  86. kind: 'model',
  87. ...{ provider: 'x', model: 'y' },
  88. },
  89. }),
  90. } }),
  91. ]
  92. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  93. try {
  94. adapter.reset(window, 10)
  95. const first = adapter.nodes()
  96. expect(first.degraded).toBe(true)
  97. expect(errorSpy).toHaveBeenCalled()
  98. expect(first.nodes.map(n => n.seq)).toEqual([10, 11]) // linear scan: append order, bad op ignored
  99. adapter.append(ev.user(12, '降级后追加')) // bump rev so the cached result is not reused
  100. const second = adapter.nodes()
  101. expect(second.degraded).toBe(true) // sticky: no re-throw loop, straight to the linear scan
  102. expect(second.nodes[0]).toBe(first.nodes[0]) // cache still serves node identity
  103. expect(second.nodes.map(n => n.seq)).toEqual([10, 11, 12])
  104. } finally {
  105. errorSpy.mockRestore()
  106. }
  107. })
  108. it('materializes a tool-result error field when present', () => {
  109. const adapter = new FoldAdapter()
  110. adapter.reset([
  111. at(0, { type: 'tool/result', surfaceOp: 'append', data: {
  112. turn: 0, step: 0,
  113. message: createToolResultMessage({
  114. callId: CallId('c1'),
  115. content: [],
  116. isError: true,
  117. }),
  118. error: { name: 'Boom', code: 'boom' },
  119. } }),
  120. ], 0)
  121. expect(adapter.nodes().nodes[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } })
  122. })
  123. it('exposes the in-window call index for runningCalls material', () => {
  124. const adapter = new FoldAdapter()
  125. adapter.reset([ev.toolCall(0, 1, 'c9', 'slow', '{}')], 0)
  126. expect(adapter.callIndex.get('c9')).toMatchObject({ name: 'slow', turn: 1 })
  127. adapter.append(ev.toolCall(1, 1, 'c10', 'fast', '{}'))
  128. expect(adapter.callIndex.size).toBe(2)
  129. })
  130. it('attaches wire views: callView into the call index, resultView onto the node by seq', () => {
  131. const adapter = new FoldAdapter()
  132. const events = [
  133. ev.toolCall(0, 1, 'c1', 'bash', '{"cmd":"ls"}'),
  134. ev.toolResult(1, 1, 'c1', 'listing'),
  135. ]
  136. const callView = { for: 'call' as const, view: { card: 'terminal' as const, command: 'ls' } }
  137. const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '完成' } }
  138. adapter.reset(events, 0, [callView, resultView] as never)
  139. expect(adapter.callIndex.get('c1')).toMatchObject({ callView: { card: 'terminal' } })
  140. const node = adapter.nodes().nodes.find(n => n.kind === 'tool-result')
  141. expect(node).toMatchObject({ callView: { card: 'terminal' }, resultView: { card: 'generic', title: '完成' } })
  142. })
  143. it('attaches views on the live append path and defaults to null without views', () => {
  144. const adapter = new FoldAdapter()
  145. adapter.reset(plainTurn(0, 0, 'a', 'b'), 0) // no views argument: legacy-shaped call
  146. adapter.append(ev.toolCall(6, 1, 'c2', 'echo', '{}'), { for: 'call', view: { card: 'generic', title: '回声' } } as never)
  147. adapter.append(ev.toolResult(7, 1, 'c2', 'ok')) // no view on the result
  148. expect(adapter.callIndex.get('c2')).toMatchObject({ callView: { title: '回声' } })
  149. const node = adapter.nodes().nodes.find(n => n.kind === 'tool-result')
  150. expect(node).toMatchObject({ callView: { title: '回声' }, resultView: null })
  151. })
  152. it('leaves callView null when the paired call fell outside the window (cross-page break)', () => {
  153. const adapter = new FoldAdapter()
  154. const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '孤儿' } }
  155. adapter.reset([ev.toolResult(50, 3, 'outside', '窗外配对')], 50, [resultView] as never)
  156. const node = adapter.nodes().nodes[0]
  157. expect(node).toMatchObject({ kind: 'tool-result', call: null, callView: null, resultView: { title: '孤儿' } })
  158. })
  159. describe('command lifecycle nodes', () => {
  160. it('folds a run/done pair into one settled node merged into flow order by seq', () => {
  161. const adapter = new FoldAdapter()
  162. adapter.reset([
  163. ev.user(0, '先说话'),
  164. ev.commandRun(1, 'cmd-1', 'plan'),
  165. ev.commandDone(2, 'cmd-1', 'success', '已进入 plan mode'),
  166. ev.assistant(3, 0, '然后回答'),
  167. ], 0)
  168. const { nodes } = adapter.nodes()
  169. expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['assistant', 3]])
  170. expect(nodes[1]).toMatchObject({
  171. kind: 'command', commandId: 'cmd-1', name: 'plan', args: '',
  172. outcome: { kind: 'success', text: '已进入 plan mode' },
  173. })
  174. })
  175. it('renders a run with no done as still executing (outcome null)', () => {
  176. const adapter = new FoldAdapter()
  177. adapter.reset([ev.commandRun(0, 'cmd-2', 'goal', ' ship it')], 0)
  178. expect(adapter.nodes().nodes[0]).toMatchObject({
  179. kind: 'command', name: 'goal', args: ' ship it', outcome: null,
  180. })
  181. })
  182. it('soft-falls a done-only window into a node built from the done (cross-window cut)', () => {
  183. const adapter = new FoldAdapter()
  184. adapter.reset([ev.commandDone(80, 'cmd-3', 'error', '失败了')], 80)
  185. expect(adapter.nodes().nodes[0]).toMatchObject({
  186. kind: 'command', seq: 80, commandId: 'cmd-3', name: null, args: null,
  187. outcome: { kind: 'error', text: '失败了' },
  188. })
  189. })
  190. it('settles a live-appended done in place, keeping the node at the run seq', () => {
  191. const adapter = new FoldAdapter()
  192. adapter.reset(plainTurn(0, 0, 'q', 'a'), 0)
  193. adapter.append(ev.commandRun(6, 'cmd-4', 'clear'))
  194. const running = adapter.nodes().nodes.find(n => n.kind === 'command')
  195. expect(running).toMatchObject({ outcome: null })
  196. adapter.append(ev.commandDone(7, 'cmd-4'))
  197. const settled = adapter.nodes().nodes.find(n => n.kind === 'command')
  198. expect(settled).toMatchObject({ seq: 6, outcome: { kind: 'success' } })
  199. // Settlement replaced the node object rather than mutating the published one.
  200. expect(settled).not.toBe(running)
  201. })
  202. it('tails command nodes whose seq is past every surface node', () => {
  203. const adapter = new FoldAdapter()
  204. adapter.reset([ev.user(0, '问'), ev.commandRun(1, 'cmd-tail', 'plan')], 0)
  205. expect(adapter.nodes().nodes.map(n => n.kind)).toEqual(['user', 'command'])
  206. })
  207. it('command nodes survive the degraded linear-scan branch', () => {
  208. const adapter = new FoldAdapter()
  209. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  210. try {
  211. adapter.reset([
  212. ev.commandRun(0, 'cmd-5', 'plan'),
  213. ev.commandDone(1, 'cmd-5'),
  214. at(2, { type: 'assistant/message', surfaceOp: 'bogus-op', data: {
  215. turn: 0,
  216. step: 0,
  217. message: createMessage({
  218. role: 'assistant',
  219. content: [{ type: 'text', text: '坏 op' }],
  220. source: { kind: 'model', provider: 'x', model: 'y' },
  221. }),
  222. } }),
  223. ], 0)
  224. const { nodes, degraded } = adapter.nodes()
  225. expect(degraded).toBe(true)
  226. expect(nodes.some(n => n.kind === 'command')).toBe(true)
  227. } finally {
  228. errorSpy.mockRestore()
  229. }
  230. })
  231. })
  232. })