feat(core): add user prompt expansion hooks#4377
Conversation
📋 Review SummaryThis PR introduces a new 🔍 General Feedback
🎯 Specific Feedback🟢 Medium
🔵 Low
✅ Highlights
|
| export interface UserPromptExpansionOutput extends HookOutput { | ||
| hookSpecificOutput?: { | ||
| hookEventName: 'UserPromptExpansion'; | ||
| additionalContext?: string; |
There was a problem hiding this comment.
[Suggestion] additionalContext is declared in UserPromptExpansionOutput but is silently dropped at every layer:
hookRunner.ts:applyHookOutputToInputhas no case forHookEventName.UserPromptExpansion(onlyUserPromptSubmitandPreToolUseare handled), so sequential hooks cannot chainadditionalContextinto the prompt.- Neither consumer (
slashCommandProcessor.tsnornonInteractiveCliCommands.ts) callsoutput.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( |
There was a problem hiding this comment.
[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 => ({ |
There was a problem hiding this comment.
[Critical] Mock object is missing openDiffDialog property, causing a TS2741 type error. This is related to the openDiffDialog prop added to SlashCommandProcessorActions.
| const createMockActions = (): SlashCommandProcessorActions => ({ | |
| openDiffDialog: vi.fn(), |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| let content = result.content; | ||
| const output = await config | ||
| ?.getHookSystem() | ||
| ?.fireUserPromptExpansionEvent( |
There was a problem hiding this comment.
[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.
| ?.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, |
There was a problem hiding this comment.
[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.
| 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; |
There was a problem hiding this comment.
[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.
| 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 { |
There was a problem hiding this comment.
[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.
| ): 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 { | |||
| }; | |||
There was a problem hiding this comment.
[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', |
There was a problem hiding this comment.
[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.
| 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
left a comment
There was a problem hiding this comment.
[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; |
There was a problem hiding this comment.
[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.
| 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; |
There was a problem hiding this comment.
[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.
| 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' }; | ||
| } |
There was a problem hiding this comment.
[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 |
There was a problem hiding this comment.
[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()) { |
There was a problem hiding this comment.
[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).
| if (blockingError.blocked || output.shouldStopExecution()) { | |
| if (blockingError.blocked || output.shouldStopExecution()) { | |
| return null; | |
| } |
— qwen3.7-max via Qwen Code /review
| * Match slash command name against matcher pattern. | ||
| */ | ||
| private matchesCommandName(matcher: string, commandName: string): boolean { | ||
| return this.matchesToolName(matcher, commandName); |
There was a problem hiding this comment.
[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
left a comment
There was a problem hiding this comment.
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):
applyHookOutputToInputinhookRunner.tshas no case forUserPromptExpansion— sequential hooks won't chainadditionalContextlikeUserPromptSubmitdoesadditionalContexttruncation at 10K chars is silent (no debug log)hasUserPromptExpansionHooksguard duplicated betweenslashCommandProcessor.tsandnonInteractiveCliCommands.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()) { |
There was a problem hiding this comment.
[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:
| 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; |
There was a problem hiding this comment.
[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.
| 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
left a comment
There was a problem hiding this comment.
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:490—applyHookOutputToInputreads rawhookSpecificOutput['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()=truebranch untested in all blocking tests (onlyblocked:truetested)- Hook-disabled guard (
getDisableAllHooks/hasHooksForEvent) never tested in disabled state for UserPromptExpansion paths serializeUserPromptExpansionPrompthas zero direct unit tests (only tested indirectly via integration)- Model-invocable executor uses useEffect's
controller.signal(component lifecycle) instead of per-commandabortController.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
| switch (eventName) { | ||
| case HookEventName.UserPromptSubmit: | ||
| case HookEventName.UserPromptExpansion: | ||
| if ('additionalContext' in hookOutput.hookSpecificOutput) { |
There was a problem hiding this comment.
[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, < → <, > → >) 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: |
There was a problem hiding this comment.
[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 < → < and > → >.
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 </> silently injected into the chained prompt. No test covers this path.
| 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( |
There was a problem hiding this comment.
[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.
| export function formatUserPromptExpansionBlockedMessage( | |
| export function formatUserPromptExpansionBlockedMessage( | |
| reason: string, | |
| ): string { | |
| const sanitized = reason | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .slice(0, 10_000); | |
| return `UserPromptExpansion blocked: ${sanitized}`; | |
| } |
— qwen3.7-max via Qwen Code /review
| const additionalContext = createHookOutput( | ||
| eventName, | ||
| hookOutput, | ||
| ).getAdditionalContext(); |
There was a problem hiding this comment.
[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( |
There was a problem hiding this comment.
[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 () => { |
There was a problem hiding this comment.
[Suggestion] Multiple test gaps in the blocking behavior:
-
shouldStopExecution()path: All blocking tests useblockingError.blocked = truewithshouldStopExecution: () => false. No test exercises theshouldStopExecution() = truebranch (which takes a different code path throughgetBlockingError()returning{ blocked: false }). -
getEffectiveReason()fallback: All blocking mocks usereason: 'Blocked by policy'(truthy), soblockingError.reason || output.getEffectiveReason()always short-circuits. The fallback is never exercised. -
Hook-disabled guard: All tests mock
getDisableAllHooks: falseandhasHooksForEvent: 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', () => { |
There was a problem hiding this comment.
[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 |
There was a problem hiding this comment.
[Suggestion] formatUserPromptExpansionBlockedMessage HTML-escapes < and >, but all three callers render the output as plain text, not HTML:
- Interactive TUI (
slashCommandProcessor.ts:822):addMessage({ type: MessageType.ERROR })— Ink renders as terminal plain text;<appears literally on screen. - Model-invocable (
slashCommandProcessor.ts:497): returned as string to SkillTool →llmContent: [{ text }]— model receives<literally. - 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 <script> pass through unchanged.
| 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
| */ | ||
| export class UserPromptExpansionHookOutput extends DefaultHookOutput { | ||
| override getAdditionalContext(): string | undefined { | ||
| return super |
There was a problem hiding this comment.
[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:
- Truncated mid-entity: If
<appears near the 10K boundary, the escaped form<(4 chars) may be cut at&lor<— producing garbled fragments in the prompt. - 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:
| 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, '<') | |
| .replace(/>/g, '>'); | |
| } | |
| } |
— 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 |
There was a problem hiding this comment.
[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:
- String input → returns the string
- Part array → returns concatenated text with verbose formatting
- Single Part object → returns text content
- 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; |
There was a problem hiding this comment.
[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 ( |
There was a problem hiding this comment.
[Suggestion] fireUserPromptExpansionHook has three guard/early-return branches that are all untested:
config.getDisableAllHooks?.()returnstrueconfig.hasHooksForEvent?.('UserPromptExpansion')returnsfalseconfig.getHookSystem()returnsnull/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]: [ |
There was a problem hiding this comment.
[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:
'block expanded prompt submission and show stderr to user only''When a slash command expands into a prompt''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
| return undefined; | ||
| } | ||
| return raw | ||
| .slice(0, MAX_USER_PROMPT_EXPANSION_ADDITIONAL_CONTEXT_LENGTH) |
There was a problem hiding this comment.
[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 (<), 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.,<→&l), producing garbled text appended to the user's prompt.
Adding a second .slice() after escaping guarantees the output never exceeds MAX_LENGTH:
| .slice(0, MAX_USER_PROMPT_EXPANSION_ADDITIONAL_CONTEXT_LENGTH) | |
| return raw | |
| .slice(0, MAX_USER_PROMPT_EXPANSION_ADDITIONAL_CONTEXT_LENGTH) | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .slice(0, MAX_USER_PROMPT_EXPANSION_ADDITIONAL_CONTEXT_LENGTH); |
— qwen3.7-max via Qwen Code /review
| */ | ||
| export class UserPromptExpansionHookOutput extends DefaultHookOutput { | ||
| override getAdditionalContext(): string | undefined { | ||
| const raw = |
There was a problem hiding this comment.
[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:
hookSpecificOutputisundefined'additionalContext'key is absent- Value is not a string (e.g., number)
- Value is empty string
""
Consider adding a describe('UserPromptExpansionHookOutput.getAdditionalContext', ...) block in the core hook tests.
— qwen3.7-max via Qwen Code /review
| export class UserPromptExpansionHookOutput extends DefaultHookOutput { | ||
| override getAdditionalContext(): string | undefined { | ||
| const raw = | ||
| this.hookSpecificOutput && |
There was a problem hiding this comment.
[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, '<')
.replace(/>/g, '>');
}— qwen3.7-max via Qwen Code /review
| typeof this.hookSpecificOutput['additionalContext'] === 'string' | ||
| ? this.hookSpecificOutput['additionalContext'] | ||
| : undefined; | ||
| if (!raw) { |
There was a problem hiding this comment.
[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.
| if (!raw) { | |
| if (raw === undefined) { |
— qwen3.7-max via Qwen Code /review
wenshao
left a comment
There was a problem hiding this comment.
Round 15 review at 419c3bba9 — 20 new commits since R14. The author addressed most R13–R14 findings:
- ✅ R13 Critical:
UserPromptSubmitHookOutput.getAdditionalContext()bypass → fixed, now callssuper.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
argsto model-invocable command executor - ✅
createHookOutputfactory now handlesUserPromptSubmitandUserPromptExpansion - ✅ 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
| /** | ||
| * Specific hook output class for UserPromptSubmit events. | ||
| */ | ||
| export class UserPromptSubmitHookOutput extends DefaultHookOutput { |
There was a problem hiding this comment.
[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
Local Verification ReportPR: #4377 — 1. Build Verification
TypeCheck: Both packages pass 2. Unit Tests
3. E2E Integration TestsProgrammatic E2E tests exercising the real
4. Code Review Notes
5. SummaryAll verification checks pass. The feature correctly adds Recommendation: Ready to merge ✅ |
…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
wenshao
left a comment
There was a problem hiding this comment.
No issues found. LGTM! ✅ — qwen3.7-max via Qwen Code /review
|
Hi @qqqys — heads up that this PR now has merge conflicts with |
tanzhenxin
left a comment
There was a problem hiding this comment.
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.
…ser-prompt-expansion
| serializeUserPromptExpansionPrompt(result.content), | ||
| controller.signal, | ||
| ) | ||
| : undefined; |
There was a problem hiding this comment.
[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.
| : undefined; | |
| : undefined; | |
| if (controller.signal.aborted) return null; | |
| if (output) { |
— qwen3.7-max via Qwen Code /review
| abortController.signal, | ||
| ) | ||
| : undefined; | ||
| if (abortController.signal.aborted) { |
There was a problem hiding this comment.
[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.
| if (abortController.signal.aborted) { | |
| if (abortController.signal.aborted) { | |
| hasError = true; | |
| return { type: 'handled' }; | |
| } |
— qwen3.7-max via Qwen Code /review
|
|
||
| getDescription(): string { | ||
| return `Use skill: "${this.params.skill}"`; | ||
| return this.params.args |
There was a problem hiding this comment.
[Suggestion] getDescription() embeds this.params.args verbatim with no length bound. The returned string flows through getConfirmationDetails() → ConsentPrompt → MarkdownDisplay, which interprets markdown formatting.
Two issues:
- Very long
argsfrom the model produce an enormous confirmation dialog. - 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:
| 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 |
There was a problem hiding this comment.
[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 { |
There was a problem hiding this comment.
[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.
| return { | |
| new SkillLaunchEvent(this.params.skill, false, this.promptId), |
— qwen3.7-max via Qwen Code /review
wenshao
left a comment
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
[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:
| 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; |
There was a problem hiding this comment.
[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
left a comment
There was a problem hiding this comment.
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
Verification ReportEnvironment: macOS Darwin 25.4.0 (Apple Silicon), Node.js, tmux parallel execution Test Results
Code Review Observations
Verdict✅ Ready to merge (after rebase to fix pre-existing Verified by: wenshao |
|
@qwen-code /triage |
yiliang114
left a comment
There was a problem hiding this comment.
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() === truepath is untested in both CLI integration paths (every blocking test only exercisesblocked: true)fireUserPromptExpansionHookinnonInteractiveCliCommands.tsis 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.
Summary
Validation
Scope / Risk
Testing Matrix
Testing matrix notes:
Linked Issues / Bugs
Refs #4343