mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-14 19:24:54 +00:00
Merge branch 'main' into agent/agent-view-supervisor-runtime
This commit is contained in:
commit
069b78bf6c
20 changed files with 1587 additions and 165 deletions
133
.github/workflows/qwen-autofix.yml
vendored
133
.github/workflows/qwen-autofix.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
16
.github/workflows/qwen-code-pr-review.yml
vendored
16
.github/workflows/qwen-code-pr-review.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
90
.github/workflows/qwen-triage.yml
vendored
90
.github/workflows/qwen-triage.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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,
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);',
|
||||
|
|
|
|||
|
|
@ -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'");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}.`);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue