Skip to content

feat(core): add user prompt expansion hooks#4377

Merged
wenshao merged 34 commits into
QwenLM:mainfrom
qqqys:feat/user-prompt-expansion
Jun 5, 2026
Merged

feat(core): add user prompt expansion hooks#4377
wenshao merged 34 commits into
QwenLM:mainfrom
qqqys:feat/user-prompt-expansion

Conversation

@qqqys

@qqqys qqqys commented May 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • What changed: Added a new hook lifecycle point for slash commands that expand into prompts, including settings schema support, UI metadata, command-name matcher behavior, and blocking behavior before the expanded prompt is submitted.
  • Why it changed: Prompt-expanding commands currently skip a distinct lifecycle point, so hook authors cannot target command expansions separately from ordinary user prompt submission.
  • Reviewer focus: Please verify that the new event only fires for slash commands that return an expanded prompt, that matchers use the command name, and that blocking output prevents the expanded prompt from being submitted.

Validation

  • Commands run:
    cd packages/core && ../../node_modules/.bin/vitest run src/hooks/hookPlanner.test.ts src/hooks/hookEventHandler.test.ts src/hooks/hookSystem.test.ts
    cd packages/cli && ../../node_modules/.bin/vitest run src/nonInteractiveCliCommands.test.ts src/ui/hooks/slashCommandProcessor.test.ts src/ui/components/hooks/constants.test.ts
    npm run build
    npm run typecheck
  • Prompts / inputs used: Unit tests exercise a slash command that expands into a prompt, a command-name matcher, and a blocking hook output.
  • Expected result: Matching hooks receive the command name, original arguments, and expanded prompt text; blocking hook output stops submission; normal expansion still submits the original expanded prompt.
  • Observed result: Core hook tests passed 256 tests, CLI hook tests passed 97 tests, build passed, and typecheck passed.
  • Quickest reviewer verification path: Configure the new event with a matcher for a prompt-expanding slash command, then verify the hook receives command metadata and can block submission.
  • Evidence (output, logs, screenshots, video, JSON, before/after, etc.): Local command output showed all targeted tests passing, followed by successful build and typecheck.

Scope / Risk

  • Main risk or tradeoff: The hook runs in the slash-command handling path, so hook latency now applies before expanded prompt submission when the event is configured.
  • Not covered / not validated: Full interactive end-to-end terminal capture was not recorded.
  • Breaking changes / migration notes: None expected.

Testing Matrix

🍏 🪟 🐧
npm run ⚠️ ⚠️
npx ⚠️ ⚠️
Docker N/A N/A N/A
Podman N/A N/A N/A
Seatbelt N/A N/A N/A

Testing matrix notes:

  • Validated locally on macOS. Windows and Linux were not run locally.

Linked Issues / Bugs

Refs #4343

@github-actions

Copy link
Copy Markdown
Contributor

📋 Review Summary

This PR introduces a new UserPromptExpansion hook lifecycle event that fires when a slash command expands into a prompt, enabling hook authors to target command expansions separately from ordinary user prompt submissions. The implementation is well-structured, follows existing patterns in the hook system, and includes comprehensive test coverage. The changes span core hook infrastructure, CLI command handling, settings schema, and UI components.

🔍 General Feedback

  • Strong architectural consistency: The new event follows the exact same patterns as existing hook events (PreToolUse, PostToolUse, etc.), making it easy to understand and maintain.
  • Comprehensive test coverage: Both core and CLI test suites include targeted tests for the new functionality, including blocking behavior and matcher semantics.
  • Good separation of concerns: The hook firing logic is cleanly separated between the core (hookSystem.ts, hookEventHandler.ts) and CLI integration (nonInteractiveCliCommands.ts, slashCommandProcessor.ts).
  • Documentation completeness: Settings schema, UI constants, and VSCode schema all updated to reflect the new event type.
  • Security-conscious: Blocking behavior is properly implemented with error message surfacing to users.

🎯 Specific Feedback

🟢 Medium

  • File: packages/core/src/hooks/hookPlanner.ts:67 - The getHookMatcherTarget function returns { kind: 'commandName', target: context?.commandName ?? '' } for UserPromptExpansion, but the default case uses TypeScript's exhaustiveness check pattern. Consider adding a comment explaining why UserPromptExpansion is explicitly handled rather than falling through to the default case, for future maintainers who may not understand the matcher semantics distinction.

  • File: packages/cli/src/nonInteractiveCliCommands.ts:172-200 - The fireUserPromptExpansionHook helper function converts PartListUnion to string using partToString(content, { verbose: true }). Consider documenting why verbose: true is chosen here - is this the correct serialization for hook consumption, or should this be configurable?

  • File: packages/cli/src/ui/hooks/slashCommandProcessor.ts (from diff) - The interactive mode implementation mirrors the non-interactive path but has slightly different error handling (uses addMessage with timestamp). Consider extracting the common blocking error message formatting into a shared utility to ensure consistent user experience across both modes.

🔵 Low

  • File: packages/core/src/hooks/types.ts:693-702 - The UserPromptExpansionInput interface uses snake_case field names (command_name, command_args) which is consistent with other hook inputs like UserPromptSubmitInput. However, this differs from the TypeScript convention used elsewhere in the codebase. Consider adding a comment explaining this naming convention is intentional for JSON serialization compatibility with hook configurations.

  • File: packages/core/src/hooks/hookPlanner.ts:278 - The HookEventContext interface adds commandName?: string but lacks JSDoc documentation. Consider adding a brief comment: /** Command name for UserPromptExpansion event matching */ for clarity.

  • File: packages/cli/src/config/settingsSchema.ts:2096-2107 - The UserPromptExpansion schema entry has showInDialog: false, matching other advanced hook types. Consider verifying this is intentional - if users should be able to configure this via UI dialog, this should be true.

  • File: packages/core/src/hooks/hookSystem.ts:154-167 - The fireUserPromptExpansionEvent method signature could benefit from JSDoc parameter documentation matching the style of other methods in the class (e.g., fireSessionStartEvent, firePreToolUseEvent).

✅ Highlights

  • Excellent test coverage: The PR includes 256 passing core hook tests and 97 passing CLI hook tests, with specific test cases for:

    • Command name matcher semantics (verifying hooks can filter by specific slash command names)
    • Blocking behavior (verifying hooks can prevent expanded prompt submission)
    • Input structure (verifying command_name, command_args, and prompt are correctly passed)
  • Clean integration points: The hook fires at exactly the right moment - after a slash command returns submit_prompt but before the expanded content is submitted to the model, giving hooks full control over command expansions.

  • Comprehensive schema updates: All configuration surfaces updated consistently:

    • settingsSchema.ts for CLI configuration
    • settings.schema.json for VSCode companion
    • UI constants for hook display information
    • Hook event constants for exit codes and descriptions
  • Proper error handling: Blocking errors are surfaced to users with clear messages (UserPromptExpansion blocked: {reason}), and the implementation correctly distinguishes between blocking errors and informational stderr output.

  • Matcher semantics well-considered: The choice to match against command name (rather than treating it like UserPromptSubmit with no matcher) enables precise targeting of specific slash commands, which is the key use case described in the PR.

@qqqys qqqys marked this pull request as ready for review May 21, 2026 03:10
@pomelo-nwu pomelo-nwu requested a review from DennisYu07 May 21, 2026 05:12
export interface UserPromptExpansionOutput extends HookOutput {
hookSpecificOutput?: {
hookEventName: 'UserPromptExpansion';
additionalContext?: string;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] additionalContext is declared in UserPromptExpansionOutput but is silently dropped at every layer:

  1. hookRunner.ts:applyHookOutputToInput has no case for HookEventName.UserPromptExpansion (only UserPromptSubmit and PreToolUse are handled), so sequential hooks cannot chain additionalContext into the prompt.
  2. Neither consumer (slashCommandProcessor.ts nor nonInteractiveCliCommands.ts) calls output.getAdditionalContext(), so even parallel hook results lose the field.

Hook authors who return additionalContext (a field the type advertises) will find it has no effect — a type-vs-implementation mismatch.

Either add the corresponding case in applyHookOutputToInput and consume it in both paths, or remove additionalContext from UserPromptExpansionOutput if it's intentionally unsupported at this lifecycle stage.

— qwen-latest-series-invite-beta-v38 via Qwen Code /review

const invocation = fullCommandContext.invocation;
const output = await config
?.getHookSystem()
?.fireUserPromptExpansionEvent(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Two observations on the hook integration here:

Model-invocable commands skip the hook. setModelInvocableCommandsExecutor (line ~456) also handles submit_prompt results from model-invoked commands (when the model calls /goal directly), but does NOT fire the UserPromptExpansion hook. If the hook's purpose is to audit/gate prompt expansion regardless of origin, model-invoked commands bypass it. Consider either firing the hook there too, or adding a comment explaining the intentional exclusion.

Duplicated blocking-check logic. The ~25-line hook-fire + blocking-check pattern is inlined here, while nonInteractiveCliCommands.ts extracts the same pattern into a helper fireUserPromptExpansionHook(). Both paths share serializeUserPromptExpansionPrompt and formatUserPromptExpansionBlockedMessage from userPromptExpansionHook.ts — consider extracting the control flow into a shared helper too, so future fixes (e.g. consuming additionalContext) only need to land in one place.

— qwen-latest-series-invite-beta-v38 via Qwen Code /review

const mockFireUserPromptExpansionEvent = vi.fn();
const mockSettings = { merged: {} } as LoadedSettings;

const createMockActions = (): SlashCommandProcessorActions => ({

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Mock object is missing openDiffDialog property, causing a TS2741 type error. This is related to the openDiffDialog prop added to SlashCommandProcessorActions.

Suggested change
const createMockActions = (): SlashCommandProcessorActions => ({
openDiffDialog: vi.fn(),

— DeepSeek/deepseek-v4-pro via Qwen Code /review

let content = result.content;
const output = await config
?.getHookSystem()
?.fireUserPromptExpansionEvent(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] serializeUserPromptExpansionPrompt(content) is called unconditionally — before any check for whether UserPromptExpansion hooks are even configured. Every slash command returning submit_prompt now pays the cost of partToString(content, { verbose: true }) even when zero hooks match. The codebase already has a hasHooksForEvent guard pattern (see client.ts:680) to skip this work on the hot path — it should be applied here too.

Also applies at line 478 (model-invocable executor path) and nonInteractiveCliCommands.ts:196.

Suggested change
?.fireUserPromptExpansionEvent(
const hookSystem = config.getHookSystem();
if (hookSystem?.hasHooksForEvent?.('UserPromptExpansion')) {
const prompt = serializeUserPromptExpansionPrompt(content);
const output = await hookSystem.fireUserPromptExpansionEvent(
invocation?.name ?? '',
invocation?.args ?? '',
prompt,
abortController.signal,
);
// ... block / additionalContext handling
}

— DeepSeek/deepseek-v4-pro via Qwen Code /review

const output = await config
.getHookSystem()
?.fireUserPromptExpansionEvent(
name,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The model-invocable executor path calls fireUserPromptExpansionEvent without passing an AbortSignal. The main user-invoked path (line 793) correctly passes abortController.signal. Without a signal, hooks spawned from model-invoked commands cannot be cancelled if the user aborts the operation.

Suggested change
name,
const output = await config
.getHookSystem()
?.fireUserPromptExpansionEvent(
name,
args,
serializeUserPromptExpansionPrompt(result.content),
abortController.signal,
);

— DeepSeek/deepseek-v4-pro via Qwen Code /review

result.content,
new AbortController().signal,
);
if (hookResult.blockedResult) return null;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] new AbortController().signal creates a fresh signal that is never aborted, making hook execution uncancellable. The parent abortController is in scope — use its signal instead.

Suggested change
if (hookResult.blockedResult) return null;
const hookResult = await fireUserPromptExpansionHook(
config,
name,
args,
result.content,
abortController.signal,
);

— DeepSeek/deepseek-v4-pro via Qwen Code /review

export function appendUserPromptExpansionAdditionalContext(
content: PartListUnion,
additionalContext: string | undefined,
): PartListUnion {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] additionalContext from hook output is appended to the prompt without any length truncation. Hook stdout is capped at 1MB (MAX_OUTPUT_LENGTH), so a malicious or buggy hook can inject up to ~1MB into every expanded prompt. Consider applying a reasonable character limit (e.g., 10000 chars) before appending.

Suggested change
): PartListUnion {
export function appendUserPromptExpansionAdditionalContext(
content: PartListUnion,
additionalContext: string | undefined,
): PartListUnion {
if (!additionalContext) {
return content;
}
const MAX_ADDITIONAL_CONTEXT_LENGTH = 10000;
const truncated = additionalContext.slice(0, MAX_ADDITIONAL_CONTEXT_LENGTH);
const suffix = `\n\n${truncated}`;

— DeepSeek/deepseek-v4-pro via Qwen Code /review

@@ -682,6 +684,28 @@ export interface UserPromptSubmitOutput extends HookOutput {
};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] UserPromptExpansionOutput interface is declared but never instantiated — createHookOutput has no case for UserPromptExpansion, so all outputs are created as generic DefaultHookOutput. Either add the case in createHookOutput or remove the unused interface and document why the default is sufficient.

— DeepSeek/deepseek-v4-pro via Qwen Code /review

description: 'A command from a file',
modelInvocable: true,
action: async () => ({
type: 'submit_prompt',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The MCP-based command submit_prompt test does not assert that fireUserPromptExpansionEvent was called, even though the source code does invoke it for MCP commands. Adding this assertion would provide regression protection for the MCP command hook integration path.

Suggested change
type: 'submit_prompt',
expect(mockFireUserPromptExpansionEvent).toHaveBeenCalledWith(
'mcpcmd',
'',
'The actual prompt from the mcp command.',
expect.any(AbortSignal),
);

— DeepSeek/deepseek-v4-pro via Qwen Code /review

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] hookEventHandler.ts:583 — fail-closed bypasses UserPromptExpansion on hook system error. executeHooks sets finalOutput: { decision: 'block' } only for TodoCreated/TodoCompleted on internal error. UserPromptExpansion gets finalOutput: undefined, so all callers treat the error as "no hooks configured" and let the prompt through. Add HookEventName.UserPromptExpansion to the fail-closed list alongside TodoCreated/TodoCompleted.

CI note: Test (windows-latest, Node 22.x) and Test (ubuntu-latest, Node 22.x) are currently failing — not related to the review findings but worth investigating.

if (output) {
const blockingError = output.getBlockingError();
if (blockingError.blocked || output.shouldStopExecution()) {
return null;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] When a UserPromptExpansion hook blocks a model-invocable command, this returns null. The SkillTool caller (skill.ts:384-420) interprets null as "command not found" and feeds the model Skill "<name>" not found — which is factually wrong. The command exists and executed; a security hook blocked the expansion.

The block is invisible to both the model and the user. The model may retry the command, try alternative commands, or report to the user that the skill doesn't exist.

Suggested change
return null;
const blockingError = output.getBlockingError();
if (blockingError.blocked || output.shouldStopExecution()) {
return formatUserPromptExpansionBlockedMessage(
blockingError.reason || output.getEffectiveReason(),
);
}

The same issue exists in nonInteractiveCliCommands.ts:321 (if (hookResult.blockedResult) return null;).

— qwen3.7-max via Qwen Code /review

result.content,
abortController.signal,
);
if (hookResult.blockedResult) return null;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Same issue as the interactive path: when a hook blocks, return null causes the SkillTool caller to report Skill not found instead of the actual block reason. Return the blocked message content instead so the model can surface the block reason accurately.

Suggested change
if (hookResult.blockedResult) return null;
if (hookResult.blockedResult) return hookResult.blockedResult.content;

— qwen3.7-max via Qwen Code /review

timestamp: new Date(),
});
return { type: 'handled' };
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] When a hook blocks a command here, hasError is never set to true (it's only set in the catch block at ~line 947). The finally block then logs SlashCommandStatus.SUCCESS for the blocked command.

This makes telemetry misleading — blocked commands appear as successful, making production debugging significantly harder. Consider setting hasError = true before returning, or introducing a dedicated SlashCommandStatus.BLOCKED.

— qwen3.7-max via Qwen Code /review

eventName === HookEventName.TodoCreated ||
eventName === HookEventName.TodoCompleted
eventName === HookEventName.TodoCompleted ||
eventName === HookEventName.UserPromptExpansion

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Adding UserPromptExpansion to the fail-closed set means any internal error in the hook planner/runner (e.g., malformed hook config, registry crash) will block all slash command expansions in that session — not just the command that triggered the error. Unlike TodoCreated/TodoCompleted (system events where blocking is safe), UserPromptExpansion is a user-facing interaction path. A single misconfigured hook definition could deny all slash-command functionality with only a generic error message.

Consider scoping the fail-closed behavior to the specific hook definition that caused the error (skip the failing hook, let the command proceed without hook context), or catching planner/registry errors at hook registration time so misconfigured hooks are excluded before execution rather than blocking all commands at runtime.

— qwen3.7-max via Qwen Code /review

: undefined;
if (output) {
const blockingError = output.getBlockingError();
if (blockingError.blocked || output.shouldStopExecution()) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Model-invocable executor returns block message as success string

When a UserPromptExpansion hook blocks a model-invocable command (invoked via SkillTool), this returns formatUserPromptExpansionBlockedMessage(...) — a non-null string. The SkillTool caller in skill.ts treats any non-null return as success: wraps it as llmContent: [{ text: content }], logs the launch as success, and feeds the block message to the model as if it were valid command output. The security/policy intent of the block is defeated in this path.

Same issue in nonInteractiveCliCommands.ts:322 (hookResult.blockedResult.content returned as string).

Suggested change
if (blockingError.blocked || output.shouldStopExecution()) {
if (blockingError.blocked || output.shouldStopExecution()) {
return null;
}

— qwen3.7-max via Qwen Code /review

Comment thread packages/core/src/hooks/hookPlanner.ts Outdated
* Match slash command name against matcher pattern.
*/
private matchesCommandName(matcher: string, commandName: string): boolean {
return this.matchesToolName(matcher, commandName);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] matchesCommandName delegates to matchesToolName, whose regex-fallback warning (line 220) says for tool "${toolName}". When called from here, the log misleadingly references "tool" for a slash command name.

Consider giving matchesCommandName its own implementation with a "for command" log message, following the matchesAgentType / matchesSessionTrigger pattern.

— qwen3.7-max via Qwen Code /review

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 5 review at 199c848f — 1 new commit since R4 (fix(cli): block model-invocable prompt expansion).

The commit correctly identified the R4 Critical (model-invocable executor returning the block message as a success string) and changed it to return null. However, null is also incorrect: SkillTool (skill.ts:385) treats null as "command not found" and feeds the model Skill "<name>" not found — a misleading error that hides the actual block reason.

Suggested fix for both paths: return the block reason as a string so the model receives actionable information:

return formatUserPromptExpansionBlockedMessage(
  blockingError.reason || output.getEffectiveReason(),
);

This gives the model "UserPromptExpansion blocked: <reason>" as a tool result — distinguishable from both "command not found" and a valid skill output.


Low-confidence findings (needs human review):

  • applyHookOutputToInput in hookRunner.ts has no case for UserPromptExpansion — sequential hooks won't chain additionalContext like UserPromptSubmit does
  • additionalContext truncation at 10K chars is silent (no debug log)
  • hasUserPromptExpansionHooks guard duplicated between slashCommandProcessor.ts and nonInteractiveCliCommands.ts
  • 5 narrow test coverage gaps (single-Part fallback, blockingError.reason fallback, regex matching, inner early returns, no-context getHookMatcherTarget)

Deterministic: tsc=0, eslint=0 | Build/tests: 361/361 pass | CI: all_pass 8/8

— qwen3.7-max via Qwen Code /review

: undefined;
if (output) {
const blockingError = output.getBlockingError();
if (blockingError.blocked || output.shouldStopExecution()) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] return null when a hook blocks causes SkillTool (skill.ts:385) to report Skill "<name>" not found to the model — hiding the actual block reason.

The R4 fix changed from returning the block message (wrong: treated as valid skill output) to returning null (wrong: treated as "command not found"). The correct behavior is to return the block reason string so the model sees it as a tool result:

Suggested change
if (blockingError.blocked || output.shouldStopExecution()) {
return formatUserPromptExpansionBlockedMessage(
blockingError.reason || output.getEffectiveReason(),
);

— qwen3.7-max via Qwen Code /review

abortController.signal,
);
if (hookResult.blockedResult) {
return null;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Same issue as the interactive path: return null when a hook blocks causes SkillTool to report Skill not found instead of the actual block reason.

Suggested change
return null;
return hookResult.blockedResult.type === 'message'
? hookResult.blockedResult.content
: null;

This returns the block message string (e.g. "UserPromptExpansion blocked: <reason>") so the model receives actionable information.

— qwen3.7-max via Qwen Code /review

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 6 review at bf5822a1 — 10 new commits since R5. The author addressed all R3-R5 Critical findings: fail-closed bypass (hookEventHandler.ts), model-invocable null return (both paths now return blocked message), additionalContext chaining (hookRunner.ts case added), and matchesCommandName (rewritten with regex + exact-match fallback). 9 prior inline comments are now on stale commits.

New finding (1 inline Suggestion):

  • hookRunner.ts:490applyHookOutputToInput reads raw hookSpecificOutput['additionalContext'] without the HTML entity escaping (getAdditionalContext()) or 10K truncation (appendUserPromptExpansionAdditionalContext) that the parallel/CLI paths apply. Sequential hook chains see unsanitized, unbounded context.

Needs Human Review (5 low-confidence, terminal-only):

  • shouldStopExecution()=true branch untested in all blocking tests (only blocked:true tested)
  • Hook-disabled guard (getDisableAllHooks/hasHooksForEvent) never tested in disabled state for UserPromptExpansion paths
  • serializeUserPromptExpansionPrompt has zero direct unit tests (only tested indirectly via integration)
  • Model-invocable executor uses useEffect's controller.signal (component lifecycle) instead of per-command abortController.signal (ESC cancellation)
  • getHookSystem()?.fireUserPromptExpansionEvent(...) silently skips when hookSystem is null despite hooks being configured — no debug log
  • Post-await abort re-check missing in interactive submit_prompt handler — user ESC during hook execution may still submit the prompt

Deterministic: tsc=0, eslint=0 | Build/tests: 395/395 pass | CI: all_pass 8/8

— qwen3.7-max via Qwen Code /review

Comment thread packages/core/src/hooks/hookRunner.ts Outdated
switch (eventName) {
case HookEventName.UserPromptSubmit:
case HookEventName.UserPromptExpansion:
if ('additionalContext' in hookOutput.hookSpecificOutput) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] applyHookOutputToInput reads raw hookSpecificOutput['additionalContext'] and appends it directly to the prompt for the next sequential hook — bypassing both the HTML entity escaping performed by getAdditionalContext() (types.ts:355, <&lt;, >&gt;) and the 10,000-character truncation applied by appendUserPromptExpansionAdditionalContext in the CLI layer.

When multiple UserPromptExpansion hooks run sequentially, each hook's raw additionalContext accumulates in the prompt piped to the next hook's stdin — unescaped and unbounded. The final output to the model is sanitized by the CLI layer, but intermediate hooks see a different (weaker) representation.

Consider applying the same sanitization/truncation here that the parallel path uses, or document the intentional difference:

const MAX_ADDITIONAL_CONTEXT = 10_000;
// ...
const additionalContext =
  hookOutput.hookSpecificOutput['additionalContext'];
if (
  typeof additionalContext === 'string' &&
  'prompt' in modifiedInput
) {
  (
    modifiedInput as
      | UserPromptSubmitInput
      | UserPromptExpansionInput
  ).prompt += '\n\n' + additionalContext.slice(0, MAX_ADDITIONAL_CONTEXT);
}

— qwen3.7-max via Qwen Code /review

) {
(modifiedInput as UserPromptSubmitInput).prompt +=
'\n\n' + additionalContext;
case HookEventName.UserPromptExpansion:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] UserPromptSubmit sequential chaining now HTML-escapes additionalContext — silent behavioral regression.

The fall-through merges UserPromptSubmit and UserPromptExpansion into a single case that routes through createHookOutput(eventName, hookOutput).getAdditionalContext(). Since createHookOutput has no case HookEventName.UserPromptSubmit, it falls through to DefaultHookOutput, whose getAdditionalContext() HTML-escapes <&lt; and >&gt;.

Before this PR, the UserPromptSubmit case directly read hookOutput.hookSpecificOutput['additionalContext'] without any escaping. Any existing sequential UserPromptSubmit hook chain that includes angle brackets in additionalContext (XML tags, HTML snippets, generic type syntax) will now see &lt;/&gt; silently injected into the chained prompt. No test covers this path.

Suggested change
case HookEventName.UserPromptExpansion:
case HookEventName.UserPromptSubmit: {
if ('additionalContext' in hookOutput.hookSpecificOutput) {
const additionalContext =
hookOutput.hookSpecificOutput['additionalContext'];
if (
typeof additionalContext === 'string' &&
'prompt' in modifiedInput
) {
(modifiedInput as UserPromptSubmitInput).prompt +=
'\n\n' + additionalContext;
}
}
break;
}
case HookEventName.UserPromptExpansion:

— qwen3.7-max via Qwen Code /review

return partToString(content, { verbose: true });
}

export function formatUserPromptExpansionBlockedMessage(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Prompt injection via hook block reason in model-invocable paths.

formatUserPromptExpansionBlockedMessage(reason) interpolates the reason string verbatim into the return value. In model-invocable command executors (slashCommandProcessor.ts:497, nonInteractiveCliCommands.ts:214), this return value is fed back to the LLM via SkillTool as llmContent. The reason is neither sanitized nor truncated.

A malicious project-level hook (via .qwen/settings.json in an untrusted repo) can set decision: "block" with a crafted reason containing prompt injection payloads. The model receives this as if it were the skill command's legitimate output.

Suggested change
export function formatUserPromptExpansionBlockedMessage(
export function formatUserPromptExpansionBlockedMessage(
reason: string,
): string {
const sanitized = reason
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.slice(0, 10_000);
return `UserPromptExpansion blocked: ${sanitized}`;
}

— qwen3.7-max via Qwen Code /review

const additionalContext = createHookOutput(
eventName,
hookOutput,
).getAdditionalContext();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Sequential hook chaining has no cumulative cap on additionalContext growth.

Each sequential hook can append up to 10,000 characters via getAdditionalContext(). With N sequential hooks, the prompt grows by up to N×10K with no total cap. The parallel path correctly concatenates all contexts and truncates once, but the sequential path accumulates unboundedly.

Consider adding a cumulative check before appending, or documenting the behavior for hook authors.

— qwen3.7-max via Qwen Code /review

return { content };
}

const output = await hookSystem.fireUserPromptExpansionEvent(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Zero debug logging at all 4 UserPromptExpansion hook call sites.

fireUserPromptExpansionHook and the two interactive paths in slashCommandProcessor.ts emit no log when hooks fire, block, or are skipped. When debugging hook-related issues, operators must enable debug-level logging for the generic hook system and correlate manually.

Add debugLogger calls at key decision points:

debugLogger.debug(`UserPromptExpansion hook firing for /${commandName}`);
// after hook execution:
debugLogger.debug(`UserPromptExpansion result: blocked=${blockingError.blocked}`);

— qwen3.7-max via Qwen Code /review

});
});

it('should return the block reason for blocked model-invocable command execution', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Multiple test gaps in the blocking behavior:

  1. shouldStopExecution() path: All blocking tests use blockingError.blocked = true with shouldStopExecution: () => false. No test exercises the shouldStopExecution() = true branch (which takes a different code path through getBlockingError() returning { blocked: false }).

  2. getEffectiveReason() fallback: All blocking mocks use reason: 'Blocked by policy' (truthy), so blockingError.reason || output.getEffectiveReason() always short-circuits. The fallback is never exercised.

  3. Hook-disabled guard: All tests mock getDisableAllHooks: false and hasHooksForEvent: true. No test verifies passthrough when hooks are disabled or not configured.

— qwen3.7-max via Qwen Code /review

expect(result).toBe(`base prompt\n\n${'x'.repeat(10_000)}`);
});

it('truncates additional context before appending to part arrays', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The third branch of appendUserPromptExpansionAdditionalContext (single Part object fallback at userPromptExpansionHook.ts:31) is never tested. Only the string and Array branches are covered. Add a test with a single Part object input to verify the fallback wrapping behavior.

— qwen3.7-max via Qwen Code /review

export function formatUserPromptExpansionBlockedMessage(
reason: string,
): string {
const sanitizedReason = reason

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] formatUserPromptExpansionBlockedMessage HTML-escapes < and >, but all three callers render the output as plain text, not HTML:

  1. Interactive TUI (slashCommandProcessor.ts:822): addMessage({ type: MessageType.ERROR }) — Ink renders as terminal plain text; &lt; appears literally on screen.
  2. Model-invocable (slashCommandProcessor.ts:497): returned as string to SkillTool → llmContent: [{ text }] — model receives &lt; literally.
  3. ACP non-interactive: thrown as new Error(result.content), displayed via plain-text emitter.

The existing UserPromptSubmit blocked message (Session.ts:656) does NOT HTML-escape: `🚫 **UserPromptSubmit blocked**: ${blockReason}`.

Additionally, & is not escaped before </>, so pre-encoded entities like &lt;script&gt; pass through unchanged.

Suggested change
const sanitizedReason = reason
export function formatUserPromptExpansionBlockedMessage(
reason: string,
): string {
const truncatedReason = reason.slice(
0,
MAX_USER_PROMPT_EXPANSION_ADDITIONAL_CONTEXT_LENGTH,
);
return `UserPromptExpansion blocked: ${truncatedReason}`;
}

— qwen3.7-max via Qwen Code /review

Comment thread packages/core/src/hooks/types.ts Outdated
*/
export class UserPromptExpansionHookOutput extends DefaultHookOutput {
override getAdditionalContext(): string | undefined {
return super

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] getAdditionalContext() calls super.getAdditionalContext() which HTML-escapes the full string (potentially up to 1MB from hook stdout), THEN .slice(0, 10_000) truncates. Two issues:

  1. Truncated mid-entity: If < appears near the 10K boundary, the escaped form &lt; (4 chars) may be cut at &l or &lt — producing garbled fragments in the prompt.
  2. Wasted work: The HTML escape runs over the full string before discarding 99% of it.

Fix: read the raw value directly, truncate first, then escape:

Suggested change
return super
export class UserPromptExpansionHookOutput extends DefaultHookOutput {
override getAdditionalContext(): string | undefined {
const raw =
this.hookSpecificOutput &&
'additionalContext' in this.hookSpecificOutput &&
typeof this.hookSpecificOutput['additionalContext'] === 'string'
? this.hookSpecificOutput['additionalContext']
: undefined;
if (!raw) return undefined;
return raw
.slice(0, MAX_USER_PROMPT_EXPANSION_ADDITIONAL_CONTEXT_LENGTH)
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
}

— qwen3.7-max via Qwen Code /review

export function serializeUserPromptExpansionPrompt(
content: PartListUnion,
): string {
// Hook inputs should see the same verbose text form the model receives after

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] serializeUserPromptExpansionPrompt is a new exported function with zero test coverage. It wraps partToString(content, { verbose: true }) and is called at all 4 hook invocation sites, but no test verifies:

  1. String input → returns the string
  2. Part array → returns concatenated text with verbose formatting
  3. Single Part object → returns text content
  4. Empty/undefined input

This is the serialization bridge between the slash command result and the hook's prompt field. If partToString with { verbose: true } produces unexpected output, hooks receive incorrect input and no test catches it.

— qwen3.7-max via Qwen Code /review

additionalContext: string | undefined,
): PartListUnion {
if (!additionalContext) {
return content;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The early-return branch when additionalContext is undefined has no test. This is the hot path — every slash command without hooks configured, or whose hooks return no additional context, hits this branch. A regression that mutates the input content when additionalContext is undefined (e.g., accidentally appending \n\nundefined) would go undetected.

Suggested test:

it('returns content unchanged when additionalContext is undefined', () => {
  expect(appendUserPromptExpansionAdditionalContext('base', undefined)).toBe('base');
});

— qwen3.7-max via Qwen Code /review

blockedResult?: NonInteractiveSlashCommandResult;
content: PartListUnion;
}> {
if (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] fireUserPromptExpansionHook has three guard/early-return branches that are all untested:

  1. config.getDisableAllHooks?.() returns true
  2. config.hasHooksForEvent?.('UserPromptExpansion') returns false
  3. config.getHookSystem() returns null/undefined

In the test setup, all three are configured to enable hooks, so only the "hooks fire" path is exercised. If any guard condition is inverted or the null-check is removed, every non-interactive slash command would attempt to fire hooks even when disabled, potentially throwing errors.

Suggested tests: set each guard to trigger early-return, assert that fireUserPromptExpansionEvent is NOT called and the original content passes through unchanged.

— qwen3.7-max via Qwen Code /review

},
{ code: 'Other', description: t('show stderr to user only') },
],
[HookEventName.UserPromptExpansion]: [

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Three new i18n keys are introduced via t() but zero locale files are updated. All 9 locale files (en.js, zh.js, fr.js, de.js, ja.js, pt.js, ru.js, zh-TW.js, ca.js) are missing translations for:

  1. 'block expanded prompt submission and show stderr to user only'
  2. 'When a slash command expands into a prompt'
  3. 'Input to command is JSON with command_name, command_args, and expanded prompt text.'

Every other hook event (UserPromptSubmit, Notification, SessionStart, etc.) has corresponding entries in all locale files. t() falls back to the key, so English renders correctly but non-English users see untranslated strings.

— qwen3.7-max via Qwen Code /review

Comment thread packages/core/src/hooks/types.ts Outdated
return undefined;
}
return raw
.slice(0, MAX_USER_PROMPT_EXPANSION_ADDITIONAL_CONTEXT_LENGTH)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Truncate-before-escape can exceed MAX_LENGTH in downstream consumers.

The raw input is truncated to 10,000 chars first, then </> are escaped. Each angle bracket expands from 1 to 4 chars (&lt;), so the output can reach up to ~40,000 chars in the worst case. This affects two paths differently:

  • hookRunner chaining (hookRunner.ts:507): no second truncation — the chained prompt receives the full escaped output, violating the intended 10K cap.
  • CLI path (appendUserPromptExpansionAdditionalContext): applies a second .slice(0, 10000) on the already-escaped string, which can cut mid-entity (e.g., &lt;&l), producing garbled text appended to the user's prompt.

Adding a second .slice() after escaping guarantees the output never exceeds MAX_LENGTH:

Suggested change
.slice(0, MAX_USER_PROMPT_EXPANSION_ADDITIONAL_CONTEXT_LENGTH)
return raw
.slice(0, MAX_USER_PROMPT_EXPANSION_ADDITIONAL_CONTEXT_LENGTH)
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.slice(0, MAX_USER_PROMPT_EXPANSION_ADDITIONAL_CONTEXT_LENGTH);

— qwen3.7-max via Qwen Code /review

Comment thread packages/core/src/hooks/types.ts Outdated
*/
export class UserPromptExpansionHookOutput extends DefaultHookOutput {
override getAdditionalContext(): string | undefined {
const raw =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] No direct unit tests for this rewritten method.

The method was substantially rewritten (bypass super, direct field access, truncate-then-escape ordering), but has no direct unit tests. It's only exercised indirectly through hookRunner.test.ts chaining tests. Four type-guard branches are untested:

  1. hookSpecificOutput is undefined
  2. 'additionalContext' key is absent
  3. Value is not a string (e.g., number)
  4. Value is empty string ""

Consider adding a describe('UserPromptExpansionHookOutput.getAdditionalContext', ...) block in the core hook tests.

— qwen3.7-max via Qwen Code /review

Comment thread packages/core/src/hooks/types.ts Outdated
export class UserPromptExpansionHookOutput extends DefaultHookOutput {
override getAdditionalContext(): string | undefined {
const raw =
this.hookSpecificOutput &&

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Override duplicates the parent's field-access pattern instead of calling super.

The 4-line raw-field access pattern (this.hookSpecificOutput && 'additionalContext' in ... && typeof ... === 'string') is identical to DefaultHookOutput.getAdditionalContext() at lines 344-356. If the parent adds logging, validation, or telemetry in the future, this override silently skips it.

Consider extracting a protected getRawAdditionalContext(): string | undefined helper in the parent that both methods reuse:

// In DefaultHookOutput:
protected getRawAdditionalContext(): string | undefined {
  if (this.hookSpecificOutput && 'additionalContext' in this.hookSpecificOutput) {
    const context = this.hookSpecificOutput['additionalContext'];
    return typeof context === 'string' ? context : undefined;
  }
  return undefined;
}

// In UserPromptExpansionHookOutput:
override getAdditionalContext(): string | undefined {
  const raw = this.getRawAdditionalContext();
  if (!raw) return undefined;
  return raw
    .slice(0, MAX_USER_PROMPT_EXPANSION_ADDITIONAL_CONTEXT_LENGTH)
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;');
}

— qwen3.7-max via Qwen Code /review

Comment thread packages/core/src/hooks/types.ts Outdated
typeof this.hookSpecificOutput['additionalContext'] === 'string'
? this.hookSpecificOutput['additionalContext']
: undefined;
if (!raw) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] if (!raw) treats empty string "" as undefined, diverging from parent semantics.

The parent DefaultHookOutput.getAdditionalContext() returns "" for empty-string input (it only rejects non-string types via typeof context !== 'string'). This override's falsy guard silently converts "" to undefined.

While downstream callers (appendUserPromptExpansionAdditionalContext) treat both identically via their own !additionalContext check, using if (raw === undefined) would be consistent with the parent and future-proof against callers that distinguish them.

Suggested change
if (!raw) {
if (raw === undefined) {

— qwen3.7-max via Qwen Code /review

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Downgraded from Approve to Comment: CI failing: Post Coverage Comment, Lint, CodeQL, Test (${{ matrix.os }}, Node ${{ matrix.node-version }}), Classify PR.

Round 15 review at 419c3bba9 — 20 new commits since R14. The author addressed most R13–R14 findings:

  • ✅ R13 Critical: UserPromptSubmitHookOutput.getAdditionalContext() bypass → fixed, now calls super.getAdditionalContext()
  • ✅ R13 Critical: Mid-entity truncation → fixed via sanitizeUserPromptExpansionAdditionalContext() (escape → truncate → strip trailing entity)
  • ✅ R14 Suggestion: Trailing-entity regex /&(?:amp?|lt?|gt?)?$/ → fixed to /&(?:a(?:mp?)?|lt?|gt?)?$/
  • ✅ R14 Suggestion: additionalContext && guard → intentionally kept (empty string = no context to chain)
  • ✅ Skill tool now forwards args to model-invocable command executor
  • createHookOutput factory now handles UserPromptSubmit and UserPromptExpansion
  • ✅ i18n locale files updated for all 9 locales
  • ✅ Comprehensive test coverage added (types.test.ts, hookRunner.test.ts chaining, skill.test.ts error path, nonInteractiveCliCommands.test.ts)

1 new Suggestion inline. Build passed, 465 tests passed, tsc/eslint 0 findings in changed files.

— qwen3.7-max via Qwen Code /review

Comment thread packages/core/src/hooks/types.ts Outdated
/**
* Specific hook output class for UserPromptSubmit events.
*/
export class UserPromptSubmitHookOutput extends DefaultHookOutput {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] UserPromptSubmitHookOutput overrides getAdditionalContext() with return super.getAdditionalContext() — a pure no-op. Compared to the sibling UserPromptExpansionHookOutput (which applies sanitizeUserPromptExpansionAdditionalContext), this class adds zero behavioral value: no extra escaping, no truncation, no validation.

The R13 Critical that motivated this class (raw getRawAdditionalContext() bypassing parent escaping) was correctly fixed by delegating to super.getAdditionalContext(). But now the override is dead weight — it creates the false impression that UserPromptSubmit has specialized sanitization when it does not.

Consider removing the class entirely and returning DefaultHookOutput from the createHookOutput factory's UserPromptSubmit case (same as SessionStart, Stop, etc.). If the class is kept as a placeholder for future specialization, a brief comment would help future readers.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented May 28, 2026

Copy link
Copy Markdown
Collaborator

Local Verification Report

PR: #4377feat(core): add user prompt expansion hooks
Verified by: Maintainer (local build + test)
Date: 2026-05-27


1. Build Verification

Package Result
@qwen-code/qwen-code-core PASS — clean build
@anthropic-ai/claude-code (CLI) PASS — clean build

TypeCheck: Both packages pass tsc --noEmit with zero errors.

2. Unit Tests

Package Tests Result
Core (packages/core) 348 tests ALL PASSED
CLI (packages/cli) 117 tests ALL PASSED
Total 465 tests ALL PASSED

3. E2E Integration Tests

Programmatic E2E tests exercising the real HookSystem stack (HookPlanner → HookRunner → HookAggregator → HookEventHandler) with actual shell command hooks:

Test Case Result Description
Hook fires with correct input PASS Verified stdin JSON contains hook_event_name, command_name, command_args, prompt, session_id
Blocking via decision:block PASS Hook stdout {"decision":"block","reason":"..."} correctly blocks prompt submission
Matcher filtering by command_name PASS Hook fires for matching command (init), skips non-matching (dream)
Event isolation PASS UserPromptExpansion hooks do NOT fire on UserPromptSubmit events

4. Code Review Notes

  • New UserPromptExpansion event integrates cleanly into existing hook lifecycle
  • Two call sites (interactive in slashCommandProcessor.ts, non-interactive in nonInteractiveCliCommands.ts) both gate on hasUserPromptExpansionHooks() before firing
  • Matcher uses commandName as target — consistent with hook planner architecture
  • additionalContext sanitization prevents injection via hook output
  • Settings schema and i18n strings added for all supported locales
  • No regressions observed in existing hook events

5. Summary

All verification checks pass. The feature correctly adds UserPromptExpansion as a new hook event that fires after slash commands expand into prompts and before submission to the model, with proper blocking, matcher filtering, and event isolation semantics.

Recommendation: Ready to merge

qqqys added 2 commits May 28, 2026 12:08
…ser-prompt-expansion

# Conflicts:
#	packages/cli/src/ui/components/hooks/constants.test.ts
#	packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
#	packages/cli/src/ui/hooks/slashCommandProcessor.ts
#	packages/core/src/hooks/hookAggregator.ts
#	packages/core/src/hooks/types.ts
@tanzhenxin tanzhenxin added the type/feature-request New feature or enhancement request label Jun 3, 2026
wenshao
wenshao previously approved these changes Jun 3, 2026

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found. LGTM! ✅ — qwen3.7-max via Qwen Code /review

@tanzhenxin

Copy link
Copy Markdown
Collaborator

Hi @qqqys — heads up that this PR now has merge conflicts with main and is showing as not mergeable. Could you rebase on the latest main and resolve them when you get a chance? Thanks!

@tanzhenxin tanzhenxin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Thanks for rebasing — conflicts are resolved and CI is green. The core feature is well-scoped and thoroughly tested: the event fires only on prompt expansion, matchers target the command name, and the block paths all route to a non-submitting outcome. Two things I'd like addressed before I approve.

1. Command args are invisible to the skill permission check (severity: medium · confidence: very high)

The rationale you noted for defaulting this sink to ask — that in AUTO mode the classifier should see the skill name and any inline args before the command runs — is exactly right. But the input the classifier actually receives, and the description shown in the confirmation dialog, both include only the skill name; the args are dropped even though they're forwarded to the command on execution. So a model-invocable command can be auto-approved (or confirmed by a user) on its name alone while the args that determine what it actually expands or runs go unseen. Since you've described skills as a privileged sink, I'd like args included in both the classifier input and the confirmation description before this merges.

2. A cancelled prompt can still be submitted after the hook runs (severity: medium · confidence: medium)

With an expansion hook configured, pressing Escape while the hook is in flight aborts the signal — but once the hook resolves, the code proceeds to submit the expanded prompt without re-checking whether it was cancelled, so a prompt the user tried to cancel mid-hook can still reach the model. The model-invocable command path already re-checks for cancellation after its async work; the user-typed path is missing the equivalent guard after the newly added hook await. (I confirmed the recheck is absent on that path but haven't traced whether a later stage catches it — worth a quick look.)

Verdict

COMMENT — solid, well-scoped, and well-tested. I'd like the args-in-permission-check gap and the post-hook cancellation recheck handled before I approve. Happy to approve on the next push once those two land.

wenshao
wenshao previously approved these changes Jun 3, 2026
serializeUserPromptExpansionPrompt(result.content),
controller.signal,
)
: undefined;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Model-invocable executor path missing abort check after hook fires.

The interactive submit_prompt path (line 821) correctly checks if (abortController.signal.aborted) return { type: 'handled' } after fireUserPromptExpansionEvent resolves. This non-interactive executor fires the same hook via controller.signal but never re-checks controller.signal.aborted afterward.

If the React effect unmounts during the hook's async execution, code continues processing hook output (blocking errors, additionalContext) on torn-down state.

Suggested change
: undefined;
: undefined;
if (controller.signal.aborted) return null;
if (output) {

— qwen3.7-max via Qwen Code /review

abortController.signal,
)
: undefined;
if (abortController.signal.aborted) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Abort check doesn't set hasError = true, so the finally block (~line 1028) evaluates !hasError as true and logs SlashCommandStatus.SUCCESS for a cancelled command. A user pressing ESC during the hook gets telemetry recorded as a successful execution.

Suggested change
if (abortController.signal.aborted) {
if (abortController.signal.aborted) {
hasError = true;
return { type: 'handled' };
}

— qwen3.7-max via Qwen Code /review

Comment thread packages/core/src/tools/skill.ts Outdated

getDescription(): string {
return `Use skill: "${this.params.skill}"`;
return this.params.args

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] getDescription() embeds this.params.args verbatim with no length bound. The returned string flows through getConfirmationDetails()ConsentPromptMarkdownDisplay, which interprets markdown formatting.

Two issues:

  1. Very long args from the model produce an enormous confirmation dialog.
  2. Markdown metacharacters in args (e.g. **bold**, [link](url)) are rendered as formatted text rather than displayed literally, which could be misleading in a trust-sensitive approval context.

Suggest truncating and escaping:

Suggested change
return this.params.args
const truncatedArgs = this.params.args.length > 120
? this.params.args.slice(0, 117) + '...'
: this.params.args;
return `Use skill: "${this.params.skill}" with args: "${truncatedArgs}"`;

— qwen3.7-max via Qwen Code /review


override toAutoClassifierInput(params: SkillParams): Record<string, unknown> {
return { skill: params.skill };
return params.args === undefined

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Three methods use different guard patterns for args, creating inconsistent behavior when args === "":

Method Guard args="" behavior
getDescription (line 381) truthy omits args → "Use skill: X"
toAutoClassifierInput (line 315) === undefined includes args: ""{ skill: X, args: "" }
execute (line 420) ?? '' passes "" to executor

The user sees "Use skill: X" (no args mentioned) in the approval dialog, but the auto-classifier receives { skill: X, args: "" } — a silent mismatch between what was approved and what was authorized.

Unify to one guard pattern and add an empty-string test.

— qwen3.7-max via Qwen Code /review

this.config,
new SkillLaunchEvent(this.params.skill, false),
);
return {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Error-object branch omits this.promptId from SkillLaunchEvent. All other branches include it for telemetry correlation:

  • Line 438 (string success): new SkillLaunchEvent(..., true, this.promptId)
  • Line 452 (not found): new SkillLaunchEvent(..., false, this.promptId)
  • Line 528 (catch): new SkillLaunchEvent(..., false, this.promptId)

This is the only branch that loses prompt attribution.

Suggested change
return {
new SkillLaunchEvent(this.params.skill, false, this.promptId),

— qwen3.7-max via Qwen Code /review

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 17 (incremental since R16 at 3d1448467): 2 commits (b7f4c2e2a, 66e7b9ec8), 4 files, +140/-4. The abort handling and skill arg sanitization fixes address the R16 Critical findings correctly; two follow-up Suggestions remain.

— qwen3.7-max via Qwen Code /review

controller.signal,
)
: undefined;
if (controller.signal.aborted) return null;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] return null on abort propagates to SkillTool.execute() as commandResult === null (skill.ts:411-449), which fails both type guards ('error' in commandResult — null is falsy; typeof === 'string') and falls through to the "Skill not found" fallback. The LLM receives Skill "<name>" not found. — factually wrong for a command that exists but was cancelled — and telemetry records success: false with no way to distinguish abort from genuine not-found.

Return an { error } object instead so the existing error-handler path (skill.ts:416-427) produces accurate LLM content and returnDisplay:

Suggested change
if (controller.signal.aborted) return null;
if (controller.signal.aborted) return { error: 'Skill execution cancelled by user.' };

This routes through logSkillLaunch(SkillLaunchEvent(..., false, promptId)) + llmContent: 'Skill execution cancelled by user.', giving both the model and 3 AM debuggers an accurate failure signal.

— qwen3.7-max via Qwen Code /review

)
: undefined;
if (abortController.signal.aborted) {
hasError = true;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] hasError = true on the user-invoked abort branch is correct and necessary (suppresses SlashCommandStatus.SUCCESS telemetry in the finally block), but no test exercises this branch.

The new test "should stop model-invocable command execution when hook unmounts" (line 919) covers the model-invocable abort path at line 494 (return null). The existing unmount tests (lines 1418, 1463, 1530) target command loading, not the submit_prompt action handler. A grep for abortController.signal.aborted + submit_prompt across this test file returns zero hits — so a future refactor that drops the hasError = true assignment would silently re-introduce the R16 bug (false-success telemetry on ESC).

Add a parallel test mirroring the model-invocable one: render the hook with a pending fireUserPromptExpansionEvent promise, submit a submit_prompt slash command, unmount, resolve the promise, then assert that logSlashCommand was not called with SlashCommandStatus.SUCCESS (or was called with FAILURE/ERROR).

— qwen3.7-max via Qwen Code /review

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 18 (incremental at 8798460c4): 1 commit, 2 files, +13/-2. The model-invocable abort path now correctly returns { error: 'Skill execution cancelled by user.' } instead of null, which prevents SkillTool.execute() from misreporting cancellations as "Skill not found". Both R17b Suggestions are addressed: (1) type guard compatibility fixed, (2) user-invoked abort branch is covered by the existing test at line 722 plus the new logSlashCommand SUCCESS negative assertion. Tests 54/54 pass. LGTM ✅ — qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

Verification Report

Environment: macOS Darwin 25.4.0 (Apple Silicon), Node.js, tmux parallel execution

Test Results

Category Result Details
Core tests ✅ 462 passed (8 files) hookEventHandler (124), hookPlanner (61), hookSystem (82), hookRunner (36), types (8), skill (56), agent (83), toAutoClassifierInput (12)
CLI tests ✅ 180 passed (5 files) userPromptExpansionHook (12), slashCommandProcessor (54), constants (43), nonInteractiveCliCommands (26), nonInteractiveCli (46, 1 pre-existing skip)
Core tsc --noEmit ⚠️ 4 pre-existing errors All in openaiContentGenerator/converter.ts (FinishReason.IMAGE_RECITATION/IMAGE_OTHER from @google/genai 2.6.0 upgrade). Zero errors in PR-changed files.
CLI tsc --noEmit ✅ No PR-file errors All PR-changed files type-check cleanly
Core build ⚠️ Blocked by above Same pre-existing @google/genai FinishReason issue as other PRs
ESLint ✅ No errors All 11 PR source files pass lint

Code Review Observations

  1. New event type: HookEventName.UserPromptExpansion fires only for slash commands that expand into prompts (not for direct user input or non-expanding commands). Properly distinguishes from UserPromptSubmit.

  2. Matcher support: hookPlanner routes UserPromptExpansion to { kind: 'commandName', target } — hook authors can target specific slash commands by name (e.g., match only /review expansions). This is a deliberate design choice vs. UserPromptSubmit which has no matcher.

  3. Blocking behavior: UserPromptExpansionHookOutput inherits shouldStopExecution() and getEffectiveReason() from DefaultHookOutput. When blocked, the expanded prompt is not submitted and a formatted blocked message is shown. Sanitization via sanitizeUserPromptExpansionAdditionalContext prevents injection.

  4. Both invocation paths covered: slashCommandProcessor.ts fires the hook at both:

    • Interactive path (line 484): user-typed slash commands in TUI
    • Non-interactive path (line 814): --slash-command CLI flag
  5. Fast-path optimization: hasUserPromptExpansionHooks() checks getDisableAllHooks() and hasHooksForEvent('UserPromptExpansion') before firing — zero overhead when no hooks are configured.

  6. Helper utilities: userPromptExpansionHook.ts provides clean helpers for context appending, prompt serialization (with verbose mode for non-text parts), and blocked-message formatting.

Verdict

Ready to merge (after rebase to fix pre-existing @google/genai build blocker) — All 642 tests pass, types clean in PR files, lint clean. Implementation follows established hook patterns with proper matcher support and blocking behavior.


Verified by: wenshao

@qqqys qqqys requested a review from tanzhenxin June 5, 2026 03:35
@yiliang114

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core design is solid — sanitization is applied in the right order, abort handling covers the interactive paths, sequential hook chaining uses the correct output subclass, and the type change from string | null to ModelInvocableCommandExecutorResult | null is correctly handled at all consumer sites. The feature is well-scoped and thoroughly tested (465 tests pass).

Two non-blocking follow-ups worth addressing before or shortly after merge:

  • shouldStopExecution() === true path is untested in both CLI integration paths (every blocking test only exercises blocked: true)
  • fireUserPromptExpansionHook in nonInteractiveCliCommands.ts is missing the abort re-check after the hook await (the interactive path has it)

The rest (hook-fire pattern duplication, matcher consolidation, chaining sanitization asymmetry comment) are quality follow-ups — not blocking.

@wenshao wenshao merged commit a47d260 into QwenLM:main Jun 5, 2026
10 checks passed
xaelistic pushed a commit to xaelistic/qwen-code that referenced this pull request Jun 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type/feature-request New feature or enhancement request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants