Files
Dragan Spiridonov 5f711b5b3e fix(hooks): UserPromptSubmit reads stdin event JSON for routing (v3.9.17)
The shipped `aqe init` template wired the UserPromptSubmit hook to
`npx agentic-qe hooks route --task "$PROMPT" --json`, but Claude Code
never exposed `$PROMPT` as an env var — only stdin event JSON. The
shell expanded `"$PROMPT"` to `""`, so every prompt routed as empty
and `routing_outcomes.task_json` was persisted as `{"description":""}`,
silently breaking the routing learning loop.

- routing-hooks.ts: demote --task to optional, read stdin (500ms
  timeout) and extract prompt/user_prompt/command/tool_input.{prompt,
  description}; explicit error when neither source has content
- 07-hooks.ts and init-wizard-hooks.ts: drop --task "\$PROMPT", let
  the CLI read stdin directly. isAqeHookEntry still matches both
  shapes, so existing projects upgrade on `aqe init --upgrade`
- tests/unit/cli/commands/hooks-routing.test.ts: 10 unit tests
  covering all event shapes, fallback ordering, malformed JSON, and
  empty-field skip

Verified end-to-end: piped a Claude Code-shaped event into a fresh
project's configured route command and confirmed task_json captures
the real prompt instead of "". Backward-compat manual --task usage
preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 06:19:05 +00:00

82 lines
2.9 KiB
TypeScript

/**
* Routing hook CLI - prompt extraction tests
*
* Regression coverage for the UserPromptSubmit stdin path. Claude Code
* delivers the prompt body on stdin as a JSON event ({prompt: "..."}),
* not as an env var. Before this fix, the init template passed
* --task "$PROMPT" which the shell expanded to "" — so every routing
* decision saw an empty task and routing_outcomes.task_json ended up
* {"description":""} on every entry, breaking the learning loop.
*/
import { describe, it, expect } from 'vitest';
import { extractPromptFromEvent } from '../../../../src/cli/commands/hooks-handlers/routing-hooks.js';
describe('extractPromptFromEvent', () => {
it('returns empty string for empty input', () => {
expect(extractPromptFromEvent('')).toBe('');
expect(extractPromptFromEvent(' ')).toBe('');
});
it('extracts prompt from UserPromptSubmit event shape', () => {
const event = JSON.stringify({
hook_event_name: 'UserPromptSubmit',
session_id: 'abc',
prompt: 'generate tests for auth module',
});
expect(extractPromptFromEvent(event)).toBe('generate tests for auth module');
});
it('falls back to user_prompt field', () => {
const event = JSON.stringify({ user_prompt: 'find coverage gaps' });
expect(extractPromptFromEvent(event)).toBe('find coverage gaps');
});
it('extracts command from PreToolUse Bash events', () => {
const event = JSON.stringify({
hook_event_name: 'PreToolUse',
tool_name: 'Bash',
command: 'npm test',
});
expect(extractPromptFromEvent(event)).toBe('npm test');
});
it('reads tool_input.prompt for Task tool events (snake_case)', () => {
const event = JSON.stringify({
hook_event_name: 'PreToolUse',
tool_name: 'Task',
tool_input: { prompt: 'review PR for security issues' },
});
expect(extractPromptFromEvent(event)).toBe('review PR for security issues');
});
it('reads toolInput.description for Task tool events (camelCase)', () => {
const event = JSON.stringify({
toolInput: { description: 'run security scan' },
});
expect(extractPromptFromEvent(event)).toBe('run security scan');
});
it('treats non-JSON input as the prompt body verbatim', () => {
expect(extractPromptFromEvent('plain text prompt\n')).toBe('plain text prompt');
});
it('returns empty string when no recognized field is present', () => {
const event = JSON.stringify({ unrelated: 'data', count: 5 });
expect(extractPromptFromEvent(event)).toBe('');
});
it('ignores empty or whitespace-only prompt fields and tries the next candidate', () => {
const event = JSON.stringify({
prompt: '',
command: ' ',
user_prompt: 'real prompt',
});
expect(extractPromptFromEvent(event)).toBe('real prompt');
});
it('does not crash on malformed JSON — falls through to verbatim path', () => {
expect(extractPromptFromEvent('{not valid json')).toBe('{not valid json');
});
});