repair.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. import { describe, expect, it } from 'vitest'
  2. import { ToolCallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
  3. import { interruptedTurnClosers as repairInterruptedTurn, SessionSeq, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts'
  4. import type { SessionEvent as LogicalSessionEvent, SurfaceEvent } from '../src/index.ts'
  5. interface SessionEvent {
  6. type: string
  7. seq: number
  8. time: number
  9. data: unknown
  10. [key: string]: unknown
  11. }
  12. function interruptedTurnClosers(events: readonly SessionEvent[]): LogicalSessionEvent[] {
  13. for (const event of events) SessionSeq(event.seq)
  14. return repairInterruptedTurn(events as unknown as readonly LogicalSessionEvent[])
  15. }
  16. /**
  17. * Unit coverage for the crash-recovery closer synthesis. The persistence
  18. * contract exercises it end-to-end through the real JSONL provider; these tests pin the
  19. * pure function's branches directly — especially the synthetic error
  20. * `tool/result` for a tool call the crash left unanswered (without it a
  21. * resumed session replays a dangling assistant tool-call and the provider
  22. * rejects the transcript).
  23. */
  24. const userTurnStart = (turn: number, seq: number): SessionEvent =>
  25. ({ type: 'turn/start', seq, time: seq, data: { turn } })
  26. describe('interruptedTurnClosers', () => {
  27. it('returns nothing for a balanced log (ends on turn/end)', () => {
  28. const balanced: SessionEvent[] = [
  29. userTurnStart(1, 0),
  30. { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } },
  31. ]
  32. expect(interruptedTurnClosers(balanced)).toEqual([])
  33. })
  34. it('returns nothing for an empty log', () => {
  35. expect(interruptedTurnClosers([])).toEqual([])
  36. })
  37. it('closes an open turn with no open step (turn/end {interrupted} only)', () => {
  38. const events: SessionEvent[] = [userTurnStart(1, 0)]
  39. const closers = interruptedTurnClosers(events)
  40. expect(closers.map(e => e.type)).toEqual(['turn/end'])
  41. const end = closers[0]!
  42. expect(end.seq).toBe(1)
  43. expect(end.type === 'turn/end' && end.data.reason).toEqual({ kind: 'interrupted' })
  44. })
  45. it('closes an open step before the turn (step/end then turn/end)', () => {
  46. const events: SessionEvent[] = [
  47. userTurnStart(1, 0),
  48. { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
  49. ]
  50. const closers = interruptedTurnClosers(events)
  51. expect(closers.map(e => e.type)).toEqual(['step/end', 'turn/end'])
  52. expect(closers.map(e => e.seq)).toEqual([2, 3])
  53. })
  54. it('marks an assistant tool request with no recorded call as not started', () => {
  55. const events: SessionEvent[] = [
  56. userTurnStart(2, 0),
  57. { type: 'step/start', seq: 1, time: 1, data: { turn: 2, step: 1 } },
  58. { type: 'assistant/message', seq: 2, time: 2, data: {
  59. turn: 2, step: 1,
  60. message: createMessage({
  61. role: 'assistant',
  62. content: [
  63. { type: 'text', text: 'calling a tool' },
  64. { type: 'tool-call', id: ToolCallId('call-1'), name: 'bash', arguments: '{}' },
  65. ],
  66. source: {
  67. kind: 'model',
  68. ...{ provider: 'mock', model: 'mock' },
  69. },
  70. }),
  71. } },
  72. ]
  73. const closers = interruptedTurnClosers(events)
  74. // tool/result (for the orphaned call) → step/end → turn/end, contiguous seqs.
  75. expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end'])
  76. expect(closers.map(e => e.seq)).toEqual([3, 4, 5])
  77. const result = closers[0]!
  78. expect(result.type === 'tool/result' && result.data).toMatchObject({
  79. turn: 2,
  80. step: 1,
  81. message: {
  82. source: { callId: ToolCallId('call-1') },
  83. content: [{ isError: true }],
  84. },
  85. error: { code: TOOL_NOT_STARTED },
  86. })
  87. expect(result.type === 'tool/result' && result.data.message.content[0].content).toEqual([{
  88. type: 'text', text: 'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.',
  89. }])
  90. })
  91. it('does NOT synthesize a result for a tool-call that already has one', () => {
  92. const events: SessionEvent[] = [
  93. userTurnStart(2, 0),
  94. { type: 'step/start', seq: 1, time: 1, data: { turn: 2, step: 1 } },
  95. { type: 'assistant/message', seq: 2, time: 2, data: {
  96. turn: 2, step: 1,
  97. message: createMessage({
  98. role: 'assistant',
  99. content: [
  100. { type: 'tool-call', id: ToolCallId('call-1'), name: 'bash', arguments: '{}' },
  101. ],
  102. source: {
  103. kind: 'model',
  104. ...{ provider: 'mock', model: 'mock' },
  105. },
  106. }),
  107. } },
  108. { type: 'tool/result', seq: 3, time: 3, data: {
  109. turn: 2, step: 1,
  110. message: createToolResultMessage({
  111. callId: ToolCallId('call-1'),
  112. content: [{ type: 'text', text: 'ok' }],
  113. isError: false,
  114. }),
  115. } },
  116. ]
  117. // The call is answered, so only the open step + turn need closing.
  118. const closers = interruptedTurnClosers(events)
  119. expect(closers.map(e => e.type)).toEqual(['step/end', 'turn/end'])
  120. })
  121. it('does NOT synthesize a result after the owning step already closed', () => {
  122. const events: SessionEvent[] = [
  123. userTurnStart(2, 0),
  124. { type: 'step/start', seq: 1, time: 1, data: { turn: 2, step: 1 } },
  125. { type: 'assistant/message', seq: 2, time: 2, data: {
  126. turn: 2, step: 1,
  127. message: createMessage({
  128. role: 'assistant',
  129. content: [
  130. { type: 'tool-call', id: ToolCallId('call-1'), name: 'bash', arguments: '{}' },
  131. ],
  132. source: {
  133. kind: 'model',
  134. ...{ provider: 'mock', model: 'mock' },
  135. },
  136. }),
  137. } },
  138. { type: 'step/end', seq: 3, time: 3, data: { turn: 2, step: 1 } },
  139. ]
  140. const closers = interruptedTurnClosers(events)
  141. expect(closers.map(e => e.type)).toEqual(['turn/end'])
  142. expect(closers[0]?.seq).toBe(4)
  143. })
  144. it('synthesizes results only for the still-open turn, not a committed earlier turn', () => {
  145. // Turn 1 completed with its own tool call+result (balanced). Turn 2 crashed
  146. // with an unanswered call. Only turn 2's call must get a synthetic result.
  147. const events: SessionEvent[] = [
  148. userTurnStart(1, 0),
  149. { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
  150. { type: 'assistant/message', seq: 2, time: 2, data: {
  151. turn: 1, step: 1,
  152. message: createMessage({
  153. role: 'assistant',
  154. content: [
  155. { type: 'tool-call', id: ToolCallId('old-call'), name: 'bash', arguments: '{}' },
  156. ],
  157. source: {
  158. kind: 'model',
  159. ...{ provider: 'mock', model: 'mock' },
  160. },
  161. }),
  162. } },
  163. { type: 'tool/result', seq: 3, time: 3, data: {
  164. turn: 1, step: 1,
  165. message: createToolResultMessage({
  166. callId: ToolCallId('old-call'),
  167. content: [],
  168. isError: false,
  169. }),
  170. } },
  171. { type: 'step/end', seq: 4, time: 4, data: { turn: 1, step: 1 } },
  172. { type: 'turn/end', seq: 5, time: 5, data: { turn: 1, reason: { kind: 'completed' } } },
  173. userTurnStart(2, 6),
  174. { type: 'step/start', seq: 7, time: 7, data: { turn: 2, step: 1 } },
  175. { type: 'assistant/message', seq: 8, time: 8, data: {
  176. turn: 2, step: 1,
  177. message: createMessage({
  178. role: 'assistant',
  179. content: [
  180. { type: 'tool-call', id: ToolCallId('new-call'), name: 'bash', arguments: '{}' },
  181. ],
  182. source: {
  183. kind: 'model',
  184. ...{ provider: 'mock', model: 'mock' },
  185. },
  186. }),
  187. } },
  188. ]
  189. const closers = interruptedTurnClosers(events)
  190. expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end'])
  191. const result = closers[0]!
  192. expect(result.type === 'tool/result' && result.data.message.source.callId).toBe('new-call')
  193. })
  194. it('synthesizes a result for each of multiple unanswered calls, in log order', () => {
  195. const events: SessionEvent[] = [
  196. userTurnStart(1, 0),
  197. { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
  198. { type: 'assistant/message', seq: 2, time: 2, data: {
  199. turn: 1, step: 1,
  200. message: createMessage({
  201. role: 'assistant',
  202. content: [
  203. { type: 'tool-call', id: ToolCallId('call-a'), name: 'bash', arguments: '{}' },
  204. { type: 'tool-call', id: ToolCallId('call-b'), name: 'bash', arguments: '{}' },
  205. ],
  206. source: {
  207. kind: 'model',
  208. ...{ provider: 'mock', model: 'mock' },
  209. },
  210. }),
  211. } },
  212. // call-a got answered before the crash; call-b did not.
  213. { type: 'tool/result', seq: 3, time: 3, data: {
  214. turn: 1, step: 1,
  215. message: createToolResultMessage({
  216. callId: ToolCallId('call-a'),
  217. content: [],
  218. isError: false,
  219. }),
  220. } },
  221. ]
  222. const closers = interruptedTurnClosers(events)
  223. expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end'])
  224. const result = closers[0]!
  225. expect(result.type === 'tool/result' && result.data.message.source.callId).toBe('call-b')
  226. })
  227. it('synthesized tool/result carries surfaceOp and sourceEventSeqs when tool/call was logged', () => {
  228. const events: SessionEvent[] = [
  229. userTurnStart(1, 0),
  230. { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
  231. { type: 'assistant/message', seq: 2, time: 2, data: {
  232. turn: 1, step: 1,
  233. message: createMessage({
  234. role: 'assistant',
  235. content: [
  236. { type: 'tool-call', id: ToolCallId('call-1'), name: 'bash', arguments: '{}' },
  237. ],
  238. source: {
  239. kind: 'model',
  240. ...{ provider: 'mock', model: 'mock' },
  241. },
  242. }),
  243. } },
  244. { type: 'tool/call', seq: 3, time: 3, data: { turn: 1, step: 1, callId: ToolCallId('call-1'), name: 'bash', arguments: '{}' } },
  245. ]
  246. const closers = interruptedTurnClosers(events)
  247. expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end'])
  248. const result = closers[0]!
  249. expect((result as SurfaceEvent).surfaceOp).toBe('append')
  250. expect((result as SurfaceEvent).sourceEventSeqs).toEqual([3])
  251. expect(result.type === 'tool/result' && result.data.error).toEqual({
  252. name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN,
  253. })
  254. if (result.type !== 'tool/result' || result.data.message.content[0].content[0]?.type !== 'text') {
  255. throw new Error('expected a text tool result')
  256. }
  257. expect(result.data.message.content[0].content[0].text).toContain('retry only if the operation is read-only or idempotent')
  258. expect(result.data.message.content[0].content[0].text).toContain('first verify external state or ask the user')
  259. })
  260. it('handles tool/call without a matching assistant/message entry gracefully', () => {
  261. // A raw tool/call with no assistant-registered pending call has nothing to
  262. // answer; repair still closes the step and turn without synthesizing a result.
  263. const events: SessionEvent[] = [
  264. userTurnStart(1, 0),
  265. { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
  266. { type: 'tool/call', seq: 2, time: 2, data: { turn: 1, step: 1, callId: ToolCallId('orphan'), name: 'bash', arguments: '{}' } },
  267. ]
  268. const closers = interruptedTurnClosers(events)
  269. // No pending calls → no synthetic tool/result, just step/end + turn/end.
  270. expect(closers.map(e => e.type)).toEqual(['step/end', 'turn/end'])
  271. })
  272. })