Merge branch 'main' into agent/agent-view-supervisor-runtime

This commit is contained in:
qwen-code-dev-bot 2026-07-29 07:04:14 +08:00 committed by GitHub
commit 069b78bf6c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1587 additions and 165 deletions

View file

@ -113,6 +113,12 @@ env:
# exists to stop an unproductive LOOP, not to ration ordinary iteration —
# a genuinely stuck PR still stops, just later.
MAX_ROUNDS: '10'
# Suggestions may improve a young PR, but continuing to implement them after
# five change-producing rounds expands the diff and creates fresh review
# churn. From round 6 onward, only Critical findings, formally requested
# changes, failed checks, and base conflicts may drive code changes;
# lower-severity feedback is recorded and left open.
CRITICAL_ONLY_AFTER_ROUND: '5'
# An auth/access model error (401/402/403, "no access"/"does not exist")
# never self-heals - only a maintainer can fix the key - and every retry
# costs an agent run AND a PR comment. Cap those attempts far below
@ -2259,15 +2265,11 @@ jobs:
| select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb)
| select((.state // "") | IN("CHANGES_REQUESTED", "COMMENTED")) ] | length' \
"${WORKDIR}/rv.json")"
# Per AGENTS.md's review policy, Suggestion-level findings ARE
# addressed during a PR's first ~5 review rounds; only past that are
# they deferred (with a recorded reason). The loop's MAX_ROUNDS cap is
# that same boundary — every round the loop actually runs is within
# the address-Suggestions window — so /review `**[Suggestion]**`
# inline comments count as actionable feedback here. The agent's
# triage still decides implement-vs-defer per finding and records the
# decision in its summary comment, and the MAX_ROUNDS handoff is the
# defer-to-a-human boundary.
# Per AGENTS.md's review policy, Suggestion-level findings are
# actionable during the first five change-producing rounds. The
# scan still selects later feedback so prepare can record and
# watermark its deferral; only the address job filters what may
# drive code changes.
N_COMMENTS="$(jq --arg wm "${EFF_WM}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \
--argjson trust "${TRUSTED_ASSOC}" '
[ .[]
@ -2817,9 +2819,76 @@ jobs:
STALE='true'
echo "⛔ live round ${ROUND} already at MAX_ROUNDS (${MAX_ROUNDS}) — discarding without action or marker"
fi
CRITICAL_ONLY='false'
if [[ "${ROUND}" -ge "${CRITICAL_ONLY_AFTER_ROUND}" ]]; then
CRITICAL_ONLY='true'
fi
echo "stale=${STALE}" >> "${GITHUB_OUTPUT}"
echo "effective_round=${ROUND}" >> "${GITHUB_OUTPUT}"
rm -f "${WORKDIR}/deferred-feedback.md"
if [[ "${CRITICAL_ONLY}" == "true" ]]; then
PR_URL="https://github.com/${REPO}/pull/${PR}"
{
echo '## Deferred non-Critical feedback'
echo
echo "Critical-only mode is active after ${CRITICAL_ONLY_AFTER_ROUND} change-producing rounds. Any items listed below stay open for human follow-up; do not modify code, resolve threads, or reply on their behalf."
echo
jq -r --arg wm "${WATERMARK}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \
--argjson trust "${TRUSTED_ASSOC}" --arg pr_url "${PR_URL}" '
.[]
| select((.submitted_at // "") > $wm)
| select((.user.login // "") != $ab)
| select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb)
| select((.state // "") == "COMMENTED")
| select(((.body // "") | contains("**[Critical]**")) | not)
| "- Review by @\(.user.login): \(.html_url // $pr_url)"' \
"${WORKDIR}/rv.json"
jq -rs --arg wm "${WATERMARK}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \
--argjson trust "${TRUSTED_ASSOC}" --arg pr_url "${PR_URL}" \
--slurpfile reviews "${WORKDIR}/rv.json" '
add as $comments
| ($reviews | add) as $reviews
| $comments[]
| select((.created_at // "") > $wm)
| select((.user.login // "") != $ab)
| select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb)
| select((
((.body // "") | contains("**[Critical]**"))
or ((.in_reply_to_id // null) as $root
| $root != null
and any($comments[];
.id == $root
and ((.body // "") | contains("**[Critical]**"))))
or ((.pull_request_review_id // null) as $review
| $review != null
and any($reviews[];
.id == $review
and ((.state // "") == "CHANGES_REQUESTED")))
) | not)
| "- Inline rc:\(.id) \(.path // "?"):\(.line // "?"): \(.html_url // $pr_url)"' \
"${WORKDIR}/rc.json"
jq -r --arg wm "${WATERMARK}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \
--argjson trust "${TRUSTED_ASSOC}" --arg pr_url "${PR_URL}" '
.[]
| select((.created_at // "") > $wm)
| select((.user.login // "") != $ab)
| select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb)
| select((.body // "") | test("<!-- (autofix-eval|autofix-rearm|qwen-triage|qwen-review-suggestion-summary|pr-force-push|qwen-review-ack) ") | not)
| select((.body // "") | test("^\\s*@qwen-code /") | not)
| select(((.body // "") | contains("**[Critical]**")) | not)
| "- PR comment by @\(.user.login): \(.html_url // $pr_url)"' \
"${WORKDIR}/ic.json"
echo
echo '<details>'
echo '<summary>中文说明</summary>'
echo
echo "完成 ${CRITICAL_ONLY_AFTER_ROUND} 个产生改动的轮次后,进入仅处理 Critical 的模式。以上内容保持开放,留待人工跟进;不要为其修改代码、解决线程或代为回复。"
echo
echo '</details>'
} > "${WORKDIR}/deferred-feedback.md"
fi
# Render the actionable feedback into one prompt-ready file.
{
ISSUE_REF=""
@ -2831,40 +2900,60 @@ jobs:
echo
echo "## Reviews"
jq -r --arg wm "${WATERMARK}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \
--argjson trust "${TRUSTED_ASSOC}" '
--argjson critical_only "${CRITICAL_ONLY}" --argjson trust "${TRUSTED_ASSOC}" '
.[]
| select((.submitted_at // "") > $wm)
| select((.user.login // "") != $ab)
| select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb)
| select((.state // "") | IN("CHANGES_REQUESTED", "COMMENTED"))
| select(($critical_only | not)
or (.state // "") == "CHANGES_REQUESTED"
or ((.body // "") | contains("**[Critical]**")))
| "- [\(.state)] @\(.user.login): \(.body // "" | gsub("\r"; ""))"' \
"${WORKDIR}/rv.json"
echo
echo "## Inline comments"
# Mirrors the review-scan gate: per AGENTS.md, Suggestion-level
# findings are actionable within the first ~5 review rounds (the
# loop's whole operating range), so they are rendered for the agent
# to triage — implement if valuable, otherwise record the deferral.
jq -r --arg wm "${WATERMARK}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \
--argjson trust "${TRUSTED_ASSOC}" '
.[]
jq -rs --arg wm "${WATERMARK}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \
--argjson critical_only "${CRITICAL_ONLY}" --argjson trust "${TRUSTED_ASSOC}" \
--slurpfile reviews "${WORKDIR}/rv.json" '
add as $comments
| ($reviews | add) as $reviews
| $comments[]
| select((.created_at // "") > $wm)
| select((.user.login // "") != $ab)
| select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb)
| select(($critical_only | not)
or ((.body // "") | contains("**[Critical]**"))
or ((.in_reply_to_id // null) as $root
| $root != null
and any($comments[];
.id == $root
and ((.body // "") | contains("**[Critical]**"))))
or ((.pull_request_review_id // null) as $review
| $review != null
and any($reviews[];
.id == $review
and ((.state // "") == "CHANGES_REQUESTED"))))
| "- [rc:\(.id)] \(.path // "?"):\(.line // "?") @\(.user.login): \(.body // "" | gsub("\r"; ""))"' \
"${WORKDIR}/rc.json"
echo
echo "## Issue-level comments"
jq -r --arg wm "${WATERMARK}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \
--argjson trust "${TRUSTED_ASSOC}" '
--argjson critical_only "${CRITICAL_ONLY}" --argjson trust "${TRUSTED_ASSOC}" '
.[]
| select((.created_at // "") > $wm)
| select((.user.login // "") != $ab)
| select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb)
| select((.body // "") | test("<!-- (autofix-eval|autofix-rearm|qwen-triage|qwen-review-suggestion-summary|pr-force-push|qwen-review-ack) ") | not)
| select((.body // "") | test("^\\s*@qwen-code /") | not)
| select(($critical_only | not)
or ((.body // "") | contains("**[Critical]**")))
| "- @\(.user.login): \(.body // "" | gsub("\r"; ""))"' \
"${WORKDIR}/ic.json"
if [[ -s "${WORKDIR}/deferred-feedback.md" ]]; then
echo
cat "${WORKDIR}/deferred-feedback.md"
fi
echo
echo "## Failed checks"
jq -r --arg wm "${WATERMARK}" '
@ -3377,6 +3466,10 @@ jobs:
# line, and jq scan() matches across newlines. The backslashes
# render away in markdown, so the visible text is unchanged.
sed 's/<!--/<!\\-\\-/g' "${WORKDIR}/address-summary.md"
if [[ -s "${WORKDIR}/deferred-feedback.md" ]]; then
echo
sed 's/<!--/<!\\-\\-/g' "${WORKDIR}/deferred-feedback.md"
fi
echo
echo "Base-conflict check · 基分支冲突检查: $([[ "${CONFLICT}" == "true" ]] && echo 'conflicted with main — resolved in this push. · 与 main 有冲突——已在本次推送中解决。' || echo 'no conflict with main. · 与 main 无冲突。')"
echo
@ -3396,6 +3489,10 @@ jobs:
echo "🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下:"
echo
sed 's/<!--/<!\\-\\-/g' "${WORKDIR}/no-action.md"
if [[ -s "${WORKDIR}/deferred-feedback.md" ]]; then
echo
sed 's/<!--/<!\\-\\-/g' "${WORKDIR}/deferred-feedback.md"
fi
echo
echo "Base-conflict check · 基分支冲突检查: $([[ "${CONFLICT}" == "true" ]] && echo 'conflicts with main (no review fix needed, but a rebase/merge is required before merge). · 与 main 有冲突(无需评审修复,但合并前需 rebase/merge。' || echo 'no conflict with main. · 与 main 无冲突。')"
echo

View file

@ -500,7 +500,9 @@ jobs:
REPO="${GITHUB_REPOSITORY}"
REVIEW_URL="${GITHUB_SERVER_URL}/${REPO}/pull/${PR_NUMBER}"
LOG_PATH="${RUNNER_TEMP:-/tmp}/qwen-review-pr-${PR_NUMBER}.jsonl"
trap 'rm -f "$LOG_PATH"' EXIT
# Set by configure_qwen_network once the wrapper dir exists.
PROXY_BIN=""
trap 'rm -f "$LOG_PATH"; [ -z "$PROXY_BIN" ] || rm -rf "$PROXY_BIN"' EXIT
if [ -z "${GH_TOKEN:-}" ]; then
fail "CI_BOT_PAT secret is required for Qwen PR review."
@ -535,8 +537,16 @@ jobs:
export QWEN_CI_https_proxy="${https_proxy:-}"
export QWEN_CI_HTTP_PROXY="${HTTP_PROXY:-}"
export QWEN_CI_http_proxy="${http_proxy:-}"
proxy_bin="${RUNNER_TEMP:-/tmp}/qwen-network-bin"
mkdir -p "$proxy_bin"
# A fixed path is a landmine on the shared self-hosted runner:
# RUNNER_TEMP survives across jobs, and the triage workflow's
# containerised jobs write this same path as root through the
# RUNNER_TEMP bind mount. This job runs as the unprivileged runner
# user, so once that happens it can neither overwrite the wrapper
# nor remove the root-owned directory holding it, and every review
# landing on that runner dies here with EACCES. Use a private
# directory per run, cleaned up by the EXIT trap.
proxy_bin="$(mktemp -d "${RUNNER_TEMP:-/tmp}/qwen-network-bin.XXXXXX")"
PROXY_BIN="$proxy_bin"
if command -v gh >/dev/null 2>&1; then
local real_gh

View file

@ -1112,14 +1112,30 @@ jobs:
# not racing a live process. This also removes the localhost blind
# scan surface the bearer-gated proxy defends against. The proxy
# started further below runs as root, so this cannot touch it.
# Zombies do not count. A zombie has already exited and released
# everything except its exit status, so it cannot re-plant an
# artifact or touch the agent's inputs — and it cannot be killed
# either, so counting it means this check can never clear. The
# container runs no init to reap orphans, so they are expected.
live_build_processes() {
ps -o pid=,stat=,args= -u node 2>/dev/null | awk '$2 !~ /^Z/'
}
pkill -KILL -u node 2>/dev/null || true
for _ in 1 2 3; do
pgrep -u node >/dev/null 2>&1 || break
[ -n "$(live_build_processes)" ] || break
sleep 1
pkill -KILL -u node 2>/dev/null || true
done
if pgrep -u node >/dev/null 2>&1; then
echo "::error::Processes owned by the build user survived; refusing to start the agent."
survivors="$(live_build_processes)" || true
if [ -n "$survivors" ]; then
# Name them. The first version of this guard failed the job with
# nothing but "processes survived", so a real threat and a
# harmless leftover were indistinguishable — including to the
# person who wrote it.
echo "::error::Processes owned by the build user survived SIGKILL; refusing to start the agent."
printf '%s\n' "$survivors" | while IFS= read -r proc; do
echo "::error:: surviving process: ${proc}"
done
exit 1
fi
@ -1162,8 +1178,13 @@ jobs:
export QWEN_CI_https_proxy="${https_proxy:-}"
export QWEN_CI_HTTP_PROXY="${HTTP_PROXY:-}"
export QWEN_CI_http_proxy="${http_proxy:-}"
proxy_bin="${RUNNER_TEMP:-/tmp}/qwen-network-bin"
mkdir -p "$proxy_bin"
# This job runs in a container, and RUNNER_TEMP is bind-mounted
# from the self-hosted runner's host filesystem. Writing the
# wrappers there leaves root-owned files on the host that later
# non-container jobs (the PR review) can neither overwrite nor
# delete, breaking every review scheduled on that runner. Keep
# them on the container's own disk, under a per-run directory.
proxy_bin="$(mktemp -d /tmp/qwen-network-bin.XXXXXX)"
if command -v gh >/dev/null 2>&1; then
local real_gh
@ -2329,14 +2350,30 @@ jobs:
# child can outlive its step, wait for the sweeps below, and then
# re-plant artifacts or tamper with the agent's inputs. Without
# this, every one-shot cleanup here is racing a live process.
# Zombies do not count. A zombie has already exited and released
# everything except its exit status, so it cannot re-plant an
# artifact or touch the agent's inputs — and it cannot be killed
# either, so counting it means this check can never clear. The
# container runs no init to reap orphans, so they are expected.
live_build_processes() {
ps -o pid=,stat=,args= -u node 2>/dev/null | awk '$2 !~ /^Z/'
}
pkill -KILL -u node 2>/dev/null || true
for _ in 1 2 3; do
pgrep -u node >/dev/null 2>&1 || break
[ -n "$(live_build_processes)" ] || break
sleep 1
pkill -KILL -u node 2>/dev/null || true
done
if pgrep -u node >/dev/null 2>&1; then
echo "::error::Processes owned by the build user survived; refusing to start the agent."
survivors="$(live_build_processes)" || true
if [ -n "$survivors" ]; then
# Name them. The first version of this guard failed the job with
# nothing but "processes survived", so a real threat and a
# harmless leftover were indistinguishable — including to the
# person who wrote it.
echo "::error::Processes owned by the build user survived SIGKILL; refusing to start the agent."
printf '%s\n' "$survivors" | while IFS= read -r proc; do
echo "::error:: surviving process: ${proc}"
done
exit 1
fi
@ -2415,8 +2452,13 @@ jobs:
export QWEN_CI_https_proxy="${https_proxy:-}"
export QWEN_CI_HTTP_PROXY="${HTTP_PROXY:-}"
export QWEN_CI_http_proxy="${http_proxy:-}"
proxy_bin="${RUNNER_TEMP:-/tmp}/qwen-network-bin"
mkdir -p "$proxy_bin"
# This job runs in a container, and RUNNER_TEMP is bind-mounted
# from the self-hosted runner's host filesystem. Writing the
# wrappers there leaves root-owned files on the host that later
# non-container jobs (the PR review) can neither overwrite nor
# delete, breaking every review scheduled on that runner. Keep
# them on the container's own disk, under a per-run directory.
proxy_bin="$(mktemp -d /tmp/qwen-network-bin.XXXXXX)"
if command -v gh >/dev/null 2>&1; then
local real_gh
@ -3194,7 +3236,7 @@ jobs:
and ([.pass, .fail, .total] | all(type == "number" and . >= 0 and . == floor))
and (.total > 0)
and (.total == .pass + .fail)
then "Scripted assertions: \(.pass) passed · \(.fail) failed · \(.total) total"
then "Scripted assertions: \(.pass) passed · \(.fail) failed · \(.total) total\n\n脚本断言\(.pass) 通过 · \(.fail) 失败 · \(.total) 总计"
else empty end' "$ASSERTIONS_FILE" 2>/dev/null || true
)"
if [ -z "$ASSERT_LINE" ]; then
@ -3216,20 +3258,25 @@ jobs:
findings|blocked|inconclusive) TRUST_AGENT_VERDICT=true ;;
esac
fi
# The verdict headline carries its Chinese twin. The verdict is the one
# line a reader acts on, and until now it was the only part of
# the unfolded comment that was English-only — the scope
# disclaimer below it has been bilingual all along, so a Chinese
# reader got the caveat and not the conclusion.
if [ "$TRUST_AGENT_VERDICT" = true ]; then
case "$AGENT_VERDICT" in
merge-ready) HEADLINE='merge-ready (agent verdict)' ;;
findings) HEADLINE='findings reported (agent verdict)' ;;
blocked) HEADLINE='blocked (agent verdict)' ;;
inconclusive) HEADLINE='inconclusive (agent verdict)' ;;
merge-ready) HEADLINE='merge-ready (agent verdict)'; HEADLINE_ZH='可合入agent 判定)' ;;
findings) HEADLINE='findings reported (agent verdict)'; HEADLINE_ZH='报告了发现agent 判定)' ;;
blocked) HEADLINE='blocked (agent verdict)'; HEADLINE_ZH='阻塞agent 判定)' ;;
inconclusive) HEADLINE='inconclusive (agent verdict)'; HEADLINE_ZH='结论不足agent 判定)' ;;
esac
else
case "${VERDICT:-}" in
pass) HEADLINE='completed (no usable structured verdict)' ;;
fail) HEADLINE='agent run failed' ;;
timeout) HEADLINE='timeout — partial evidence' ;;
infra-error) HEADLINE='infra-error (crash, OOM, or unwritable results)' ;;
*) HEADLINE='unknown' ;;
pass) HEADLINE='completed (no usable structured verdict)'; HEADLINE_ZH='已完成(无可用的结构化判定)' ;;
fail) HEADLINE='agent run failed'; HEADLINE_ZH='agent 运行失败' ;;
timeout) HEADLINE='timeout — partial evidence'; HEADLINE_ZH='超时——证据不完整' ;;
infra-error) HEADLINE='infra-error (crash, OOM, or unwritable results)'; HEADLINE_ZH='基础设施故障崩溃、OOM 或结果不可写)' ;;
*) HEADLINE='unknown'; HEADLINE_ZH='未知' ;;
esac
if [ -n "${AGENT_VERDICT:-}" ]; then
echo "::warning::Agent wrote verdict '${AGENT_VERDICT}' but the run did not complete cleanly (process verdict '${VERDICT:-}'); reporting the process outcome instead."
@ -3255,7 +3302,8 @@ jobs:
printf '%s\n' '<!-- qwen-triage:verify-substantive -->'
fi
printf '\n'
printf '**Sandboxed verification: %s** - [workflow run](%s)\n\n' "$HEADLINE" "$RUN_URL"
printf '**Sandboxed verification: %s** - [workflow run](%s)\n' "$HEADLINE" "$RUN_URL"
printf '**沙箱验证:%s**\n\n' "${HEADLINE_ZH:-$HEADLINE}"
if [ "${VERDICT:-}" = 'pass' ]; then
printf '%s\n\n' "$SCOPE_EN"
printf '%s\n\n' "$SCOPE_ZH"

View file

@ -202,6 +202,13 @@ implement — satisfying a nit is never a reason to bloat the code.
reason per finding (out of scope, conflicts with the PR's direction, or not
worth the diff growth) so the deferral is visible in the PR thread — never
drop one silently.
- Critical-only mode: when `feedback.md` contains a
`Deferred non-Critical feedback` section, the PR has already completed five
suggestion-capable, change-producing rounds. That section is an audit record,
not work: do not modify code, resolve threads, or write comment replies for
those items. Act only on Critical feedback and formally requested changes
rendered in the actionable sections, failed checks, and the requested
base-conflict resolution.
- Needs a maintainer's decision: a finding that turns on a judgment that is
NOT yours to make — a product or scope tradeoff (is this acceptable for v1?
should the PR be split?), two reviewers asking for opposite things, or whether

View file

@ -347,25 +347,31 @@ central claim from being tested — say why.
1. **Verdict line first**, with assertion totals and the verified head OID
(`git rev-parse HEAD^2` — not the snapshot's, which may have drifted).
2. **Central claim + A/B table** (cells, oracles, head vs control counts).
3. **Corrections**, when an earlier review round or bot comment described
2. **中文摘要** in a collapsed `<details>` block, **immediately after the
verdict**: verdict, A/B 结论, findings, 未覆盖范围. Collapsed, so it costs a
reader who does not want it exactly one line; placed here rather than at
the end, because the whole report is already inside a `<details>` on the
PR — burying the Chinese summary under it made a Chinese reader expand a
fold and scroll the entire report to reach the one section written for
them. Cite the tables below by name instead of restating their numbers in
prose: a number written twice is a number that can disagree with itself.
3. **Central claim + A/B table** (cells, oracles, head vs control counts).
4. **Corrections**, when an earlier review round or bot comment described
the code inaccurately (a wrong ARIA role, a wrong mechanism, a
misattributed cause). State the correct fact with its evidence and label
it explicitly as a correction to the description — not as a request to
change the code. Leaving a wrong description standing costs the next
reader more than the original finding did.
4. **Findings**, ordered by severity, each with the exact reproducing
5. **Findings**, ordered by severity, each with the exact reproducing
command; for a blocker, enumerate the blast radius (the affected call
sites, not just the one you hit), demonstrate the sharpest consequence
end-to-end when budget allows, and where the cause is clear add a
collapsed minimal suggested fix that preserves the original commit's
intent.
5. **Not covered** — every claim, surface, or gate you skipped. A silent cap
6. **Not covered** — every claim, surface, or gate you skipped. A silent cap
reads as "covered everything"; never allow that.
6. **Methodology** — one paragraph: environment, how each harness drove the
7. **Methodology** — one paragraph: environment, how each harness drove the
code, where the raw logs live.
7. **中文摘要** in a collapsed `<details>` block: verdict, A/B 结论, findings,
未覆盖范围.
## Hard rules

View file

@ -0,0 +1,71 @@
# Web Shell Composer Intent Suggestions
## Summary
Extend Web Shell's existing new-topic suggestion so one conservative
classification can recommend either asking a side question with `/btw` or
sending a substantial new topic in a fresh session.
The composer continues to show at most one non-blocking action. A valid
`none` decision renders nothing. Invalid, failed, or cancelled classifications
also render nothing.
## Decision contract
```ts
type SuggestionKind = 'btw' | 'new_session' | 'none';
interface SuggestionDecision {
suggestion: SuggestionKind;
confidence: number;
}
```
Only `btw` and `new_session` decisions at or above the existing confidence
threshold become actionable. The actionable state records the exact classified
draft and source session so both can be checked again when the user clicks.
## Behavior
- `btw` is for a quick, self-contained side question that should not disturb
the main task.
- `new_session` is for a clearly different, substantial task or topic.
- `none` covers continuations, uncertainty, and drafts that fit neither action.
- BTW classification starts after one prior user/assistant exchange. New-session
suggestions keep their stricter existing context thresholds.
- Follow-up-like wording may be classified for BTW, but can never use the
relaxed BTW threshold to surface a new-session action.
- Clicking a `btw` suggestion submits `/btw <draft>` through the existing
editor path, which preserves the command's current history and composer-clear
semantics.
- A draft with an image or composer tag is never eligible for `btw`.
- `new_session` retains the existing clear, detach, create, and auto-submit
sequence, including image preservation and session-race cancellation.
## Safety
The classifier remains conservative and fail-closed:
- malformed output, unknown actions, invalid confidence, errors, and
cancellation produce no action;
- a session change aborts pending classification and invalidates a visible
suggestion;
- a draft or attachment change invalidates a visible suggestion;
- click handling checks the current draft, source session, and attachment state
immediately before executing;
- attachments are treated as present until ChatEditor reports otherwise, so a
transient unknown state cannot expose a `/btw` action.
## Scope
The change stays inside Web Shell. It reuses existing daemon session generation,
editor submission, and `/btw` behavior. It does not add daemon or SDK routes,
change styling, or introduce a general-purpose suggestion framework.
## Test strategy
- Hook tests cover the three decision values, strict parsing, confidence,
attachment gating, and stale-session results.
- App tests cover `/btw` execution and composer clearing, stale draft/session
rejection, attachment rejection, and the existing new-session races.
- ChatEditor tests cover attachment-presence reporting.

View file

@ -51,6 +51,7 @@ type ChatEditorTestProps = {
) => boolean | void;
onCancel?: () => void;
onInputTextChange?: (text: string) => void;
onAttachmentsChange?: (hasAttachments: boolean) => void;
onStartNewSessionSuggestion?: () => void;
newSessionSuggestion?: { isVisible: boolean; classifiedInput: string } | null;
skills?: Array<{ name: string; description: string }>;
@ -170,6 +171,7 @@ const {
mockConnection: connection,
mockSessionActions: {
sendPrompt: vi.fn().mockResolvedValue(undefined),
btwSession: vi.fn().mockResolvedValue({ answer: 'side answer' }),
generateSessionContent: vi.fn(async function* () {}),
createSession: vi.fn().mockResolvedValue({ sessionId: 'session-1' }),
attachSession: vi.fn().mockResolvedValue(undefined),
@ -400,12 +402,23 @@ vi.mock('./components/ChatEditor', async () => {
props: ChatEditorTestProps,
ref: React.ForwardedRef<{
clear: () => void;
hasAttachments: () => boolean;
hasInput: () => boolean;
insertText: (text: string) => void;
submit: (input?: { text?: string }) => void;
focus: () => void;
}>,
) {
testState.latestChatEditorProps = props;
const { onAttachmentsChange } = props;
React.useEffect(() => {
onAttachmentsChange?.(
Boolean(
testState.promptImages?.length ||
testState.inputAnnotations?.length,
),
);
}, [onAttachmentsChange]);
React.useImperativeHandle(ref, () => ({
clear: () => {
testState.prompt = '';
@ -413,17 +426,23 @@ vi.mock('./components/ChatEditor', async () => {
props.onInputTextChange?.('');
editorClear();
},
hasAttachments: () =>
Boolean(
testState.promptImages?.length ||
testState.inputAnnotations?.length,
),
hasInput: () => testState.prompt.trim().length > 0,
insertText: editorInsertText,
submit: () => {
props.onSubmit(
testState.prompt,
submit: (input) => {
const accepted = props.onSubmit(
input?.text ?? testState.prompt,
testState.promptImages,
editorCommit,
testState.inputAnnotations
? { inputAnnotations: testState.inputAnnotations }
: undefined,
);
if (accepted) editorCommit();
},
// The panel focus effect calls editorRef.current?.focus() when a panel
// closes with no pending approval (e.g. resuming a session).
@ -1587,6 +1606,7 @@ beforeEach(() => {
if (typeof value === 'function' && 'mockClear' in value) value.mockClear();
}
mockSessionActions.sendPrompt.mockResolvedValue(undefined);
mockSessionActions.btwSession.mockResolvedValue({ answer: 'side answer' });
mockSessionActions.createSession.mockResolvedValue({
sessionId: 'session-1',
});
@ -5477,7 +5497,7 @@ describe('App session callbacks', () => {
requestId: 'req-1',
seq: 0,
text: JSON.stringify({
shouldSuggestNewSession: true,
suggestion: 'new_session',
confidence: 0.91,
}),
};
@ -5534,6 +5554,207 @@ describe('App session callbacks', () => {
expect(editorInsertText).not.toHaveBeenCalled();
});
it('suggests sending a side question with BTW and clears the accepted draft', async () => {
vi.useFakeTimers();
mockConnection.capabilities.features = ['session_generation'];
(
mockConnection as typeof mockConnection & {
tokenCount?: number;
contextWindow?: number;
}
).tokenCount = 600;
(
mockConnection as typeof mockConnection & {
tokenCount?: number;
contextWindow?: number;
}
).contextWindow = 1000;
testState.messages = Array.from({ length: 8 }, (_, index) => ({
id: `m-btw-${index}`,
role: index % 2 === 0 ? 'user' : 'assistant',
content: `existing session topic ${index} about daemon generation review work`,
timestamp: index,
}));
const sideQuestion = '这里的 confidence 阈值为什么是 0.75';
testState.prompt = sideQuestion;
mockSessionActions.generateSessionContent.mockImplementation(
async function* () {
yield {
type: 'delta',
requestId: 'req-btw',
seq: 0,
text: JSON.stringify({
suggestion: 'btw',
confidence: 0.92,
}),
};
yield {
type: 'done',
requestId: 'req-btw',
model: 'fast-model',
modelSource: 'fast',
};
},
);
const { container } = renderApp();
await flush();
act(() => {
testState.latestChatEditorProps?.onInputTextChange?.(testState.prompt);
});
await flush();
act(() => {
vi.advanceTimersByTime(121);
});
await flush();
act(() => {
vi.advanceTimersByTime(701);
});
await flush();
expect(
container.querySelector('[data-testid="btw-suggestion"]')?.textContent,
).toContain('side question');
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="btw-suggestion-send"]')
?.click();
await Promise.resolve();
});
expect(mockSessionActions.btwSession).toHaveBeenCalledWith(
sideQuestion,
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
expect(editorCommit).toHaveBeenCalledTimes(1);
const newTask = '帮我写一篇新的设计文档,主题是 Web Shell 新功能方案';
mockSessionActions.generateSessionContent.mockImplementation(
async function* () {
yield {
type: 'delta',
requestId: 'req-new-session-after-btw',
seq: 0,
text: JSON.stringify({
suggestion: 'new_session',
confidence: 0.94,
}),
};
yield {
type: 'done',
requestId: 'req-new-session-after-btw',
model: 'fast-model',
modelSource: 'fast',
};
},
);
testState.prompt = newTask;
act(() => {
testState.latestChatEditorProps?.onInputTextChange?.(newTask);
});
await flush();
act(() => {
vi.advanceTimersByTime(121);
});
await flush();
act(() => {
vi.advanceTimersByTime(701);
});
await flush();
expect(
container.querySelector('[data-testid="new-session-suggestion"]'),
).not.toBeNull();
});
it('refuses a visible BTW suggestion when an inline tag is added before acceptance', async () => {
vi.useFakeTimers();
mockConnection.capabilities.features = ['session_generation'];
(
mockConnection as typeof mockConnection & {
tokenCount?: number;
contextWindow?: number;
}
).tokenCount = 600;
(
mockConnection as typeof mockConnection & {
tokenCount?: number;
contextWindow?: number;
}
).contextWindow = 1000;
testState.messages = Array.from({ length: 8 }, (_, index) => ({
id: `m-btw-attachment-${index}`,
role: index % 2 === 0 ? 'user' : 'assistant',
content: `existing session topic ${index} about daemon generation review work`,
timestamp: index,
}));
testState.prompt = '顺便看看这里为什么会报错?';
mockSessionActions.generateSessionContent.mockImplementation(
async function* () {
yield {
type: 'delta',
requestId: 'req-btw-attachment',
seq: 0,
text: JSON.stringify({
suggestion: 'btw',
confidence: 0.95,
}),
};
yield {
type: 'done',
requestId: 'req-btw-attachment',
model: 'fast-model',
modelSource: 'fast',
};
},
);
const { container } = renderApp();
await flush();
act(() => {
testState.latestChatEditorProps?.onInputTextChange?.(testState.prompt);
});
await flush();
act(() => {
vi.advanceTimersByTime(121);
});
await flush();
act(() => {
vi.advanceTimersByTime(701);
});
await flush();
expect(
container.querySelector('[data-testid="btw-suggestion"]'),
).not.toBeNull();
testState.inputAnnotations = [
{
type: 'reference',
text: '@src/App.tsx',
start: 0,
end: 12,
reference: {
id: 'src/App.tsx',
value: 'src/App.tsx',
serialized: '@src/App.tsx',
},
},
];
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="btw-suggestion-send"]')
?.click();
await Promise.resolve();
});
expect(mockSessionActions.btwSession).not.toHaveBeenCalled();
expect(editorCommit).not.toHaveBeenCalled();
});
it('waits for the current session to detach before auto-submitting the suggested new-session draft', async () => {
vi.useFakeTimers();
const clear = deferred<void>();
@ -5570,7 +5791,7 @@ describe('App session callbacks', () => {
requestId: 'req-2',
seq: 0,
text: JSON.stringify({
shouldSuggestNewSession: true,
suggestion: 'new_session',
confidence: 0.91,
}),
};
@ -5659,7 +5880,7 @@ describe('App session callbacks', () => {
requestId: 'req-stale',
seq: 0,
text: JSON.stringify({
shouldSuggestNewSession: true,
suggestion: 'new_session',
confidence: 0.91,
}),
};
@ -5747,7 +5968,7 @@ describe('App session callbacks', () => {
requestId: 'req-switch',
seq: 0,
text: JSON.stringify({
shouldSuggestNewSession: true,
suggestion: 'new_session',
confidence: 0.91,
}),
};

View file

@ -2618,6 +2618,9 @@ export function App({
null,
);
const [composerText, setComposerText] = useState('');
const [hasComposerAttachments, setHasComposerAttachments] = useState<
boolean | null
>(null);
const [isStartingNewSessionSuggestion, setIsStartingNewSessionSuggestion] =
useState(false);
const streamingState = useStreamingState();
@ -5184,6 +5187,13 @@ export function App({
}, 120);
}, []);
const handleComposerAttachmentsChange = useCallback(
(hasAttachments: boolean) => {
setHasComposerAttachments(hasAttachments);
},
[],
);
const {
suggestion: newSessionSuggestion,
dismiss: dismissNewSessionSuggestion,
@ -5200,6 +5210,7 @@ export function App({
: 0,
isRunning: streamingState !== 'idle',
dialogOpen: interactionBlocked || approvalOverlayActive,
hasAttachments: hasComposerAttachments,
generateContent: sessionActions.generateSessionContent,
});
@ -5263,7 +5274,11 @@ export function App({
const handleAcceptNewSessionSuggestion = useCallback(() => {
const draft = composerTextRef.current.trim();
if (!draft || isStartingNewSessionSuggestion) return;
if (newSessionSuggestion?.classifiedInput !== draft) {
if (
newSessionSuggestion?.suggestion !== 'new_session' ||
newSessionSuggestion.classifiedInput !== draft ||
newSessionSuggestion.sourceSessionId !== connectionRef.current.sessionId
) {
dismissNewSessionSuggestion();
return;
}
@ -5303,6 +5318,24 @@ export function App({
suppressNewSessionSuggestion,
]);
const handleAcceptBtwSuggestion = useCallback(() => {
const draft = composerTextRef.current.trim();
if (
!draft ||
newSessionSuggestion?.suggestion !== 'btw' ||
newSessionSuggestion.classifiedInput !== draft ||
newSessionSuggestion.sourceSessionId !==
connectionRef.current.sessionId ||
editorRef.current?.hasAttachments() !== false
) {
dismissNewSessionSuggestion();
return;
}
dismissNewSessionSuggestion();
editorRef.current?.submit({ text: `/btw ${draft}` });
editorRef.current?.focus();
}, [dismissNewSessionSuggestion, newSessionSuggestion]);
const shellApi = useMemo<WebShellApi>(
() => ({
openSplitView: () => {
@ -8653,7 +8686,11 @@ export function App({
<div
className={styles.composerActionTip}
role="status"
data-testid="new-session-suggestion"
data-testid={
newSessionSuggestion.suggestion === 'btw'
? 'btw-suggestion'
: 'new-session-suggestion'
}
>
<span
className={styles.composerActionTipIcon}
@ -8662,17 +8699,29 @@ export function App({
</span>
<span className={styles.composerActionTipText}>
{t('editor.newSessionSuggestionTitle')}
{newSessionSuggestion.suggestion === 'btw'
? t('editor.btwSuggestionTitle')
: t('editor.newSessionSuggestionTitle')}
</span>
<div className={styles.composerActionTipActions}>
<button
type="button"
className={`${styles.composerActionTipButton} ${styles.composerActionTipButtonPrimary}`}
data-testid="new-session-suggestion-start"
data-testid={
newSessionSuggestion.suggestion === 'btw'
? 'btw-suggestion-send'
: 'new-session-suggestion-start'
}
onMouseDown={(event) => event.preventDefault()}
onClick={handleAcceptNewSessionSuggestion}
onClick={
newSessionSuggestion.suggestion === 'btw'
? handleAcceptBtwSuggestion
: handleAcceptNewSessionSuggestion
}
>
{t('editor.newSessionSuggestionStart')}
{newSessionSuggestion.suggestion === 'btw'
? t('editor.btwSuggestionSend')
: t('editor.newSessionSuggestionStart')}
</button>
</div>
</div>
@ -8703,6 +8752,9 @@ export function App({
ref={setEditorHandle}
onSubmit={handleEditorSubmit}
onInputTextChange={handleComposerTextChange}
onAttachmentsChange={
handleComposerAttachmentsChange
}
onCycleMode={handleCycleMode}
onToggleShortcuts={handleToggleShortcuts}
onCancel={handleCancel}

View file

@ -24,6 +24,7 @@ Element.prototype.scrollIntoView = vi.fn();
const mockComposerCoreState = vi.hoisted(() => ({
composerTags: [] as WebShellComposerTag[],
pastedImages: [] as Array<{ data: string; media_type: string }>,
removeTopTag: vi.fn(),
}));
@ -129,6 +130,9 @@ vi.mock('../hooks/useComposerCore', async (importOriginal) => {
clearText: vi.fn(),
getText: vi.fn(() => ''),
hasInput: vi.fn(() => false),
hasAttachments:
mockComposerCoreState.pastedImages.length > 0 ||
mockComposerCoreState.composerTags.length > 0,
hasContent: false,
handle: {
focus: vi.fn(),
@ -139,8 +143,11 @@ vi.mock('../hooks/useComposerCore', async (importOriginal) => {
addTags: vi.fn(),
removeInlineTags: vi.fn(),
submit: vi.fn(),
hasAttachments: () =>
mockComposerCoreState.pastedImages.length > 0 ||
mockComposerCoreState.composerTags.length > 0,
},
pastedImages: [],
pastedImages: mockComposerCoreState.pastedImages,
removeImage: vi.fn(),
composerTags: mockComposerCoreState.composerTags,
removeTopTag: mockComposerCoreState.removeTopTag,
@ -224,11 +231,13 @@ afterEach(() => {
portalRoot.remove();
}
mockComposerCoreState.composerTags = [];
mockComposerCoreState.pastedImages = [];
mockComposerCoreState.removeTopTag.mockReset();
});
function renderChatEditor(props: {
composerTags?: WebShellComposerTag[];
pastedImages?: Array<{ data: string; media_type: string }>;
gitBranch?: string;
workspaceName?: string;
workspaceTitle?: string;
@ -240,10 +249,12 @@ function renderChatEditor(props: {
availableModels?: Array<{ id: string; label?: string }>;
onSelectMode?: (mode: string) => void;
onSelectModel?: (model: string) => void;
onAttachmentsChange?: (hasAttachments: boolean) => void;
customization?: WebShellCustomization;
}) {
const {
composerTags,
pastedImages,
customization,
renderComposerTagTooltip,
onComposerTagClick,
@ -252,6 +263,9 @@ function renderChatEditor(props: {
if (composerTags) {
mockComposerCoreState.composerTags = composerTags;
}
if (pastedImages) {
mockComposerCoreState.pastedImages = pastedImages;
}
const container = document.createElement('div');
container.dataset.webShellRoot = '';
const portalRoot = document.createElement('div');
@ -307,6 +321,36 @@ describe('ChatEditor voice toolbar integration', () => {
});
});
describe('ChatEditor attachment reporting', () => {
it('reports whether the composer has tags or pasted images', () => {
const onEmptyAttachmentsChange = vi.fn();
renderChatEditor({
onAttachmentsChange: onEmptyAttachmentsChange,
});
expect(onEmptyAttachmentsChange).toHaveBeenLastCalledWith(false);
const onTaggedAttachmentsChange = vi.fn();
renderChatEditor({
composerTags: [
{
id: 'file:reference',
kind: 'file',
value: 'reference',
},
],
onAttachmentsChange: onTaggedAttachmentsChange,
});
expect(onTaggedAttachmentsChange).toHaveBeenLastCalledWith(true);
const onImageAttachmentsChange = vi.fn();
renderChatEditor({
pastedImages: [{ data: 'abc', media_type: 'image/png' }],
onAttachmentsChange: onImageAttachmentsChange,
});
expect(onImageAttachmentsChange).toHaveBeenLastCalledWith(true);
});
});
describe('ChatEditor composer tag icons', () => {
it('renders built-in icons for top composer tags', () => {
const kinds = ['extension', 'file', 'mcp', 'skill'] as const;

View file

@ -116,6 +116,7 @@ interface ChatEditorProps {
metadata?: ComposerSubmitMetadata,
) => boolean | void;
onInputTextChange?: (text: string) => void;
onAttachmentsChange?: (hasAttachments: boolean) => void;
onCycleMode?: () => void;
onToggleShortcuts?: () => void;
onCancel?: () => void;
@ -1161,6 +1162,7 @@ export const ChatEditor = memo(
const {
onSubmit,
onInputTextChange,
onAttachmentsChange,
onCycleMode,
onToggleShortcuts,
onCancel,
@ -1268,6 +1270,10 @@ export const ChatEditor = memo(
useImperativeHandle(ref, () => core.handle, [core.handle]);
useEffect(() => {
onAttachmentsChange?.(core.hasAttachments);
}, [core.hasAttachments, onAttachmentsChange]);
const [modeDropdownOpen, setModeDropdownOpen] = useState(false);
const [modelDropdownOpen, setModelDropdownOpen] = useState(false);
const [quickActionsOpen, setQuickActionsOpen] = useState(false);

View file

@ -880,6 +880,28 @@ describe('useComposerCore tags', () => {
).toHaveLength(kinds.length);
});
it('reports inline composer tags as attachments', async () => {
await mount();
expect(latest!.handle.hasAttachments()).toBe(false);
expect(latest!.hasAttachments).toBe(false);
act(() => {
latest!.addTags(
[{ id: 'orders', value: 'orders', serialized: '@orders' }],
{ placement: 'inline' },
);
});
expect(latest!.handle.hasAttachments()).toBe(true);
expect(latest!.hasAttachments).toBe(true);
act(() => {
latest!.removeInlineTags();
});
expect(latest!.handle.hasAttachments()).toBe(false);
expect(latest!.hasAttachments).toBe(false);
});
it('keeps inline tags after trimming leading whitespace on submit', async () => {
const { onSubmit } = await mount();

View file

@ -961,6 +961,7 @@ export interface EditorHandle extends WebShellComposerApi {
clearText(): void;
focus(): void;
getText(): string;
hasAttachments(): boolean;
hasInput(): boolean;
retryLast(): void;
restoreImages(images: readonly PromptImage[]): void;
@ -1268,6 +1269,7 @@ export interface UseComposerCoreReturn {
clearText: () => void;
getText: () => string;
hasInput: () => boolean;
hasAttachments: boolean;
hasContent: boolean;
handle: EditorHandle;
pastedImages: PromptImage[];
@ -1607,6 +1609,7 @@ export function useComposerCore(
const [composerTags, setComposerTags] = useState<WebShellComposerTag[]>([]);
const composerTagsRef = useRef<WebShellComposerTag[]>([]);
composerTagsRef.current = composerTags;
const [hasInlineTags, setHasInlineTags] = useState(false);
const historyDraftComposerTagsRef = useRef<WebShellComposerTag[] | null>(
null,
);
@ -2825,6 +2828,7 @@ export function useComposerCore(
triggerCleanupListener,
// Update hasContent state when document changes
EditorView.updateListener.of((update) => {
setHasInlineTags(getInlineComposerTags(update.view).length > 0);
if (update.docChanged) {
const text = getDocText(update.state);
if (draftIdentityRef.current.storageKey === undefined) {
@ -3553,6 +3557,17 @@ export function useComposerCore(
);
}, [isTouchComposer]);
const hasAttachments = useCallback(() => {
const inlineTags = viewRef.current
? getInlineComposerTags(viewRef.current)
: [];
return (
inlineTags.length > 0 ||
composerTagsRef.current.length > 0 ||
pastedImagesRef.current.length > 0
);
}, []);
const submit = useCallback(
(input?: WebShellComposerInput) => {
const view = viewRef.current;
@ -3856,6 +3871,7 @@ export function useComposerCore(
clear,
focus,
getText,
hasAttachments,
hasInput,
setText,
addTags,
@ -3871,6 +3887,7 @@ export function useComposerCore(
clearText,
focus,
getText,
hasAttachments,
hasInput,
insertText,
removeTopTag,
@ -3902,6 +3919,8 @@ export function useComposerCore(
clearText,
getText,
hasInput,
hasAttachments:
hasInlineTags || composerTags.length > 0 || pastedImages.length > 0,
hasContent,
handle,
pastedImages,

View file

@ -29,6 +29,7 @@ const testState = {
contextUsageRatio: 0,
isRunning: false,
dialogOpen: false,
hasAttachments: false as boolean | null,
generateContent: vi.fn(async function* () {}),
};
@ -78,6 +79,7 @@ afterEach(async () => {
testState.contextUsageRatio = 0;
testState.isRunning = false;
testState.dialogOpen = false;
testState.hasAttachments = false;
testState.generateContent.mockReset();
vi.useRealTimers();
});
@ -129,7 +131,7 @@ describe('useNewSessionSuggestion', () => {
requestId: 'req-1',
seq: 0,
text: JSON.stringify({
shouldSuggestNewSession: true,
suggestion: 'new_session',
confidence: 0.9,
}),
};
@ -149,8 +151,9 @@ describe('useNewSessionSuggestion', () => {
expect(testState.generateContent).toHaveBeenCalledOnce();
expect(latestSuggestion).toEqual({
isVisible: true,
suggestion: 'new_session',
classifiedInput: '帮我写一篇新的设计文档,主题是 Web Shell 新功能方案',
sourceSessionId: 'session-1',
});
testState.inputText = '顺手补个测试';
@ -182,10 +185,14 @@ describe('useNewSessionSuggestion', () => {
},
] as Message[];
async function classify(decisionText: string) {
async function classify(
decisionText: string,
inputText = NEW_TASK_DRAFT,
messages = CONTEXT_MESSAGES,
) {
vi.useFakeTimers();
testState.inputText = NEW_TASK_DRAFT;
testState.messages = CONTEXT_MESSAGES;
testState.inputText = inputText;
testState.messages = messages;
testState.generateContent.mockImplementation(async function* () {
yield {
type: 'delta',
@ -208,44 +215,137 @@ describe('useNewSessionSuggestion', () => {
await flush(3);
}
it('classifies a side question after only one prior exchange', async () => {
const sideQuestion = '这里的 confidence 阈值为什么是 0.75';
await classify(
JSON.stringify({ suggestion: 'btw', confidence: 0.92 }),
sideQuestion,
);
expect(testState.generateContent).toHaveBeenCalledOnce();
expect(latestSuggestion).toEqual({
suggestion: 'btw',
classifiedInput: sideQuestion,
sourceSessionId: 'session-1',
});
});
it('lets common side-question wording reach the classifier', async () => {
const sideQuestion = '顺手问下,这里的 confidence 阈值为什么是 0.75';
await classify(
JSON.stringify({ suggestion: 'btw', confidence: 0.9 }),
sideQuestion,
);
expect(testState.generateContent).toHaveBeenCalledOnce();
expect(latestSuggestion?.suggestion).toBe('btw');
});
it('does not surface new_session from the relaxed BTW context floor', async () => {
await classify(
JSON.stringify({ suggestion: 'new_session', confidence: 0.96 }),
'这里的 confidence 阈值为什么是 0.75',
);
expect(testState.generateContent).toHaveBeenCalledOnce();
expect(latestSuggestion).toBeNull();
});
it('does not classify BTW with less than one prior exchange', async () => {
await classify(
JSON.stringify({ suggestion: 'btw', confidence: 0.96 }),
'这里的 confidence 阈值为什么是 0.75',
CONTEXT_MESSAGES.slice(0, 1),
);
expect(testState.generateContent).not.toHaveBeenCalled();
expect(latestSuggestion).toBeNull();
});
it.each([true, null])(
'does not classify a low-context side question when attachment presence is %s',
async (hasAttachments) => {
testState.hasAttachments = hasAttachments;
await classify(
JSON.stringify({ suggestion: 'btw', confidence: 0.96 }),
'这里的 confidence 阈值为什么是 0.75',
);
expect(testState.generateContent).not.toHaveBeenCalled();
expect(latestSuggestion).toBeNull();
},
);
it('recovers a positive decision wrapped in prose (observed live)', async () => {
// Verbatim shape from a live run: prose preamble + bare JSON.
await classify(
'The user is explicitly switching to a completely new task, which is ' +
'unrelated to the previous discussion. This is a clear topic change.\n\n' +
JSON.stringify({ shouldSuggestNewSession: true, confidence: 0.98 }),
JSON.stringify({ suggestion: 'new_session', confidence: 0.98 }),
);
expect(testState.generateContent).toHaveBeenCalledOnce();
expect(latestSuggestion).toEqual({
isVisible: true,
suggestion: 'new_session',
classifiedInput: NEW_TASK_DRAFT,
sourceSessionId: 'session-1',
});
});
it('recovers a positive decision inside a code fence', async () => {
await classify(
'```json\n' +
JSON.stringify({ shouldSuggestNewSession: true, confidence: 0.95 }) +
JSON.stringify({ suggestion: 'new_session', confidence: 0.95 }) +
'\n```',
);
expect(latestSuggestion).toEqual({
isVisible: true,
suggestion: 'new_session',
classifiedInput: NEW_TASK_DRAFT,
sourceSessionId: 'session-1',
});
});
it('keeps the banner hidden for a prose-wrapped negative decision', async () => {
it('keeps the banner hidden for a valid none decision', async () => {
await classify(
'This is a follow-up on the same topic.\n\n' +
JSON.stringify({ shouldSuggestNewSession: false, confidence: 0.97 }),
JSON.stringify({ suggestion: 'none', confidence: 0.97 }),
);
expect(testState.generateContent).toHaveBeenCalledOnce();
expect(latestSuggestion).toBeNull();
});
it('suggests BTW for a side question without attachments', async () => {
await classify(JSON.stringify({ suggestion: 'btw', confidence: 0.92 }));
expect(latestSuggestion).toEqual({
suggestion: 'btw',
classifiedInput: NEW_TASK_DRAFT,
sourceSessionId: 'session-1',
});
});
it.each([true, null])(
'does not suggest BTW when attachment presence is %s',
async (hasAttachments) => {
testState.hasAttachments = hasAttachments;
await classify(JSON.stringify({ suggestion: 'btw', confidence: 0.92 }));
expect(latestSuggestion).toBeNull();
},
);
it.each([
JSON.stringify({ shouldSuggestNewSession: true, confidence: 0.98 }),
JSON.stringify({ suggestion: 'later', confidence: 0.98 }),
JSON.stringify({ suggestion: 'btw', confidence: 1.1 }),
])('stays fail-closed for an invalid decision: %s', async (decision) => {
await classify(decision);
expect(latestSuggestion).toBeNull();
});
it('stays fail-closed on prose with no recoverable JSON object', async () => {
await classify(
'I think {this draft} switches topics, but here is no JSON to parse.',

View file

@ -3,6 +3,7 @@ import type { Message } from '../adapters/types';
import type { DaemonSessionActions } from '@qwen-code/webui/daemon-react-sdk';
const MIN_PROMPT_LENGTH = 12;
const MIN_BTW_MESSAGE_COUNT = 2;
const MIN_MESSAGE_COUNT = 8;
const MIN_CONTEXT_USAGE_RATIO = 0.35;
const MIN_EXPLICIT_CUE_MESSAGE_COUNT = 2;
@ -50,14 +51,17 @@ const EXPLICIT_NEW_TASK_PATTERNS = [
/brainstorm/i,
];
interface TopicShiftDecision {
shouldSuggestNewSession: boolean;
type SuggestionKind = 'btw' | 'new_session' | 'none';
interface ComposerSuggestionDecision {
suggestion: SuggestionKind;
confidence: number;
}
export interface NewSessionSuggestionState {
isVisible: boolean;
suggestion: Exclude<SuggestionKind, 'none'>;
classifiedInput: string;
sourceSessionId: string;
}
export interface UseNewSessionSuggestionOptions {
@ -68,6 +72,7 @@ export interface UseNewSessionSuggestionOptions {
contextUsageRatio: number;
isRunning: boolean;
dialogOpen: boolean;
hasAttachments: boolean | null;
generateContent?: DaemonSessionActions['generateSessionContent'];
}
@ -114,16 +119,20 @@ function buildPrompt(params: {
currentInput: string;
contextUsageRatio: number;
messageCount: number;
allowBtw: boolean;
allowNewSession: boolean;
}): string {
const recent = params.recentMessages
.map((message, index) => `${index + 1}. ${message.role}: ${message.text}`)
.join('\n');
return [
"You are deciding whether a user's new message still belongs in the current coding session.",
'Suggest starting a new session only when the new message is clearly a different task or topic, and continuing in the current session would likely add context noise or wasted token usage.',
'Be conservative. When in doubt, keep the current session.',
'Do NOT suggest a new session for follow-up questions, implementation continuations, debugging iterations, review follow-ups, or adjacent design discussion about the same repo, PR, bug, or feature.',
'Return JSON only with keys: shouldSuggestNewSession (boolean) and confidence (0-1 number).',
"You are deciding how a user's new message should be handled in the current coding session.",
'Choose "new_session" only when the message is clearly a different task or topic and continuing here would add context noise.',
'Choose "btw" only for a brief side question that can be answered without changing the main task or adding its answer to the main conversation context.',
'Choose "none" for follow-ups, implementation continuations, debugging iterations, review follow-ups, and adjacent discussion about the same repo, PR, bug, or feature.',
'Be conservative. When in doubt, choose "none".',
`Allowed actions: btw=${params.allowBtw ? 'yes' : 'no'}, new_session=${params.allowNewSession ? 'yes' : 'no'}. Never choose an action marked no.`,
'Return JSON only with keys: suggestion ("btw", "new_session", or "none") and confidence (0-1 number).',
'',
`Context usage ratio: ${params.contextUsageRatio.toFixed(2)}`,
`Visible message count: ${params.messageCount}`,
@ -136,13 +145,26 @@ function buildPrompt(params: {
].join('\n');
}
function tryParseDecision(text: string): TopicShiftDecision | null {
function tryParseDecision(text: string): ComposerSuggestionDecision | null {
try {
const parsed = JSON.parse(text) as Partial<TopicShiftDecision>;
if (typeof parsed.shouldSuggestNewSession !== 'boolean') return null;
if (typeof parsed.confidence !== 'number') return null;
const parsed = JSON.parse(text) as Partial<ComposerSuggestionDecision>;
if (
parsed.suggestion !== 'btw' &&
parsed.suggestion !== 'new_session' &&
parsed.suggestion !== 'none'
) {
return null;
}
if (
typeof parsed.confidence !== 'number' ||
!Number.isFinite(parsed.confidence) ||
parsed.confidence < 0 ||
parsed.confidence > 1
) {
return null;
}
return {
shouldSuggestNewSession: parsed.shouldSuggestNewSession,
suggestion: parsed.suggestion,
confidence: parsed.confidence,
};
} catch {
@ -150,7 +172,7 @@ function tryParseDecision(text: string): TopicShiftDecision | null {
}
}
function parseDecision(text: string): TopicShiftDecision | null {
function parseDecision(text: string): ComposerSuggestionDecision | null {
const direct = tryParseDecision(text);
if (direct) return direct;
// Despite the JSON-only instruction, the model sometimes wraps a perfectly
@ -176,6 +198,7 @@ export function useNewSessionSuggestion({
contextUsageRatio,
isRunning,
dialogOpen,
hasAttachments,
generateContent,
}: UseNewSessionSuggestionOptions): UseNewSessionSuggestionReturn {
const [suggestion, setSuggestion] =
@ -226,22 +249,22 @@ export function useNewSessionSuggestion({
setSuggestion(null);
return;
}
if (isFollowupLike(trimmed)) {
setSuggestion(null);
return;
}
const explicitNewTaskCue = hasExplicitNewTaskCue(trimmed);
if (isRunning || dialogOpen) {
setSuggestion(null);
return;
}
if (
explicitNewTaskCue
? recentMessages.length < MIN_EXPLICIT_CUE_MESSAGE_COUNT &&
contextUsageRatio < MIN_EXPLICIT_CUE_CONTEXT_USAGE_RATIO
: recentMessages.length < MIN_MESSAGE_COUNT &&
contextUsageRatio < MIN_CONTEXT_USAGE_RATIO
) {
const allowBtw =
hasAttachments === false &&
recentMessages.length >= MIN_BTW_MESSAGE_COUNT;
const allowNewSession =
!isFollowupLike(trimmed) &&
(explicitNewTaskCue
? recentMessages.length >= MIN_EXPLICIT_CUE_MESSAGE_COUNT ||
contextUsageRatio >= MIN_EXPLICIT_CUE_CONTEXT_USAGE_RATIO
: recentMessages.length >= MIN_MESSAGE_COUNT ||
contextUsageRatio >= MIN_CONTEXT_USAGE_RATIO);
if (!allowBtw && !allowNewSession) {
setSuggestion(null);
return;
}
@ -259,6 +282,8 @@ export function useNewSessionSuggestion({
currentInput: trimmed,
contextUsageRatio,
messageCount: recentMessages.length,
allowBtw,
allowNewSession,
});
void (async () => {
let text = '';
@ -280,10 +305,16 @@ export function useNewSessionSuggestion({
const decision = parseDecision(text.trim());
if (
decision &&
decision.shouldSuggestNewSession &&
decision.suggestion !== 'none' &&
((decision.suggestion === 'btw' && allowBtw) ||
(decision.suggestion === 'new_session' && allowNewSession)) &&
decision.confidence >= MIN_CONFIDENCE
) {
setSuggestion({ isVisible: true, classifiedInput: trimmed });
setSuggestion({
suggestion: decision.suggestion,
classifiedInput: trimmed,
sourceSessionId: sessionId,
});
return;
}
setSuggestion(null);
@ -308,6 +339,7 @@ export function useNewSessionSuggestion({
dialogOpen,
enabled,
generateContent,
hasAttachments,
inputText,
isRunning,
recentMessages,

View file

@ -881,6 +881,8 @@ const EN: Messages = {
'editor.searchPlaceholder': 'type to search...',
'editor.newSessionSuggestionTitle': 'This looks like a new topic',
'editor.newSessionSuggestionStart': 'Send in new session',
'editor.btwSuggestionTitle': 'This looks like a side question',
'editor.btwSuggestionSend': 'Ask with BTW',
'quickActions.open': 'more actions',
'quickActions.title': 'more actions',
'quickActions.mcp': 'MCP',
@ -3353,6 +3355,8 @@ const ZH: Messages = {
'editor.searchPlaceholder': '输入以搜索…',
'editor.newSessionSuggestionTitle': '这条消息看起来像新话题',
'editor.newSessionSuggestionStart': '在新会话发送',
'editor.btwSuggestionTitle': '这条消息看起来像顺带一问',
'editor.btwSuggestionSend': '用 BTW 提问',
'quickActions.open': '更多操作',
'quickActions.title': '更多操作',
'quickActions.mcp': 'MCP',

View file

@ -34,11 +34,7 @@ export function buildPullRequestQuery(numbers) {
pr${index}: pullRequest(number: ${number}) {
number
body
additions
deletions
changedFiles
labels(first: 20) { nodes { name } }
files(first: 40) { nodes { path } }
}`,
)
.join('\n');
@ -262,14 +258,7 @@ function compactEntry(entry) {
return {
number: entry.number,
title: entry.title,
body: (entry.body || '').slice(0, 3000),
labels: (entry.labels || []).map((label) =>
typeof label === 'string' ? label : label.name,
),
files: (entry.files || []).slice(0, 40),
additions: entry.additions,
deletions: entry.deletions,
changedFiles: entry.changedFiles,
body: (entry.body || '').slice(0, 700),
category: classifyChange(entry),
};
}
@ -375,15 +364,10 @@ export function enrichEntries(entries, metadata) {
const byNumber = new Map(metadata.map((item) => [item.number, item]));
return entries.map((entry) => {
const details = byNumber.get(entry.number) || {};
const files = details.files?.nodes || details.files || [];
return {
...entry,
body: details.body || '',
labels: details.labels?.nodes || details.labels || [],
files: files.map((file) => (typeof file === 'string' ? file : file.path)),
additions: details.additions || 0,
deletions: details.deletions || 0,
changedFiles: details.changedFiles || files.length,
};
});
}
@ -468,6 +452,7 @@ export function createOpenAiCompleter({
if (remainingMs <= 0) {
throw deadlineError();
}
const attemptStartedAt = Date.now();
try {
const response = await fetchImpl(endpoint, {
method: 'POST',
@ -495,10 +480,16 @@ export function createOpenAiCompleter({
if (typeof content !== 'string' || !content.trim()) {
throw new Error(CONTENT_VALIDATION_ERROR_MESSAGE);
}
console.error(
`Model ${request.kind} request succeeded in ${Date.now() - attemptStartedAt}ms (prompt ${prompt.user.length} chars).`,
);
return content;
} catch (error) {
lastError = error;
attempt += 1;
console.error(
`Model ${request.kind} request failed after ${Date.now() - attemptStartedAt}ms (prompt ${prompt.user.length} chars): ${escapeWorkflowCommand(error.message)}`,
);
if (Date.now() >= deadline) {
throw deadlineError();
}

View file

@ -39,10 +39,6 @@ const entry = (number, title, labels = []) => ({
author: 'alice',
labels,
body: '',
files: [],
additions: 1,
deletions: 0,
changedFiles: 1,
});
describe('parseGeneratedEntries', () => {
@ -240,6 +236,34 @@ describe('generateAiContent', () => {
]);
});
it('sends only title, a bounded body excerpt, and category to the model', async () => {
const long = { ...entry(1, 'feat: long body'), body: 'x'.repeat(5000) };
const calls = [];
const complete = async (request) => {
calls.push(request);
if (request.kind === 'summaries') {
return JSON.stringify({
summaries: request.entries.map((item) => ({
pr: item.number,
summary: 'Summary.',
})),
});
}
return JSON.stringify({ highlights: [] });
};
await generateAiContent([long], complete);
const [payload] = calls[0].entries;
expect(Object.keys(payload).sort()).toEqual([
'body',
'category',
'number',
'title',
]);
expect(payload.body).toHaveLength(700);
});
it('falls back to original titles for an invalid summary batch', async () => {
const entries = [entry(1, 'feat: original'), entry(2, 'fix: original')];
const complete = async (request) => {
@ -359,17 +383,13 @@ describe('enrichEntries', () => {
number: 1,
body: 'Why it matters.',
labels: [{ name: 'type/bug' }],
files: [{ path: 'packages/core/a.ts' }],
additions: 3,
deletions: 2,
changedFiles: 1,
},
]);
expect(enriched.map((item) => item.number)).toEqual([2, 1]);
expect(enriched[0].body).toBe('');
expect(enriched[1].body).toBe('Why it matters.');
expect(enriched[1].files).toEqual(['packages/core/a.ts']);
expect(enriched[1].labels).toEqual([{ name: 'type/bug' }]);
});
});
@ -379,7 +399,8 @@ describe('buildPullRequestQuery', () => {
expect(query).toContain('pr0: pullRequest(number: 12)');
expect(query).toContain('pr1: pullRequest(number: 8)');
expect(query).toContain('files(first: 40)');
expect(query).toContain('labels(first: 20)');
expect(query).not.toContain('files(first: 40)');
expect(query).not.toContain('pullRequest(number: undefined)');
});
});
@ -480,7 +501,7 @@ describe('generateReleaseNotes', () => {
' process.exit(0);',
'}',
"if (args[0] === 'api' && args[1] === 'graphql') {",
" process.stdout.write(JSON.stringify({ data: { repository: { pr0: { number: 1, body: 'Body.', additions: 1, deletions: 0, changedFiles: 1, labels: { nodes: [] }, files: { nodes: [] } } } } }));",
" process.stdout.write(JSON.stringify({ data: { repository: { pr0: { number: 1, body: 'Body.', labels: { nodes: [] } } } } }));",
' process.exit(0);',
'}',
'process.exit(1);',

View file

@ -2289,13 +2289,14 @@ describe('qwen-autofix workflow', () => {
'--json headRefName,headRefOid,statusCheckRollup,createdAt,labels',
);
// Command-style comments are instructions, not feedback — excluded at
// ALL FOUR feedback sites (scan count via $cf; NEWEST, LIVE_NEW, and
// the renderer inline) so /triage-, /review-, and /takeover-style
// ALL FIVE feedback sites (scan count via $cf; NEWEST, LIVE_NEW,
// Critical-only deferral rendering, and the renderer inline) so /triage-,
// /review-, and /takeover-style
// invocations never burn an agent cycle on a no-action report.
expect(reviewScanJob).toContain("COMMAND_FILTER='^\\s*@qwen-code /'");
expect(reviewScanJob).toContain('test($cf) | not');
expect(workflow.split('test("^\\\\s*@qwen-code /") | not').length - 1).toBe(
3,
4,
);
});
@ -3204,25 +3205,420 @@ describe('qwen-autofix workflow', () => {
expect(broken.body).toBe('');
});
it('treats Suggestion-level review findings as actionable feedback', () => {
// AGENTS.md: Suggestions ARE addressed during a PR's first ~5 review
// rounds; only past that are they deferred with a recorded reason. The
// loop's MAX_ROUNDS cap is that same boundary, so every round the loop
// runs is within the address-Suggestions window — the scan and the
// feedback rendering must NOT filter `**[Suggestion]**` /review comments.
expect(workflow).not.toContain('QWEN_SUGGESTION_FILTER');
// The filter REGEX (escaped form only ever appears in filter code, not in
// prose comments) must be gone from both the scan and the feedback render.
expect(workflow).not.toContain('\\*\\*\\[Suggestion\\]\\*\\*');
// The agent-facing policy lives in the SKILL: implement valuable
// suggestions, decline only with a recorded per-finding reason.
it('switches to Critical-only feedback after five change rounds', () => {
// ROUND counts change-producing rounds, so 4 still starts the fifth
// suggestion-capable change while 5 starts the first Critical-only round.
expect(workflow).toContain("CRITICAL_ONLY_AFTER_ROUND: '5'");
expect(prepareBranchAndFeedbackStep).toContain(
'[[ "${ROUND}" -ge "${CRITICAL_ONLY_AFTER_ROUND}" ]]',
);
const modeBlock = prepareBranchAndFeedbackStep.match(
/(CRITICAL_ONLY='false'\n\s+if \[\[ "\$\{ROUND\}" -ge "\$\{CRITICAL_ONLY_AFTER_ROUND\}" \]\]; then\n\s+CRITICAL_ONLY='true'\n\s+fi)/,
)?.[1];
expect(modeBlock).toBeTruthy();
const modeAt = (round) =>
execFileSync(
'bash',
[
'-c',
`ROUND=${round}\nCRITICAL_ONLY_AFTER_ROUND=5\n${modeBlock}\nprintf '%s' "$CRITICAL_ONLY"`,
],
{ encoding: 'utf8' },
);
expect(modeAt(4)).toBe('false');
expect(modeAt(5)).toBe('true');
// Once the boundary is crossed, only an explicit Critical inline finding
// or a formal changes-requested review is actionable. Suggestion and
// unclassified comments stay open instead of driving more code changes.
expect(prepareBranchAndFeedbackStep).toContain('CRITICAL_ONLY');
expect(prepareBranchAndFeedbackStep).toContain('**[Critical]**');
const inlineFilter = prepareBranchAndFeedbackStep.match(
/echo "## Inline comments"[\s\S]*?jq -rs --arg wm "\$\{WATERMARK\}"[\s\S]*?--slurpfile reviews "\$\{WORKDIR\}\/rv\.json" '([\s\S]*?)' \\\n\s+"\$\{WORKDIR\}\/rc\.json"/,
)?.[1];
expect(inlineFilter).toBeTruthy();
const inlineFeedback = [
{
id: 10,
created_at: '2025-12-31T00:00:00Z',
user: { login: 'qwen-code-ci-bot' },
author_association: 'NONE',
body: '**[Critical]** stale owner routes writes to the wrong runtime',
},
{
id: 11,
created_at: '2026-01-02T00:00:00Z',
user: { login: 'qwen-code-ci-bot' },
author_association: 'NONE',
body: '**[Critical]** wrong workspace is mutated',
},
{
id: 12,
created_at: '2026-01-02T00:00:01Z',
user: { login: 'qwen-code-ci-bot' },
author_association: 'NONE',
body: '**[Suggestion]** add an aria-label',
},
{
id: 13,
created_at: '2026-01-02T00:00:02Z',
user: { login: 'maintainer' },
author_association: 'MEMBER',
body: 'Could this helper be renamed?',
},
{
id: 14,
in_reply_to_id: 10,
created_at: '2026-01-02T00:00:03Z',
user: { login: 'maintainer' },
author_association: 'MEMBER',
body: 'This still routes through the legacy primary.',
},
{
id: 15,
pull_request_review_id: 20,
created_at: '2026-01-02T00:00:04Z',
user: { login: 'maintainer' },
author_association: 'MEMBER',
body: 'The null branch still crashes.',
},
];
const reviews = [
{
id: 20,
state: 'CHANGES_REQUESTED',
},
];
const countInline = (criticalOnly) =>
Number(
execFileSync(
'jq',
[
'-s',
'--arg',
'wm',
'2026-01-01T00:00:00Z',
'--arg',
'rb',
'qwen-code-ci-bot',
'--arg',
'ab',
'qwen-code-dev-bot',
'--argjson',
'critical_only',
String(criticalOnly),
'--argjson',
'trust',
'["OWNER","MEMBER","COLLABORATOR"]',
'--argjson',
'reviews',
JSON.stringify([reviews]),
`[\n${inlineFilter}\n] | length`,
],
{
encoding: 'utf8',
input: JSON.stringify(inlineFeedback),
},
),
);
expect(countInline(false)).toBe(5);
expect(countInline(true)).toBe(3);
// Actionable reviews and issue-level comments filters: extract and
// execute against fixture data with critical_only both ways, mirroring
// the inline filter test above.
const actionableReviewsFilter = prepareBranchAndFeedbackStep.match(
/echo "## Reviews"[\s\S]*?jq -r --arg wm "\$\{WATERMARK\}" --arg rb "\$\{REVIEW_BOT\}" --arg ab "\$\{AUTOFIX_BOT\}" \\\n\s+--argjson critical_only "\$\{CRITICAL_ONLY\}" --argjson trust "\$\{TRUSTED_ASSOC\}" '([\s\S]*?)' \\\n\s+"\$\{WORKDIR\}\/rv\.json"/,
)?.[1];
expect(actionableReviewsFilter).toBeTruthy();
const actionableReviews = [
{
id: 20,
state: 'CHANGES_REQUESTED',
submitted_at: '2026-01-02T00:00:00Z',
user: { login: 'maintainer' },
author_association: 'MEMBER',
body: 'The null branch still crashes.',
},
{
id: 21,
state: 'COMMENTED',
submitted_at: '2026-01-02T00:00:01Z',
user: { login: 'qwen-code-ci-bot' },
author_association: 'NONE',
body: 'Looks good overall',
},
{
id: 22,
state: 'COMMENTED',
submitted_at: '2026-01-02T00:00:02Z',
user: { login: 'qwen-code-ci-bot' },
author_association: 'NONE',
body: '**[Critical]** memory leak in the owner route',
},
];
const countActionableReviews = (criticalOnly) =>
Number(
execFileSync(
'jq',
[
'--arg',
'wm',
'2026-01-01T00:00:00Z',
'--arg',
'rb',
'qwen-code-ci-bot',
'--arg',
'ab',
'qwen-code-dev-bot',
'--argjson',
'critical_only',
String(criticalOnly),
'--argjson',
'trust',
'["OWNER","MEMBER","COLLABORATOR"]',
`[${actionableReviewsFilter}] | length`,
],
{ encoding: 'utf8', input: JSON.stringify(actionableReviews) },
),
);
// All three are actionable while suggestions are in scope; in
// Critical-only mode the non-Critical COMMENTED review is excluded.
expect(countActionableReviews(false)).toBe(3);
expect(countActionableReviews(true)).toBe(2);
const actionableIssueFilter = prepareBranchAndFeedbackStep.match(
/echo "## Issue-level comments"[\s\S]*?jq -r --arg wm "\$\{WATERMARK\}" --arg rb "\$\{REVIEW_BOT\}" --arg ab "\$\{AUTOFIX_BOT\}" \\\n\s+--argjson critical_only "\$\{CRITICAL_ONLY\}" --argjson trust "\$\{TRUSTED_ASSOC\}" '([\s\S]*?)' \\\n\s+"\$\{WORKDIR\}\/ic\.json"/,
)?.[1];
expect(actionableIssueFilter).toBeTruthy();
const actionableIssueComments = [
{
id: 30,
created_at: '2026-01-02T00:00:00Z',
user: { login: 'maintainer' },
author_association: 'MEMBER',
body: 'Please also update the docs.',
},
{
id: 31,
created_at: '2026-01-02T00:00:01Z',
user: { login: 'qwen-code-ci-bot' },
author_association: 'NONE',
body: '**[Critical]** data loss on concurrent writes',
},
{
id: 32,
created_at: '2026-01-02T00:00:02Z',
user: { login: 'maintainer' },
author_association: 'MEMBER',
body: '@qwen-code /review',
},
];
const countActionableIssue = (criticalOnly) =>
Number(
execFileSync(
'jq',
[
'--arg',
'wm',
'2026-01-01T00:00:00Z',
'--arg',
'rb',
'qwen-code-ci-bot',
'--arg',
'ab',
'qwen-code-dev-bot',
'--argjson',
'critical_only',
String(criticalOnly),
'--argjson',
'trust',
'["OWNER","MEMBER","COLLABORATOR"]',
`[${actionableIssueFilter}] | length`,
],
{ encoding: 'utf8', input: JSON.stringify(actionableIssueComments) },
),
);
// Normal and Critical comments are actionable while suggestions are in
// scope; the command-style comment is always excluded. In Critical-only
// mode, only the Critical comment remains.
expect(countActionableIssue(false)).toBe(2);
expect(countActionableIssue(true)).toBe(1);
// Deferred queries: extract and execute against fixture data,
// mirroring the actionable inline filter test above.
const deferredReviewsFilter = prepareBranchAndFeedbackStep.match(
/## Deferred non-Critical feedback[\s\S]*?jq -r --arg wm "\$\{WATERMARK\}" --arg rb "\$\{REVIEW_BOT\}" --arg ab "\$\{AUTOFIX_BOT\}" \\\n\s+--argjson trust "\$\{TRUSTED_ASSOC\}" --arg pr_url "\$\{PR_URL\}" '([\s\S]*?)' \\\n\s+"\$\{WORKDIR\}\/rv\.json"/,
)?.[1];
expect(deferredReviewsFilter).toBeTruthy();
const deferredReviews = [
...reviews,
{
id: 21,
state: 'COMMENTED',
submitted_at: '2026-01-02T00:00:00Z',
user: { login: 'qwen-code-ci-bot' },
author_association: 'NONE',
body: 'Looks good overall',
html_url: 'https://github.com/test/pull/1#review-21',
},
{
id: 22,
state: 'COMMENTED',
submitted_at: '2026-01-02T00:00:01Z',
user: { login: 'qwen-code-ci-bot' },
author_association: 'NONE',
body: '**[Critical]** memory leak in the owner route',
html_url: 'https://github.com/test/pull/1#review-22',
},
];
const countDeferredReviews = Number(
execFileSync(
'jq',
[
'--arg',
'wm',
'2026-01-01T00:00:00Z',
'--arg',
'rb',
'qwen-code-ci-bot',
'--arg',
'ab',
'qwen-code-dev-bot',
'--argjson',
'trust',
'["OWNER","MEMBER","COLLABORATOR"]',
'--arg',
'pr_url',
'https://github.com/test/pull/1',
`[${deferredReviewsFilter}] | length`,
],
{ encoding: 'utf8', input: JSON.stringify(deferredReviews) },
),
);
// COMMENTED non-Critical review is deferred; CHANGES_REQUESTED and
// COMMENTED Critical reviews are not.
expect(countDeferredReviews).toBe(1);
const deferredInlineFilter = prepareBranchAndFeedbackStep.match(
/jq -rs --arg wm "\$\{WATERMARK\}" --arg rb "\$\{REVIEW_BOT\}" --arg ab "\$\{AUTOFIX_BOT\}" \\\n\s+--argjson trust "\$\{TRUSTED_ASSOC\}" --arg pr_url "\$\{PR_URL\}" \\\n\s+--slurpfile reviews "\$\{WORKDIR\}\/rv\.json" '([\s\S]*?)' \\\n\s+"\$\{WORKDIR\}\/rc\.json"/,
)?.[1];
expect(deferredInlineFilter).toBeTruthy();
const countDeferredInline = Number(
execFileSync(
'jq',
[
'-s',
'--arg',
'wm',
'2026-01-01T00:00:00Z',
'--arg',
'rb',
'qwen-code-ci-bot',
'--arg',
'ab',
'qwen-code-dev-bot',
'--argjson',
'trust',
'["OWNER","MEMBER","COLLABORATOR"]',
'--arg',
'pr_url',
'https://github.com/test/pull/1',
'--argjson',
'reviews',
JSON.stringify([reviews]),
`[\n${deferredInlineFilter}\n] | length`,
],
{ encoding: 'utf8', input: JSON.stringify(inlineFeedback) },
),
);
// Suggestion (id 12) and unclassified (id 13) are deferred; Critical
// (11), reply-to-Critical (14), and CHANGES_REQUESTED-associated (15)
// are not.
expect(countDeferredInline).toBe(2);
const deferredIssueFilter = prepareBranchAndFeedbackStep.match(
/"\$\{WORKDIR\}\/rc\.json"\n\s+jq -r --arg wm "\$\{WATERMARK\}" --arg rb "\$\{REVIEW_BOT\}" --arg ab "\$\{AUTOFIX_BOT\}" \\\n\s+--argjson trust "\$\{TRUSTED_ASSOC\}" --arg pr_url "\$\{PR_URL\}" '([\s\S]*?)' \\\n\s+"\$\{WORKDIR\}\/ic\.json"/,
)?.[1];
expect(deferredIssueFilter).toBeTruthy();
const issueComments = [
{
id: 30,
created_at: '2026-01-02T00:00:00Z',
user: { login: 'maintainer' },
author_association: 'MEMBER',
body: 'Please also update the docs.',
html_url: 'https://github.com/test/pull/1#issuecomment-30',
},
{
id: 31,
created_at: '2026-01-02T00:00:01Z',
user: { login: 'qwen-code-ci-bot' },
author_association: 'NONE',
body: '**[Critical]** data loss on concurrent writes',
html_url: 'https://github.com/test/pull/1#issuecomment-31',
},
{
id: 32,
created_at: '2026-01-02T00:00:02Z',
user: { login: 'maintainer' },
author_association: 'MEMBER',
body: '@qwen-code /review',
html_url: 'https://github.com/test/pull/1#issuecomment-32',
},
];
const countDeferredIssue = Number(
execFileSync(
'jq',
[
'--arg',
'wm',
'2026-01-01T00:00:00Z',
'--arg',
'rb',
'qwen-code-ci-bot',
'--arg',
'ab',
'qwen-code-dev-bot',
'--argjson',
'trust',
'["OWNER","MEMBER","COLLABORATOR"]',
'--arg',
'pr_url',
'https://github.com/test/pull/1',
`[${deferredIssueFilter}] | length`,
],
{ encoding: 'utf8', input: JSON.stringify(issueComments) },
),
);
// Normal comment is deferred; Critical and command-style comments are
// not.
expect(countDeferredIssue).toBe(1);
// CHANGES_REQUESTED is a formal merge blocker, so its review summary and
// associated inline details remain actionable even without the marker.
expect(prepareBranchAndFeedbackStep).toContain(
'or (.state // "") == "CHANGES_REQUESTED"',
);
expect(inlineFilter).toContain('pull_request_review_id');
// Scan still selects fresh suggestions so a no-op report can advance the
// watermark; prepare hides their bodies from the agent and the
// deterministic report records links to the items left open.
expect(reviewScanJob).not.toContain('CRITICAL_ONLY');
expect(prepareBranchAndFeedbackStep).toContain(
'## Deferred non-Critical feedback',
);
expect(prepareBranchAndFeedbackStep).toContain('deferred-feedback.md');
expect(pushAndReportStep).toContain('deferred-feedback.md');
// The agent-facing policy is an independent second guard: even if someone
// later changes the rendering, a declared Critical-only round must never
// modify code for the deferred section.
const skill = readAutofixSkill();
expect(skill).toContain('never');
expect(skill).toContain('drop one silently');
// A third disposition beyond fix/decline: escalate a judgment that is the
// maintainer's to make, instead of silently deciding it.
expect(skill).toContain("Needs a maintainer's decision");
expect(skill).toContain('escalate when the');
expect(skill).toContain('Critical-only mode');
expect(skill).toContain('do not modify code');
expect(skill).toContain('Deferred non-Critical feedback');
});
it('requires the address path to run verification and record it as evidence', () => {
@ -4764,10 +5160,12 @@ describe('qwen-autofix workflow', () => {
'::warning::Failed to post handoff comment on PR #${PR}',
);
expect(reviewAddressReportStep).toContain('human should take over');
// Token-breaking neutralization at ALL SIX agent-derived publish sites
// Token-breaking neutralization at ALL EIGHT agent-derived publish sites
// (address-summary, no-action, DETAIL_FILE, API_ERROR_DETAIL, the
// gate-rejection body, and the comment-reply body, whose content is
// agent stdout that can echo external comment text), and it
// gate-rejection body, the comment-reply body whose content is agent
// stdout that can echo external comment text, and the two
// deferred-feedback report sections, which render untrusted
// review-comment paths into a bot-authored comment), and it
// must be LINE-INDEPENDENT: a whole-comment strip misses a marker whose
// --> sits on another line, while jq scan() matches across newlines.
// Proven end-to-end on a split forged marker.
@ -4776,7 +5174,7 @@ describe('qwen-autofix workflow', () => {
// backslashes — a NO-OP on both GNU and BSD sed, verified) left the count
// at four and this test green, shipping an unescaped publish site.
const escapeSites = workflow.match(/sed 's\/<!--\/[^']*\/g'/g) ?? [];
expect(escapeSites).toHaveLength(6);
expect(escapeSites).toHaveLength(8);
for (const site of escapeSites) {
expect(site).toBe("sed 's/<!--/<!\\\\-\\\\-/g'");
}

View file

@ -21,6 +21,7 @@ const prSkill = readFileSync(
'.qwen/skills/triage/references/pr-workflow.md',
'utf8',
);
const verifySkill = readFileSync('.qwen/skills/verify-pr/SKILL.md', 'utf8');
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@ -1256,8 +1257,9 @@ describe('qwen-triage verify hardening round 2', () => {
expect(chown).toBeGreaterThan(repin);
expect(launch).toBeGreaterThan(home);
// Killing is not enough on its own: surviving build processes must
// fail the step rather than race the sweeps that follow.
expect(runStep).toContain('pgrep -u node');
// fail the step rather than race the sweeps that follow. The check
// disregards zombies — see the build-process-guard suite for why.
expect(runStep).toContain('live_build_processes');
expect(runStep).toContain('refusing to start the agent');
expect(runStep).toContain('"HOME=$AGENT_HOME"');
// The proxy must require this run's bearer, not just a fixed dummy key.
@ -1535,6 +1537,24 @@ describe('qwen-triage verify hardening round 2', () => {
}
});
// The whole report is already inside a <details> on the PR. With the
// Chinese summary as the last item, reaching it meant expanding that fold
// and scrolling the entire English report — ~90 lines on a real one.
it('puts the report Chinese summary next to the verdict, not last', () => {
const struct = verifySkill.slice(
verifySkill.indexOf('### report.md structure'),
verifySkill.indexOf('## Hard rules'),
);
expect(struct).toBeTruthy();
const zh = struct.indexOf('中文摘要');
expect(zh).toBeGreaterThan(-1);
expect(zh).toBeLessThan(struct.indexOf('Central claim + A/B table'));
expect(zh).toBeLessThan(struct.indexOf('**Not covered**'));
expect(zh).toBeLessThan(struct.indexOf('**Methodology**'));
// Moved, not duplicated — two summaries would drift apart.
expect(struct.match(/中文摘要/g)?.length).toBe(1);
});
// Only a validated assertions object counts as evidence.
it('rejects inconsistent assertions objects', () => {
const publishStep = step('Post verification report comment');
@ -1695,6 +1715,100 @@ describe('qwen-triage verify publish fidelity', () => {
// Only bodies carrying findings are substantive; weak notices must not be
// snapshotted as the previous round's report.
// The scope disclaimer under the headline has been bilingual since the
// lane shipped, but the verdict itself — the one line a reader acts on —
// was English-only. A Chinese reader got the caveat and not the
// conclusion.
it('renders every verdict headline in both languages', () => {
const dir = fixture();
try {
const ARMS = [
[
{ VERDICT: 'pass', AGENT_VERDICT: 'merge-ready' },
'merge-ready (agent verdict)',
'可合入agent 判定)',
],
[
{ VERDICT: 'pass', AGENT_VERDICT: 'findings' },
'findings reported (agent verdict)',
'报告了发现agent 判定)',
],
[
{ VERDICT: 'pass', AGENT_VERDICT: 'blocked' },
'blocked (agent verdict)',
'阻塞agent 判定)',
],
[
{ VERDICT: 'pass', AGENT_VERDICT: 'inconclusive' },
'inconclusive (agent verdict)',
'结论不足agent 判定)',
],
[
{ VERDICT: 'pass' },
'completed (no usable structured verdict)',
'已完成(无可用的结构化判定)',
],
[{ VERDICT: 'fail' }, 'agent run failed', 'agent 运行失败'],
[
{ VERDICT: 'timeout' },
'timeout — partial evidence',
'超时——证据不完整',
],
[
{ VERDICT: 'infra-error' },
'infra-error (crash, OOM, or unwritable results)',
'基础设施故障崩溃、OOM 或结果不可写)',
],
[{ VERDICT: 'bogus' }, 'unknown', '未知'],
];
const seenZh = new Set();
ARMS.forEach(([env, en, zh], i) => {
const body = render(dir, { NAME: `hl${i}`, AGENT_VERDICT: '', ...env });
expect(body).toContain(`**Sandboxed verification: ${en}**`);
expect(body).toContain(`**沙箱验证:${zh}**`);
seenZh.add(zh);
});
// Distinct per arm. A single hardcoded Chinese string — or one that
// renders the English text twice — satisfies a per-arm containment
// check, so the pairing has to be pinned as a bijection.
expect(seenZh.size).toBe(ARMS.length);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('renders the assertion count in both languages', () => {
const dir = fixture();
try {
const body = render(dir, {
NAME: 'assertzh',
VERDICT: 'pass',
AGENT_VERDICT: 'merge-ready',
});
expect(body).toContain(
'Scripted assertions: 10 passed · 0 failed · 10 total',
);
expect(body).toContain('脚本断言10 通过 · 0 失败 · 10 总计');
// The Chinese line rides the same validated object as the English one:
// an inconsistent assertions.json must suppress BOTH, or the comment
// grows a number that no gate checked.
writeFileSync(
join(dir, 'work', 'verify-results', 'prA-verify-1', 'assertions.json'),
'{"pass":1,"fail":0,"total":0}',
);
const bad = render(dir, {
NAME: 'assertbad',
VERDICT: 'pass',
AGENT_VERDICT: 'merge-ready',
});
expect(bad).not.toContain('Scripted assertions:');
expect(bad).not.toContain('脚本断言');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('marks only finding-bearing bodies as substantive', () => {
const dir = fixture();
const M = 'qwen-triage:verify-substantive';
@ -2442,7 +2556,9 @@ describe('qwen-triage verify maintainer-review round', () => {
// A mid-body stall closes the response (curl 18), not a hang until the
// client's own timeout (curl 28).
expect(out).toContain('stall_exit=18');
});
// 20 chunks x 200 ms is 4 s before the stall arm even starts, so this
// cannot fit vitest's 5 s default. It was timing out on main.
}, 30000);
// GitHub cancels the OLDER pending run in a concurrency group, so the
// requester's own /verify proceeds — the earlier "queued behind other
@ -2715,7 +2831,9 @@ describe('qwen-triage tmux lane parity', () => {
const out = runProxyWatchdogTest(proxy);
expect(out).toContain('chunks=20');
expect(out).toContain('stall_exit=18');
});
// Same reason as its verify-lane twin: the stream alone outlasts the
// 5 s default.
}, 30000);
// PR lifecycle scripts run before the agent and can plant a
// tmp/<name>-tmux-<ts>/ directory whose report.md and transcript the
@ -2821,7 +2939,7 @@ describe('qwen-triage tmux lane parity', () => {
const runStep = stepIn('tmux-testing', 'Run tmux real-user testing');
expect(runStep).toContain('pkill -KILL -u node');
expect(runStep).toContain(
'Processes owned by the build user survived; refusing to start the agent.',
'Processes owned by the build user survived SIGKILL; refusing to start the agent.',
);
// Before the sweep and the proxy: the cleanup must not race a live
// process, and no leftover child may be alive when the proxy binds.
@ -2926,3 +3044,122 @@ describe('qwen-triage tmux lane parity', () => {
expect(reportCap + transcriptCap + envelope).toBeLessThan(65536);
});
});
describe('qwen-triage build-process guard', () => {
// The guard fired on a real run (job 30267953352) and failed the job with
// nothing but "processes survived" — no pid, no state, no command line.
// Nobody could tell a genuine leftover from a harmless one, including the
// person who wrote it. Both lanes now name what survived.
it('names the surviving processes instead of just refusing', () => {
for (const lane of ['verify', 'tmux-testing']) {
const runStep = stepIn(
lane,
lane === 'verify'
? 'Run verification agent'
: 'Run tmux real-user testing',
);
expect(runStep, `${lane} lost the guard`).toContain(
'live_build_processes',
);
expect(runStep).toContain('surviving process:');
expect(runStep).toContain(
'Processes owned by the build user survived SIGKILL; refusing to start the agent.',
);
}
});
// `ps -u node` exits 1 when the user owns zero processes. Under
// `set -euo pipefail` the bare assignment would die silently on the
// success path — the `|| true` absorbs the no-match status.
it('"survivors" assignment tolerates zero processes under pipefail', () => {
for (const lane of ['verify', 'tmux-testing']) {
const runStep = stepIn(
lane,
lane === 'verify'
? 'Run verification agent'
: 'Run tmux real-user testing',
);
expect(
runStep,
`${lane}: survivors assignment must survive ps exit 1`,
).toContain('survivors="$(live_build_processes)" || true');
}
});
// A zombie cannot be killed and cannot execute anything, so counting one
// means this check can never clear.
//
// PLATFORM NOTE, and the reason this test is split in two: Linux pgrep
// reports defunct processes ("Defunct processes are reported." — pgrep(1),
// procps-ng), which is why the original `pgrep -u node` guard could hang
// on a zombie in CI. macOS pgrep does NOT list them, so the behavioural
// arm below cannot discriminate the old implementation from the new one
// here — verified directly: ps lists our zombie, pgrep does not. The
// structural assertion is therefore the one that holds on every platform.
it('excludes zombies from the surviving-process check', () => {
for (const lane of ['verify', 'tmux-testing']) {
const runStep = stepIn(
lane,
lane === 'verify'
? 'Run verification agent'
: 'Run tmux real-user testing',
);
const body = runStep
.match(/live_build_processes\(\) \{\n([\s\S]*?)\n\s*\}/)?.[1]
?.trim();
expect(body, `${lane}: no live_build_processes body`).toBeTruthy();
// It must read process STATE and drop zombies. `pgrep` alone cannot:
// on Linux it reports defunct processes and offers no default filter.
expect(body, `${lane}: the filter must inspect process state`).toMatch(
/stat=/,
);
expect(body, `${lane}: the filter must exclude zombies`).toMatch(
/\/\^Z\//,
);
expect(body).not.toMatch(/^pgrep\b/);
}
});
// The OS property the exclusion rests on: a zombie survives SIGKILL and
// ps still lists it, so an unfiltered check would never clear.
it('confirms a zombie survives SIGKILL and stays visible to ps', () => {
const dir = mkdtempSync(join(tmpdir(), 'zombie-'));
try {
writeFileSync(
join(dir, 'mkzombie.py'),
[
'import os, time',
'pid = os.fork()',
'if pid == 0:',
' os._exit(0)',
'print(pid, flush=True)',
'time.sleep(8)',
].join('\n'),
);
const driver = [
'set -u',
`python3 "$1/mkzombie.py" > "$1/zpid" &`,
'PP=$!',
'sleep 1',
'Z="$(tr -d " \n" < "$1/zpid")"',
'[ -n "$Z" ] || { echo "no-zombie"; kill $PP 2>/dev/null; exit 0; }',
'kill -9 "$Z" 2>/dev/null',
'sleep 0.5',
'echo "state=$(ps -o stat= -p "$Z" 2>/dev/null | tr -d " ")"',
'echo "unfiltered=$(ps -o pid= -p "$Z" 2>/dev/null | wc -l | tr -d " ")"',
`echo "filtered=$(ps -o pid=,stat=,args= -p "$Z" 2>/dev/null | awk '$2 !~ /^Z/' | wc -l | tr -d ' ')"`,
'kill $PP 2>/dev/null',
].join('\n');
const out = spawnSync('bash', ['-c', driver, '_', dir], {
encoding: 'utf8',
timeout: 30000,
}).stdout;
if (out.includes('no-zombie')) return;
expect(out).toMatch(/state=Z/);
expect(out).toContain('unfiltered=1');
expect(out).toContain('filtered=0');
} finally {
rmSync(dir, { recursive: true, force: true });
}
}, 30000);
});

View file

@ -5,8 +5,14 @@
*/
import { execSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import {
existsSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { join, resolve } from 'node:path';
// A script to handle versioning and ensure all related changes are in a single, atomic commit.
@ -104,12 +110,42 @@ if (cliPackageJson.config?.sandboxImageUri) {
writeJson(cliPackageJsonPath, cliPackageJson);
}
// 7. Run `npm install` to update package-lock.json.
// 7. Pin channel adapters' semver dependency on @qwen-code/channel-base to
// the exact new version. A caret range like ^0.21.0 does not match a
// prerelease bump (e.g. 0.21.1-preview.0), so npm would replace the workspace
// link with the stale registry package and the release build would compile
// against outdated types.
const channelsDir = resolve(process.cwd(), 'packages/channels');
for (const entry of readdirSync(channelsDir)) {
const pkgPath = join(channelsDir, entry, 'package.json');
if (!existsSync(pkgPath)) continue;
const pkg = readJson(pkgPath);
const dep = pkg.dependencies?.['@qwen-code/channel-base'];
if (dep && !dep.startsWith('file:')) {
pkg.dependencies['@qwen-code/channel-base'] = newVersion;
writeJson(pkgPath, pkg);
console.log(
`Pinned @qwen-code/channel-base to ${newVersion} in ${pkg.name}`,
);
}
}
// 8. Refresh node_modules and package-lock.json against the pinned exact
// versions so the adapters resolve channel-base to the workspace link again.
// --ignore-scripts prevents the root `prepare` lifecycle from triggering a
// redundant full build that fails with TS5055 when dist/ already exists from
// the initial `npm ci` install.
run(
'npm install --workspace packages/cli --workspace packages/core --workspace packages/channels/base --workspace packages/channels/plugin-example --package-lock-only --ignore-scripts',
);
run('npm install --ignore-scripts');
// 9. The per-workspace `npm version` reifies above nested a stale registry
// copy of channel-base under each adapter while ranges briefly mismatched.
// The install above cleans both lockfiles but can leave that directory on
// disk, where it shadows the workspace link during tsc. Remove it.
for (const entry of readdirSync(channelsDir)) {
rmSync(join(channelsDir, entry, 'node_modules', '@qwen-code'), {
recursive: true,
force: true,
});
}
console.log(`Successfully bumped versions to v${newVersion}.`);