repair.spec.ts 12 KB

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