repair.spec.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. import { describe, expect, it } from 'vitest'
  2. import { CallId } from '@deepseek-ai/dsh-llm'
  3. import { interruptedTurnClosers, 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, trigger: { kind: 'message', source: { kind: 'user' } } } })
  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: { turn: 2, step: 1, content: [
  48. { type: 'text', text: 'calling a tool' },
  49. { type: 'tool-call', id: CallId('call-1'), name: 'bash', arguments: '{}' },
  50. ], provenance: { provider: 'mock', model: 'mock' } } },
  51. ]
  52. const closers = interruptedTurnClosers(events)
  53. // tool/result (for the orphaned call) → step/end → turn/end, contiguous seqs.
  54. expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end'])
  55. expect(closers.map(e => e.seq)).toEqual([3, 4, 5])
  56. const result = closers[0]!
  57. expect(result.type === 'tool/result' && result.data).toMatchObject({
  58. turn: 2, step: 1, callId: CallId('call-1'), isError: true, error: { code: TOOL_NOT_STARTED },
  59. })
  60. expect(result.type === 'tool/result' && result.data.content).toEqual([{
  61. type: 'text', text: 'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.',
  62. }])
  63. })
  64. it('does NOT synthesize a result for a tool-call that already has one', () => {
  65. const events: SessionEvent[] = [
  66. userTurnStart(2, 0),
  67. { type: 'step/start', seq: 1, time: 1, data: { turn: 2, step: 1 } },
  68. { type: 'assistant/message', seq: 2, time: 2, data: { turn: 2, step: 1, content: [
  69. { type: 'tool-call', id: CallId('call-1'), name: 'bash', arguments: '{}' },
  70. ], provenance: { provider: 'mock', model: 'mock' } } },
  71. { type: 'tool/result', seq: 3, time: 3, data: { turn: 2, step: 1, callId: CallId('call-1'), content: [{ type: 'text', text: 'ok' }], isError: false } },
  72. ]
  73. // The call is answered, so only the open step + turn need closing.
  74. const closers = interruptedTurnClosers(events)
  75. expect(closers.map(e => e.type)).toEqual(['step/end', 'turn/end'])
  76. })
  77. it('does NOT synthesize a result after the owning step already closed', () => {
  78. const events: SessionEvent[] = [
  79. userTurnStart(2, 0),
  80. { type: 'step/start', seq: 1, time: 1, data: { turn: 2, step: 1 } },
  81. { type: 'assistant/message', seq: 2, time: 2, data: { turn: 2, step: 1, content: [
  82. { type: 'tool-call', id: CallId('call-1'), name: 'bash', arguments: '{}' },
  83. ], provenance: { provider: 'mock', model: 'mock' } } },
  84. { type: 'step/end', seq: 3, time: 3, data: { turn: 2, step: 1 } },
  85. ]
  86. const closers = interruptedTurnClosers(events)
  87. expect(closers.map(e => e.type)).toEqual(['turn/end'])
  88. expect(closers[0]?.seq).toBe(4)
  89. })
  90. it('synthesizes results only for the still-open turn, not a committed earlier turn', () => {
  91. // Turn 1 completed with its own tool call+result (balanced). Turn 2 crashed
  92. // with an unanswered call. Only turn 2's call must get a synthetic result.
  93. const events: SessionEvent[] = [
  94. userTurnStart(1, 0),
  95. { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
  96. { type: 'assistant/message', seq: 2, time: 2, data: { turn: 1, step: 1, content: [
  97. { type: 'tool-call', id: CallId('old-call'), name: 'bash', arguments: '{}' },
  98. ], provenance: { provider: 'mock', model: 'mock' } } },
  99. { type: 'tool/result', seq: 3, time: 3, data: { turn: 1, step: 1, callId: CallId('old-call'), content: [], isError: false } },
  100. { type: 'step/end', seq: 4, time: 4, data: { turn: 1, step: 1 } },
  101. { type: 'turn/end', seq: 5, time: 5, data: { turn: 1, reason: { kind: 'completed' } } },
  102. userTurnStart(2, 6),
  103. { type: 'step/start', seq: 7, time: 7, data: { turn: 2, step: 1 } },
  104. { type: 'assistant/message', seq: 8, time: 8, data: { turn: 2, step: 1, content: [
  105. { type: 'tool-call', id: CallId('new-call'), name: 'bash', arguments: '{}' },
  106. ], provenance: { provider: 'mock', model: 'mock' } } },
  107. ]
  108. const closers = interruptedTurnClosers(events)
  109. expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end'])
  110. const result = closers[0]!
  111. expect(result.type === 'tool/result' && result.data.callId).toBe('new-call')
  112. })
  113. it('synthesizes a result for each of multiple unanswered calls, in log order', () => {
  114. const events: SessionEvent[] = [
  115. userTurnStart(1, 0),
  116. { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
  117. { type: 'assistant/message', seq: 2, time: 2, data: { turn: 1, step: 1, content: [
  118. { type: 'tool-call', id: CallId('call-a'), name: 'bash', arguments: '{}' },
  119. { type: 'tool-call', id: CallId('call-b'), name: 'bash', arguments: '{}' },
  120. ], provenance: { provider: 'mock', model: 'mock' } } },
  121. // call-a got answered before the crash; call-b did not.
  122. { type: 'tool/result', seq: 3, time: 3, data: { turn: 1, step: 1, callId: CallId('call-a'), content: [], isError: false } },
  123. ]
  124. const closers = interruptedTurnClosers(events)
  125. expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end'])
  126. const result = closers[0]!
  127. expect(result.type === 'tool/result' && result.data.callId).toBe('call-b')
  128. })
  129. it('synthesized tool/result carries surfaceOp and sourceEventSeqs when tool/call was logged', () => {
  130. const events: SessionEvent[] = [
  131. userTurnStart(1, 0),
  132. { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
  133. { type: 'assistant/message', seq: 2, time: 2, data: { turn: 1, step: 1, content: [
  134. { type: 'tool-call', id: CallId('call-1'), name: 'bash', arguments: '{}' },
  135. ], provenance: { provider: 'mock', model: 'mock' } } },
  136. { type: 'tool/call', seq: 3, time: 3, data: { turn: 1, step: 1, callId: CallId('call-1'), name: 'bash', arguments: '{}' } },
  137. ]
  138. const closers = interruptedTurnClosers(events)
  139. expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end'])
  140. const result = closers[0]!
  141. expect((result as SurfaceEvent).surfaceOp).toBe('append')
  142. expect((result as SurfaceEvent).sourceEventSeqs).toEqual([3])
  143. expect(result.type === 'tool/result' && result.data.error).toEqual({
  144. name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN,
  145. })
  146. if (result.type !== 'tool/result' || result.data.content[0]?.type !== 'text') {
  147. throw new Error('expected a text tool result')
  148. }
  149. expect(result.data.content[0].text).toContain('retry only if the operation is read-only or idempotent')
  150. expect(result.data.content[0].text).toContain('first verify external state or ask the user')
  151. })
  152. it('handles tool/call without a matching assistant/message entry gracefully', () => {
  153. // A raw tool/call with no assistant-registered pending call has nothing to
  154. // answer; repair still closes the step and turn without synthesizing a result.
  155. const events: SessionEvent[] = [
  156. userTurnStart(1, 0),
  157. { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
  158. { type: 'tool/call', seq: 2, time: 2, data: { turn: 1, step: 1, callId: CallId('orphan'), name: 'bash', arguments: '{}' } },
  159. ]
  160. const closers = interruptedTurnClosers(events)
  161. // No pending calls → no synthetic tool/result, just step/end + turn/end.
  162. expect(closers.map(e => e.type)).toEqual(['step/end', 'turn/end'])
  163. })
  164. })