Files
deepseek-harness/packages/hooks/hook-protocol/tests/codec.spec.ts
T
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

107 lines
4.5 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { parseHookOutput } from '@deepseek-ai/dsh-hook-protocol'
describe('parseHookOutput — exit code semantics', () => {
it('exit 0 with no stdout is a neutral success', () => {
const out = parseHookOutput(0, '', '')
expect(out.exitCode).toBe(0)
expect(out.decision).toBeUndefined()
expect(out.continue).toBeUndefined()
})
it('exit 2 is a blocking error: stderr becomes the block decision + reason', () => {
const out = parseHookOutput(2, '', 'this command is not allowed')
expect(out.decision).toBe('block')
expect(out.reason).toBe('this command is not allowed')
expect(out.stderr).toBe('this command is not allowed')
})
it('exit 2 with empty stderr still blocks, with no reason', () => {
const out = parseHookOutput(2, '', ' ')
expect(out.decision).toBe('block')
expect(out.reason).toBeUndefined()
})
it('other non-zero exit is a non-blocking error (no decision, stderr recorded)', () => {
const out = parseHookOutput(1, '', 'some warning')
expect(out.decision).toBeUndefined()
expect(out.exitCode).toBe(1)
expect(out.stderr).toBe('some warning')
})
it('undefined exit (could not run) carries no decision', () => {
const out = parseHookOutput(undefined, '', 'spawn failed: ENOENT')
expect(out.exitCode).toBeUndefined()
expect(out.decision).toBeUndefined()
expect(out.stderr).toBe('spawn failed: ENOENT')
})
})
describe('parseHookOutput — structured stdout (exit 0 only)', () => {
it('parses top-level continue/stopReason/suppressOutput/systemMessage', () => {
const out = parseHookOutput(0, JSON.stringify({
continue: false, stopReason: 'budget exceeded', suppressOutput: true, systemMessage: 'heads up',
}), '')
expect(out.continue).toBe(false)
expect(out.stopReason).toBe('budget exceeded')
expect(out.suppressOutput).toBe(true)
expect(out.systemMessage).toBe('heads up')
})
it('parses legacy top-level decision + reason (approve/block)', () => {
expect(parseHookOutput(0, JSON.stringify({ decision: 'block', reason: 'nope' }), '').decision).toBe('block')
expect(parseHookOutput(0, JSON.stringify({ decision: 'approve' }), '').decision).toBe('approve')
})
it('hookSpecificOutput.permissionDecision OVERRIDES the legacy top-level decision', () => {
const out = parseHookOutput(0, JSON.stringify({
decision: 'approve',
hookSpecificOutput: { permissionDecision: 'deny', permissionDecisionReason: 'denied by policy' },
}), '')
expect(out.decision).toBe('deny')
expect(out.reason).toBe('denied by policy')
})
it('parses allow/ask permissionDecision (the bridge decides whether to honor)', () => {
expect(parseHookOutput(0, JSON.stringify({ hookSpecificOutput: { permissionDecision: 'allow' } }), '').decision).toBe('allow')
expect(parseHookOutput(0, JSON.stringify({ hookSpecificOutput: { permissionDecision: 'ask' } }), '').decision).toBe('ask')
})
it('parses additionalContext and updatedInput from hookSpecificOutput', () => {
const out = parseHookOutput(0, JSON.stringify({
hookSpecificOutput: { additionalContext: 'remember X', updatedInput: { command: 'safe' } },
}), '')
expect(out.additionalContext).toBe('remember X')
expect(out.updatedInput).toEqual({ command: 'safe' })
})
it('an unknown decision string is ignored (not coerced)', () => {
expect(parseHookOutput(0, JSON.stringify({ decision: 'maybe' }), '').decision).toBeUndefined()
})
it('malformed JSON on a clean exit is lenient (no structured output, no throw)', () => {
const out = parseHookOutput(0, '{ not valid json', '')
expect(out.decision).toBeUndefined()
expect(out.continue).toBeUndefined()
})
it('non-object stdout (plain text) on exit 0 is left for the bridge (no JSON attempt)', () => {
const out = parseHookOutput(0, 'just some text output', '')
expect(out.decision).toBeUndefined()
expect(out.continue).toBeUndefined()
})
it('a JSON array stdout parses but yields no fields (not an object)', () => {
// Starts with '{'? No — '[' — so it is not even attempted. Neutral.
const out = parseHookOutput(0, '[1,2,3]', '')
expect(out.decision).toBeUndefined()
})
it('structured stdout is IGNORED on a blocking (exit 2) run — stderr is authoritative', () => {
const out = parseHookOutput(2, JSON.stringify({ decision: 'approve' }), 'blocked')
// exit 2 forces block regardless of what stdout claims
expect(out.decision).toBe('block')
expect(out.reason).toBe('blocked')
})
})