fix(core): don't treat an empty-parts message as a function call/response#5494
fix(core): don't treat an empty-parts message as a function call/response#5494wenshao merged 1 commit intoQwenLM: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
…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.
|
@qwen-code /triage |
|
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. On approach: this is as minimal as it gets — two Moving on to code review. 🔍 中文说明感谢贡献! 模板:标题与模板略有不同("## What" vs "## What this PR does",缺少 "Reviewer Test Plan" 矩阵和 "Linked Issues"),但正文包含了审查所需的全部内容——清晰的问题描述、测试计划和风险评估。不因此阻塞。 方向:这是一个真实的 bug。 方案:已经是最小改动了——两处 进入代码审查 🔍 — Qwen Code · qwen3.7-max |
Code ReviewIndependent proposal (before reading the diff): the bug is The PR does exactly this. No divergence from my proposal — the approach is correct and minimal. Correctness: ✓ — the 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)With fix (this PR)Typecheck & lint: both clean ( Caller impact: No concerns found. 中文说明代码审查独立方案(看 diff 之前):bug 是 PR 正是这样做的,与我的方案完全一致——正确且最小化。 正确性: ✓ — 测试: 结构良好——覆盖了正常路径(真正的 function response/call)、反面路径(text parts)和边界情况(空 parts)。测试在没有修复时确实会失败。 类型检查和 lint: 均通过。 调用方影响: 没有发现问题。 — Qwen Code · qwen3.7-max |
ReflectionGoing back to my independent proposal: the PR's approach is identical — This is a clean bugfix that solves a real problem. The vacuous-truth trap in 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 ( 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 的做法完全一致——在 这是一个干净的 bug 修复,解决了一个真实问题。 测试是真实的——没有修复时失败,有修复时通过,覆盖了边界情况和正常路径。类型检查和 lint 均通过。唯一的调用方( diff 中每一行都服务于既定目标,没有无关改动,没有范围蔓延。如果六个月后我来维护这段代码,我会感谢作者把它写得这么精简。 结论:批准。 ✅ — Qwen Code · qwen3.7-max |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
✅ Maintainer verification — safe to mergeBuilt and ran the real test suite locally (under tmux) against the PR head Environment
Results
A/B proof that the test actually guards the bugTo confirm the new test is not a no-op, I removed only the two Restoring the guards → back to green (
Real-world impact (confirmed in source)The only production consumer is if (isFunctionResponse(lastComprehensiveMessage)) {
return { reasoning: 'The last message was a function response, ...', next_speaker: 'model' };
}A user message with Code assessment
CIAll substantive checks are green (Classify, CodeQL, Lint, Test on ubuntu/macos/windows · Node 22.x). Merge shows 🇨🇳 中文版(点击展开)✅ 维护者验证 — 可以安全合并已在本地(tmux 下)针对 PR head 环境
结果
A/B 验证(证明测试确实能拦住该 bug)为确认新测试不是空跑,我仅删掉那两行
真实影响(已在源码确认)唯一的生产调用方是 代码评估
CI所有实质性检查均已通过(Classify、CodeQL、Lint、ubuntu/macos/windows · Node 22.x 测试)。合并显示 |
| }); | ||
| }); | ||
|
|
||
| describe('isFunctionCall', () => { |
There was a problem hiding this comment.
[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.
| 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 && |
There was a problem hiding this comment.
[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
What
isFunctionResponseandisFunctionCall(packages/core/src/utils/messageInspectors.ts) now require at least one part before theparts.every(...)check, so a message withparts: []is no longer reported as an all-function-responses / all-function-calls turn.Why
[].every(...)is vacuouslytrue, so a user (or model) message with an empty parts array passed both inspectors. IncheckNextSpeaker, an empty user turn then matchedisFunctionResponse(...)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: []returnsfalsefor both inspectors, while a real function-response / function-call part still returnstrueand a plain text part returnsfalse.vitest run packages/core/src/utils/messageInspectors.test.tspasses, and the two empty-parts cases fail without the fix.tsc(typecheck) andeslint --max-warnings 0are 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 returningtrue.