Skip to content

fix(core): align session hook matcher targets#4354

Merged
pomelo-nwu merged 3 commits into
QwenLM:mainfrom
qqqys:feat/session-hook-matcher
May 21, 2026
Merged

fix(core): align session hook matcher targets#4354
pomelo-nwu merged 3 commits into
QwenLM:mainfrom
qqqys:feat/session-hook-matcher

Conversation

@qqqys

@qqqys qqqys commented May 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • What changed: session-scoped hooks now choose matcher targets from the same event-specific context fields used by configured hooks. Tool events still match tool names, while session, notification, subagent, compact, and stop-failure events match their own lifecycle fields.
  • Why it changed: session hooks previously always matched against toolName, so non-tool events such as SessionStart, Notification, and StopFailure could not be filtered by their meaningful payload values.
  • Reviewer focus: please check that events without matcher semantics still run all session hooks for the event, while events with matcher semantics continue to use the existing session-hook matcher language.

Validation

  • Commands run:
    cd packages/core && npx vitest run src/hooks/hookEventHandler.test.ts
    npm run build
    npm run typecheck
  • Prompts / inputs used: N/A
  • Expected result: focused hook tests pass; build and typecheck complete successfully.
  • Observed result: hookEventHandler.test.ts passed with 114 tests. npm run build completed successfully with one unrelated existing lint warning in packages/vscode-ide-companion/src/utils/editorGroupUtils.ts and a Browserslist data-age warning. npm run typecheck completed successfully.
  • Quickest reviewer verification path:
    cd packages/core && npx vitest run src/hooks/hookEventHandler.test.ts
  • Evidence: local test output showed 1 passed, 114 passed; build and typecheck exited with code 0.

Scope / Risk

  • Main risk or tradeoff: session hooks for non-tool events may now filter more narrowly when a matcher is configured, matching the configured-hook behavior for the same event.
  • Not covered / not validated: full test suite was not run.
  • Breaking changes / migration notes: no config migration required.

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:

  • Tested locally on macOS with npm run build, npm run typecheck, and the focused package-level Vitest command.
  • Windows and Linux were not tested locally.

Linked Issues / Bugs

Related to #4343.

@qqqys qqqys marked this pull request as ready for review May 20, 2026 07:37
@github-actions

Copy link
Copy Markdown
Contributor

📋 Review Summary

This PR aligns session hook matcher targets with the event-specific context fields used by configured hooks, ensuring consistent behavior across both hook systems. The implementation is well-tested, type-safe, and mirrors the existing HookPlanner.matchesContext() logic appropriately.

🔍 General Feedback

  • Clean implementation: The new getSessionHookMatcherTarget() method cleanly extracts matcher logic and follows the same event-specific dispatch pattern as HookPlanner.matchesContext().
  • Good test coverage: The 4 new test cases cover the key event types (SessionStart, StopFailure, Notification) and the important edge case of events without matcher semantics.
  • Consistent with existing patterns: The change maintains consistency with how configured hooks handle matcher targets across different event types.
  • Risk is well-contained: The change only affects session hook matching logic; events without matcher semantics continue to run all session hooks as before.

🎯 Specific Feedback

🔵 Low

  • File: hookEventHandler.ts:547 - Consider adding a comment explaining why context?.toolName ?? '' uses empty string instead of undefined for tool events, since an empty string would still be passed to getMatchingHooks() and match via the pattern matching logic. This could be clearer with a brief inline comment.

  • File: hookEventHandler.ts:564-566 - The comment mentions "Mirrors HookPlanner.matchesContext()" - consider adding a reference to the specific line numbers or method signature in hookPlanner.ts for easier cross-referencing during future maintenance (e.g., "Mirrors HookPlanner.matchesContext() at lines 72-147").

  • File: hookEventHandler.test.ts:122-135 - The createSessionHookEntry helper is great, but consider adding it to a shared test utilities section or documenting why it's defined at the describe block level rather than in a separate utilities file, especially if similar helpers exist elsewhere in the test suite.

✅ Highlights

  • Excellent test design: The test "does not matcher-filter session hooks for events without matcher semantics" is particularly valuable as it explicitly validates the boundary condition where matcher semantics should not apply.

  • Clear PR description: The summary clearly explains what changed, why it changed, and what the reviewer should focus on. The validation section with specific commands and expected results makes verification straightforward.

  • Minimal risk approach: By returning undefined for events without matcher semantics and using that to branch between getHooksForEvent() vs getMatchingHooks(), the implementation preserves backward compatibility for events like UserPromptSubmit, Stop, and TodoCreated.

  • Good alignment with related issue: Linking to Improve hook lifecycle coverage and matcher targets #4343 provides helpful context for the broader initiative this change supports.

context?: HookEventContext,
): string | undefined {
switch (eventName) {
case HookEventName.PreToolUse:

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] getSessionHookMatcherTarget reimplements the same event-to-field mapping that HookPlanner.matchesContext() already encodes — two switch (eventName) blocks that must stay in lockstep. If a future PR adds a new HookEventName and updates one switch but not the other, session hooks and configured hooks will silently diverge with no compile-time error.

Consider extracting a shared helper (e.g., in hookPlanner.ts or a new matcherUtils.ts):

export function getEventMatcherField(eventName: HookEventName): keyof HookEventContext | undefined {
  switch (eventName) {
    case HookEventName.PreToolUse:
    case HookEventName.PostToolUse:
    case HookEventName.PostToolUseFailure:
    case HookEventName.PermissionRequest:
      return 'toolName';
    case HookEventName.SubagentStart:
    case HookEventName.SubagentStop:
      return 'agentType';
    // ... etc
    default:
      return undefined;
  }
}

Both matchesContext and this method consume the single source of truth, eliminating drift risk.

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

case HookEventName.TodoCompleted:
default:
return 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] The explicit default case prevents TypeScript from flagging unhandled HookEventName members. If someone adds a new event to the enum and forgets to add a case here, session hooks for that event will silently lose matcher filtering (running unconditionally) with no compile error.

Consider removing default and adding an exhaustiveness assertion:

      case HookEventName.UserPromptSubmit:
      case HookEventName.Stop:
      case HookEventName.TodoCreated:
      case HookEventName.TodoCompleted:
        return undefined;
    }
    const _exhaustive: never = eventName;
    return _exhaustive;

Note: HookPlanner.matchesContext has the same pattern — both would benefit from this fix.

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

});
});

describe('session hook matcher targets', () => {

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 new tests cover 4 event types: SessionStart (trigger branch), StopFailure (error branch), Notification (notificationType branch), and UserPromptSubmit (no-matcher path). Two branches of getSessionHookMatcherTarget remain untested:

  1. toolName branchPreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest. This is the most commonly fired path (every tool call). A regression here would silently break session hook matching for all tool invocations.

  2. agentType branchSubagentStart, SubagentStop. Uses a distinct context field (agentType) not covered by any other test.

Consider adding one test per untested branch:

it('matches PreToolUse session hooks against the tool name', async () => {
  const sessionHook = createSessionHookEntry(HookEventName.PreToolUse, 'shell');
  // ... setup mocks ...
  await hookEventHandler.firePreToolUseEvent('toolu_123', 'shell', { command: 'ls' });
  expect(mockSessionHooksManager.getMatchingHooks).toHaveBeenCalledWith(
    'test-session-id', HookEventName.PreToolUse, 'shell',
  );
});

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

eventName: HookEventName,
context?: HookEventContext,
): HookMatcherTarget | undefined {
switch (eventName) {

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] ESLint default-case: this switch (eventName) is missing a default case, causing the CI Lint check to fail. The project convention (see mermaidImageRenderer.ts:832) wraps the never exhaustiveness assertion inside a default block:

Suggested change
switch (eventName) {
case HookEventName.UserPromptSubmit:
case HookEventName.Stop:
case HookEventName.TodoCreated:
case HookEventName.TodoCompleted:
return undefined;
default: {
const exhaustive: never = eventName;
return exhaustive;
}
}

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

return true;
}

switch (matcherTarget.kind) {

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 ESLint default-case violation as above. Apply the same fix:

Suggested change
switch (matcherTarget.kind) {
case 'sessionTrigger':
return this.matchesSessionTrigger(matcher, matcherTarget.target);
default: {
const exhaustive: never = matcherTarget.kind;
return exhaustive;
}
}

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

target: string;
}

export function getHookMatcherTarget(

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] getHookMatcherTarget is a new exported pure function with 7 branch groups but has zero direct unit tests — all coverage is indirect through integration tests. Consider adding a describe('getHookMatcherTarget', ...) block in hookPlanner.test.ts that directly asserts each branch's return value, including the ?? '' fallback for missing context fields and the undefined return for matcher-less events.

— qwen-latest-series-invite-beta-v34 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.

All Round 2 findings are addressed in this revision:

  • ESLint default-case (Critical): Both switches in hookPlanner.ts now wrap the never exhaustiveness assertion inside default blocks — CI Lint passes.
  • getHookMatcherTarget tests (Suggestion): 7 new unit tests covering all event categories added in hookPlanner.test.ts.
  • Session hook matcher tests (Suggestion): 6 new integration tests covering SessionStart, PreToolUse, SubagentStart, StopFailure, Notification, and the no-matcher-semantics path added in hookEventHandler.test.ts.

Build passes, typecheck passes, all 174 tests pass. LGTM! ✅

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

@pomelo-nwu pomelo-nwu 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: Approve

Product direction — sound, and this is a real bug fix, not just a style unification.

Before this change, hookEventHandler always used context?.toolName || '' as the matcher target for session hooks. For non-tool events (SessionStart / Notification / StopFailure / Subagent*, etc.) the target was always '', so matchesPattern('resume', '') -> /^resume$/.test('') -> false. That means a session hook with a concrete matcher on a non-tool event would never fire, while config hooks (via HookPlanner) matched correctly against event-specific fields — a genuine behavior divergence. Extracting getHookMatcherTarget as the single source of truth for "event -> matched field", shared by both paths, is the right fix.

Code quality — good.

  • Exhaustiveness check is solid: I verified all 16 HookEventName values are covered (toolName x4 / agentType x2 / trigger x2 / sessionTrigger x2 / error x1 / notificationType x1 / undefined x4), so const exhaustive: never = eventName compiles, and any future event added without handling will fail the build.
  • Behavior alignment is correct: events without matcher semantics (UserPromptSubmit / Stop / TodoCreated / TodoCompleted) go through getHooksForEvent (no filtering), matching config-hook behavior. Notably Stop now always runs, which is safer for /goal.
  • Test coverage is adequate.

Optional (non-blocking):

  1. For tool events with a missing context, getHookMatcherTarget returns target: '' (not undefined), so it goes through getMatchingHooks(…, '') rather than "match all". Harmless in practice (tool events always carry a toolName), but slightly inconsistent with the "empty target = match all" semantics — worth a comment.
  2. Consider adding a reverse regression assertion — e.g. "a resume matcher does NOT fire on a startup SessionStart" — to lock down exactly the bug being fixed. Current tests assert the correct target is passed, but not the negative filtering case.
中文说明

评审结论:通过(Approve)

产品方向 —— 合理,且是真实 bug 修复,不只是风格统一。

改动前,hookEventHandler 对 session hook 永远用 context?.toolName || '' 当匹配目标。对非工具事件(SessionStart / Notification / StopFailure / Subagent* 等)target 恒为 '',于是 matchesPattern('resume', '') -> /^resume$/.test('') -> false,意味着在非工具事件上配了具体 matcher 的 session hook 永远不会触发;而 config hook(走 HookPlanner)用 event-specific 字段匹配是对的 —— 两条路径行为分歧。把"事件 -> 匹配字段"映射抽成单一函数 getHookMatcherTarget、两条路径共用,是正确的修法。

代码质量 —— 好。

  • 穷尽性检查可靠:已核对 HookEventName 全部 16 个值都被覆盖,const exhaustive: never = eventName 能编译通过,将来新增事件漏处理会直接编译失败。
  • 行为对齐正确:无 matcher 语义的事件走 getHooksForEvent(不过滤),与 config hook 一致;尤其 Stop 现在一定执行,对 /goal 更安全。
  • 测试覆盖充分。

可选建议(不阻塞):

  1. 工具事件在 context 缺失时返回 target: ''(非 undefined),会走 getMatchingHooks(…, '') 而非"全过"。实际无害(工具事件必带 toolName),但与"空 target = 全过"语义略不一致,值得一句注释。
  2. 可补一个反向回归断言(如"startup 触发的 SessionStart 上 matcher=resume 的 hook 不执行"),直接锁住这次修的 bug。

@pomelo-nwu pomelo-nwu merged commit b588f74 into QwenLM:main May 21, 2026
10 checks passed
@wenshao

wenshao commented May 21, 2026

Copy link
Copy Markdown
Collaborator

Local maintainer validation — all PR-relevant gates green ✅

Reviewed at head bf809d43 (against base 0a14bf830) in a dedicated tmux session (pr4354, 7 windows) under git worktree /.qwen/tmp/review-pr-4354.

Environment

  • macOS 26.4.1 (Darwin 25.4.0 arm64), Node 22.17.0, npm 11.8.0
  • Fresh npm ci (1453 packages)
  • Repo version 0.15.11

Results

Stage Command Result
Install npm ci ✅ exit 0
Build npm run build ✅ exit 0 (only pre-existing vscode-companion curly warning the PR author already calls out)
Typecheck npm run typecheck ✅ exit 0 across all 5 workspaces (acp-bridge, qwen-code, qwen-code-core, sdk, webui)
Lint npm run lint ✅ exit 0 (root eslint + integration-tests both clean)
PR-author validation: hookEventHandler + hookPlanner cd packages/core && npx vitest run src/hooks/hookEventHandler.test.ts src/hooks/hookPlanner.test.ts 174/174 (116 + 58) in 3.5s
Full packages/core suite cd packages/core && npx vitest run 8821 passed / 3 skipped in 322 files. 3 unrelated failures, all reproduced on base — see below.
End-to-end against built artifact custom Node harness driving dist/.../hookPlanner.js and getHookMatcherTarget 33/33 assertions

Unrelated test failures (reproduced on base; NOT caused by this PR)

  1. src/skills/skill-manager.test.ts — 2 fails (bundled skills > should load bundled skills in listSkills, ... should fall back to bundled level in loadSkill).

    • Root cause: the test mock's bundled-path detection rejects any path containing .qwen (isBundled = ... && !pathStr.includes('.qwen')). Our review worktree lives at /Users/wenshao/Work/git/qwen-code/.qwen/tmp/review-pr-4354/, so the literal .qwen segment in the path makes the mock treat the bundled directory as non-bundled.
    • Reproduced identically:
      • on PR base 0a14bf8fb (same fail)
      • on an unrelated worktree under /.qwen/tmp/review-pr-4314 (same fail)
    • Not present on qwen-code-x3 (path /Users/wenshao/Work/git/qwen-code-x3, no .qwen segment).
    • Test fragility in fixture design, not related to PR 4354. The relevant CI runners use a clean checkout path without .qwen, which is why CI passed on macOS/Ubuntu/Windows.
  2. src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts — 1 fail (treats unset baseURL as Anthropic-native ...).

    • Root cause: test asserts User-Agent contains QwenCode/1.2.3, but receives claude-cli/1.2.3 (external, cli) — this validator is being run inside Claude Code, which injects its own User-Agent into the env. Pre-existing environmental issue.
    • Reproduced identically on PR base 0a14bf8fb and on qwen-code-x3 main.
    • Not related to PR 4354.

End-to-end proof (against built packages/core/dist)

The PR introduces a new exported helper getHookMatcherTarget(eventName, context) and rewires HookEventHandler and HookPlanner to dispatch session-hook matcher targets per event type. The e2e harness drives the compiled module with every HookEventName and asserts:

  • 12/12 matcher cases — each event resolves to the correct (kind, target):
    • PreToolUse / PostToolUse / PostToolUseFailure / PermissionRequesttoolName
    • SubagentStart / SubagentStopagentType
    • PreCompact / PostCompacttrigger
    • SessionStart / SessionEndsessionTrigger
    • StopFailureerror
    • NotificationnotificationType
  • 4/4 events without matcher semantics → undefined (no filtering): UserPromptSubmit, Stop, TodoCreated, TodoCompleted
  • 5/5 missing-context cases → defaults to target: "" (graceful, no crash)
  • 10/10 HookPlanner.createExecutionPlan cases — registers two hooks per event with different matchers, fires the event with one context value, and verifies only the matching hook ends up in the plan:
    • PreToolUse {toolName:'shell'} → only A-shell
    • SubagentStart {agentType:'explorer'} → only B-explorer (would have failed under old code that always matched on toolName)
    • SessionStart {trigger:'startup'} → only S-startup
    • Notification {notificationType:'permission_prompt'} → only N-perm
    • StopFailure {error:'rate_limit'} → only F-rate
    • ... and equivalent symmetric cases
  • 1/1 cross-event regression: SubagentStart {toolName:'shell', agentType:'explorer'} → only B-explorer, confirming toolName is fully ignored for subagent events (this is the bug fix's central guarantee).

Behavioral observations

  • The new exported getHookMatcherTarget shares matcher-target dispatch between HookPlanner.matchesHook and HookEventHandler.fireEvent, eliminating the divergence that caused the bug.
  • The default: const exhaustive: never = … blocks in both call sites guarantee future HookEventName additions trigger a typecheck failure — that's what the 3rd PR commit (fix(core): satisfy hook matcher exhaustiveness lint) was added to enforce.
  • HookEventHandler correctly routes events with no matcher target to getHooksForEvent(sessionId, eventName) (unfiltered), and events with a matcher target to getMatchingHooks(sessionId, eventName, matcherTarget).
  • SessionStart/SessionEnd use a distinct sessionTrigger kind (vs the generic trigger for compact events) — preserves the existing matchesSessionTrigger semantics.

Scope / risk

  • Diff is well-scoped: +393 / -67 across 4 files, all under packages/core/src/hooks/. No surface area outside hook plumbing.
  • The change centralizes matcher-target selection, so any future event added must be added in one place (getHookMatcherTarget) instead of two — net reduction in surprise.
  • All GitHub CI checks were green (Lint, CodeQL, Test on macOS/Ubuntu/Windows Node 22).

Reviewer recommendation

Safe to merge. Behavior, type-exhaustiveness, and tests are coherent; the e2e against the compiled artifact confirms each event type correctly routes through its event-specific matcher field. The only test failures observed in the local full-suite run are pre-existing and environmental (path-shape fixture fragility + Claude-Code-injected User-Agent), both reproduced on the PR's base commit.

— Maintainer local validation, run on bf809d43 from upstream pull/4354/head.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants