merge.spec.ts 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
  1. import { describe, expect, it } from 'vitest'
  2. import { mergeHookOutputs } from '@deepseek-ai/dsh-hook-protocol'
  3. import type { HookOutput } from '@deepseek-ai/dsh-hook-protocol'
  4. function out(over: Partial<HookOutput> = {}): HookOutput {
  5. return { exitCode: 0, stderr: '', stdout: '', ...over }
  6. }
  7. describe('mergeHookOutputs — permission precedence deny > ask > allow', () => {
  8. it('empty list yields a neutral outcome', () => {
  9. const m = mergeHookOutputs([])
  10. expect(m.decision).toBe('none')
  11. expect(m.stop).toBe(false)
  12. expect(m.additionalContext).toEqual([])
  13. expect(m.systemMessages).toEqual([])
  14. })
  15. it('a single allow yields allow', () => {
  16. expect(mergeHookOutputs([out({ decision: 'allow' })]).decision).toBe('allow')
  17. expect(mergeHookOutputs([out({ decision: 'approve' })]).decision).toBe('allow')
  18. })
  19. it('deny beats ask beats allow regardless of order', () => {
  20. expect(mergeHookOutputs([out({ decision: 'allow' }), out({ decision: 'ask' })]).decision).toBe('ask')
  21. expect(mergeHookOutputs([out({ decision: 'ask' }), out({ decision: 'deny' })]).decision).toBe('deny')
  22. expect(mergeHookOutputs([out({ decision: 'deny' }), out({ decision: 'allow' })]).decision).toBe('deny')
  23. // block folds to deny
  24. expect(mergeHookOutputs([out({ decision: 'allow' }), out({ decision: 'block' })]).decision).toBe('deny')
  25. })
  26. it('no decision anywhere yields none', () => {
  27. expect(mergeHookOutputs([out(), out()]).decision).toBe('none')
  28. })
  29. })
  30. describe('mergeHookOutputs — reasons, stop, context, systemMessages accumulate', () => {
  31. it('joins block/deny reasons with a blank line (only from blocking hooks)', () => {
  32. const m = mergeHookOutputs([
  33. out({ decision: 'deny', reason: 'first objection' }),
  34. out({ decision: 'allow', reason: 'this allow reason is NOT collected' }),
  35. out({ decision: 'block', reason: 'second objection' }),
  36. ])
  37. expect(m.reason).toBe('first objection\n\nsecond objection')
  38. })
  39. it('no reason when nothing blocked', () => {
  40. expect(mergeHookOutputs([out({ decision: 'allow' })]).reason).toBeUndefined()
  41. })
  42. it('surfaces the reason of the WINNING decision: an ask-winning outcome shows the ask reason', () => {
  43. const m = mergeHookOutputs([
  44. out({ decision: 'allow', reason: 'allow reason — not surfaced' }),
  45. out({ decision: 'ask', reason: 'needs approval' }),
  46. ])
  47. expect(m.decision).toBe('ask')
  48. expect(m.reason).toBe('needs approval')
  49. })
  50. it('when deny wins over ask, the ask reasons are dropped (only the winning rank\'s reasons)', () => {
  51. const m = mergeHookOutputs([
  52. out({ decision: 'ask', reason: 'ask reason — not surfaced once deny wins' }),
  53. out({ decision: 'deny', reason: 'the real objection' }),
  54. ])
  55. expect(m.decision).toBe('deny')
  56. expect(m.reason).toBe('the real objection')
  57. })
  58. it('stop is sticky on the first continue:false, capturing its stopReason', () => {
  59. const m = mergeHookOutputs([
  60. out({ continue: true }),
  61. out({ continue: false, stopReason: 'halt now' }),
  62. out({ continue: false, stopReason: 'second halt — ignored' }),
  63. ])
  64. expect(m.stop).toBe(true)
  65. expect(m.stopReason).toBe('halt now')
  66. })
  67. it('no stop when every hook continues', () => {
  68. const m = mergeHookOutputs([out({ continue: true }), out()])
  69. expect(m.stop).toBe(false)
  70. expect(m.stopReason).toBeUndefined()
  71. })
  72. it('a continue:false with no stopReason stops with an undefined reason', () => {
  73. const m = mergeHookOutputs([out({ continue: false })])
  74. expect(m.stop).toBe(true)
  75. expect(m.stopReason).toBeUndefined()
  76. })
  77. it('collects additionalContext and systemMessages in hook order, skipping empties', () => {
  78. const m = mergeHookOutputs([
  79. out({ additionalContext: 'ctx-A', systemMessage: 'warn-A' }),
  80. out({ additionalContext: '', systemMessage: '' }), // empties skipped
  81. out({ additionalContext: 'ctx-B' }),
  82. out({ systemMessage: 'warn-B' }),
  83. ])
  84. expect(m.additionalContext).toEqual(['ctx-A', 'ctx-B'])
  85. expect(m.systemMessages).toEqual(['warn-A', 'warn-B'])
  86. })
  87. })