tool-pairing.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. import { describe, expect, it } from 'vitest'
  2. import { CallId } from '@deepseek-ai/dsh-llm'
  3. import { Session, SessionId, isToolPairingBalanced } from '../src/index.ts'
  4. import type { SessionEvent, SurfaceNode } from '../src/index.ts'
  5. /**
  6. * Unit coverage for compaction-cut safety: a cut is balanced only when it
  7. * separates no assistant tool call from its result. Non-step nodes are neutral,
  8. * and replace operations prove surface order—not raw log order—is authoritative.
  9. */
  10. const SURFACE = { surfaceOp: 'append' as const }
  11. /** Surface nodes + log for a session, the two args the balance check takes. */
  12. function surfaceOf(session: Session): { nodes: readonly SurfaceNode[]; events: readonly SessionEvent[] } {
  13. return { nodes: session.surface.nodes, events: session.events }
  14. }
  15. /** The cut BEFORE the surface node at `seq` is balanced (safe region start). */
  16. function startBalanced(session: Session, seq: number): boolean {
  17. const { nodes, events } = surfaceOf(session)
  18. return isToolPairingBalanced(nodes, events, seq)
  19. }
  20. /** The cut AFTER the surface node at `seq` is balanced (safe region end). */
  21. function endBalanced(session: Session, seq: number): boolean {
  22. const { nodes, events } = surfaceOf(session)
  23. const node = nodes.find(n => n.seq === seq)
  24. if (!node) throw new Error(`seq ${seq} is not a surface node`)
  25. return isToolPairingBalanced(nodes, events, node.next)
  26. }
  27. /** Surface seq of the nth (0-based) event of a given type. */
  28. function seqOf(s: Session, type: SessionEvent['type'], nth = 0): number {
  29. return s.events.filter(e => e.type === type)[nth]!.seq
  30. }
  31. /** A closed turn with one closed step holding an assistant + its tool result. */
  32. function toolStepSession(): Session {
  33. const s = new Session(SessionId('tool-step'))
  34. s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  35. s.append('user/message', { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, SURFACE)
  36. s.append('step/start', { turn: 1, step: 1 })
  37. s.append('assistant/message', {
  38. turn: 1, step: 1,
  39. content: [
  40. { type: 'text', text: 'calling' },
  41. { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' },
  42. ],
  43. }, SURFACE)
  44. s.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{}' })
  45. s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, SURFACE)
  46. s.append('step/end', { turn: 1, step: 1 })
  47. s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  48. return s
  49. }
  50. describe('isToolPairingBalanced — region START (cut before a node)', () => {
  51. it('is true for a pre-step user/message (belongs to no step)', () => {
  52. const s = toolStepSession()
  53. expect(startBalanced(s, seqOf(s, 'user/message'))).toBe(true)
  54. })
  55. it('is true for the first surface node of a step (the assistant/message)', () => {
  56. // The cut before the assistant is balanced — nothing unanswered precedes it.
  57. const s = toolStepSession()
  58. expect(startBalanced(s, seqOf(s, 'assistant/message'))).toBe(true)
  59. })
  60. it('is false for a tool/result whose assistant/message precedes it in the same step', () => {
  61. // The cut before the tool/result has one unanswered tool-call (the
  62. // assistant's) → starting the region here would orphan that call.
  63. const s = toolStepSession()
  64. expect(startBalanced(s, seqOf(s, 'tool/result'))).toBe(false)
  65. })
  66. it('is true at the surface head (nothing precedes)', () => {
  67. const s = new Session(SessionId('lone'))
  68. s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  69. s.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, SURFACE)
  70. expect(startBalanced(s, seqOf(s, 'user/message'))).toBe(true)
  71. })
  72. })
  73. describe('isToolPairingBalanced — region END (cut after a node)', () => {
  74. it('is true for the last surface node of a closed step (the tool/result)', () => {
  75. // After the tool/result the assistant's single call is answered → balanced.
  76. const s = toolStepSession()
  77. expect(endBalanced(s, seqOf(s, 'tool/result'))).toBe(true)
  78. })
  79. it('is false for an assistant/message with a later tool/result in the same step', () => {
  80. // After the assistant its tool-call is still unanswered → ending here strands
  81. // the result.
  82. const s = toolStepSession()
  83. expect(endBalanced(s, seqOf(s, 'assistant/message'))).toBe(false)
  84. })
  85. it('is true for a pre-step user/message', () => {
  86. const s = toolStepSession()
  87. expect(endBalanced(s, seqOf(s, 'user/message'))).toBe(true)
  88. })
  89. it('is false at the tail when the node is inside an open (unclosed) step', () => {
  90. // step/start then an assistant tool-call, but no tool/result yet (mid-flight).
  91. // The after-tail cut still has one unanswered call → not balanced.
  92. const s = new Session(SessionId('open-step'))
  93. s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  94. s.append('step/start', { turn: 1, step: 1 })
  95. s.append('assistant/message', {
  96. turn: 1, step: 1,
  97. content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
  98. }, SURFACE)
  99. expect(endBalanced(s, seqOf(s, 'assistant/message'))).toBe(false)
  100. })
  101. it('is true at the tail when the node is a trailing inter-step node (step already closed)', () => {
  102. // A steering message appended after step/end, at the tail. The prior step's
  103. // pair is balanced and steering is neutral → the after-tail cut is balanced.
  104. const s = new Session(SessionId('trailing-steer'))
  105. s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  106. s.append('step/start', { turn: 1, step: 1 })
  107. s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, SURFACE)
  108. s.append('step/end', { turn: 1, step: 1 })
  109. s.append('steering/message', { turn: 1, content: [{ type: 'text', text: 's' }], source: { kind: 'user' } }, SURFACE)
  110. expect(endBalanced(s, seqOf(s, 'steering/message'))).toBe(true)
  111. })
  112. it('is true at the tail when no step ever opened', () => {
  113. const s = new Session(SessionId('no-step'))
  114. s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  115. s.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, SURFACE)
  116. expect(endBalanced(s, seqOf(s, 'user/message'))).toBe(true)
  117. })
  118. })
  119. describe('isToolPairingBalanced — multiple tool calls in one assistant message', () => {
  120. // An assistant message with two tool-calls needs BOTH results before the cut
  121. // after it is balanced — depth +2, then -1, -1.
  122. function twoCallStep(): Session {
  123. const s = new Session(SessionId('two-call'))
  124. s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  125. s.append('step/start', { turn: 1, step: 1 })
  126. s.append('assistant/message', {
  127. turn: 1, step: 1,
  128. content: [
  129. { type: 'tool-call', id: CallId('c1'), name: 'a', arguments: '{}' },
  130. { type: 'tool-call', id: CallId('c2'), name: 'b', arguments: '{}' },
  131. ],
  132. }, SURFACE)
  133. s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: '1' }], isError: false }, SURFACE)
  134. s.append('tool/result', { turn: 1, step: 1, callId: CallId('c2'), content: [{ type: 'text', text: '2' }], isError: false }, SURFACE)
  135. s.append('step/end', { turn: 1, step: 1 })
  136. s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  137. return s
  138. }
  139. it('is unbalanced after the first of two results (one call still open)', () => {
  140. const s = twoCallStep()
  141. expect(endBalanced(s, seqOf(s, 'tool/result', 0))).toBe(false)
  142. })
  143. it('is balanced after the second result (both calls answered)', () => {
  144. const s = twoCallStep()
  145. expect(endBalanced(s, seqOf(s, 'tool/result', 1))).toBe(true)
  146. })
  147. })
  148. describe('isToolPairingBalanced — a mid-step injection context/message', () => {
  149. // The injected context is pairing-neutral, but both adjacent cuts remain
  150. // unbalanced because the tool call is still open across them.
  151. function midStepInjection(): Session {
  152. const s = new Session(SessionId('mid-inject'))
  153. s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  154. s.append('step/start', { turn: 1, step: 1 })
  155. s.append('assistant/message', {
  156. turn: 1, step: 1,
  157. content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
  158. }, SURFACE)
  159. s.append('context/message', { content: [{ type: 'text', text: 'bg task done' }], source: { kind: 'plugin', plugin: 'tool-bash' } }, SURFACE)
  160. s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, SURFACE)
  161. s.append('step/end', { turn: 1, step: 1 })
  162. s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  163. return s
  164. }
  165. it('start cut before the mid-step context/message is unbalanced (call still open)', () => {
  166. const s = midStepInjection()
  167. expect(startBalanced(s, seqOf(s, 'context/message'))).toBe(false)
  168. })
  169. it('end cut after the mid-step context/message is unbalanced (call still open)', () => {
  170. const s = midStepInjection()
  171. expect(endBalanced(s, seqOf(s, 'context/message'))).toBe(false)
  172. })
  173. })
  174. describe('isToolPairingBalanced on an injection turn (no step)', () => {
  175. // An idle inject() wraps a context/message in a bare turn/start →
  176. // context/message → turn/end with NO step. The context node is a free boundary
  177. // both ways (pairing-neutral, nothing open around it).
  178. function injectionSession(): Session {
  179. const s = new Session(SessionId('injection'))
  180. s.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: { kind: 'user' } } })
  181. s.append('context/message', { content: [{ type: 'text', text: 'ctx' }], source: { kind: 'user' } }, SURFACE)
  182. s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  183. return s
  184. }
  185. it('start: balanced', () => {
  186. const s = injectionSession()
  187. expect(startBalanced(s, seqOf(s, 'context/message'))).toBe(true)
  188. })
  189. it('end: balanced', () => {
  190. const s = injectionSession()
  191. expect(endBalanced(s, seqOf(s, 'context/message'))).toBe(true)
  192. })
  193. })
  194. describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace op', () => {
  195. // A replacement checkpoint has a high log seq but sits at the surface head;
  196. // its cuts are balanced regardless of later raw-log neighbors.
  197. function checkpointHeadedSession(): Session {
  198. const s = new Session(SessionId('checkpoint'))
  199. // A closed turn with a tool step → surface [u1, asst(call), result].
  200. s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  201. s.append('step/start', { turn: 1, step: 1 })
  202. s.append('user/message', { content: [{ type: 'text', text: 'u1' }], source: { kind: 'user' } }, SURFACE)
  203. s.append('assistant/message', {
  204. turn: 1, step: 1,
  205. content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
  206. }, SURFACE)
  207. s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, SURFACE)
  208. s.append('step/end', { turn: 1, step: 1 })
  209. s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  210. // An OPEN turn whose step is in progress (loop fires compaction here).
  211. s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
  212. s.append('step/start', { turn: 2, step: 1 })
  213. // Compaction replaces the whole turn-1 surface ([u1, asst, result]) with one
  214. // summary user/message — appended now, so it carries a high log seq.
  215. const u1 = seqOf(s, 'user/message')
  216. const result = s.events.find(e => e.type === 'tool/result')!.seq
  217. s.append('user/message', {
  218. content: [{ type: 'text', text: 'CHECKPOINT' }],
  219. source: { kind: 'plugin', plugin: 'compact' },
  220. }, { surfaceOp: { op: 'replace', start: u1, end: result } })
  221. // The step's own assistant/message lands AFTER the checkpoint in the log,
  222. // still inside the open step.
  223. s.append('assistant/message', { turn: 2, step: 1, content: [{ type: 'text', text: 'a2' }] }, SURFACE)
  224. return s
  225. }
  226. it('the head checkpoint sits at the surface head while a later surface node follows it in the log', () => {
  227. const s = checkpointHeadedSession()
  228. const nodes = s.surface.nodes
  229. const checkpointSeq = nodes[0]!.seq
  230. // The checkpoint heads the surface, yet a surface node (the open step's
  231. // assistant) follows it in LOG order — the exact split between surface
  232. // position and log position that the log-position scan tripped on.
  233. const laterSurfaceInLog = s.events.find(
  234. e => e.seq > checkpointSeq && nodes.some(n => n.seq === e.seq),
  235. )
  236. expect(laterSurfaceInLog).toBeDefined()
  237. expect(nodes[0]!.seq).toBe(checkpointSeq)
  238. })
  239. it('start cut before the head checkpoint is balanced (it is the head)', () => {
  240. const s = checkpointHeadedSession()
  241. expect(startBalanced(s, s.surface.nodes[0]!.seq)).toBe(true)
  242. })
  243. it('end cut after the head checkpoint is balanced (it carries no tool pair)', () => {
  244. // This is the exact assertion the log-position scan failed: the forward log scan from the
  245. // checkpoint reached the open step's assistant/message and wrongly reported mid-step.
  246. const s = checkpointHeadedSession()
  247. expect(endBalanced(s, s.surface.nodes[0]!.seq)).toBe(true)
  248. })
  249. })
  250. describe('isToolPairingBalanced — corrupt surface guard', () => {
  251. it('throws when a tool/result has no preceding tool-call (depth goes negative)', () => {
  252. // A surface that opens with a tool/result (no assistant call before it) is
  253. // structurally corrupt — surfaced loudly rather than mis-classified.
  254. const s = new Session(SessionId('corrupt'))
  255. s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  256. s.append('step/start', { turn: 1, step: 1 })
  257. s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'x' }], isError: false }, SURFACE)
  258. const { nodes, events } = surfaceOf(s)
  259. expect(() => isToolPairingBalanced(nodes, events, null)).toThrow(/no matching tool-call/)
  260. })
  261. })