[DOCS] claude plugin test: document how test hooks answer for the engine ({ value } for ops, { deny } vs throw for refusals)

Status Open
Reported on v2.1.296
Maintainer reply None cached
Activity 0 comments · opened Oct 12, 2026

Documentation Type

Missing documentation (feature not documented)

Documentation Location

claude-code/testing (the typings written beside a mod as .claude-plugin/types/claude-code/index.d.ts) and the plugin-authoring skill's reference.md ("what a test holds").

Section/Topic

claude plugin test: writing the test's own on hooks that stand for the engine beneath the plugin

Current Documentation

The docs say the test's hooks "sit beneath the plugin" and "stand for the engine, so what one answers reaches the plugin as given", and describe mock.clock / mock.store / mock.env / mock.session. They don't say what shape those hooks must answer with, or how to simulate an engine refusal.

What's Wrong or Missing?

We had to discover these by trial and error; two of them make failures look like something else:

  1. Op hooks must return { value }. Returning the bare value fails with returned something that is not a result object.

``ts
on('session.root', () => 'C:/proj') // fails
on('session.root', () => ({ value: 'C:/proj' })) // works
``

  1. Engine refusals must be answered with { deny }, not throw. If the test hook throws to simulate a refusal, the kit treats it as a skipped hook and the plugin's call rejects with no implementation for <op> instead of the reason:

``ts
// plugin receives "no implementation for command.register"
on('command.register', ($, e) => { throw new Error(
"/${e.name}" refused: ...) })
// plugin's $.command.register rejects with this reason, as in a real session
on('command.register', ($, e) => ({ deny:
"/${e.name}" refused: it is the user's /${e.name} }))
`
Using
throw hid a real-session bug from our tests (a refused command name cascading through the plugin's init) until we switched to deny`.

  1. Some answers are required and missing ones fail silently. Without test hooks for session.start, session.root and command.register, the plugin's initialization hook is skipped and the test fails later, far from the cause.
  2. The test $ has no store noun. State has to be seeded with mock.store(on, entries) and checked through behavior.
  3. $.command.run in a test takes the full input (origin, presentation), not the CommandRunArgs shape a plugin passes.
  4. $.ui.ask is mocked through tool.call { tool: 'AskUserQuestion' }, answering with that tool's result shape.

Suggested Improvement

Add an "answering for the engine" section to the claude-code/testing docs with the points above, especially { value } for op hooks and { deny } vs throw for refusals, plus a minimal example of the hooks a typical mod needs (session.start, session.root, command.register).

Optionally: report a throwing test hook as a test error instead of a skipped hook, and add helpers for the common answers (e.g. mock.session(on, { root }), mock.commands(on, { refuse })).

Impact

Medium - Makes feature difficult to understand

Additional Context

  • Claude Code 2.1.296 (type declarations written by 2.1.295), Windows 11.
  • Related test-kit issues: #100929 (no implementation for ui.focus even with a test hook), #99995 (test $ lacks nouns a plugin adds).

View original on GitHub ↗