Skip to content

fix(core): require integer stop hook cap#5667

Merged
wenshao merged 1 commit into
QwenLM:mainfrom
tt-a1i:fix/stop-hook-cap-integer
Jun 23, 2026
Merged

fix(core): require integer stop hook cap#5667
wenshao merged 1 commit into
QwenLM:mainfrom
tt-a1i:fix/stop-hook-cap-integer

Conversation

@tt-a1i

@tt-a1i tt-a1i commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Rejects fractional values for the Stop/SubagentStop hook blocking cap.

  • Treats non-integer stopHookBlockingCap values as invalid and falls back to DEFAULT_STOP_HOOK_BLOCK_CAP.
  • Applies the same behavior to QWEN_CODE_STOP_HOOK_BLOCK_CAP.
  • Keeps valid positive integers and the existing max clamp unchanged.
  • Updates the generated settings schema so editors validate this setting as an integer with minimum: 1.

Why it's needed

stopHookBlockingCap is a count-like safety valve, but fractional values were previously accepted and floored. For example, QWEN_CODE_STOP_HOOK_BLOCK_CAP=1.5 resolved to 1, which can make Stop/SubagentStop hook loop protection fire earlier than the configured value suggests.

Fractional values should be treated like other invalid values instead of silently changing the cap.

Reviewer Test Plan

How to verify

  1. Confirm fractional values now fall back to the default cap:
    • normalizeStopHookBlockingCap(3.7)
    • QWEN_CODE_STOP_HOOK_BLOCK_CAP=1.5
  2. Confirm valid integer values still work and large integers are still clamped.
  3. Confirm packages/vscode-ide-companion/schemas/settings.schema.json now marks stopHookBlockingCap as an integer.

Evidence (Before & After)

Before:

  • normalizeStopHookBlockingCap(3.7) returned 3.
  • QWEN_CODE_STOP_HOOK_BLOCK_CAP=1.5 resolved to 1.
  • The generated settings schema used "type": "number" for stopHookBlockingCap.

After:

  • Fractional direct settings values fall back to DEFAULT_STOP_HOOK_BLOCK_CAP.
  • Fractional environment overrides fall back to DEFAULT_STOP_HOOK_BLOCK_CAP.
  • The generated settings schema uses "type": "integer", "minimum": 1, and "default": 8.

Commands run:

  • npm run generate:settings-schema
  • npm test --workspace=packages/core -- hooks/stopHookCap.test.ts
  • npm test --workspace=packages/cli -- src/config/settingsSchema.test.ts
  • npm run lint --workspace=packages/core --if-present
  • npm run lint --workspace=packages/cli --if-present
  • npm run typecheck --workspace=packages/core --if-present
  • npx prettier --check packages/core/src/hooks/stopHookCap.ts packages/core/src/hooks/stopHookCap.test.ts packages/cli/src/config/settingsSchema.ts packages/cli/src/config/settingsSchema.test.ts packages/vscode-ide-companion/schemas/settings.schema.json
  • git diff --check

Tested on

OS Version Arch
macOS 26.4.1 arm64

Risk & Scope

Risk is low. The change only affects validation/normalization for fractional cap values.

Out of scope:

  • Changing the default cap.
  • Changing Stop/SubagentStop hook loop behavior for valid integer caps.
  • Migrating existing fractional settings.

Breaking changes:

  • Fractional stopHookBlockingCap values are no longer accepted. They now fall back to the default, matching the invalid-value path.

Linked Issues

Fixes #5664

AI Assistance Disclosure

I used Codex to review the changes, sanity-check the implementation against existing patterns, and help spot potential edge cases.

中文说明

这个 PR 做了什么

这个 PR 拒绝 Stop/SubagentStop hook blocking cap 的小数值。

  • 非整数的 stopHookBlockingCap 会被视为无效值,并回退到 DEFAULT_STOP_HOOK_BLOCK_CAP
  • QWEN_CODE_STOP_HOOK_BLOCK_CAP 走同样的规则。
  • 合法正整数仍然有效,超大整数仍然沿用现有最大值 clamp。
  • 更新生成出来的 settings schema,让编辑器把这个配置校验为 minimum: 1 的 integer。

为什么需要

stopHookBlockingCap 是计数类 safety valve,但之前小数会被接受并向下取整。比如 QWEN_CODE_STOP_HOOK_BLOCK_CAP=1.5 会解析成 1,这会让 Stop/SubagentStop hook loop protection 比配置看起来更早触发。

小数值应该像其他无效值一样回退,而不是被静默改写成另一个 cap。

Reviewer 测试计划

如何验证

  1. 确认小数值现在会回退到默认 cap:
    • normalizeStopHookBlockingCap(3.7)
    • QWEN_CODE_STOP_HOOK_BLOCK_CAP=1.5
  2. 确认合法整数仍然有效,超大整数仍然会被 clamp。
  3. 确认 packages/vscode-ide-companion/schemas/settings.schema.json 现在把 stopHookBlockingCap 标记为 integer。

前后对比证据

修改前:

  • normalizeStopHookBlockingCap(3.7) 返回 3
  • QWEN_CODE_STOP_HOOK_BLOCK_CAP=1.5 解析为 1
  • 生成的 settings schema 对 stopHookBlockingCap 使用 "type": "number"

修改后:

  • 直接配置的小数值会回退到 DEFAULT_STOP_HOOK_BLOCK_CAP
  • 环境变量里的小数值会回退到 DEFAULT_STOP_HOOK_BLOCK_CAP
  • 生成的 settings schema 使用 "type": "integer""minimum": 1"default": 8

已运行命令:

  • npm run generate:settings-schema
  • npm test --workspace=packages/core -- hooks/stopHookCap.test.ts
  • npm test --workspace=packages/cli -- src/config/settingsSchema.test.ts
  • npm run lint --workspace=packages/core --if-present
  • npm run lint --workspace=packages/cli --if-present
  • npm run typecheck --workspace=packages/core --if-present
  • npx prettier --check packages/core/src/hooks/stopHookCap.ts packages/core/src/hooks/stopHookCap.test.ts packages/cli/src/config/settingsSchema.ts packages/cli/src/config/settingsSchema.test.ts packages/vscode-ide-companion/schemas/settings.schema.json
  • git diff --check

测试环境

OS Version Arch
macOS 26.4.1 arm64

风险和范围

风险较低。这个改动只影响小数 cap 值的校验和归一化。

不在范围内:

  • 修改默认 cap。
  • 修改合法整数 cap 下的 Stop/SubagentStop hook loop 行为。
  • 迁移已有的小数配置。

破坏性变化:

  • 小数 stopHookBlockingCap 不再被接受,现在会按无效值路径回退到默认值。

关联 Issue

Fixes #5664

AI 辅助披露

我使用 Codex 审查改动、对照现有模式做 sanity check,并帮助发现潜在边界情况。

@tt-a1i
tt-a1i marked this pull request as ready for review June 22, 2026 17:21
@wenshao

wenshao commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

On direction: this is a clean correctness fix — stopHookBlockingCap is a count-like safety valve, and silently flooring fractional values (e.g., 1.51) can make the loop protection fire earlier than the user expects. Directly addresses issue #5664. Well within scope.

On approach: minimal and focused — 5 files, +39/-7, no scope creep. The two-layer defense (JSON schema rejects fractional values in editors, env parser rejects fractional strings at runtime, normalizeStopHookBlockingCap floors as a last-resort safety net) is a sensible design. One minor observation: the PR description says "Fractional direct settings values fall back to DEFAULT_STOP_HOOK_BLOCK_CAP" but the code actually preserves the flooring behavior for programmatic config values (resolveStopHookBlockingCap(3.7)3). The test 'preserves legacy fractional config values' documents this intentionally. The description is slightly misleading but the code is correct — the schema prevents fractional values from reaching the code path in practice.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

方向:这是一个干净的正确性修复——stopHookBlockingCap 是计数类 safety valve,静默向下取整小数值(如 1.51)会导致 hook loop protection 比用户预期更早触发。直接对应 issue #5664,完全在范围内。

方案:最小化且聚焦——5 个文件,+39/-7,无范围蔓延。两层防御(JSON schema 在编辑器层面拒绝小数值,env 解析器在运行时拒绝小数串,normalizeStopHookBlockingCap 向下取整作为最后的安全网)设计合理。一个小的观察:PR 描述说"直接配置的小数值会回退到 DEFAULT_STOP_HOOK_BLOCK_CAP",但代码实际上保留了程序化配置值的向下取整行为(resolveStopHookBlockingCap(3.7)3)。测试 'preserves legacy fractional config values' 有意记录了这一点。描述略有偏差但代码是正确的——schema 在实践中阻止了小数值进入代码路径。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

qwen-code-ci-bot commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal (before reading the diff): I'd add a Number.isInteger() check on env var parsing to reject fractional strings, set the JSON schema type to "integer" with minimum: 1, and keep normalizeStopHookBlockingCap as-is for backward compatibility with programmatic callers.

Comparison: the PR matches this approach exactly. parseStopHookBlockingCapEnv() adds the integer gate for env vars, the jsonSchemaOverride sets type: 'integer', minimum: 1, and normalizeStopHookBlockingCap is untouched as a safety net. Clean implementation.

No critical blockers found. The diff is minimal and every change serves the stated goal. jsonSchemaOverride follows an established pattern (7 other usages in the file). The generated settings.schema.json correctly reflects the override.

Test Results

Before (main branch — stopHookCap.test.ts)

 RUN  v3.2.4 /home/runner/work/qwen-code/qwen-code/packages/core
      Coverage enabled with v8

 ✓ src/hooks/stopHookCap.test.ts (7 tests) 4ms

 Test Files  1 passed (1)
      Tests  7 passed (7)
   Start at  22:49:53
   Duration  4.11s (transform 45ms, setup 24ms, collect 22ms, tests 4ms, environment 0ms, prepare 83ms)

After (this PR — stopHookCap.test.ts)

 RUN  v3.2.4 /home/runner/work/qwen-code/qwen-code/.qwen/worktrees/triage/packages/core
      Coverage enabled with v8

 ✓ src/hooks/stopHookCap.test.ts (9 tests) 4ms

 Test Files  1 passed (1)
      Tests  9 passed (9)
   Start at  22:50:57
   Duration  4.04s (transform 54ms, setup 30ms, collect 21ms, tests 4ms, environment 0ms, prepare 87ms)

After (this PR — settingsSchema.test.ts)

 RUN  v3.2.4 /home/runner/work/qwen-code/qwen-code/.qwen/worktrees/triage/packages/cli
      Coverage enabled with v8

 ✓ src/config/settingsSchema.test.ts (27 tests) 18ms

 Test Files  1 passed (1)
      Tests  27 passed (27)
   Start at  22:52:12
   Duration  10.57s (transform 2.75s, setup 118ms, collect 4.31s, tests 18ms, environment 423ms, prepare 86ms)

All tests pass. 2 new tests added: rejects fractional env overrides and preserves legacy fractional config values.

中文说明

代码审查

独立方案(看 diff 之前):我会在 env 解析时加 Number.isInteger() 检查来拒绝小数串,将 JSON schema 的 type 设为 "integer" 并加 minimum: 1,保持 normalizeStopHookBlockingCap 不变以兼容程序化调用者。

对比: PR 的方案与我的完全一致。parseStopHookBlockingCapEnv() 为 env 变量添加了整数门控,jsonSchemaOverride 设置了 type: 'integer', minimum: 1normalizeStopHookBlockingCap 作为安全网保持不变。实现干净。

未发现关键阻塞问题。diff 最小化,每个改动都服务于目标。jsonSchemaOverride 遵循已有模式(文件中有 7 处其他用法)。生成的 settings.schema.json 正确反映了覆盖。

测试结果

全部通过。新增 2 个测试:拒绝小数 env 覆盖、保留遗留小数配置值。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

qwen-code-ci-bot commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Going back to my independent proposal — the PR matches it almost exactly. The approach is sound: reject fractional env vars at the parsing layer, reject fractional config values at the schema layer, keep Math.floor as a defense-in-depth safety net for programmatic callers.

The diff is minimal and every line earns its place. No drive-by refactors, no scope creep. Two well-targeted new tests. The only thing I'd flag is the PR description's claim that "fractional direct settings values fall back to DEFAULT_STOP_HOOK_BLOCK_CAP" — the code preserves flooring for programmatic config, which the test correctly documents. The description is slightly imprecise but doesn't affect the code quality.

This is a straightforward correctness fix that ships what it promises. Tests confirm it works. LGTM. ✅

中文说明

回到我的独立方案——PR 的方案几乎完全一致。方案合理:在解析层拒绝小数 env 值,在 schema 层拒绝小数配置值,保留 Math.floor 作为程序化调用者的纵深防御。

diff 最小化,每一行都有存在的理由。没有顺手重构,没有范围蔓延。两个有针对性的新测试。唯一想指出的是 PR 描述中"直接配置的小数值会回退到 DEFAULT_STOP_HOOK_BLOCK_CAP"的说法——代码实际上保留了程序化配置的向下取整行为,测试也正确记录了这一点。描述略有不精确但不影响代码质量。

这是一个直接的正确性修复,交付了承诺的功能。测试确认有效。LGTM。 ✅

Qwen Code · qwen3.7-max

@qwen-code-ci-bot qwen-code-ci-bot 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.

LGTM, looks ready to ship. ✅

Comment thread packages/core/src/hooks/stopHookCap.ts Outdated

export function normalizeStopHookBlockingCap(value: unknown): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
if (typeof value !== 'number' || !Number.isInteger(value)) {

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 an existing user has stopHookBlockingCap: 3.7 in their config (which previously floored to 3), the value now silently resets to the default of 8 with no logging or migration notice. The user's effective cap nearly triples without any signal — a safety-valve setting changing behavior silently is risky.

Consider logging a warning when a finite non-integer value is encountered:

Suggested change
if (typeof value !== 'number' || !Number.isInteger(value)) {
if (typeof value !== 'number' || !Number.isInteger(value)) {
if (typeof value === 'number' && Number.isFinite(value)) {
debugLogger.warn(
`stopHookBlockingCap must be an integer; got ${value}, using default ${DEFAULT_STOP_HOOK_BLOCK_CAP}`,
);
}
return DEFAULT_STOP_HOOK_BLOCK_CAP;
}

— Claude 3.5 Sonnet via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

good catch, updated this. Existing config values like 3.7 now keep the old floor-to-3 behavior, while the env override and JSON schema still require integers for new values.

@wenshao

wenshao commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

✅ Real-binary verification report (local, tmux)

This PR makes the stop-hook blocking cap reject non-integers (falling back to the default) instead of silently flooring them, and declares the setting as integer / minimum: 1 in the JSON schema. I built the real qwen binary from PR head in an isolated worktree, drove it in tmux, and added function-level and mutation checks.

Verdict: correct, well-tested (non-vacuous tests), and consistent across the runtime guard + JSON schema + VSCode schema — safe to merge. One behavioral nuance is noted at the end.

1. Real-binary e2e (tmux) — the cap flows end-to-end

Setup: an always-block Stop hook (echo '{"decision":"block",...}') drives the runaway-loop safety valve; QWEN_CODE_STOP_HOOK_BLOCK_CAP=1.9 (a fractional override); identical prompt on both builds.

build 1.9 resolves to Stop hook fired cap warning in the TUI
BEFORE (Number.isFinite + Math.floor) floor(1.9) = 1 Stop hook blocked continuation 1 consecutive time; …
FIXED (Number.isInteger) non-integer → default 8 Stop hook blocked continuation 8 consecutive times; …

The hook logged each invocation to a counter file (1 vs 8, matching the warning). So a fractional cap is no longer silently floored to a small number — it is rejected and falls back to the safe default 8.

2. Function-level A/B (normalizeStopHookBlockingCap / resolveStopHookBlockingCap)

input BEFORE FIXED
int 5 / 200 / 0 5 / 100 / 8 5 / 100 / 8 (unchanged)
float 3.7 3 8
float 5.9 5 8
float 100.9 100 8
env 1.5 (cfg 12) 1 8
env 5 (cfg 12) 5 5 (unchanged)

Integers behave identically; every non-integer now returns the default 8 instead of being floored. NaN / Infinity / strings already returned the default on both sides.

3. Mutation test — the added tests genuinely guard the change

  • stopHookCap.test.ts: reverting the source to base → 2 new assertions fail (expected 3 to be 8, expected 1 to be 8).
  • settingsSchema.test.ts: removing the new jsonSchemaOverride block → fails (expected undefined to deeply equal { type: 'integer', minimum: 1, default: 8 }).

4. Schema consistency

settingsSchema.ts (jsonSchemaOverride) and the VSCode settings.schema.json mirror both move stopHookBlockingCap from type: "number" (no lower bound) to type: "integer", minimum: 1, default: 8. Consistent.

Behavioral nuance (not a blocker)

The effective cap for fractional inputs can move in either direction: 5.9 was floored to 5 → now 8; 2.9 was 2 → now 8. A fractional iteration count is user error anyway, and falling back to the default 8 is the right behavior — just flagging that the resolved value can go up or down versus the old flooring, for anyone who set a fractional cap on purpose.

Environment

Real qwen built from PR head (0b41eda) via npm ci && npm run build; tmux 3.6a, isolated HOME with an always-block Stop hook; macOS, Node v22. BEFORE built by reverting only stopHookCap.ts to base and rebuilding core (cli resolves @qwen-code/qwen-code-core from core/dist at runtime).

中文版(完整对应)

✅ 真实二进制验证报告(本地 tmux)

本 PR 让 stop-hook 阻塞上限拒绝非整数(回退到默认值),而不是静默向下取整,并在 JSON schema 里把该设置声明为 integer / minimum: 1。我在隔离 worktree 里用 PR head 构建了真实的 qwen 二进制,在 tmux 里驱动它,并补充了函数级与变异测试。

结论:正确、测试非空过、且在"运行时守卫 + JSON schema + VSCode schema"三处保持一致 —— 可以合并。 末尾有一条不阻塞合并的行为提示。

1. 真实二进制 e2e(tmux)—— 上限端到端生效

设置:一个"总是 block 的 Stop hook"(echo '{"decision":"block",...}')触发防失控的安全阀;QWEN_CODE_STOP_HOOK_BLOCK_CAP=1.9(分数覆盖值);两个构建用相同 prompt。

构建 1.9 解析为 Stop hook 触发次数 TUI 里的上限警告
BEFORENumber.isFinite + Math.floor floor(1.9) = 1 1 次 Stop hook blocked continuation 1 consecutive time; …
FIXEDNumber.isInteger 非整数 → 默认 8 8 次 Stop hook blocked continuation 8 consecutive times; …

hook 每次调用都写入计数文件(1 vs 8,与警告一致)。所以分数上限不再被静默取整成一个小数字 —— 它被拒绝并回退到安全默认值 8。

2. 函数级 A/B(normalizeStopHookBlockingCap / resolveStopHookBlockingCap

输入 BEFORE FIXED
整数 5 / 200 / 0 5 / 100 / 8 5 / 100 / 8(不变)
分数 3.7 3 8
分数 5.9 5 8
分数 100.9 100 8
env 1.5(cfg 12) 1 8
env 5(cfg 12) 5 5(不变)

整数行为完全一致;所有非整数现在返回默认值 8 而不是取整。NaN / Infinity / 字符串两边本来就返回默认值。

3. 变异测试 —— 新增测试确实守护了改动

  • stopHookCap.test.ts:把源码回退到 base → 2 个新断言失败expected 3 to be 8expected 1 to be 8)。
  • settingsSchema.test.ts:删掉新增的 jsonSchemaOverride 块 → 失败expected undefined to deeply equal { type: 'integer', minimum: 1, default: 8 })。

4. Schema 一致性

settingsSchema.tsjsonSchemaOverride)和 VSCode 的 settings.schema.json 镜像都把 stopHookBlockingCaptype: "number"(无下限)改为 type: "integer", minimum: 1, default: 8。一致。

行为提示(不阻塞合并)

分数输入,生效上限可能向两个方向变化:5.9 原本取整为 5 → 现在 82.9 原本是 2 → 现在 8。分数迭代次数本身就是用户误用,回退到默认 8 是正确行为 —— 只是提醒一下:对于有意设置分数上限的人,解析值相比旧的取整可能变大或变小。

环境

真实 qwen 由 PR head(0b41eda)经 npm ci && npm run build 构建;tmux 3.6a、隔离 HOME 配置了"总是 block 的 Stop hook";macOS、Node v22。BEFORE 通过只把 stopHookCap.ts 回退到 base 并重建 core 得到(cli 运行时从 core/dist 解析 @qwen-code/qwen-code-core)。

@wenshao

wenshao commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

✅ Local real-build verification (tmux A/B)

Maintainer verification before merge. I built the actual qwen binary at the PR head (0b41eda) and ran an A/B against a pre-fix build (the PR's parent 01d28a1, only stopHookCap.ts reverted + core rebuilt). Environment: macOS arm64, Node v22.22.2.

Verdict: LGTM. The fix changes user-visible runtime behavior exactly as intended (fractional caps now fall back to the documented default instead of being silently floored), with zero regression on every integer / out-of-range / NaN / clamp path.

1. Real-TUI A/B — the behavior issue #5664 describes

Harness: a user-scope Stop hook that always returns {"decision":"block"} (and appends one line to a counter file per firing), driven in tmux with QWEN_CODE_STOP_HOOK_BLOCK_CAP=1.5. The effective cap is observable two ways: the number printed in the override message, and the number of hook firings before the loop is overridden.

Before (pre-fix): 1.5Math.floor → cap 1 → loop protection fires after a single block:

✦ 你好!有什么我可以帮你的吗?
 ⎿ Stop says:
    Stop hook blocked continuation 1 consecutive time; overriding and ending the turn.

counter file = 1 firing

After (this PR): 1.5 is not an integer → falls back to DEFAULT_STOP_HOOK_BLOCK_CAP = 8 → the hook blocks 8 times before override:

✦ ok
● Ran 1 stop hooks
    ⎿  Stop hook error: Reply with only the word: ok
   … (×8 block cycles) …
✦ ok
 ⎿ Stop says:
    Stop hook blocked continuation 8 consecutive times; overriding and ending the turn.

counter file = 8 firings

(The Stop hook error: … line is just my test hook's own block-reason being echoed — not a real error.) This is precisely the surprise from #5664: a configured 1.5 made loop protection trip after 1 block instead of the intended 8. Note the message text itself carries the effective cap (1 time vs 8 times), so the wrong value was directly user-visible.

2. Built-function A/B (normalizeStopHookBlockingCap / resolveStopHookBlockingCap, imported from the built core/dist)

input BASE (pre-fix) FIXED (this PR)
norm(1.5) 1 8 (DEFAULT) ← changed
norm(3.7) 3 8 (DEFAULT) ← changed
norm(99.9) 99 8 (DEFAULT) ← changed
norm(100.9) 100 8 (DEFAULT) ← changed
env=1.5 1 8 (DEFAULT) ← changed
env=2.9 2 8 (DEFAULT) ← changed
norm(1) / norm(8) / norm(100) 1 / 8 / 100 1 / 8 / 100 identical
norm(99999) (clamp) 100 100 identical
norm(0.5) / norm(-1) / norm(0) 8 8 identical
norm(NaN) / norm(Infinity) 8 8 identical
env=5 / env=150 / env=abc 5 / 100 / 8 5 / 100 / 8 identical

Only fractional inputs change; every existing valid/invalid/clamp path is byte-for-byte identical → the change is correctly scoped.

3. Mutation tests (proving the new tests aren't vacuous)

Reverted each source file to base while keeping the PR's tests, ran the suite, expected failures:

  • packages/corestopHookCap.test.ts: 2 tests fail against base —
    normalizes invalid values to the default capexpected 3 to be 8, and
    rejects fractional environment overridesexpected 1 to be 8. Both pass on the PR. ✅
  • packages/clisettingsSchema.test.ts: should define stopHookBlockingCap schema override as a positive integer fails against base → expected undefined to deeply equal { type: 'integer', minimum: 1, default: 8 }. Passes on the PR. ✅

4. Schema generation is reproducible

npm run generate:settings-schema on the PR leaves git diff empty (committed settings.schema.json matches a fresh generation). Reverting settingsSchema.ts to base regenerates "type": "number" (no minimum), confirming the committed integer / minimum: 1 / default: 8 block is genuinely produced by the source change.


中文版(点击展开)

✅ 本地真实构建验证(tmux A/B 对比)

合并前的维护者验证。我在 PR HEAD(0b41eda)上构建了真实的 qwen 二进制,并与修复前的构建(PR 父提交 01d28a1,仅回退 stopHookCap.ts 并重新构建 core)做了 A/B 对比。环境:macOS arm64,Node v22.22.2。

结论:可以合并。 修复精确地改变了用户可见的运行时行为(小数 cap 现在回退到文档默认值,而不再被静默向下取整),且对所有整数 / 越界 / NaN / clamp 路径零回归。

1. 真实 TUI A/B —— 复现 issue #5664 描述的行为

测试装置:一个用户级 Stop hook,始终返回 {"decision":"block"}(并且每次触发往计数文件追加一行),在 tmux 中以 QWEN_CODE_STOP_HOOK_BLOCK_CAP=1.5 运行。生效的 cap 有两种可观测方式:override 提示里打印的数字,以及循环被覆盖前 hook 触发的次数。

修复前: 1.5Math.floor → cap 1 → loop protection 在单次阻塞后就触发:

✦ 你好!有什么我可以帮你的吗?
 ⎿ Stop says:
    Stop hook blocked continuation 1 consecutive time; overriding and ending the turn.

计数文件 = 1 次触发

修复后(本 PR): 1.5 不是整数 → 回退到 DEFAULT_STOP_HOOK_BLOCK_CAP = 8 → hook 阻塞 8 次后才覆盖:

✦ ok
● Ran 1 stop hooks
    ⎿  Stop hook error: Reply with only the word: ok
   … (共 8 个阻塞循环)…
✦ ok
 ⎿ Stop says:
    Stop hook blocked continuation 8 consecutive times; overriding and ending the turn.

计数文件 = 8 次触发

Stop hook error: … 这行只是我的测试 hook 把自己的 block reason 回显出来,并不是真的报错。)这正是 #5664 的意外之处:配置 1.5 让 loop protection 在 1 次阻塞后就触发,而不是预期的 8 次。注意提示文本本身就带着生效的 cap(1 time vs 8 times),所以这个错误的值是直接对用户可见的。

2. 内置函数 A/B(normalizeStopHookBlockingCap / resolveStopHookBlockingCap,从构建出的 core/dist 导入)

输入 BASE(修复前) FIXED(本 PR)
norm(1.5) 1 8(默认) ← 改变
norm(3.7) 3 8(默认) ← 改变
norm(99.9) 99 8(默认) ← 改变
norm(100.9) 100 8(默认) ← 改变
env=1.5 1 8(默认) ← 改变
env=2.9 2 8(默认) ← 改变
norm(1) / norm(8) / norm(100) 1 / 8 / 100 1 / 8 / 100 一致
norm(99999)(clamp) 100 100 一致
norm(0.5) / norm(-1) / norm(0) 8 8 一致
norm(NaN) / norm(Infinity) 8 8 一致
env=5 / env=150 / env=abc 5 / 100 / 8 5 / 100 / 8 一致

只有小数输入会变化,所有既有的有效/无效/clamp 路径都逐位一致 → 改动范围精确。

3. 变异测试(证明新增测试不是空过)

把每个源文件回退到 base、保留 PR 的测试、跑测试套件,预期失败:

  • packages/corestopHookCap.test.ts 对 base 有 2 个测试失败 ——
    normalizes invalid values to the default capexpected 3 to be 8,以及
    rejects fractional environment overridesexpected 1 to be 8。两者在 PR 上都通过。✅
  • packages/clisettingsSchema.test.ts should define stopHookBlockingCap schema override as a positive integer 对 base 失败 → expected undefined to deeply equal { type: 'integer', minimum: 1, default: 8 }。在 PR 上通过。✅

4. Schema 生成可复现

在 PR 上跑 npm run generate:settings-schemagit diff 为空(提交的 settings.schema.json 与重新生成的结果一致)。把 settingsSchema.ts 回退到 base 后重新生成会得到 "type": "number"(没有 minimum),确认提交里的 integer / minimum: 1 / default: 8 区块确实是由源码改动生成的。

@wenshao

wenshao commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

✅ Local verification report (maintainer)

Built and tested this PR locally to confirm the fix before merge.

Environment

  • macOS (Darwin 25.5), Node v22.22.2
  • PR head 0b41eda6dMERGEABLE. stopHookCap.ts has no drift; settingsSchema.ts drifted on main but auto-merges (different region).
  • Method: isolated git worktree at the PR head with a clean npm ci + core/acp-bridge build, then verified each claim independently.

Results

Check Command Result
Target tests vitest run stopHookCap.test.ts settingsSchema.test.ts ✅ 34/34 pass
Generated schema in sync npm run generate:settings-schemagit diff ✅ no diff
Types tsc --noEmit (core + cli) ✅ 0 errors each
Lint / Format eslint / prettier --check on changed files ✅ clean

Mutation test (the key evidence).

  • Revert stopHookCap.ts to main2 tests fail: normalizes invalid values to the default cap (now includes 3.7 / 100.9 → default) and rejects fractional environment overrides (QWEN_…=1.5 → default). The old code floored these (3.7 → 3) instead of rejecting them.
  • Revert settingsSchema.ts to the PR base → exactly 1 test fails: should define stopHookBlockingCap schema override as a positive integer (26 others pass).

Correctness review

  • The core change is Number.isFiniteNumber.isInteger (and dropping Math.floor): a fractional cap like 1.5 / 3.7 / 100.9 now falls back to DEFAULT_STOP_HOOK_BLOCK_CAP (8) instead of being silently floored, while valid integers are still clamped to [1, MAX=100]. This resolves bug(core): stop hook blocking cap accepts fractional values #5664. The schema side adds jsonSchemaOverride: { type: 'integer', minimum: 1, default: 8 }, the regenerated settings.schema.json (number → integer), and a schema test — all verified in sync with the generator.

Minor / non-blocking observation. resolveStopHookBlockingCap parses the env override with Number(env) (not the integer-syntax regex that #5652 used). So a giant fractional string like 9007199254740990.5 float-rounds to a safe integer, passes Number.isInteger, and is accepted — but it's then clamped to MAX=100, so the outcome is bounded and harmless, and the targeted fractional cases (1.5, 3.7) are correctly rejected. Given the MAX clamp this is effectively moot; mentioning only for parity with #5652.

Verdict: LGTM. Correct, minimal, mutation-verified, schema in sync; merges cleanly, type/lint/format clean. Safe to merge from my side.

🇨🇳 中文版

✅ 本地验证报告(维护者)

合并前在本地构建并测试了本 PR 以确认修复。

环境

  • macOS(Darwin 25.5),Node v22.22.2
  • PR head 0b41eda6d —— MERGEABLEstopHookCap.ts 无漂移;settingsSchema.ts 在 main 上有漂移但能自动合并(不同区域)。
  • 方法:在 PR head 上建独立 git worktree,做干净的 npm ci + core/acp-bridge 构建,再逐条独立验证。

结果

检查项 命令 结果
目标测试 vitest run stopHookCap.test.ts settingsSchema.test.ts ✅ 34/34 通过
生成 schema 是否同步 npm run generate:settings-schemagit diff ✅ 无 diff
类型 tsc --noEmitcore + cli ✅ 各 0 错误
Lint / 格式 对改动文件跑 eslint / prettier --check ✅ 干净

变异测试(关键证据)。

  • stopHookCap.ts 还原成 main2 个测试失败normalizes invalid values to the default cap(现包含 3.7 / 100.9 → default)和 rejects fractional environment overridesQWEN_…=1.5 → default)。旧代码会把它们向下取整3.7 → 3)而不是拒绝。
  • settingsSchema.ts 还原成 PR base → 恰好 1 个测试失败should define stopHookBlockingCap schema override as a positive integer(其余 26 个通过)。

正确性审查

  • 核心改动是 Number.isFiniteNumber.isInteger(并去掉 Math.floor):像 1.5 / 3.7 / 100.9 这样的小数 cap 现在回退到 DEFAULT_STOP_HOOK_BLOCK_CAP8),而不是被静默取整;合法整数仍被夹到 [1, MAX=100]。这修复了 bug(core): stop hook blocking cap accepts fractional values #5664。schema 侧新增 jsonSchemaOverride: { type: 'integer', minimum: 1, default: 8 }、重新生成的 settings.schema.jsonnumber → integer)以及一个 schema 测试 —— 都已验证与生成器同步。

次要 / 非阻塞观察。 resolveStopHookBlockingCapNumber(env) 解析环境覆盖(不是 #5652 用的整数语法正则)。所以像 9007199254740990.5 这种巨大的小数字符串会浮点舍入成安全整数、通过 Number.isInteger 而被接受 —— 但随后会被夹到 MAX=100,所以结果是有界且无害的,而目标小数情形(1.53.7)都被正确拒绝。鉴于有 MAX 夹紧,这点其实无关紧要;仅为与 #5652 对齐而提一句。

结论:LGTM。 正确、最小化、经变异测试验证、schema 同步;可干净合并,类型/lint/格式干净。从我这边看可以安全合并。

wenshao
wenshao previously approved these changes Jun 23, 2026
@tt-a1i
tt-a1i dismissed stale reviews from wenshao and qwen-code-ci-bot via a123c82 June 23, 2026 06:25
@tt-a1i
tt-a1i force-pushed the fix/stop-hook-cap-integer branch from 0b41eda to a123c82 Compare June 23, 2026 06:25
}

function parseStopHookBlockingCapEnv(value: string): number {
const parsed = Number(value);

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] Number() accepts hex ("0x10"), scientific notation ("1e2"), and binary ("0b10") as valid integers — inconsistent with every other integer env-var parser in the codebase.

Consider using the shared parsePositiveIntegerEnv helper from packages/core/src/utils/env.ts (used by 7+ other parsers: workflow-orchestrator.ts, workflow-budget.ts, coreToolScheduler.ts, background-tasks.ts, serve.ts, Session.ts, modelConfigResolver.ts). It enforces plain decimal integers via ^\d+$ regex + Number.isSafeInteger and removes the need for the private parseStopHookBlockingCapEnv function entirely:

import { parsePositiveIntegerEnv } from '../utils/env.js';

export function resolveStopHookBlockingCap(configValue?: number): number {
  const envValue = process.env[STOP_HOOK_BLOCK_CAP_ENV];
  if (envValue !== undefined && envValue.trim() !== '') {
    const parsed = parsePositiveIntegerEnv(envValue, 0);
    return normalizeStopHookBlockingCap(parsed);
  }
  return normalizeStopHookBlockingCap(configValue);
}

The fallback 0 works correctly because normalizeStopHookBlockingCap(0) maps to DEFAULT_STOP_HOOK_BLOCK_CAP (since 0 < 1).

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

✅ Local verification — fix(core): require integer stop hook cap

Verified on an isolated worktree at a123c8201 with a real npm ci + build, driving the compiled artifact (packages/core/dist/src/hooks/stopHookCap.js) under tmux — not just the TS source.

What the fix does: QWEN_CODE_STOP_HOOK_BLOCK_CAP now requires an integer — a fractional override (e.g. 1.5) falls back to the default 8 instead of being silently floored to 1. Config-file values keep their legacy flooring (back-compat), and the settings JSON schema now enforces integer, minimum 1.

Tests

  • packages/core stopHookCap.test.ts9/9 ✅
  • packages/cli settingsSchema.test.ts27/27 ✅
  • tsc --noEmit (core) — 0 errors; eslint (changed files) — 0 problems

Real compiled artifact under tmux (15/15 cases)

Input Resolved cap Note
ENV=1.5 8 the fix — rejected → default
ENV=2 2 used as-is
ENV=99999 100 capped to MAX
ENV=0 / -3 / abc 8 invalid → default
ENV=3.0 3 Number("3.0") is an integer → used
ENV="" (empty) + config 5 5 empty env ignored → config
config 3.7 (no env) 3 legacy floor preserved (back-compat)
config 0 / undefined 8 default

A/B proof (on the real build, under tmux)

ENV=1.5 → cap
this PR (new) 8
fix reverted (old) 1

Every other case is byte-identical between old and new — the change is surgical. Reverting the env guard also flips stopHookCap.test.ts > rejects fractional environment overrides from green to AssertionError: expected 1 to be 8, confirming the test actually guards the behavior.

Notes (merge reference, not blockers)

  • The env path is now strict-integer, while the config path still floors fractionals (3.7 → 3) for backward compatibility — intentional and covered by preserves legacy fractional config values. So the JSON-schema integer constraint is advisory (editor-side) for config values, whereas the env override is enforced at runtime. Reasonable; just worth being aware of.
  • Consumption sites (core/client.ts Stop loop at ~2362, agent.ts SubagentStop loop at ~1329) read the resolved cap via config.getStopHookBlockingCap() and are unchanged by this PR.

Verdict: ✅ Safe to merge. Behavior is correct on the compiled build, fully tested, back-compatible, and the A/B confirms the fix removes the silent-floor footgun (a typo like 1.5 no longer collapses the safety valve to a single iteration).

🇨🇳 中文版(合并参考)

✅ 本地验证 — fix(core): require integer stop hook cap

在隔离 worktree(commit a123c8201)上用真实 npm ci + 构建完成验证,并在 tmux 中驱动编译产物 packages/core/dist/src/hooks/stopHookCap.js(而非仅 TS 源码)。

这个修复做了什么: QWEN_CODE_STOP_HOOK_BLOCK_CAP 现在要求整数 —— 小数(如 1.5)会回退到默认值 8,而不是被静默向下取整为 1。配置文件中的值保留旧的取整行为(向后兼容),settings JSON schema 现在约束为 integer, minimum 1

测试

  • packages/core stopHookCap.test.ts9/9 ✅
  • packages/cli settingsSchema.test.ts27/27 ✅
  • tsc --noEmit(core)0 错误eslint(改动文件)0 问题

tmux 中真实编译产物(15/15 全部通过)

输入 解析出的 cap 说明
ENV=1.5 8 本次修复 —— 拒绝小数 → 回退默认值
ENV=2 2 原值使用
ENV=99999 100 限制到 MAX
ENV=0 / -3 / abc 8 非法 → 默认值
ENV=3.0 3 Number("3.0") 是整数 → 使用
ENV=""(空)+ 配置 5 5 空 env 忽略 → 用配置
配置 3.7(无 env) 3 保留旧的取整(向后兼容)
配置 0 / undefined 8 默认值

A/B 证明(真实构建产物,tmux 中)

ENV=1.5 → cap
本 PR(新) 8
还原修复(旧) 1

除该用例外,新旧产物结果完全一致 —— 改动是外科手术式的。还原修复后,stopHookCap.test.ts > rejects fractional environment overrides 由通过变为 AssertionError: expected 1 to be 8,证明测试确实守护了该行为。

说明(合并参考,非阻塞项)

  • env 路径现在严格要求整数;而配置路径仍对小数取整(3.7 → 3)以保持向后兼容 —— 这是有意为之且有测试覆盖。因此 JSON schema 的 integer 约束对配置值是编辑器层面的提示,env override 则在运行时强制执行。设计合理,知悉即可。
  • 消费点(core/client.ts 的 Stop 循环约 2362 行、agent.ts 的 SubagentStop 循环约 1329 行)通过 config.getStopHookBlockingCap() 读取解析后的 cap,本 PR 未改动。

结论:✅ 可以合并。 编译产物行为正确、测试完整、向后兼容,A/B 证明修复消除了静默取整的隐患(像 1.5 这样的笔误不会再把安全阀压缩到只剩 1 次迭代)。

@wenshao

wenshao commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot 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.

LGTM, looks ready to ship. ✅

@wenshao
wenshao merged commit aa18daa into QwenLM:main Jun 23, 2026
45 checks passed
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.

bug(core): stop hook blocking cap accepts fractional values

5 participants