Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

fix(core): don't treat an empty-parts message as a function call/response#5494

Merged
wenshao merged 1 commit into
QwenLM:mainQwenLM/qwen-code:mainfrom
he-yufeng:fix/message-inspectors-empty-partshe-yufeng/qwen-code:fix/message-inspectors-empty-partsCopy head branch name to clipboard
Jun 20, 2026
Merged

fix(core): don't treat an empty-parts message as a function call/response#5494
wenshao merged 1 commit into
QwenLM:mainQwenLM/qwen-code:mainfrom
he-yufeng:fix/message-inspectors-empty-partshe-yufeng/qwen-code:fix/message-inspectors-empty-partsCopy head branch name to clipboard

Conversation

@he-yufeng

Copy link
Copy Markdown
Contributor

What

isFunctionResponse and isFunctionCall (packages/core/src/utils/messageInspectors.ts) now require at least one part before the parts.every(...) check, so a message with parts: [] is no longer reported as an all-function-responses / all-function-calls turn.

Why

[].every(...) is vacuously true, so a user (or model) message with an empty parts array passed both inspectors. In checkNextSpeaker, an empty user turn then matched isFunctionResponse(...) and handed the next turn to the model with the reasoning "the last message was a function response" — which it wasn't. The model-with-empty-parts case is already special-cased just below that branch, but a user message with empty parts fell into the function-response branch first.

Test Plan

Added messageInspectors.test.ts: parts: [] returns false for both inspectors, while a real function-response / function-call part still returns true and a plain text part returns false. vitest run packages/core/src/utils/messageInspectors.test.ts passes, and the two empty-parts cases fail without the fix. tsc (typecheck) and eslint --max-warnings 0 are clean.

Risk

Low. The change only narrows two predicates to exclude empty-parts messages that previously slipped through a vacuous every(). No call site relied on the empty-parts case returning true.

…onse

isFunctionResponse / isFunctionCall use parts.every(...), which is vacuously
true for an empty parts array — so a user (or model) message with parts: []
was reported as an all-function-responses (or all-function-calls) turn. In
checkNextSpeaker that makes an empty user turn look like a function-response
turn and hands the next turn to the model with the wrong reasoning. Require at
least one part before the every() check so an empty message is neither.
@wenshao

wenshao commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template: the headings deviate slightly from the template (e.g. "## What" vs "## What this PR does", no "Reviewer Test Plan" matrix or "Linked Issues" section), but the body has everything needed to review — clear problem statement, test plan, and risk assessment. Not blocking on this.

On direction: this is a real bug. [].every(...) returning true is a classic vacuous-truth trap, and the impact is concrete — checkNextSpeaker would hand the next turn to the model with incorrect reasoning when it encounters an empty-parts user message. The fix is squarely within qwen-code's core conversation flow. No Claude Code CHANGELOG reference for this exact issue, but the area (turn routing, next-speaker logic) is well within scope.

On approach: this is as minimal as it gets — two parts.length > 0 guards, one focused test file. No simpler path exists; you can't fix a vacuous every() without checking the array isn't empty first. No scope creep, no drive-by refactors.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板:标题与模板略有不同("## What" vs "## What this PR does",缺少 "Reviewer Test Plan" 矩阵和 "Linked Issues"),但正文包含了审查所需的全部内容——清晰的问题描述、测试计划和风险评估。不因此阻塞。

方向:这是一个真实的 bug。[].every(...) 返回 true 是经典的空真陷阱,影响也很具体——checkNextSpeaker 在遇到空 parts 的用户消息时会以错误的理由将下一轮交给模型。修复直接针对 qwen-code 核心对话流,完全在范围内。Claude Code CHANGELOG 中没有找到完全对应的问题,但 turn 路由/next-speaker 逻辑属于核心功能。

方案:已经是最小改动了——两处 parts.length > 0 守卫,一个专注的测试文件。没有更简单的路径了;要修复空数组上的 every(),就必须先检查数组非空。没有范围蔓延,没有顺手重构。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal (before reading the diff): the bug is [].every(fn) === true. The fix is to add a parts.length > 0 guard before the .every() call in both isFunctionResponse and isFunctionCall. Two lines, symmetric change.

The PR does exactly this. No divergence from my proposal — the approach is correct and minimal.

Correctness: ✓ — the length > 0 check is placed after the !!content.parts null-check and before .every(), which is the right order. Both functions get the same treatment.

Tests: well-structured — covers the happy path (real function response/call), the negative path (text parts), and the edge case (empty parts). The tests genuinely fail without the fix:

Without fix (main)

 ❯ src/utils/messageInspectors.test.ts (5 tests | 2 failed) 13ms
   ✓ isFunctionResponse > is true when every user part is a function response 2ms
   ✓ isFunctionResponse > is false when a user part is not a function response 0ms
   × isFunctionResponse > is false for a user message with no parts 9ms
     → expected true to be false // Object.is equality
   ✓ isFunctionCall > is true when every model part is a function call 0ms
   × isFunctionCall > is false for a model message with no parts 1ms
     → expected true to be false // Object.is equality

 Test Files  1 failed (1)
      Tests  2 failed | 3 passed (5)

With fix (this PR)

 ✓ src/utils/messageInspectors.test.ts (5 tests) 4ms

 Test Files  1 passed (1)
      Tests  5 passed (5)

Typecheck & lint: both clean (npm run typecheck ✓, npm run lint ✓).

Caller impact: isFunctionResponse is used in nextSpeakerChecker.ts line 60 — the only call site. An empty-parts user message previously returned true, causing checkNextSpeaker to short-circuit with "the last message was a function response" reasoning. With the fix, it falls through to the model-empty-parts special case below it (line 67) or the general LLM-based check. This is the correct behavior.

No concerns found.

中文说明

代码审查

独立方案(看 diff 之前):bug 是 [].every(fn) === true,修复方式是在 .every() 前加 parts.length > 0 守卫,两行对称改动。

PR 正是这样做的,与我的方案完全一致——正确且最小化。

正确性: ✓ — length > 0 放在 !!content.parts 空值检查之后、.every() 之前,顺序正确。两个函数都做同样的处理。

测试: 结构良好——覆盖了正常路径(真正的 function response/call)、反面路径(text parts)和边界情况(空 parts)。测试在没有修复时确实会失败。

类型检查和 lint: 均通过。

调用方影响: isFunctionResponse 仅在 nextSpeakerChecker.ts 第 60 行使用。修复前,空 parts 的用户消息会返回 true,导致 checkNextSpeaker 以错误的"上一条是 function response"理由短路。修复后,会正确地落入下方的 model 空 parts 特殊处理或通用 LLM 检查。

没有发现问题。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Reflection

Going back to my independent proposal: the PR's approach is identical — parts.length > 0 before .every(). I can't think of a simpler fix, and there isn't one.

This is a clean bugfix that solves a real problem. The vacuous-truth trap in [].every() is a well-known JavaScript pitfall, and the impact here is non-trivial: checkNextSpeaker would incorrectly route the conversation when it encounters an empty-parts user message, handing the next turn to the model with bogus reasoning. The fix is two lines of code that any reviewer would write if starting from scratch.

The tests are honest — they fail without the fix and pass with it, covering both the edge case and the happy path. Typecheck and lint are clean. The only call site (nextSpeakerChecker.ts) benefits directly: empty-parts messages now fall through to the correct handling path instead of being misclassified.

Every line in the diff serves the stated goal. No unrelated changes, no scope creep. If I had to maintain this in six months, I'd thank the author for keeping it this tight.

Verdict: approve.

中文说明

反思

回到我的独立方案:PR 的做法完全一致——在 .every() 前加 parts.length > 0。想不到更简单的修复方式,也确实没有。

这是一个干净的 bug 修复,解决了一个真实问题。[].every() 的空真陷阱是 JavaScript 中众所周知的坑,而这里的影响也不小:checkNextSpeaker 遇到空 parts 的用户消息时会错误地路由对话,以错误的理由把下一轮交给模型。修复只有两行代码,任何审查者从零开始也会写出同样的代码。

测试是真实的——没有修复时失败,有修复时通过,覆盖了边界情况和正常路径。类型检查和 lint 均通过。唯一的调用方(nextSpeakerChecker.ts)直接受益:空 parts 消息现在会正确地落入后续处理路径,而不是被错误分类。

diff 中每一行都服务于既定目标,没有无关改动,没有范围蔓延。如果六个月后我来维护这段代码,我会感谢作者把它写得这么精简。

结论:批准。

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. ✅

@wenshao

wenshao commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

✅ Maintainer verification — safe to merge

Built and ran the real test suite locally (under tmux) against the PR head aa37c59cb. The fix is correct, the new test genuinely guards the bug (verified by toggling the fix off), and the real consumer (nextSpeakerChecker) is regression-clean.

Environment

  • Dedicated worktree off PR head aa37c59cb (clean — source matches the PR exactly, no local edits)
  • Node v22.22.2, npm 10.9.7
  • Real npm ci (no symlinked node_modules) → exit 0 in 2m30s
  • Run from packages/core

Results

Check Command Result
New regression test vitest run src/utils/messageInspectors.test.ts ✅ 5 pass / 0 fail
Real consumer (regression) vitest run src/utils/nextSpeakerChecker.test.ts ✅ 13 pass / 0 fail
Typecheck tsc --noEmit (core) ✅ clean, exit 0
Lint eslint --max-warnings 0 (touched files) ✅ clean, exit 0

A/B proof that the test actually guards the bug

To confirm the new test is not a no-op, I removed only the two content.parts.length > 0 && guard lines and re-ran:

FAIL  isFunctionResponse > is false for a user message with no parts
  AssertionError: expected true to be false
FAIL  isFunctionCall > is false for a model message with no parts
  AssertionError: expected true to be false
  Tests  2 failed | 3 passed (5)

Restoring the guards → back to green (5 pass / 0 fail). So both empty-parts cases fail without the fix and pass with it, while the three non-empty cases (real function response / call / plain text) pass either way — the test isolates exactly the fixed behavior. ✔

expected true to be false is the smoking gun: [].every(...) is vacuously true.

Real-world impact (confirmed in source)

The only production consumer is nextSpeakerChecker.ts:60:

if (isFunctionResponse(lastComprehensiveMessage)) {
  return { reasoning: 'The last message was a function response, ...', next_speaker: 'model' };
}

A user message with parts: [] previously satisfied the vacuous every(), so an empty user turn was misread as a function-response turn and silently handed to the model with a false reasoning. The model-with-empty-parts case is already special-cased just below (lines 68–79), so only the user-empty-parts path was broken — exactly what this fix closes.

Code assessment

  • Minimal and correct; symmetric guard on both predicates.
  • Note: isFunctionCall currently has no production call sites in the repo (only used in tests), so that half of the change is defensive/symmetric — harmless and consistent, just not exercised by runtime code today.
  • No behavior change for non-empty parts, missing parts, or wrong-role messages.

CI

All substantive checks are green (Classify, CodeQL, Lint, Test on ubuntu/macos/windows · Node 22.x). Merge shows BLOCKED only because of REVIEW_REQUIRED (needs an approving review) — no technical blocker.


🇨🇳 中文版(点击展开)

✅ 维护者验证 — 可以安全合并

已在本地(tmux 下)针对 PR head aa37c59cb 构建并运行了真实测试套件。修复正确;新增测试确实能拦住该 bug(通过关掉修复来验证);真正的调用方 nextSpeakerChecker 也无回归。

环境

  • 基于 PR head aa37c59cb 的独立 worktree(干净,源码与 PR 完全一致,无本地改动)
  • Node v22.22.2,npm 10.9.7
  • 真实 npm ci(非软链 node_modules)→ 退出码 0,用时 2m30s
  • 运行目录:packages/core

结果

检查项 命令 结果
新增回归测试 vitest run src/utils/messageInspectors.test.ts ✅ 5 通过 / 0 失败
真实调用方(回归) vitest run src/utils/nextSpeakerChecker.test.ts ✅ 13 通过 / 0 失败
类型检查 tsc --noEmit(core) ✅ 通过,退出码 0
Lint eslint --max-warnings 0(改动文件) ✅ 通过,退出码 0

A/B 验证(证明测试确实能拦住该 bug)

为确认新测试不是空跑,我删掉那两行 content.parts.length > 0 && 守卫后重跑:两个「空 parts」用例失败(expected true to be false),共 2 失败 / 3 通过;恢复守卫后重新变绿(5 通过 / 0 失败)。即两个空 parts 用例没有修复时失败、有修复时通过,而三个非空用例(真实 function response / call / 纯文本)两种情况下都通过——测试精准地隔离了被修复的行为。

expected true to be false 就是铁证:[].every(...) 是「空真」(vacuously true)。

真实影响(已在源码确认)

唯一的生产调用方是 nextSpeakerChecker.ts:60:一个 parts: []用户消息此前会满足空真的 every(),于是空的用户轮被误判为 function-response 轮,被以错误的理由「上一条是 function response」静默交给模型继续说。模型空 parts 的情况在紧随其后的分支(68–79 行)已单独处理,所以只有用户空 parts 这条路径有 bug——正是本 PR 修复的点。

代码评估

  • 改动最小且正确,两个判定函数对称加守卫。
  • 注意:isFunctionCall 目前在仓库中没有任何生产调用方(仅测试使用),所以这半边属于防御性/对称改动——无害且一致,只是当前运行时代码尚未触达。
  • 对非空 parts、缺失 parts、角色不匹配的消息均无行为变化。

CI

所有实质性检查均已通过(Classify、CodeQL、Lint、ubuntu/macos/windows · Node 22.x 测试)。合并显示 BLOCKED 仅因为 REVIEW_REQUIRED(需要一个批准 review),不存在技术性阻塞。

});
});

describe('isFunctionCall', () => {

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 isFunctionCall test block is missing a negative test for non-function-call model parts (e.g., parts: [{ text: 'hi' }]). The parallel isFunctionResponse block at line 20-23 covers this case. Adding it would make the test coverage symmetric and guard against a future regression that breaks the .every() predicate for mixed parts.

Suggested change
describe('isFunctionCall', () => {
it('is false when a model part is not a function call', () => {
const content: Content = { role: 'model', parts: [{ text: 'hi' }] };
expect(isFunctionCall(content)).toBe(false);
});

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

return (
content.role === 'model' &&
!!content.parts &&
content.parts.length > 0 &&

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] isFunctionCall has zero production callers — only the test file imports it. The only production consumer of this module (nextSpeakerChecker.ts) imports isFunctionResponse exclusively. The fix is correct, but it's fixing dead code. Consider either removing the export (if it's not needed) or adding a JSDoc comment documenting its intended future use so future maintainers don't mistake it for active code.

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

@wenshao
wenshao merged commit 8b6ac72 into QwenLM:main Jun 20, 2026
41 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.

3 participants

Morty Proxy This is a proxified and sanitized view of the page, visit original site.