fold-adapter.spec.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. /**
  2. * FoldAdapter over the real core SurfaceManager: padding sentinels for paged
  3. * windows, incremental append with node-cache identity, six-variant
  4. * materialization, call-index backfill, and the degraded linear-scan branch.
  5. */
  6. import { describe, expect, it, vi } from 'vitest'
  7. import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
  8. import { FoldAdapter } from '../src/client/sessions/fold-adapter.ts'
  9. import { ev, plainTurn } from './event-script.ts'
  10. const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
  11. ({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent
  12. describe('FoldAdapter', () => {
  13. it('folds a baseSeq>0 window through padding sentinels with correct seqs', () => {
  14. const adapter = new FoldAdapter()
  15. const window = plainTurn(100, 5, '偏移问', '偏移答')
  16. adapter.reset(window, 100)
  17. const { nodes, degraded } = adapter.nodes()
  18. expect(degraded).toBe(false)
  19. expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 101], ['assistant', 103]])
  20. })
  21. it('appends incrementally keeping old node references (cache identity)', () => {
  22. const adapter = new FoldAdapter()
  23. adapter.reset(plainTurn(0, 0, 'a', 'b'), 0)
  24. const first = adapter.nodes()
  25. adapter.append(ev.user(6, '追加'))
  26. const second = adapter.nodes()
  27. expect(second.nodes).toHaveLength(3)
  28. expect(second.nodes[0]).toBe(first.nodes[0])
  29. expect(second.nodes[1]).toBe(first.nodes[1])
  30. expect(second.nodes).not.toBe(first.nodes) // array itself fresh per call
  31. })
  32. it('materializes all six node variants with field mapping', () => {
  33. const adapter = new FoldAdapter()
  34. const events = [
  35. ev.user(0, '用户'),
  36. ev.assistant(1, 0, '助手'),
  37. at(2, { type: 'steering/message', surfaceOp: 'append', data: { turn: 0, content: [{ type: 'text', text: '插话' }], source: { kind: 'user' } } }),
  38. at(3, { type: 'user/message', surfaceOp: 'append', data: { content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' } } }),
  39. ev.toolCall(4, 0, 'c1', 'echo', '{"x":1}'),
  40. ev.toolResult(5, 0, 'c1', '结果'),
  41. ]
  42. adapter.reset(events, 0)
  43. const { nodes } = adapter.nodes()
  44. const kinds = nodes.map(n => n.kind)
  45. expect(kinds).toContain('user')
  46. expect(kinds).toContain('assistant')
  47. expect(kinds).toContain('steering')
  48. expect(kinds).toContain('context')
  49. const result = nodes.find(n => n.kind === 'tool-result')
  50. expect(result).toMatchObject({ callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false })
  51. })
  52. it('returns call:null for a tool-result whose call fell outside the window', () => {
  53. const adapter = new FoldAdapter()
  54. adapter.reset([ev.toolResult(50, 3, 'outside-call', '孤儿结果')], 50)
  55. const { nodes } = adapter.nodes()
  56. expect(nodes[0]).toMatchObject({ kind: 'tool-result', callId: 'outside-call', call: null })
  57. })
  58. it('materializes surface-eligible types it does not know as unknown nodes', () => {
  59. const adapter = new FoldAdapter()
  60. adapter.reset([at(0, { type: 'notice/message', surfaceOp: 'append', data: { note: 1 } })], 0)
  61. const { nodes } = adapter.nodes()
  62. // Either the fold surfaces it (unknown node) or skips it as non-eligible — both are valid
  63. // shapes; what matters is no throw and no misclassification into a known kind.
  64. for (const node of nodes) expect(node.kind).toBe('unknown')
  65. })
  66. it('degrades to the lenient linear scan when the fold throws, and stays degraded', () => {
  67. const adapter = new FoldAdapter()
  68. // An invalid surfaceOp on a surface-eligible event deterministically throws in the core fold.
  69. const window = [
  70. ev.user(10, '正常'),
  71. at(11, { type: 'assistant/message', surfaceOp: 'bogus-op', data: { turn: 0, step: 0, content: [{ type: 'text', text: '坏 op' }], provenance: { provider: 'x', model: 'y' } } }),
  72. ]
  73. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  74. try {
  75. adapter.reset(window, 10)
  76. const first = adapter.nodes()
  77. expect(first.degraded).toBe(true)
  78. expect(errorSpy).toHaveBeenCalled()
  79. expect(first.nodes.map(n => n.seq)).toEqual([10, 11]) // linear scan: append order, bad op ignored
  80. adapter.append(ev.user(12, '降级后追加')) // bump rev so the cached result is not reused
  81. const second = adapter.nodes()
  82. expect(second.degraded).toBe(true) // sticky: no re-throw loop, straight to the linear scan
  83. expect(second.nodes[0]).toBe(first.nodes[0]) // cache still serves node identity
  84. expect(second.nodes.map(n => n.seq)).toEqual([10, 11, 12])
  85. } finally {
  86. errorSpy.mockRestore()
  87. }
  88. })
  89. it('materializes a tool-result error field when present', () => {
  90. const adapter = new FoldAdapter()
  91. adapter.reset([
  92. at(0, { type: 'tool/result', surfaceOp: 'append', data: { turn: 0, step: 0, callId: 'c1', content: [], isError: true, error: { name: 'Boom', code: 'boom' } } }),
  93. ], 0)
  94. expect(adapter.nodes().nodes[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } })
  95. })
  96. it('exposes the in-window call index for runningCalls material', () => {
  97. const adapter = new FoldAdapter()
  98. adapter.reset([ev.toolCall(0, 1, 'c9', 'slow', '{}')], 0)
  99. expect(adapter.callIndex.get('c9')).toMatchObject({ name: 'slow', turn: 1 })
  100. adapter.append(ev.toolCall(1, 1, 'c10', 'fast', '{}'))
  101. expect(adapter.callIndex.size).toBe(2)
  102. })
  103. it('attaches wire views: callView into the call index, resultView onto the node by seq', () => {
  104. const adapter = new FoldAdapter()
  105. const events = [
  106. ev.toolCall(0, 1, 'c1', 'bash', '{"cmd":"ls"}'),
  107. ev.toolResult(1, 1, 'c1', 'listing'),
  108. ]
  109. const callView = { for: 'call' as const, view: { card: 'terminal' as const, command: 'ls' } }
  110. const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '完成' } }
  111. adapter.reset(events, 0, [callView, resultView] as never)
  112. expect(adapter.callIndex.get('c1')).toMatchObject({ callView: { card: 'terminal' } })
  113. const node = adapter.nodes().nodes.find(n => n.kind === 'tool-result')
  114. expect(node).toMatchObject({ callView: { card: 'terminal' }, resultView: { card: 'generic', title: '完成' } })
  115. })
  116. it('attaches views on the live append path and defaults to null without views', () => {
  117. const adapter = new FoldAdapter()
  118. adapter.reset(plainTurn(0, 0, 'a', 'b'), 0) // no views argument: legacy-shaped call
  119. adapter.append(ev.toolCall(6, 1, 'c2', 'echo', '{}'), { for: 'call', view: { card: 'generic', title: '回声' } } as never)
  120. adapter.append(ev.toolResult(7, 1, 'c2', 'ok')) // no view on the result
  121. expect(adapter.callIndex.get('c2')).toMatchObject({ callView: { title: '回声' } })
  122. const node = adapter.nodes().nodes.find(n => n.kind === 'tool-result')
  123. expect(node).toMatchObject({ callView: { title: '回声' }, resultView: null })
  124. })
  125. it('leaves callView null when the paired call fell outside the window (cross-page break)', () => {
  126. const adapter = new FoldAdapter()
  127. const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '孤儿' } }
  128. adapter.reset([ev.toolResult(50, 3, 'outside', '窗外配对')], 50, [resultView] as never)
  129. const node = adapter.nodes().nodes[0]
  130. expect(node).toMatchObject({ kind: 'tool-result', call: null, callView: null, resultView: { title: '孤儿' } })
  131. })
  132. })