tool-pairing.spec.ts 15 KB

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