docs(hooks): correct fold description + drop history-narrating test comments

Codex convergence findings on the delegate-and-fold fix (code path verified
correct, prose only):

- The hook-bridges RFC claimed a downstream `block` "carries the bridge context
  too" for BOTH seams. True for `tools/post-execute` (PostToolDecision.block has
  an additionalContext field) but false for `agent/prompt-submit`
  (PromptDecision.block is `{kind,reason}` with no context field). The code is
  already correct — a blocked prompt drops the context, which is right since the
  prompt never reaches the model. Reworded the RFC to state the per-seam
  difference accurately.
- Two test comments narrated "Before the fix…", which the current-state-only
  doc rule forbids. Reworded to describe the behavior, not its history.
- Documented on concatContext (both bridges) why the merged block carries a
  single source: a HookContext holds one MessageSource and the seam cannot
  represent mixed provenance; rendering distinguishes only by source.kind, so a
  downstream plugin's text stays framed as plugin context.
This commit is contained in:
Tianyi Cui
2026-07-02 20:07:10 +08:00
parent 4fc0d4833c
commit 2a66b7c4e0
5 changed files with 15 additions and 6 deletions

View File

@@ -214,7 +214,12 @@ export function apply(ctx: Context, config: Config): void {
/**
* Concatenate this bridge's {@link HookContext} (`ours`, always present at the
* call sites) with a downstream listener's optional one, so folding our
* additionalContext onto a delegated decision drops neither.
* additionalContext onto a delegated decision drops neither. The merged block
* carries a single `source` — this bridge's — because a `HookContext` holds one
* `MessageSource` and the seam cannot represent mixed provenance; the rendered
* `context/message` only distinguishes by `source.kind` ('plugin'), so a
* downstream plugin's text is still correctly framed as plugin context, not a
* user prompt.
*/
function concatContext(ours: HookContext, theirs: HookContext | undefined): HookContext {
if (!theirs) return ours

View File

@@ -433,7 +433,7 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () =>
it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => {
// A hook that only adds context must NOT short-circuit the waterfall: a
// downstream agent/prompt-submit listener (a policy plugin) must still get to
// block the prompt. Before the fix the bridge returned `allow` without next().
// block the prompt. The bridge delegates via next() and folds its context.
const d = dir()
const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n')
const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })

View File

@@ -169,7 +169,11 @@ export function apply(ctx: Context, config: Config): void {
/**
* Concatenate this bridge's {@link HookContext} (`ours`, always present at the
* call sites) with a downstream listener's optional one, so folding our
* additionalContext onto a delegated decision drops neither.
* additionalContext onto a delegated decision drops neither. The merged block
* carries a single `source` — this bridge's — because a `HookContext` holds one
* `MessageSource` and the seam cannot represent mixed provenance; the rendered
* `context/message` only distinguishes by `source.kind` ('plugin'), so a
* downstream plugin's text is still correctly framed as plugin context.
*/
function concatContext(ours: HookContext, theirs: HookContext | undefined): HookContext {
if (!theirs) return ours

View File

@@ -71,8 +71,8 @@ describe('hooks-codex coverage — decision mapping paths', () => {
it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => {
// Context alone is not a veto: a downstream agent/prompt-submit listener (a
// policy plugin registered after the bridge) must still get to block. Before
// the fix the bridge returned `allow` without calling next().
// policy plugin registered after the bridge) must still get to block. The
// bridge delegates via next() and folds its context onto the decision.
const d = dir()
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n') }] }] })
const adapter = new MockAdapter([textResponse('should not run')])