Files
deepseek-harness/packages/hooks/hook-protocol/tests/merge.spec.ts
Tianyi Cui 65165b5d54 feat(hooks): dsh-hook-protocol — shared Claude Code / Codex hook wire-protocol core
The two hook bridges (dsh-hooks-claude, dsh-hooks-codex) would otherwise duplicate
the bulk of the protocol — Codex deliberately reimplements a SUBSET of the Claude
Code protocol (same hooks.json shape, exit-code/stdout contract, command-hook
model). This library holds the genuinely-identical primitives; each bridge owns
only what differs (per-event stdin payload, env/substitution, decision mapping).

New packages/hooks/ group; hook-protocol is a LIBRARY (no plugin, registers/injects
nothing):
- matcher: matchesMatcher(pattern, query, mode) — the one dialect axis collapsed to
  a mode param (claude = literal-or-regex with pipe alternation; codex = always
  unanchored regex). Match-all on absent/''/'*'; invalid regex matches nothing.
- codec: parseHookOutput(exit, stdout, stderr) → dialect-neutral HookOutput. Exit 0
  → lenient JSON; exit 2 → blocking error (stderr = reason, surfaced as
  decision:'block'); other → non-blocking. Parses the CC superset
  (continue/stopReason/decision/hookSpecificOutput.{permissionDecision,
  additionalContext,updatedInput}/systemMessage); permissionDecision overrides the
  legacy top-level decision.
- runner: runHook(bash, hook, opts, now) — runs a command hook via ctx.bash (stdin
  payload + trusted-plugin env), honors timeoutSec, never throws (executor reject →
  non-blocking-error HookOutput). Injected clock for testable durations.
- merge: mergeHookOutputs — most-restrictive fold (deny>ask>allow, sticky stop,
  block reasons joined, context/system-messages accumulated).
- hook/* session events (declaration-merged into SessionEventMap, log-only like
  compact/*) + appendHookInvoked/appendHookResult helpers.

updatedInput is parsed but NOT honored (deferred pre-tool-input-rewrite RFC); a
bridge logs+warns. 47 unit tests at per-file 100% (matcher per-mode, codec per
exit-code/field, runner plumbing w/ stub executor, merge precedence, hook/*
helpers). RFC: implemented/feature/2026-06-30-hook-protocol-lib.md.
2026-07-01 00:41:53 +08:00

83 lines
3.3 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { mergeHookOutputs } from '@deepseek-ai/dsh-hook-protocol'
import type { HookOutput } from '@deepseek-ai/dsh-hook-protocol'
function out(over: Partial<HookOutput> = {}): HookOutput {
return { exitCode: 0, stderr: '', ...over }
}
describe('mergeHookOutputs — permission precedence deny > ask > allow', () => {
it('empty list yields a neutral outcome', () => {
const m = mergeHookOutputs([])
expect(m.decision).toBe('none')
expect(m.stop).toBe(false)
expect(m.additionalContext).toEqual([])
expect(m.systemMessages).toEqual([])
})
it('a single allow yields allow', () => {
expect(mergeHookOutputs([out({ decision: 'allow' })]).decision).toBe('allow')
expect(mergeHookOutputs([out({ decision: 'approve' })]).decision).toBe('allow')
})
it('deny beats ask beats allow regardless of order', () => {
expect(mergeHookOutputs([out({ decision: 'allow' }), out({ decision: 'ask' })]).decision).toBe('ask')
expect(mergeHookOutputs([out({ decision: 'ask' }), out({ decision: 'deny' })]).decision).toBe('deny')
expect(mergeHookOutputs([out({ decision: 'deny' }), out({ decision: 'allow' })]).decision).toBe('deny')
// block folds to deny
expect(mergeHookOutputs([out({ decision: 'allow' }), out({ decision: 'block' })]).decision).toBe('deny')
})
it('no decision anywhere yields none', () => {
expect(mergeHookOutputs([out(), out()]).decision).toBe('none')
})
})
describe('mergeHookOutputs — reasons, stop, context, systemMessages accumulate', () => {
it('joins block/deny reasons with a blank line (only from blocking hooks)', () => {
const m = mergeHookOutputs([
out({ decision: 'deny', reason: 'first objection' }),
out({ decision: 'allow', reason: 'this allow reason is NOT collected' }),
out({ decision: 'block', reason: 'second objection' }),
])
expect(m.reason).toBe('first objection\n\nsecond objection')
})
it('no reason when nothing blocked', () => {
expect(mergeHookOutputs([out({ decision: 'allow' })]).reason).toBeUndefined()
})
it('stop is sticky on the first continue:false, capturing its stopReason', () => {
const m = mergeHookOutputs([
out({ continue: true }),
out({ continue: false, stopReason: 'halt now' }),
out({ continue: false, stopReason: 'second halt — ignored' }),
])
expect(m.stop).toBe(true)
expect(m.stopReason).toBe('halt now')
})
it('no stop when every hook continues', () => {
const m = mergeHookOutputs([out({ continue: true }), out()])
expect(m.stop).toBe(false)
expect(m.stopReason).toBeUndefined()
})
it('a continue:false with no stopReason stops with an undefined reason', () => {
const m = mergeHookOutputs([out({ continue: false })])
expect(m.stop).toBe(true)
expect(m.stopReason).toBeUndefined()
})
it('collects additionalContext and systemMessages in hook order, skipping empties', () => {
const m = mergeHookOutputs([
out({ additionalContext: 'ctx-A', systemMessage: 'warn-A' }),
out({ additionalContext: '', systemMessage: '' }), // empties skipped
out({ additionalContext: 'ctx-B' }),
out({ systemMessage: 'warn-B' }),
])
expect(m.additionalContext).toEqual(['ctx-A', 'ctx-B'])
expect(m.systemMessages).toEqual(['warn-A', 'warn-B'])
})
})