codec.spec.ts 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  1. import { describe, expect, it } from 'vitest'
  2. import { parseHookOutput } from '@deepseek-ai/dsh-hook-protocol'
  3. describe('parseHookOutput — exit code semantics', () => {
  4. it('exit 0 with no stdout is a neutral success', () => {
  5. const out = parseHookOutput(0, '', '')
  6. expect(out.exitCode).toBe(0)
  7. expect(out.decision).toBeUndefined()
  8. expect(out.continue).toBeUndefined()
  9. })
  10. it('exit 2 is a blocking error: stderr becomes the block decision + reason', () => {
  11. const out = parseHookOutput(2, '', 'this command is not allowed')
  12. expect(out.decision).toBe('block')
  13. expect(out.reason).toBe('this command is not allowed')
  14. expect(out.stderr).toBe('this command is not allowed')
  15. })
  16. it('exit 2 with empty stderr still blocks, with no reason', () => {
  17. const out = parseHookOutput(2, '', ' ')
  18. expect(out.decision).toBe('block')
  19. expect(out.reason).toBeUndefined()
  20. })
  21. it('other non-zero exit is a non-blocking error (no decision, stderr recorded)', () => {
  22. const out = parseHookOutput(1, '', 'some warning')
  23. expect(out.decision).toBeUndefined()
  24. expect(out.exitCode).toBe(1)
  25. expect(out.stderr).toBe('some warning')
  26. })
  27. it('undefined exit (could not run) carries no decision', () => {
  28. const out = parseHookOutput(undefined, '', 'spawn failed: ENOENT')
  29. expect(out.exitCode).toBeUndefined()
  30. expect(out.decision).toBeUndefined()
  31. expect(out.stderr).toBe('spawn failed: ENOENT')
  32. })
  33. })
  34. describe('parseHookOutput — structured stdout (exit 0 only)', () => {
  35. it('parses top-level continue/stopReason/suppressOutput/systemMessage', () => {
  36. const out = parseHookOutput(0, JSON.stringify({
  37. continue: false, stopReason: 'budget exceeded', suppressOutput: true, systemMessage: 'heads up',
  38. }), '')
  39. expect(out.continue).toBe(false)
  40. expect(out.stopReason).toBe('budget exceeded')
  41. expect(out.suppressOutput).toBe(true)
  42. expect(out.systemMessage).toBe('heads up')
  43. })
  44. it('parses legacy top-level decision + reason (approve/block)', () => {
  45. expect(parseHookOutput(0, JSON.stringify({ decision: 'block', reason: 'nope' }), '').decision).toBe('block')
  46. expect(parseHookOutput(0, JSON.stringify({ decision: 'approve' }), '').decision).toBe('approve')
  47. })
  48. it('hookSpecificOutput.permissionDecision OVERRIDES the legacy top-level decision', () => {
  49. const out = parseHookOutput(0, JSON.stringify({
  50. decision: 'approve',
  51. hookSpecificOutput: { permissionDecision: 'deny', permissionDecisionReason: 'denied by policy' },
  52. }), '')
  53. expect(out.decision).toBe('deny')
  54. expect(out.reason).toBe('denied by policy')
  55. })
  56. it('parses allow/ask permissionDecision (the bridge decides whether to honor)', () => {
  57. expect(parseHookOutput(0, JSON.stringify({ hookSpecificOutput: { permissionDecision: 'allow' } }), '').decision).toBe('allow')
  58. expect(parseHookOutput(0, JSON.stringify({ hookSpecificOutput: { permissionDecision: 'ask' } }), '').decision).toBe('ask')
  59. })
  60. it('parses additionalContext and updatedInput from hookSpecificOutput', () => {
  61. const out = parseHookOutput(0, JSON.stringify({
  62. hookSpecificOutput: { additionalContext: 'remember X', updatedInput: { command: 'safe' } },
  63. }), '')
  64. expect(out.additionalContext).toBe('remember X')
  65. expect(out.updatedInput).toEqual({ command: 'safe' })
  66. })
  67. it('an unknown decision string is ignored (not coerced)', () => {
  68. expect(parseHookOutput(0, JSON.stringify({ decision: 'maybe' }), '').decision).toBeUndefined()
  69. })
  70. it('malformed JSON on a clean exit is lenient (no structured output, no throw)', () => {
  71. const out = parseHookOutput(0, '{ not valid json', '')
  72. expect(out.decision).toBeUndefined()
  73. expect(out.continue).toBeUndefined()
  74. })
  75. it('non-object stdout (plain text) on exit 0 is left for the bridge (no JSON attempt)', () => {
  76. const out = parseHookOutput(0, 'just some text output', '')
  77. expect(out.decision).toBeUndefined()
  78. expect(out.continue).toBeUndefined()
  79. })
  80. it('a JSON array stdout parses but yields no fields (not an object)', () => {
  81. // Starts with '{'? No — '[' — so it is not even attempted. Neutral.
  82. const out = parseHookOutput(0, '[1,2,3]', '')
  83. expect(out.decision).toBeUndefined()
  84. })
  85. it('structured stdout is IGNORED on a blocking (exit 2) run — stderr is authoritative', () => {
  86. const out = parseHookOutput(2, JSON.stringify({ decision: 'approve' }), 'blocked')
  87. // exit 2 forces block regardless of what stdout claims
  88. expect(out.decision).toBe('block')
  89. expect(out.reason).toBe('blocked')
  90. })
  91. })