Merge remote-tracking branch 'origin/main' into pr11206-resolve

# Conflicts:
#	packages/acp-bridge/src/bridge.test.ts
#	packages/acp-bridge/src/bridgeTypes.ts
This commit is contained in:
易良 committed 2026-09-10 23:51:13 +08:00
commit 8d3e506a32
112 files changed
+8612 -6442

No files matched your search

@@ -618,6 +618,22 @@ if [[ "${OUTCOME}" == "fixed" ]]; then
# the per-PR tracking issue (script content from expression
# context; append-only comment design — see the script).
run_deferred_upsert
# The sha the push loop above actually left on the branch — the merge
# retry can advance it past the verified head, and the regression marker
# must name what the PR now carries.
PUSHED_HEAD="$(git rev-parse HEAD 2> /dev/null || echo '')"
# The premise the push marker stamps: the head state prepare classified,
# unless the pushed head did not start from that head -- a salvage-merged
# branch move or a base-conflict merge carries content prepare never
# classified, and an unknown premise is never green.
PUSH_PRE="${CHECK_STATE:-none}"
[[ "${PUSH_RACE_MERGED}" == 'true' ]] && PUSH_PRE='none'
[[ "${CONFLICT:-false}" == 'true' ]] && PUSH_PRE='none'
# A merge commit between the head prepare classified and the head this
# round pushes -- a clean in-round merge of main trips neither arm above
# -- means the pushed head carries content prepare never classified: the
# premise is unknown, and an unknown premise is never green.
[[ -n "$(git rev-list --merges "${REPORT_HEAD}..${PUSHED_HEAD}" 2>/dev/null)" ]] && PUSH_PRE='none'
{
echo "🤖 Addressed the latest review feedback (round ${NEXT_ROUND}/${MAX_ROUNDS}). What changed, and what I pushed back on: · 已处理最新评审反馈(第 ${NEXT_ROUND}/${MAX_ROUNDS} 轮)。改动内容与我反驳保留之处如下:"
echo
@@ -653,6 +669,10 @@ if [[ "${OUTCOME}" == "fixed" ]]; then
echo
echo "${RESOLUTION_NOTE}"
fi
if [[ -n "${REGRESSED_ROUND:-}" ]]; then
echo
echo "🩸 Regression charged to round ${REGRESSED_ROUND}: that round pushed onto a head whose checks were all green and left them red. It no longer counts as progress for the consecutive-failure brake. · 已将回归记在第 ${REGRESSED_ROUND} 轮:该轮在检查全绿的 head 上推送后检查转红,因此不再计入连续失败熔断的有进展判定。"
fi
echo
echo "Re-review when you have a moment. After round ${MAX_ROUNDS} this bot stops and leaves the PR for a human. · 有空请复审;第 ${MAX_ROUNDS} 轮后本 bot 停止并将 PR 交给人工。"
echo
@@ -661,6 +681,21 @@ if [[ "${OUTCOME}" == "fixed" ]]; then
echo
echo "<!-- autofix-eval ts=${NEWEST} acted=true round=${NEXT_ROUND} win=${WINDOW:-none} -->"
echo "<!-- autofix-redcheck head=${REPORT_HEAD} -->"
# Regression accounting (af-157). Two distinct facts, both keyed to the
# eval marker's window so the consecutive-failure walk reads them with
# the same filter it already applies to the eval markers:
# autofix-push — what THIS round pushed, and whether the head it
# pushed ONTO was fully green. Only an acted round
# writes it; a no-op pushed nothing to regress.
# head= is the PUSHED sha, not REPORT_HEAD (which
# deliberately records the pre-round head).
# autofix-regression — the PRIOR round prepare found had left a green
# head red. Written by whichever report this round
# posts, so the record survives a later failure.
echo "<!-- autofix-push round=${NEXT_ROUND} head=${PUSHED_HEAD} pre=${PUSH_PRE} key=${WINDOW:-none} -->"
if [[ -n "${REGRESSED_ROUND:-}" ]]; then
echo "<!-- autofix-regression round=${REGRESSED_ROUND} key=${WINDOW:-none} -->"
fi
if [[ "${GROWTH_BASE_NEW}" == 'true' ]]; then
echo "<!-- autofix-growth-base src=${GROWTH_BASE_SRC} test=${GROWTH_BASE_TEST} key=${GROWTH_BASE_WIN:-${WINDOW:-none}} -->"
fi
@@ -699,12 +734,21 @@ else
echo
echo "${RESOLUTION_NOTE}"
fi
if [[ -n "${REGRESSED_ROUND:-}" ]]; then
echo
echo "🩸 Regression charged to round ${REGRESSED_ROUND}: that round pushed onto a head whose checks were all green and left them red. It no longer counts as progress for the consecutive-failure brake. · 已将回归记在第 ${REGRESSED_ROUND} 轮:该轮在检查全绿的 head 上推送后检查转红,因此不再计入连续失败熔断的有进展判定。"
fi
echo
echo "---"
echo "🧠 Handled by **Qwen Code** · model/模型 \`${MODEL_DISPLAY}\`${CLI_DISPLAY}"
echo
echo "<!-- autofix-eval ts=${NEWEST} acted=false round=${ROUND} win=${WINDOW:-none} -->"
echo "<!-- autofix-redcheck head=${REPORT_HEAD} -->"
# No autofix-push marker: a no-op round pushed nothing that could
# regress. The regression it OBSERVED still has to be recorded here.
if [[ -n "${REGRESSED_ROUND:-}" ]]; then
echo "<!-- autofix-regression round=${REGRESSED_ROUND} key=${WINDOW:-none} -->"
fi
if [[ "${GROWTH_BASE_NEW}" == 'true' ]]; then
echo "<!-- autofix-growth-base src=${GROWTH_BASE_SRC} test=${GROWTH_BASE_TEST} key=${GROWTH_BASE_WIN:-${WINDOW:-none}} -->"
fi
@@ -731,6 +775,42 @@ for attempt in 1 2 3; do
fi
if [[ "${attempt}" == 3 ]]; then
echo "::error::report post failed ${attempt} times for PR #${PR}; giving up"
# The report is the only carrier of this round's af-157 state, and no
# other step re-posts the report of a round that reached this script:
# the handoff comment the workflow can still post is gated on an
# outcome that is neither `fixed` nor `noop`. So whenever THIS round
# authored af-157 state -- a push whose head a later round can charge,
# an observation it made about the PRIOR round, or both -- a
# marker-only note carries it. Best-effort, and deliberately not
# gated on the outcome: a no-op round pushes nothing, but the
# regression it observed is just as unrecoverable once its report is
# lost, because the marker it read is superseded by the next push.
if [[ -n "${PUSHED_HEAD:-}" || -n "${REGRESSED_ROUND:-}" ]]; then
{
if [[ -n "${PUSHED_HEAD:-}" ]]; then
echo "🤖 Round ${NEXT_ROUND} pushed ${PUSHED_HEAD:0:9} but its report could not be posted; this note carries the round's push record only. · 第 ${NEXT_ROUND} 轮已推送 ${PUSHED_HEAD:0:9},但报告发布失败;本条仅记录本轮的推送标记。"
else
echo "🤖 Round ${ROUND} could not post its report; this note carries the round's regression record only. · 第 ${ROUND} 轮报告发布失败;本条仅记录本轮的回归标记。"
fi
echo
# R31-2: the note must also carry the eval marker — round numbering
# reads autofix-eval only, and a fallback-posted round that consumes
# no number makes the NEXT round reuse it (the brake's
# regression-round walk then charges two rounds as one).
if [[ -n "${PUSHED_HEAD:-}" ]]; then
echo "<!-- autofix-eval ts=${NEWEST} acted=true round=${NEXT_ROUND} win=${WINDOW:-none} -->"
else
echo "<!-- autofix-eval ts=${NEWEST} acted=false round=${ROUND} win=${WINDOW:-none} -->"
fi
if [[ -n "${PUSHED_HEAD:-}" ]]; then
echo "<!-- autofix-push round=${NEXT_ROUND} head=${PUSHED_HEAD} pre=${PUSH_PRE} key=${WINDOW:-none} -->"
fi
if [[ -n "${REGRESSED_ROUND:-}" ]]; then
echo "<!-- autofix-regression round=${REGRESSED_ROUND} key=${WINDOW:-none} -->"
fi
} > "${WORKDIR}/push-marker.md"
gh pr comment "${PR}" --repo "${REPO}" --body-file "${WORKDIR}/push-marker.md" || echo "::warning::push marker fallback post failed for PR #${PR}"
fi
else
echo "::warning::report post attempt ${attempt} failed for PR #${PR}; retrying"
sleep 10
File diff suppressed because it is too large. Load diff
@@ -1026,6 +1026,618 @@ if [[ -n "${DELETED_TESTS}" || "${NET_TEST_LINES}" -le -25 ]]; then
echo '⚖️ test coverage shrank this round — advisory written for the report' | tee -a "${GATE_LOG}"
fi
# --- Test-weakening gate ----------------------------------------------------
# The advisory above renders in the round report only AFTER the round has
# already been accepted, and the SKILL rule it points at ("deleting or
# weakening tests requires content evidence, not an author's say-so") had no
# deterministic enforcement at all. Relaxing an existing assertion is the
# cheapest way for a fix to reach green while the behaviour it broke goes
# unpinned, and it is structurally invisible to every other check here:
# build/typecheck/lint never read assertions, the package tests run the
# WEAKENED file, and the bite check reads only the tests a round ADDS --
# never the ones it edits away.
#
# CONTRACT. Any pre-existing test file whose DECLARED test surface this
# round shrinks must be named in <workdir>/test-weakening.json -- a JSON
# array of {"path": "<file>", "reason": "<evidence>"} -- or the round is
# rejected, retryably. The gate judges that the claim EXISTS and carries a
# non-trivial reason, never the reason's merit: no semantic oracle is
# available here, and turning a silent edit into an explicit, attributable
# claim is the whole point. The reasons ride into the round report, where
# a maintainer reads them against the diff.
#
# AUTHORITY. The surface is measured by count-test-surface.mjs -- the
# TypeScript compiler's parser over the WHOLE file, never text patterns
# over diff lines -- so comments, strings, regex literals, JSX and line
# breaks are the parser's business and can neither decoy nor hide a token.
# Per file it counts statement-position assertion call chains, test/
# describe registrations with their enabled/disabled state (every collector
# spelling of skip/todo/fails, the x-aliases, a constant skipIf/runIf or
# options object, a body-level unconditional skip(), a disabled describe's
# nesting), and bare early returns ahead of a test body's assertions. The
# script's header is the definition of record. Neither the counter nor its
# parser is the round's to choose: both are staged from the trusted base
# (the parser installed from the base lockfile's pin) and digested there,
# and the digests -- carried in expression context, unreachable from any
# disk write branch code makes later -- are verified here before either
# executes. A mismatch is tampering and rejects non-retryably; an
# instrument that was never digested measures nothing (UNAVAILABLE below).
# The parser is a `.cjs` copy of typescript's single-file build, so its
# loading cannot be steered by a package.json planted beside it.
#
# ATTRIBUTION. Each file's round delta is tip - pre-round - main's own
# contribution. Across a main-derived merge (any parent from the second on
# that origin/main reaches), main's contribution is MAIN'S OWN DELTA,
# measured on main's own side against the merge base; across a
# fast-forwarded main commit it is the commit's own delta. Never a
# three-way splice of the two sides: an auto-merge mixes the round's edits
# into main's contribution and can invent a surface neither side ever had
# -- an un-skip the round made, spliced with a test main added, produces
# an enabled registration that existed in no tree and is then charged to
# somebody. Measuring main where main made it removes that whole class,
# and it makes the two arms one rule: a modify/delete resolved for main
# was already measured from the merge base.
# So a weakening committed before, during, or after a merge of main
# measures the same, an assertion moved within a file nets zero whichever
# commit sequence produced the tip, and main's own delta neither charges
# nor shields the round. An event that moved nothing for the file
# (byte-identical or absent on both sides) is not recorded.
#
# What main did is not always what the merge KEPT, so the contribution is
# clamped by the blob the merge commit actually landed, measured against
# the same merge base. Main's REMOVALS are credited only as far as they
# landed: a round could otherwise merge main, discard its side, and let
# the phantom credit absorb its own removal exactly. Main's ADDITIONS are
# never clamped: they raise the baseline whatever the merge kept, or a
# round that drops what main added during the round gets that removal for
# free. Presence is a running state, not a reading of the newest event.
# Main ADDING the path puts the file in the baseline; so does main moving
# its measured surface; main deleting it takes it out only when the merge
# adopted that deletion. Where both sides MEASURE to nothing -- the Python
# and Rust shapes, and equally a JS file that declares nothing -- movement
# is read from the bytes instead, since "the surface did not move" is
# unmeasurable there. That reads main touching such a file at all as a
# contribution, so a file an earlier round deleted is charged again: one
# ack entry answers it, where the alternative is losing the deletion arm,
# the only arm those shapes have.
# What none of this can see is identity: main removing one assertion while
# the resolution puts it back and drops a different one nets to zero, the
# same way an assertion moved within a file always has.
#
# The model assumes main's side is MAIN's. It stops being main's if the
# round's own work reaches origin/main WHILE THE LOOP IS STILL RUNNING --
# the PR squash-merged or cherry-picked mid-round -- because main's chain
# then carries the round's authorship while the merge base does not, and
# measuring the round against it cancels the round's own removals. (Main
# MERGING the branch is self-correcting: the merge base carries the same
# commit, so the two cancel.) A guard was written for it and removed:
# every history these fixtures can build measures correctly without one,
# so it was code no test could reach. The residual is bounded by the same
# thing that bounds the flake carve-out -- a round whose work has already
# landed has nothing left to gate.
# Assertions are counted per file; registrations by kind and title as
# multisets, so un-skipping one test never licenses silencing another,
# while a brand-new todo/skip registration is the round's own and charges
# nothing. Every file is measured under ONE name end to end: a round that
# RENAMES a test file is measured as the deletion of the old path plus a
# new file at the new one, so the rename costs one ack entry naming the
# old path -- deliberately fail-closed, and never silent, since whatever
# shrank inside the destination rides into the round report as the
# reason a maintainer reads against the diff. Following a name across a
# round's own renames is a non-goal: it is a second rename tracker layered
# on git's own, and every reading of it that this gate tried disagreed
# with git in some history shape. A parentless commit in the round's
# history is the round's own authorship, measured against the empty tree.
#
# SIGNALS, one entry per file, the first that applies: the file was
# deleted (held by the baseline -- pre-round, or landed by main during the
# round -- and absent at the tip); a baseline-enabled registration now
# disabled; net assertions removed (an early return planted ahead of them
# counts here: the runner then reports the test passed having asserted
# nothing); net enabled tests removed. Files are selected by NAME: `*.test.*`, `*.spec.*`,
# `test_*.py`, and the `tests/*.rs` / `*_test.rs` / `*_tests.rs` shapes,
# snapshots excluded. Non-JS shapes measure a zero surface and are judged
# by the deletion arm alone. A test surface only a runner can enumerate --
# a Rust `#[cfg(test)]` module inside a production file, a suite registered
# under a condition -- is outside this gate by design.
#
# NOT MEASURED, by design: whether an assertion is REACHABLE (dead code, a
# condition false in CI, a helper never called), condition-valued guards
# (`.skipIf(cond)`, `skip(cond, reason)`, `if (cond) ctx.skip()` -- this
# repository's environment-guard idiom, which the runner reports as
# skipped), and options or collector names carried by a binding. Those are
# runtime facts; the package test run and the bite check are the
# runner-backed instruments, and this gate certifies only what it
# measures: the declared surface.
#
# Fails OPEN on the measured signals -- a counter or parser that is absent
# or unverifiable, or a history the walk cannot read, skips them with a
# logged UNAVAILABLE -- and never on a whole-file deletion or typechange,
# which the pre-round->tip pair proves without the walk; an enumeration git
# itself refuses fails CLOSED there, since the absence of deletions cannot
# then be certified.
WEAKEN_PATHSPEC=(':(glob)**/*.test.*' ':(glob)**/*.spec.*' ':(glob)**/test_*.py' ':(glob)**/tests/*.rs' ':(glob)**/*_test.rs' ':(glob)**/*_tests.rs' ':(exclude,glob)**/__snapshots__/**')
WEAKEN_COUNTER="${RUNNER_TEMP}/count-test-surface.mjs"
WEAKEN_PARSER="${RUNNER_TEMP}/weaken-parser/typescript.cjs"
WEAKEN_MEASURED='true'
WEAKEN_TMP="$(mktemp -d "${RUNNER_TEMP}/weaken.XXXXXX")"
EMPTY_TREE="$(git hash-object -t tree /dev/null)"
# Parallel indexed arrays throughout (bash 3.2 has no associative arrays):
# measured files and their signal text.
WEAKENED_PATHS=()
WEAKENED_SIGNALS=()
# Success when ${1} exactly matches one of the remaining arguments.
weaken_member() {
local f="${1}" weaken_e
shift
for weaken_e in "$@"; do
if [[ "${weaken_e}" == "${f}" ]]; then
return 0
fi
done
return 1
}
weaken_digest() {
if command -v sha256sum > /dev/null 2>&1; then
sha256sum "${1}" | cut -d' ' -f1
else
shasum -a 256 "${1}" | cut -d' ' -f1
fi
}
# The instrument's trust chain: digested at staging, present, unchanged.
# Undigested (never staged) measures nothing; a digested instrument that is
# now absent or changed is tampering.
weaken_trusted() {
local file="${1}" expected="${2}" what="${3}" actual
[[ -n "${expected}" ]] || return 1
if [[ ! -f "${file}" ]]; then
echo "staged ${what} is missing although it was digested at staging (${expected})" >> "${GATE_LOG}"
reject_fix "staged ${what} was removed after staging" 'false' 'false'
fi
if ! actual="$(weaken_digest "${file}")"; then
echo "staged ${what} cannot be digested although it was digested at staging (${expected})" >> "${GATE_LOG}"
reject_fix "staged ${what} was made unreadable after staging" 'false' 'false'
fi
if [[ "${actual}" != "${expected}" ]]; then
echo "staged ${what} does not match its trusted-base digest (expected ${expected}, found ${actual})" >> "${GATE_LOG}"
reject_fix "staged ${what} was modified after staging" 'false' 'false'
fi
return 0
}
weaken_trusted "${WEAKEN_COUNTER}" "${WEAKEN_COUNTER_SHA256:-}" 'test-surface counter' || WEAKEN_MEASURED='false'
weaken_trusted "${WEAKEN_PARSER}" "${WEAKEN_PARSER_SHA256:-}" 'test-surface parser' || WEAKEN_MEASURED='false'
# The round's first-parent history, oldest first, each commit classified
# once: main = a commit on origin/main's own FIRST-PARENT chain (a
# fast-forwarded ride of main's own history), merge = a merge one of whose
# parents from the second on sits on that chain, own = the round's
# authorship, a parentless root included. Reachability alone is not
# enough: a commit main merged in as someone's feature-branch tip is
# reachable from origin/main while never having been main, and merging one
# with `-s ours` would otherwise credit the round with a smaller copy of a
# file that main never carried. Only main and merge commits are events the
# measurement subtracts; the round's own commits are already inside
# tip - pre-round.
WEAKEN_COMMITS=()
WEAKEN_KINDS=()
WEAKEN_MAIN_PARENT=()
WEAKEN_MAIN_LINE="$(git rev-list --first-parent origin/main 2> /dev/null)" || WEAKEN_MEASURED='false'
# Success when ${1} is a commit origin/main itself has been.
weaken_on_main_line() {
local weaken_sha
weaken_sha="$(git rev-parse -q --verify "${1}^{commit}" 2> /dev/null)" || return 1
grep -qxF -- "${weaken_sha}" <<< "${WEAKEN_MAIN_LINE}"
}
if weaken_list="$(git rev-list --first-parent --reverse "origin/${BRANCH}..${BRANCH}" 2> /dev/null)"; then
while IFS= read -r c; do
[[ -n "${c}" ]] || continue
weaken_kind='own'
weaken_mp=''
if weaken_on_main_line "${c}"; then
weaken_kind='main'
else
weaken_pi=2
while git rev-parse -q --verify "${c}^${weaken_pi}" > /dev/null 2>&1; do
if weaken_on_main_line "${c}^${weaken_pi}"; then
weaken_kind='merge'
weaken_mp="${weaken_pi}"
break
fi
weaken_pi=$(( weaken_pi + 1 ))
done
fi
WEAKEN_COMMITS+=("${c}")
WEAKEN_KINDS+=("${weaken_kind}")
WEAKEN_MAIN_PARENT+=("${weaken_mp}")
done <<< "${weaken_list}"
else
WEAKEN_MEASURED='false'
fi
# The first parent of ${1}, or the empty tree for a parentless root.
weaken_parent() {
git rev-parse -q --verify "${1}^" 2> /dev/null || printf '%s\n' "${EMPTY_TREE}"
}
# Export ${1}:${2} to a file under WEAKEN_TMP named ${3}; print the file's
# path, or nothing when the ref holds no such blob. Failure means git
# itself failed, never an absent blob.
# Success when ${1}:${2} is a BLOB -- a tree that took the file's name is
# not the file, and `git cat-file -e` alone accepts one.
weaken_is_blob() {
[[ "$(git cat-file -t "${1}:${2}" 2> /dev/null || true)" == 'blob' ]]
}
weaken_blob() {
local ref="${1}" f="${2}" out="${WEAKEN_TMP}/${3}"
if weaken_is_blob "${ref}" "${f}"; then
git show "${ref}:${f}" > "${out}" 2> /dev/null || return 1
printf '%s\n' "${out}"
fi
}
# One event, five lines, always: main's own side at the event, the merge
# base to measure it against, the blob the merge commit actually landed,
# whether main held the file at all, and the BRANCH's own side at the
# merge -- the baseline the landed delta is measured against, so the
# round's own pre-merge edits never enter main's landed contribution
# (R27-20). Empty lines are absent sides.
weaken_emit() {
printf '%s\n%s\n%s\n%s\n%s\n' "${1}" "${2}" "${3}" "${4}" "${5}"
}
# MAIN's contribution at merge commit ${1} for file ${2}, main being parent
# ${3}: main's OWN delta, measured on main's own side against the merge
# base. Never a three-way splice of the two sides -- an auto-merge mixes
# the round's edits into main's contribution and can invent a surface
# neither side ever had, which is then credited or charged to somebody.
# What the merge actually DID with that contribution rides along as the
# landed blob, and the counter clamps by it.
weaken_auto_blob() {
local c="${1}" f="${2}" mp="${3}" tag="${4}" mb p1 p2 base res holds='0'
# A criss-cross history has more than one equally valid merge base, and
# git picks one without promising which. Main's delta is measured against
# that base, so a pick can decide the verdict -- one candidate can credit
# main with the ROUND's own removal. It only matters when the candidates
# DISAGREE about this file: refuse then (the caller charges it as
# unmeasurable, which one ack entry answers) and measure normally when
# they hold the same blob, which is the ordinary case.
local weaken_bases weaken_b weaken_seen='' weaken_seen_set='' weaken_bi=0
weaken_bases="$(git merge-base --all "${c}^" "${c}^${mp}" 2> /dev/null)" || weaken_bases=''
while IFS= read -r weaken_b; do
[[ -n "${weaken_b}" ]] || continue
# Compared through weaken_blob, the very reader the measurement uses,
# rather than a second identity built beside it: one reader cannot
# drift from the other about what counts as holding the file. Absent
# and present are told apart by weaken_seen_set, not by the path being
# empty -- an empty path IS the absent case.
weaken_bi=$(( weaken_bi + 1 ))
weaken_b="$(weaken_blob "${weaken_b}" "${f}" "${tag}.mb${weaken_bi}")" || return 1
if [[ -z "${weaken_seen_set}" ]]; then
weaken_seen="${weaken_b}"
weaken_seen_set='1'
elif [[ -z "${weaken_seen}" && -z "${weaken_b}" ]]; then
:
elif [[ -z "${weaken_seen}" || -z "${weaken_b}" ]]; then
return 1
elif ! cmp -s "${weaken_seen}" "${weaken_b}"; then
return 1
fi
done <<< "${weaken_bases}"
mb="$(git merge-base "${c}^" "${c}^${mp}" 2> /dev/null)" || mb=''
p1="$(weaken_blob "${c}^" "${f}" "${tag}.p1")" || return 1
p2="$(weaken_blob "${c}^${mp}" "${f}" "${tag}.p2")" || return 1
res="$(weaken_blob "${c}" "${f}" "${tag}.res")" || return 1
base=''
if [[ -n "${mb}" ]]; then
base="$(weaken_blob "${mb}" "${f}" "${tag}.mb")" || return 1
fi
if [[ -z "${p2}" ]] && weaken_is_blob "${c}" "${f}"; then
# Main holds no side and the merge left the file in the round's hands:
# main contributed nothing here. Not an event -- recording it would let
# the round's own copy stand in for main's side as the LANDED blob,
# and a copy weaker than whatever it is measured against would credit
# main with the round's own removal. The merge base is not part of the
# test: after main deletes a file the round keeps, the NEXT merge sees
# no base either, and that is the same case.
return 0
fi
[[ -z "${p2}" ]] || holds='1'
weaken_emit "${p2}" "${base}" "${res}" "${holds}" "${p1}"
}
# Measure file ${1}: write the manifest (tip, pre-round, and every main
# event that moved the file) and print the counter's verdict JSON. One
# name throughout -- a round that renames a test file is measured as the
# deletion of the old path and a new file at the new one.
weaken_measure() {
local f="${1}" tag="${2}" tip pre before after landed holds branch_side events='' weaken_i c kind mp j=0
local weaken_pair weaken_prev='' weaken_prev_set=''
tip="$(weaken_blob "${BRANCH}" "${f}" "${tag}.tip")" || return 1
pre="$(weaken_blob "origin/${BRANCH}" "${f}" "${tag}.pre")" || return 1
for (( weaken_i = 0; weaken_i < ${#WEAKEN_COMMITS[@]}; weaken_i++ )); do
c="${WEAKEN_COMMITS[weaken_i]}"
kind="${WEAKEN_KINDS[weaken_i]}"
mp="${WEAKEN_MAIN_PARENT[weaken_i]}"
[[ "${kind}" != 'own' ]] || continue
# An event only where the commit moved this file relative to a parent.
if git diff --quiet "${c}^" "${c}" -- ":(literal)${f}" 2> /dev/null &&
{ [[ "${kind}" != 'merge' ]] || git diff --quiet "${c}^${mp}" "${c}" -- ":(literal)${f}" 2> /dev/null; }; then
continue
fi
j=$(( j + 1 ))
before="$(weaken_blob "${c}^" "${f}" "${tag}.e${j}.before")" || return 1
landed=''
holds='0'
branch_side=''
if [[ "${kind}" == 'main' ]]; then
# A commit main itself has been: its own delta IS main's, and what it
# landed is what it holds.
after="$(weaken_blob "${c}" "${f}" "${tag}.e${j}.after")" || return 1
landed="${after}"
[[ -z "${after}" ]] || holds='1'
else
weaken_pair="$(weaken_auto_blob "${c}" "${f}" "${mp}" "${tag}.e${j}")" || return 1
if [[ -z "${weaken_pair}" ]]; then
j=$(( j - 1 ))
continue
fi
# Main's own side. The first event measures it against the merge
# base; later ones against main's side at the previous event (the
# chain below), never against the branch's side, which is the round's
# own authorship and already inside tip - pre-round.
after="$(sed -n 1p <<< "${weaken_pair}")"
before="$(sed -n 2p <<< "${weaken_pair}")"
landed="$(sed -n 3p <<< "${weaken_pair}")"
holds="$(sed -n 4p <<< "${weaken_pair}")"
branch_side="$(sed -n 5p <<< "${weaken_pair}")"
[[ "${holds}" == '1' ]] || holds='0'
fi
# Main's contributions CHAIN: after the first event, main's side is
# measured against main's side at the PREVIOUS event, not against a
# fresh merge base that already reflects it. Chained, the events
# telescope to main's own net for the round; unchained, main deleting
# and re-adding a file is credited for the re-add twice.
[[ -z "${weaken_prev_set}" ]] || before="${weaken_prev}"
weaken_prev="${after}"
weaken_prev_set='1'
events+="$(jq -cn --arg b "${before}" --arg a "${after}" --arg l "${landed}" \
--arg bs "${branch_side}" --argjson h "${holds}" \
'{before: (if $b == "" then null else $b end),
after: (if $a == "" then null else $a end),
landed: (if $l == "" then null else $l end),
branch: (if $bs == "" then null else $bs end),
mainHolds: ($h == 1)}'),"
done
jq -n --arg path "${f}" --arg tip "${tip}" --arg pre "${pre}" --argjson events "[${events%,}]" '
{path: $path,
tip: (if $tip == "" then null else $tip end),
pre: (if $pre == "" then null else $pre end),
events: $events}' > "${WEAKEN_TMP}/${tag}.json" || return 1
local weaken_out
weaken_out="$(WEAKEN_PARSER_FILE="${WEAKEN_PARSER}" node "${WEAKEN_COUNTER}" measure "${WEAKEN_TMP}/${tag}.json")" || return 1
# An exit-0 run that printed no readable verdict is a measurement
# FAILURE, not a verdict: read as one, its missing `baselinePresent`
# would say "not the round's to weaken" and silently uncharge the file
# while the round still reports itself measured. Every field the reader
# below consumes must be present and of the right type, or the caller's
# fail-closed arm takes the file.
jq -e '(.baselinePresent | type) == "boolean"
and (.assertions | type) == "number"
and (.enabled | type) == "number"
and (.newlyDisabled | type) == "array"' <<< "${weaken_out}" > /dev/null 2>&1 || return 1
printf '%s\n' "${weaken_out}"
}
weaken_add_file() {
local weaken_e
for weaken_e in "${WEAKEN_FILES[@]}"; do
[[ "${weaken_e}" != "${1}" ]] || return 0
done
WEAKEN_FILES+=("${1}")
}
# Add every pathspec file `git diff ${@}` lists. The producer's status is
# read, not swallowed behind a process substitution: an enumeration git
# could not perform marks the measurement UNAVAILABLE instead of silently
# measuring nothing.
weaken_add_diff() {
if ! git diff --name-only -z --no-renames "$@" -- "${WEAKEN_PATHSPEC[@]}" > "${WEAKEN_TMP}/list" 2> /dev/null; then
WEAKEN_MEASURED='false'
return 0
fi
while IFS= read -r -d '' f; do
[[ -n "${f}" ]] || continue
weaken_add_file "${f}"
done < "${WEAKEN_TMP}/list"
}
# Candidates: every pathspec file the round's pre-round->tip pair moved at
# all -- the authority on what the round changed, and the only arm that
# sees a merge whose TREE equals its first parent's (`-s ours` onto main,
# a reset that drops a pre-round commit): those move nothing per commit
# while moving plenty end to end. A file only MAIN moved comes in here too
# and measures to a zero delta, so the wider net costs a measurement, never
# a verdict. The per-commit arms stay: a merge lists what it moved relative
# to MAIN's side as well, so an --ours resolution that discards a test main
# landed mid-round is enumerated even though the pre-round ref never held
# it.
WEAKEN_FILES=()
if [[ "${WEAKEN_MEASURED}" == 'true' ]]; then
for (( weaken_i = 0; weaken_i < ${#WEAKEN_COMMITS[@]}; weaken_i++ )); do
c="${WEAKEN_COMMITS[weaken_i]}"
[[ "${WEAKEN_KINDS[weaken_i]}" != 'main' ]] || continue
weaken_add_diff "$(weaken_parent "${c}")" "${c}"
[[ "${WEAKEN_KINDS[weaken_i]}" == 'merge' ]] || continue
weaken_add_diff "${c}^${WEAKEN_MAIN_PARENT[weaken_i]}" "${c}"
done
fi
weaken_add_diff "origin/${BRANCH}" "${BRANCH}"
# A pre-existing test replaced by a symlink or a submodule still has a blob
# at the tip, so the deletion arm reads the tip's MODE: anything but a
# regular file at that path is the deletion of its surface, whatever the
# counter would read from the link's target text. Direction matters -- a
# symlink the round replaces with a real test file grew the surface and is
# measured, not charged.
weaken_tip_mode() {
git ls-tree "${BRANCH}" -- ":(literal)${1}" 2> /dev/null | awk '{print $1; exit}'
}
# Success when the round's BASELINE holds ${1} -- the pre-round ref, or any
# main-derived event that carried it during the round. Deliberately WIDER
# than the measured arm's `baselinePresent`, which also asks whether main
# contributed anything and whether the merge adopted its deletion: the
# fail-closed arm must never be the narrower of the two, so a file it
# cannot measure is charged rather than waived.
weaken_baseline_holds() {
local weaken_bi
weaken_is_blob "origin/${BRANCH}" "${1}" && return 0
for (( weaken_bi = 0; weaken_bi < ${#WEAKEN_COMMITS[@]}; weaken_bi++ )); do
[[ "${WEAKEN_KINDS[weaken_bi]}" != 'own' ]] || continue
if weaken_is_blob "${WEAKEN_COMMITS[weaken_bi]}" "${1}"; then
return 0
fi
if [[ "${WEAKEN_KINDS[weaken_bi]}" == 'merge' ]] &&
weaken_is_blob "${WEAKEN_COMMITS[weaken_bi]}^${WEAKEN_MAIN_PARENT[weaken_bi]}" "${1}"; then
return 0
fi
done
return 1
}
if [[ "${WEAKEN_MEASURED}" == 'true' ]]; then
for (( weaken_idx = 0; weaken_idx < ${#WEAKEN_FILES[@]}; weaken_idx++ )); do
f="${WEAKEN_FILES[weaken_idx]}"
if ! weaken_verdict="$(weaken_measure "${f}" "f${weaken_idx}")"; then
# The instrument could not measure THIS file -- an input the round
# itself authored can exhaust the parser. Fail closed for the file
# alone (one ack entry answers it) and keep measuring the rest of
# the round, rather than waiving every measured signal because one
# file was unreadable. A file the round's BASELINE never held has no
# coverage to weaken, so it is skipped rather than charged.
if weaken_baseline_holds "${f}"; then
WEAKENED_PATHS+=("${f}")
WEAKENED_SIGNALS+=('test surface could not be measured')
fi
continue
fi
weaken_baseline="$(jq -r '.baselinePresent' <<< "${weaken_verdict}" 2> /dev/null)" || weaken_baseline=''
signal=''
if [[ "${weaken_baseline}" != 'true' ]]; then
# Not the round's to weaken: the file is its own (pre-round absent and
# never landed by main) or main itself removed it.
:
elif [[ "$(weaken_tip_mode "${f}")" != '100644' &&
"$(weaken_tip_mode "${f}")" != '100755' ]]; then
signal='test file deleted'
else
signal="$(jq -r '
if (.newlyDisabled | length) > 0 then "\(.newlyDisabled | length) pre-existing test registration(s) disabled"
elif .assertions < 0 then "net \(-.assertions) assertion(s) removed"
elif .enabled < 0 then "net \(-.enabled) enabled test(s) removed"
else "" end' <<< "${weaken_verdict}" 2> /dev/null)" || signal=''
fi
if [[ -n "${signal}" ]]; then
WEAKENED_PATHS+=("${f}")
WEAKENED_SIGNALS+=("${signal}")
fi
done
fi
if [[ "${WEAKEN_MEASURED}" != 'true' ]]; then
# UNAVAILABLE: only whole-file deletions and typechanges are judged, from
# the explicit pre-round->tip pair, and every one of them is surfaced --
# without the walk nothing proves that main, not the round, removed a
# file, and a deletion main did make is acknowledged like any other. An
# enumeration git refuses is a fail-closed rejection: the absence of
# deletions cannot be certified from nothing.
echo '🧪 test-weakening measurement UNAVAILABLE this round (instrument or history unreadable) — only whole-file deletions are judged' | tee -a "${GATE_LOG}"
WEAKENED_PATHS=()
WEAKENED_SIGNALS=()
if ! git diff --name-only -z --no-renames --diff-filter=DT "origin/${BRANCH}" "${BRANCH}" \
-- "${WEAKEN_PATHSPEC[@]}" > "${WEAKEN_TMP}/deleted" 2> /dev/null; then
echo 'the pre-round->tip deletion enumeration failed; the absence of test deletions cannot be certified' >> "${GATE_LOG}"
reject_fix 'test-weakening gate could not enumerate whole-file deletions; refusing to certify their absence'
fi
while IFS= read -r -d '' f; do
[[ -n "${f}" ]] || continue
WEAKENED_PATHS+=("${f}")
WEAKENED_SIGNALS+=('test file deleted')
done < "${WEAKEN_TMP}/deleted"
fi
rm -rf "${WEAKEN_TMP}"
if (( ${#WEAKENED_PATHS[@]} > 0 )); then
# The acknowledgement is the agent's own machine-readable claim, held to
# the same shape rules as deferred-findings.json: an array, a string path,
# and a reason with enough substance to be read as evidence. A malformed or
# unreadable file acknowledges nothing rather than everything.
# Acknowledged paths travel base64-encoded: newline-safe through the
# line-based read below, and comparable against the measured set without
# ever decoding branch-controlled bytes through shell parsing.
WEAKEN_ACKED=()
if [[ -s "${WORKDIR}/test-weakening.json" ]]; then
weaken_ack_b64="$(jq -j '
if type == "array" then
.[]
| select((.path? | type) == "string")
| select((.path | length) > 0)
| select((.reason? | type) == "string")
| select((.reason | gsub("\\s+"; " ") | ltrimstr(" ") | rtrimstr(" ") | length) >= 40)
| (.path | @base64) + "\n"
else empty end' "${WORKDIR}/test-weakening.json" 2> /dev/null)" || weaken_ack_b64=''
while IFS= read -r weaken_entry; do
[[ -n "${weaken_entry}" ]] && WEAKEN_ACKED+=("${weaken_entry}")
done <<< "${weaken_ack_b64}"
fi
WEAKEN_MISSING=''
WEAKEN_OK=''
WEAKEN_OK_B64=''
for (( weaken_idx = 0; weaken_idx < ${#WEAKENED_PATHS[@]}; weaken_idx++ )); do
f="${WEAKENED_PATHS[weaken_idx]}"
signal="${WEAKENED_SIGNALS[weaken_idx]}"
# Filenames are branch-controlled bytes rendered inside gate-authored
# (trusted-voice) documents, so they go through the same conservative
# safe-character set the shrink advisory above uses.
if weaken_member "$(printf '%s' "${f}" | base64 | tr -d '\n')" "${WEAKEN_ACKED[@]}"; then
WEAKEN_OK+="- \`${f//[^A-Za-z0-9._\/ -]/?}\` — ${signal}"$'\n'
WEAKEN_OK_B64+="$(printf '%s' "${f}" | base64 | tr -d '\n')"$'\n'
else
WEAKEN_MISSING+="- \`${f//[^A-Za-z0-9._\/ -]/?}\` — ${signal}"$'\n'
fi
done
if [[ -n "${WEAKEN_MISSING}" ]]; then
# The ack list must be complete and reachable: the rejection document
# is a tail-bounded window of GATE_LOG, and a long list gets cut from
# the FRONT while the remedy text below survives — the agent would be
# told to ack a list it was never shown (R32-2). The full measured
# list rides in its own file, which the repair pass can read from the
# same workdir.
printf '%s' "${WEAKEN_MISSING}" > "${WORKDIR}/weaken-missing.txt"
{
echo 'This round deleted or weakened pre-existing tests without recording the required evidence:'
printf '%s' "${WEAKEN_MISSING}"
echo 'Deleting or weakening a test is sound only when the pinned behaviour itself was wrong (show the probe that proves the correct behaviour) or the coverage demonstrably survives in a named surviving test.'
echo 'Either restore the assertions, or record the evidence: write <workdir>/test-weakening.json — a JSON array of {"path": "<file>", "reason": "<evidence, at least 40 characters>"} carrying one entry for every file listed above. If the list above is cut off, the complete list is <workdir>/weaken-missing.txt.'
} >> "${GATE_LOG}"
reject_fix 'round weakened pre-existing tests without recorded evidence'
fi
{
echo '🧪 **Gate advisory — this round weakened or removed pre-existing tests** (machine-measured, not agent-authored):'
printf '%s' "${WEAKEN_OK}"
echo
echo 'The round recorded evidence for each (below, agent-authored). Weakening is sound only when the pinned behaviour itself was wrong or the coverage demonstrably survives elsewhere — read each reason against the diff. · 本轮弱化或删除了既有测试(门自动测量,非 agent 文本)。下列理由由 agent 撰写:仅当被钉住的行为本身有误、或覆盖确有替代时才成立,请对照 diff 逐条审阅。'
# Agent-authored bytes inside a gate-authored document: neutralize both
# comment-marker and details/summary forms (a severed <details> would
# swallow the rest of the posted comment) and cap each reason, the same
# hygiene the report step applies to failure.md excerpts.
# Rendered from the MEASURED set, one line per file: the entries are
# agent-authored and otherwise unbounded, so an ack file stuffed with
# thousands of junk rows would decide the size of a posted PR comment.
jq -r --arg ok "${WEAKEN_OK_B64}" '
($ok | split("\n") | map(select(length > 0) | @base64d)) as $ok
| if type == "array" then
map(select((.path? | type) == "string")
| select(.path | IN($ok[]))
| select((.reason? | type) == "string")
| select((.reason | gsub("\\s+"; " ") | ltrimstr(" ") | rtrimstr(" ") | length) >= 40))
| unique_by(.path) | .[]
| " - \(.path | gsub("[^A-Za-z0-9._/ -]"; "?")): \(.reason | gsub("\\s+"; " "))"
else empty end' "${WORKDIR}/test-weakening.json" 2> /dev/null |
cut -b1-300 | iconv -f utf-8 -t utf-8 -c |
sed -e 's/<!--/<!\\-\\-/g' -e 's/<[dD][eE][tT][aA][iI][lL][sS]/<details/g' \
-e 's/<\/[dD][eE][tT][aA][iI][lL][sS]/<\/details/g' \
-e 's/<[sS][uU][mM][mM][aA][rR][yY]/<summary/g' || true
} >> "${WORKDIR}/gate-advisories.md"
echo "🧪 test weakening recorded and acknowledged: $(grep -c '^- ' <<< "${WEAKEN_OK}" || true) file(s)" | tee -a "${GATE_LOG}"
fi
echo '🔬 Re-running deterministic checks (independent of the agent)...'
run_check 'build failed on the agent-committed fix' npm run build
# Typecheck consumes core's dist (sdk-typescript resolves
+1 -1
View File
@@ -37,7 +37,7 @@
6495 pr-self-report-label.yml
9646 qwen-autofix-fork-bridge.yml
5942 qwen-autofix-fork-signal.yml
469259 qwen-autofix.yml
469165 qwen-autofix.yml
7061 qwen-ci-flaky-rerun.yml
261105 qwen-code-pr-review.yml
79041 qwen-fleet-shepherd.yml
+240 -1
View File
@@ -258,6 +258,8 @@ task-oriented guides — what a maintainer types and what happens next — see:
- [154. review-address · Report dry-run / failure — Convergence-break report guard (#10122): the report step's stale-base retry is a sibling…](#af-154)
- [155. review-address · Report dry-run / failure — Hold the stale-base refresh while a review-pr is in flight on the PR.…](#af-155)
- [156. review-address · Triage and address — In-round self-review A/B: one bounded delta review before the round's commit,…](#af-156)
- [157. review-address · Prepare branch and feedback — Classify the live head: a round that pushes onto a fully GREEN head and leaves…](#af-157)
- [158. review-address · Stage trusted test-surface instrument — The test-weakening gate's instrument: the counter from the same trusted…](#af-158)
---
@@ -3009,6 +3011,23 @@ its own verdict. The trusted PATH is recorded before any branch
code runs, so a $GITHUB_ENV-planted PATH/preload cannot swap the
sha256sum/bash/git the steps resolve (that would defeat the digest
gate itself).
The gate's two verification passes decide whether the PAT push runs,
and the first pass executes the branch's own build/test on the host
before the second — so the staged runner's digest (recorded in
GITHUB_OUTPUT, unreachable from a disk write) is verified before
execution, or a mid-run overwrite lets the branch define its own
verdict. Every command word in the digest check is an absolute path:
bare names — even builtins like echo, export, or unset — are shadowed
by $GITHUB_ENV-planted BASH_FUNC_<name>%% functions, imported at bash
STARTUP even under --norc, ahead of builtins and PATH (R6-4; a
shadowed echo prints any digest line, blinding the check to a mid-run
overwrite of the staged runner, and a shadowed export/unset arms a
DEBUG trap that swaps the staged runner AFTER the digest passes and
BEFORE the launch executes it, R8-1). The body therefore carries no
in-shell pin of its own: PATH reaches the child through the allowlist,
and the preload channels are closed by the step-level LD_* pins, the
env execve prefix, and env -i.
```
<a id="af-112"></a>
@@ -3977,6 +3996,54 @@ deletion reopens only the cosmetic overwrite (nothing
rides the stamp but the comment text), and finalize's
read is bounded like the loop's pid-file read so a
planted FIFO cannot stall it.
The gate-side kill block (review-address's verify gate is
the first step that runs branch code ON THE HOST — the
agent phase sandboxes it in docker — and the loop holds
the bot PAT in its env, so the PAT overlap is bounded to
the sandboxed agent phase, never the host-side gate) kills
from the pid the launch recorded in expression context;
WORKDIR files are sandbox-writable and are never read as
kill targets. The session kill covers a kill landing
MID-TICK, per the KILL paragraph above. Its command words
are ABSOLUTE-PATH-ONLY, NOT builtin: the block runs in the
gate's OUTER shell, which inherits every $GITHUB_ENV plant
— a BASH_FUNC_builtin%% import shadows the `builtin`
keyword itself (the R6-4 doctrine). `builtin kill` is
sound only inside the env -i clean child (finalize); in
the outer shell kill is /usr/bin/kill, the same procps
already relied on for pkill.
The finalize-side kill runs in its step's OUTER shell, which
inherits every $GITHUB_ENV plant earlier steps left — a
BASH_FUNC_cat%% import shadows cat at bash startup and forges
the /proc/<pid>/stat line (the matching start time sits in that
step's own env), passing the lifecycle check on a REUSED pid so
the real kill TERMs an unrelated process, its group and session;
a planted kill or a PATH-planted pkill no-ops the delivery
instead, leaving the loop holding the PAT alive to its age cap
(R17-4). The kill target travels through expression context — a
pid read from a WORKDIR file would be an untrusted kill target
(WORKDIR is sandbox-writable) — and the whole PAT-touching body
runs in the gate's env -i clean-child form, re-declaring only
what it needs from the step-level pins and expression context:
a BASH_FUNC_<name>%% import loads as a function at bash STARTUP
even under --norc, ahead of builtins and PATH, under
attacker-chosen names no step-level pin can enumerate
(probe-verified: plants shadowing set/export/builtin/gh each
hijacked the step's former inline body), so the class is closed
by construction rather than enumerated.
The terminal flip's stamp drain reads the tick's start-epoch file:
a stamp older than 65s proves its request committed or died, no
stamp means nothing in flight, and the read is bounded in time AND
bytes — a planted FIFO must not stall the step, and a symlink to an
endless non-NUL target (/dev/urandom, a fed FIFO) would otherwise
stream into the substitution buffer GB-scale and kill the clean
child before the terminal PATCH (R17-3); a valid stamp is ≤ ~20
bytes, so the 64-byte cap covers it. A planted fresh stamp costs at
most the 65s bound; a planted deletion reopens only the cosmetic
overwrite.
```
<a id="af-150"></a>
@@ -4271,7 +4338,7 @@ for having been reviewed at the wrong moment.
In `review-address` · `Triage and address` (arm), `Verification gate` (record), `Push and report` (marker).
```text
````text
Measured on the takeover fleet on 2026-09-10 (40 PRs, 79 acted rounds,
1369 inline findings, each blamed at its review head): after a round
pushes, 73% of the next review's new Criticals and 93% of its
@@ -4341,4 +4408,176 @@ a plain path and the deadline to an ISO instant). Whether the nested
`review run` resolves its model credentials inside the sandbox is a
runner fact the first armed round has to prove; the skill treats a
failed or incomplete pass as review-failed and pushes regardless.
<a id="af-157"></a>
### 157. review-address · Prepare branch and feedback — Classify the live head: a round that pushes onto a fully GREEN head and leaves…
In `review-address` · `Prepare branch and feedback`.
```text
Classify the live head: a round that pushes onto a fully
GREEN head and leaves it red introduced the red. Until this
existed the loop had no notion of a regression at all —
`grep -rE 'regress|introduced'` over the workflow, the gate
script and the SKILL hit only prose about WRITING regression
tests. The consecutive-failure brake counts "rounds that
pushed nothing", so a round that pushed a fix and turned CI
red counted as a SUCCESS and reset the counter; the red it
created came back as the next round's input and was paid for
out of the round budget. A PR could alternate regress /
repair indefinitely while every brake read it as converging.
The gate cannot close this on its own. It runs build,
typecheck, lint and `--changed` tests for the touched
workspaces only, and says so — "Full regression is covered by
regular CI on the PR after the push". Everything the gate
does run IS already charged: a check that fails on the round
tree and passes at `origin/<branch>` is a rejection, and a
rejected round pushes nothing and feeds the brake. What is
left over is precisely the post-push signal — the full suite,
the other packages, other platforms — and that verdict does
not exist until CI finishes, long after the job has ended.
So the accounting is deferred rather than waited on, and it
is measured, never inferred:
- Every ACTED round stamps `autofix-push round= head= pre=
key=`. `pre=` is this classifier's verdict on the head
the round STARTED from; `head=` is the sha it pushed
(NOT the redcheck marker's, which deliberately records
the pre-round head).
- The next round's prepare charges a regression only when
all four hold: the live head is exactly the sha that
marker names (nothing else moved the branch), `pre=green`
(fully green — `pending` and `none` are not green, so a
check still running at push time can never be charged),
the marker's window key matches, and the head is red NOW.
- The observing round writes `autofix-regression round=
key=` into whichever report it posts — pushed, no-op or
failure — so the record survives the round that made it.
- The consecutive-failure walk stops resetting on a
regressing round's headline, reading the round number out
of the headline the report already wrote it into. The
walk also adds THIS round's own observation, which is not
in the fetched comment list yet: without it the newest
regressing push escapes by exactly one round, and that is
the round that matters.
Attribution is deliberately conservative on every axis that
could charge the loop for someone else's red. A head that
moved (a human push, a base update) breaks the head equality
and drops the charge — and the equality binds BOTH halves:
the marker's head to the checked-out head, and the check
rollup to the commit it describes (`headRefOid` is read in
the same call as the rollup; a rollup for any other commit
classifies `none`, unknown, never chargeable). A re-arm
changes the window key and drops the whole set with it —
and the charge itself fires only when the run's matrix
window IS the live re-arm key: a supersede-exempt conflict
round can still run under a stale window, and a charge
keyed to that dead window is one the brake's live-window
headline walk would never read, so it is not charged. A
head classified from a base-conflict merge or a salvage merge
stamps `pre=none`: the pushed head did not start from the
head prepare classified. A cancelled check is neither red
(matching the scan's own N_RED_NOW filter) nor green: it
classifies pending, like every other verdict-less state.
GREEN is an ALLOWLIST on both axes, never a fall-through.
The charge-enabling verdict requires every counted check to
have FINISHED and to hold a conclusion the classifier
recognises as passing (SUCCESS, NEUTRAL, SKIPPED); anything
else — a conclusion it does not know, a verdict that never
came, a re-run still in flight over a stale conclusion — is
pending, and pending is never chargeable. Both axes are
allowlists for the same reason: a denylist forgets the member
it was not told about, which is how CANCELLED entered the
green arm in the first place and how REQUESTED would have
followed it. The status axis is `status == "COMPLETED"` with
an absent field reading as finished, because a commit status
carries no status field at all.
When the push landed but the round report could not be
posted, a marker-only comment carries the push marker, so
the regression it may have caused can still be charged. The loop's own lanes are excluded wholesale
by the canonical five-name filter this file's other own-lane
filters use, plus the loop's own dispatch-pending commit
status by its exact context value (a StatusContext carries no
workflowName) — deliberately NOT the feedback renderer's
review-address carve-out, which keeps failed and in-flight
address runs visible as feedback: a charge verdict must never
see them (a failed own round is feedback, not a regression
the pushed code authored, and an in-flight own check would
hold the verdict at pending across the trigger family whose
suite attaches to the PR head). What remains uncovered is red the round did
not author but cannot be distinguished from red it did: a
genuinely flaky check failing on the bot's push, and main's
own breakage arriving between the classification and the
CI run, since CI reports on the merge of the pushed head
with a main that moved. Neither is separable from here. A
base-equality axis alongside the head one was considered and
rejected: main moves between almost every pair of rounds, so
requiring it would make the charge unreachable rather than
accurate, and reading main's own health would put a second
API dependency inside the charge. The consequence is bounded
on purpose — one regression only declines to RESET a counter
that needs five consecutive non-progress rounds to trip, and
the recovery is automatic, since a clean push resets it. What
the round itself can tell is already told: a push carrying a
conflict or salvage merge stamps `pre=none`, because its head
did not start from the head prepare classified.
````
<a id="af-158"></a>
### 158. review-address · Stage trusted test-surface instrument — The test-weakening gate's instrument: the counter from the same trusted…
In `review-address` · `Stage trusted test-surface instrument`.
```text
The test-weakening gate's instrument: the counter from the
same trusted checkout as the gate runner, and its parser
from the dependencies `npm ci` just installed against the
TRUSTED lockfile — integrity-checked there, before any
branch step runs.
It is a step of its own, and it sits where it does because
both halves have a placement constraint. The counter must
come from the trusted checkout, so it must be staged BEFORE
"Prepare branch and feedback" switches the working tree to
the PR branch — a branch-supplied counter would let the code
under test define its own measurement. The parser must come
from node_modules, so it can only be staged AFTER "Install
dependencies". Nothing else runs in that window, which is
why this is not folded into the schema-gate staging step
above.
The parser is staged as a `.cjs` copy of typescript's
single-file build rather than required from node_modules at
gate time: the copy carries its own extension, so no
`package.json` a later step plants beside it can change how
Node loads it, and the digest below pins the bytes that
actually execute.
Both digests travel in expression context (the af-111
doctrine) and the gate verifies them before the counter
runs; a digest that does not match is tampering, and the
gate fails the round without a retry.
Absent from the trusted base implies absent on disk. This
script is new in its PR, so the tolerant `cp` fails every
pre-merge round — and `rm -rf` runs first so a leftover a
same-UID run planted at the staged path on this persistent
host cannot survive the failure and be digested as trusted
content. `-rf`, not `-f`: the leftover can be a directory,
whose non-zero `rm -f` exit would abort this `-e` step
before the tolerant cp and wedge every later round on the
host. When the counter is absent no digest is recorded and
the gate fails open (`WEAKEN_MEASURED=false`), reporting
that the surface could not be measured.
A counter WITHOUT its parser is the one case that is not
tolerated: the parser copy is a bare `cp` inside the
presence branch, so a tree that has the counter but no
installed typescript fails the step loudly instead of
producing a round that quietly measures nothing.
```
+143 -147
View File
@@ -12,15 +12,10 @@ name: 'Qwen Autofix'
# qwen-autofix.md#af-001.
#
# SIZE IS A HARD CONSTRAINT, NOT A STYLE PREFERENCE. GitHub refuses to start
# runs for a workflow file over 500 KB (512,000 bytes), and it fails SILENTLY:
# schedule ticks stop firing, dispatches sit 'queued' forever with zero jobs,
# and only PR-event runs keep working — those resolve the workflow from the
# PR's own branch, so an older, smaller copy of this file runs and the loop
# looks half-alive. That is precisely what happened on 2026-08-19: this file
# crossed the limit at 512,782 bytes and the loop went dark for a day while
# `pull_request_review` runs kept reporting success. Prose belongs in
# qwen-autofix.md and long steps belong in .github/scripts/ — that is what
# keeps this file runnable at all.
# runs for a workflow file over 500 KB, and it fails SILENTLY (2026-08-19 this
# file crossed it and the loop went dark for a day). Prose belongs in
# qwen-autofix.md and long steps belong in .github/scripts/.
# Full rationale → qwen-autofix.md (its own header carries the size doctrine)
on:
issues:
types:
@@ -89,6 +84,11 @@ env:
# is the prompt-injection trust gate: feedback from anyone else is ignored so a
# hostile commenter cannot steer the agent.
TRUSTED_ASSOC: '["OWNER", "MEMBER", "COLLABORATOR"]'
# Every lane this loop owns whose check runs attach to the PR head:
# excluded from the head-state classifiers (charge + wake) by name, or
# the loop reads its own red as the round's regression (af-157, R27-23).
# A test pins the names against the workflows' `name:` lines.
LOOP_OWNED_LANES: '["Qwen Autofix","🧐 Qwen Pull Request Review","Qwen CI Failure Patrol","Qwen Autofix Fork Bridge","Qwen Autofix Fork Signal","Qwen Triage","Qwen Triage Finalize","PR self-report label","Qwen PR Safety Precheck","Comment Attachment Guard","PR Asset Branch Cleanup"]'
# The review bot's thread lifecycle replies `R<id> fixed by <what>` into a
# thread — a NOTE, never a finding: it carries its own marker
# (FIXED_RULING_MARKER in lib/review-footer.ts) at the end of its first
@@ -3155,7 +3155,7 @@ jobs:
CONFLICT_WAKE_SCAN="$(jq -rs \
--arg since "${CONFLICT_SINCE_SCAN}" --arg baseupd "${BASE_UPD_AT_SCAN}" \
--arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \
--argjson trust "${TRUSTED_ASSOC}" '
--argjson trust "${TRUSTED_ASSOC}" --argjson looplanes "${LOOP_OWNED_LANES}" '
(.[0] | map(select((.submitted_at // "") > $since)
| select((.user.login // "") != $ab and (.user.login // "") != $rb)
| select(((.author_association // "") | IN($trust[])))
@@ -3169,7 +3169,7 @@ jobs:
| 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)) | length)
+ (.[3] | map(select((.conclusion // .state // "") | IN("FAILURE", "FAILED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED"))
| select(((.workflowName // "") | IN("Qwen Autofix", "🧐 Qwen Pull Request Review", "Qwen CI Failure Patrol", "Qwen Autofix Fork Bridge", "Qwen Autofix Fork Signal")) | not)
| select(((.workflowName // "") | IN($looplanes[])) | not)
| select((.completedAt // .updatedAt // "") > $since)
| select((.completedAt // .updatedAt // "") > $baseupd)) | length)' \
"${WORKDIR}/rv.scan.json" "${WORKDIR}/rc.scan.json" "${WORKDIR}/ic.json" "${WORKDIR}/checks.scan.json" 2> /dev/null || echo 0)"
@@ -3184,21 +3184,9 @@ jobs:
fleet_row "${PR}" 'conflict-parked' 'conflict handoff pending — no stale-base head move while parked'
fi
# Convergence-signal circuit breaker (#10107): the verdict is
# derived HERE, above the stale-base update, and the update's
# gate refuses a parked PR beside the conflict park: a base
# merge into a parked PR re-fires every synchronize-triggered
# workflow on the new head, and the base-merge round reviews
# an unchanged diff — its clean marker resets the streak,
# silently lifting the park with zero human activity (af-108's
# exact hazard, the one the conflict park above was added
# for). The notice's promise that base conflicts stay
# unhandled while paused only holds with this guard. The
# verdict and the park action BOTH sit above the idle
# fast-path (an idle parked PR must still reach its notice),
# and BOTH stale-base gates reuse this one derivation, so the
# reads cannot drift. Mirrored live in prepare; keep in
# lockstep.
# Full rationale → qwen-autofix.md#af-152
# derived HERE, above the stale-base update; BOTH stale-base
# gates reuse this one derivation, mirrored live in prepare —
# keep in lockstep. Full rationale → qwen-autofix.md#af-152
gh api "repos/${REPO}/pulls/${PR}/reviews" --paginate \
| jq -s 'add // []' > "${WORKDIR}/rv.json"
gh api "repos/${REPO}/pulls/${PR}/comments" --paginate \
@@ -4087,6 +4075,20 @@ jobs:
done
git config core.hooksPath .husky
# The test-weakening gate's instrument, staged trusted and digested.
# Full rationale → qwen-autofix.md#af-158
- name: 'Stage trusted test-surface instrument'
id: 'stage_surface'
run: |-
rm -rf "${RUNNER_TEMP}/count-test-surface.mjs" "${RUNNER_TEMP}/weaken-parser"
cp .github/scripts/count-test-surface.mjs "${RUNNER_TEMP}/count-test-surface.mjs" 2> /dev/null || true
if [[ -f "${RUNNER_TEMP}/count-test-surface.mjs" ]]; then
mkdir -p "${RUNNER_TEMP}/weaken-parser"
cp node_modules/typescript/lib/typescript.js "${RUNNER_TEMP}/weaken-parser/typescript.cjs"
echo "weaken_counter_sha256=$(sha256sum "${RUNNER_TEMP}/count-test-surface.mjs" | cut -d' ' -f1)" >> "${GITHUB_OUTPUT}"
echo "weaken_parser_sha256=$(sha256sum "${RUNNER_TEMP}/weaken-parser/typescript.cjs" | cut -d' ' -f1)" >> "${GITHUB_OUTPUT}"
fi
- name: 'Download CLI bundle'
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
with:
@@ -4347,9 +4349,12 @@ jobs:
| jq -s 'add // []' > "${WORKDIR}/rc.json"
gh api "repos/${REPO}/issues/${PR}/comments" --paginate \
| jq -s 'add // []' > "${WORKDIR}/ic.json"
gh pr view "${PR}" --repo "${REPO}" \
--json statusCheckRollup --jq '.statusCheckRollup // []' > "${WORKDIR}/checks.json" \
2> /dev/null || echo '[]' > "${WORKDIR}/checks.json"
# headRefOid rides the same read as the rollup (af-157).
PR_ROLLUP="$(gh pr view "${PR}" --repo "${REPO}" \
--json statusCheckRollup,headRefOid 2> /dev/null || echo '{}')"
jq '.statusCheckRollup // []' <<< "${PR_ROLLUP}" > "${WORKDIR}/checks.json" 2> /dev/null \
|| echo '[]' > "${WORKDIR}/checks.json"
ROLLUP_HEAD="$(jq -r '.headRefOid // ""' <<< "${PR_ROLLUP}" 2> /dev/null || echo '')"
# Newest actionable feedback timestamp — stamped into the eval marker so
# the next scan knows everything up to here has been considered.
@@ -4649,7 +4654,7 @@ jobs:
CONFLICT_WAKE="$(jq -rs \
--arg since "${CONFLICT_SINCE}" --arg baseupd "${BASE_UPD_AT}" \
--arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \
--argjson trust "${TRUSTED_ASSOC}" '
--argjson trust "${TRUSTED_ASSOC}" --argjson looplanes "${LOOP_OWNED_LANES}" '
(.[0] | map(select((.submitted_at // "") > $since)
| select((.user.login // "") != $ab and (.user.login // "") != $rb)
| select(((.author_association // "") | IN($trust[])))
@@ -4663,7 +4668,7 @@ jobs:
| 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)) | length)
+ (.[3] | map(select((.conclusion // .state // "") | IN("FAILURE", "FAILED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED"))
| select(((.workflowName // "") | IN("Qwen Autofix", "🧐 Qwen Pull Request Review", "Qwen CI Failure Patrol", "Qwen Autofix Fork Bridge", "Qwen Autofix Fork Signal")) | not)
| select(((.workflowName // "") | IN($looplanes[])) | not)
| select((.completedAt // .updatedAt // "") > $since)
| select((.completedAt // .updatedAt // "") > $baseupd)) | length)' \
"${WORKDIR}/rv.json" "${WORKDIR}/rc.json" "${WORKDIR}/ic.json" "${WORKDIR}/checks.json" 2> /dev/null || echo 0)"
@@ -4788,6 +4793,47 @@ jobs:
[[ -z "${OVER_BUDGET_AUTHORS}" ]] && OVER_BUDGET_AUTHORS='[]'
[[ "${OVER_BUDGET_AUTHORS}" != '[]' ]] && echo "🚦 regular-feedback budget exhausted this window for: $(jq -r 'join(", ")' <<< "${OVER_BUDGET_AUTHORS}")"
fi
# ---- regression accounting ------------------------------------
# Classify the live head: a push onto a fully GREEN head that
# leaves it red introduced the red.
# Full rationale → qwen-autofix.md#af-157
CHECK_STATE="$(jq -r --arg dispatch "${DISPATCH_STATUS_CONTEXT}" \
--argjson looplanes "${LOOP_OWNED_LANES}" '
[ .[] | select((((.workflowName // "")
| IN($looplanes[])) | not))
| select(((.context // "") == $dispatch) | not) ] as $c
| if ($c | length) == 0 then "none"
elif any($c[]; (.conclusion // .state // "")
| IN("FAILURE", "FAILED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED")) then "red"
elif all($c[]; ((.status // "COMPLETED") == "COMPLETED")
and ((.conclusion // .state // "")
| IN("SUCCESS", "NEUTRAL", "SKIPPED"))) then "green"
else "pending" end' "${WORKDIR}/checks.json" 2> /dev/null || echo 'none')"
case "${CHECK_STATE}" in green | red | pending | none) ;; *) CHECK_STATE='none' ;; esac
if [[ "${ROLLUP_HEAD:-}" != "${CHECKED_OUT_HEAD}" ]]; then
echo "🩸 #${PR}: the check rollup describes ${ROLLUP_HEAD:-<unknown>}, not the checked-out head ${CHECKED_OUT_HEAD} — head state unknown"
CHECK_STATE='none'
fi
REGRESSED_ROUND=''
LAST_PUSH="$(jq -r --arg ab "${AUTOFIX_BOT}" '
[ .[] | select((.user.login // "") == $ab) | . as $c | ($c.body // "")
| [ scan("<!-- autofix-push round=([0-9]+) head=([0-9a-f]+) pre=([a-z]+) key=([^ ]+) -->") ] | .[]
| {r: .[0], h: .[1], p: .[2], k: .[3], at: ($c.created_at // "")} ]
| sort_by(.at) | last // {}
| "\(.r // "") \(.h // "") \(.p // "") \(.k // "")"' \
"${WORKDIR}/ic.json" 2> /dev/null || echo '')"
read -r LP_ROUND LP_HEAD LP_PRE LP_KEY <<< "${LAST_PUSH}" || true
# Charged only in the live window: a supersede-exempt round's
# stale matrix WINDOW must not key a charge the brake never walks.
if [[ -n "${LP_ROUND:-}" && "${LP_HEAD:-}" == "${CHECKED_OUT_HEAD}" \
&& "${LP_PRE:-}" == 'green' && "${LP_KEY:-}" == "${WINDOW:-none}" \
&& "${WINDOW:-none}" == "${LIVE_REARM_KEY}" \
&& "${CHECK_STATE}" == 'red' ]]; then
REGRESSED_ROUND="${LP_ROUND}"
echo "🩸 #${PR}: round ${LP_ROUND} pushed ${CHECKED_OUT_HEAD:0:9} onto a green head and it is red now — charging that round as a regression"
fi
echo "check_state=${CHECK_STATE}" >> "${GITHUB_OUTPUT}"
echo "regressed_round=${REGRESSED_ROUND}" >> "${GITHUB_OUTPUT}"
echo "stale=${STALE}" >> "${GITHUB_OUTPUT}"
echo "effective_round=${ROUND}" >> "${GITHUB_OUTPUT}"
echo "round_start=${LIVE_ROUND_START}" >> "${GITHUB_OUTPUT}"
@@ -5429,6 +5475,8 @@ jobs:
LD_LIBRARY_PATH: ''
TRUSTED_PATH: '${{ steps.stage.outputs.trusted_path }}'
VERIFY_RUNNER_SHA256: '${{ steps.stage.outputs.verify_runner_sha256 }}'
WEAKEN_COUNTER_SHA256: '${{ steps.stage_surface.outputs.weaken_counter_sha256 }}'
WEAKEN_PARSER_SHA256: '${{ steps.stage_surface.outputs.weaken_parser_sha256 }}'
# RUNNER_TEMP, WORKDIR, and BRANCH re-enter the digest check and
# the allowlisted child below from the step environment: a
# $GITHUB_ENV plant points the digest oracle at a decoy runner
@@ -5470,24 +5518,9 @@ jobs:
HB_PID: '${{ steps.post_status.outputs.heartbeat_pid }}'
HB_START_TICKS: '${{ steps.post_status.outputs.heartbeat_start_ticks }}'
run: |-
# The round heartbeat ends HERE: this is the first step that runs
# branch code ON THE HOST (the agent phase sandboxes it in
# docker), and the loop holds the bot PAT in its env — the
# overlap is bounded to the sandboxed agent phase, never the
# host-side gate. Kill from the pid the launch recorded in
# expression context; WORKDIR files are sandbox-writable and are
# never read as kill targets. The session kill covers a kill
# landing MID-TICK: each tick's `timeout 60 gh` subtree sits in
# its OWN process group (coreutils timeout default), which the
# group+pid kills miss — but the loop owns its session via
# setsid, so -s <pid> targets exactly this round's tree.
# ABSOLUTE-PATH-ONLY command words here, NOT builtin: this block
# runs in the gate's OUTER shell, which inherits every
# $GITHUB_ENV plant — a BASH_FUNC_builtin%% import shadows the
# `builtin` keyword itself (the R6-4 doctrine the gate body
# below documents). `builtin kill` is sound only inside the
# env -i clean child (finalize); here kill is /usr/bin/kill,
# the same procps already relied on for pkill.
# The round heartbeat ends HERE: kill from the pid the launch
# recorded in expression context, with absolute-path command
# words only (the outer shell inherits $GITHUB_ENV plants).
# Full rationale → qwen-autofix.md#af-149
/usr/bin/touch "${WORKDIR}/heartbeat-stop" 2> /dev/null || true
# Lifecycle-confirmed shutdown: this kill can land the whole
@@ -5511,23 +5544,10 @@ jobs:
/usr/bin/kill "${HB_PID}" 2> /dev/null || true
/usr/bin/pkill -TERM -s "${HB_PID}" 2> /dev/null || true
fi
# The gate decides whether the PAT push runs, and the first pass
# executes the branch's own build/test on the host before the
# second — so verify the staged runner's digest (recorded in
# GITHUB_OUTPUT, unreachable from a disk write) before executing,
# or a mid-run overwrite lets the branch define its own verdict.
# Every command word below is called by absolute path: bare names
# — even builtins like echo, export, or unset — are shadowed by
# $GITHUB_ENV-planted BASH_FUNC_<name>%% functions, imported at
# bash STARTUP even under --norc, ahead of builtins and PATH
# (R6-4; a shadowed echo prints any digest line, blinding the
# check to a mid-run overwrite of the staged runner, and a
# shadowed export/unset arms a DEBUG trap that swaps the staged
# runner AFTER the digest passes and BEFORE the launch executes
# it, R8-1). The body therefore carries no in-shell pin of its
# own: PATH reaches the child through the allowlist below, and
# the preload channels are closed by the step-level LD_* pins
# above, the env execve prefix, and env -i.
# The gate decides whether the PAT push runs: verify the staged
# runner's digest before executing it (a mid-run overwrite would
# let the branch define its own verdict); absolute-path command
# words only. Full rationale → qwen-autofix.md#af-111
/usr/bin/echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | /usr/bin/sha256sum -c - > /dev/null
# Launch the gate through the workflow's env -i clean-child
# pattern: the step environment inherits every $GITHUB_ENV plant
@@ -5546,6 +5566,8 @@ jobs:
CI="${CI:-true}" \
KISS_AUDIT="${KISS_AUDIT:-false}" \
FOOTPRINT_ENFORCE="${FOOTPRINT_ENFORCE:-advisory}" \
WEAKEN_COUNTER_SHA256="${WEAKEN_COUNTER_SHA256:-}" \
WEAKEN_PARSER_SHA256="${WEAKEN_PARSER_SHA256:-}" \
SELF_REVIEW_ARM="${SELF_REVIEW_ARM:-off}" \
bash --norc "${RUNNER_TEMP}/run-autofix-review-verification.sh"
@@ -5748,6 +5770,8 @@ jobs:
LD_LIBRARY_PATH: ''
TRUSTED_PATH: '${{ steps.stage.outputs.trusted_path }}'
VERIFY_RUNNER_SHA256: '${{ steps.stage.outputs.verify_runner_sha256 }}'
WEAKEN_COUNTER_SHA256: '${{ steps.stage_surface.outputs.weaken_counter_sha256 }}'
WEAKEN_PARSER_SHA256: '${{ steps.stage_surface.outputs.weaken_parser_sha256 }}'
# RUNNER_TEMP, WORKDIR, and BRANCH re-enter the digest check and
# the allowlisted child below from the step environment: a
# $GITHUB_ENV plant points the digest oracle at a decoy runner
@@ -5782,23 +5806,10 @@ jobs:
# that died before recording it.
KISS_AUDIT: '${{ steps.verify.outputs.kiss_audit || steps.prepare.outputs.kiss_audit }}'
run: |-
# The gate decides whether the PAT push runs, and the first pass
# executes the branch's own build/test on the host before the
# second — so verify the staged runner's digest (recorded in
# GITHUB_OUTPUT, unreachable from a disk write) before executing,
# or a mid-run overwrite lets the branch define its own verdict.
# Every command word below is called by absolute path: bare names
# — even builtins like echo, export, or unset — are shadowed by
# $GITHUB_ENV-planted BASH_FUNC_<name>%% functions, imported at
# bash STARTUP even under --norc, ahead of builtins and PATH
# (R6-4; a shadowed echo prints any digest line, blinding the
# check to a mid-run overwrite of the staged runner, and a
# shadowed export/unset arms a DEBUG trap that swaps the staged
# runner AFTER the digest passes and BEFORE the launch executes
# it, R8-1). The body therefore carries no in-shell pin of its
# own: PATH reaches the child through the allowlist below, and
# the preload channels are closed by the step-level LD_* pins
# above, the env execve prefix, and env -i.
# The gate decides whether the PAT push runs: verify the staged
# runner's digest before executing it (a mid-run overwrite would
# let the branch define its own verdict); absolute-path command
# words only. Full rationale → qwen-autofix.md#af-111
/usr/bin/echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | /usr/bin/sha256sum -c - > /dev/null
# Launch the gate through the workflow's env -i clean-child
# pattern: the step environment inherits every $GITHUB_ENV plant
@@ -5817,6 +5828,8 @@ jobs:
CI="${CI:-true}" \
KISS_AUDIT="${KISS_AUDIT:-false}" \
FOOTPRINT_ENFORCE="${FOOTPRINT_ENFORCE:-advisory}" \
WEAKEN_COUNTER_SHA256="${WEAKEN_COUNTER_SHA256:-}" \
WEAKEN_PARSER_SHA256="${WEAKEN_PARSER_SHA256:-}" \
SELF_REVIEW_ARM="${SELF_REVIEW_ARM:-off}" \
bash --norc "${RUNNER_TEMP}/run-autofix-review-verification.sh"
@@ -5975,6 +5988,9 @@ jobs:
# variable (not a secret), already the agent's OPENAI_MODEL.
MODEL: '${{ vars.QWEN_AUTOFIX_MODEL || vars.QWEN_PR_REVIEW_MODEL }}'
CHECKED_OUT_HEAD: '${{ steps.prepare.outputs.checked_out_head }}'
# Regression accounting (af-157).
CHECK_STATE: '${{ steps.prepare.outputs.check_state }}'
REGRESSED_ROUND: '${{ steps.prepare.outputs.regressed_round }}'
VERIFIED_HEAD: '${{ steps.final_verify.outputs.verified_head }}'
RESANITIZE_SHA256: '${{ steps.stage.outputs.resanitize_sha256 }}'
UPSERT_SRC: '${{ steps.stage.outputs.upsert_src }}'
@@ -6058,6 +6074,8 @@ jobs:
EFFECTIVE_ROUND: '${{ steps.prepare.outputs.effective_round }}'
MODEL: '${{ vars.QWEN_AUTOFIX_MODEL || vars.QWEN_PR_REVIEW_MODEL }}'
CHECKED_OUT_HEAD: '${{ steps.prepare.outputs.checked_out_head }}'
# Regression accounting (af-157).
REGRESSED_ROUND: '${{ steps.prepare.outputs.regressed_round }}'
# This step also posts a round report (timeout / gate-rejection /
# abort), so it writes the per-round growth-now marker too — else an
# over-budget round that never reaches 'Push and report' leaves a
@@ -6470,7 +6488,7 @@ jobs:
# the base refresh once the review has landed.
MARK_TS='9999-12-31T23:59:59Z'
HEADLINE="🤖 AutoFix deferred a stale-base refresh — the fix did not pass verification and this PR is behind \`${DEFAULT_BRANCH:-main}\`, but a lifecycle review of this PR is still in flight, and merging main now would cancel it or invalidate its posting (#10110). Command-triggered reviews are invisible to this probe (af-155). The refresh and this item's retry move to the next scan."
HEADLINE_ZH="🤖 AutoFix 暂缓了一次过期 base 刷新 —— 修复未通过验证且本 PR 落后于 \`${DEFAULT_BRANCH:-main}\`,但该 PR 上仍有一轮生命周期评审在运行,现在合入 main 会取消该评审或使其发布失效(#10110)。命令触发的评审对本探测不可见(af-155)。base 刷新与此项的重试推迟到下次扫描。"
HEADLINE_ZH="🤖 AutoFix 暂缓了一次过期 base 刷新 —— 修复未通过验证且本 PR 落后于 \`${DEFAULT_BRANCH:-main}\`,但该 PR 上仍有一轮生命周期评审在运行,现在合入 main 会取消该评审或使其发布失效(#10110)。命令触发的评审对本探测不可见(af-156)。base 刷新与此项的重试推迟到下次扫描。"
else
# Say what actually happens next. The old "A human should
# take over this PR" read as a full release, but the loop
@@ -6547,18 +6565,34 @@ jobs:
| ($wins | length) > 0 and (($wins | last) == $win))]
| sort_by(.created_at) | .[]
| (.body | gsub("\r"; "") | split("\n")[0])' <<< "${COMMENTS_JSON}" 2> /dev/null || true)"
# Rounds later measured as regressing (af-157).
REGRESSED_ROUNDS="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg win "${WINDOW:-none}" '
[ .[] | select((.user.login // "") == $ab) | (.body // "")
| [ scan("<!-- autofix-regression round=([0-9]+) key=([^ ]+) -->") ] | .[]
| select(.[1] == $win) | .[0] ]
| unique | join(" ")' <<< "${COMMENTS_JSON}" 2> /dev/null || true)"
# THIS round's observation is not in COMMENTS_JSON yet.
[[ -n "${REGRESSED_ROUND:-}" ]] && REGRESSED_ROUNDS="${REGRESSED_ROUNDS} ${REGRESSED_ROUND}"
while IFS= read -r H; do
[[ -n "${H}" ]] || continue
if [[ "${H}" == *"Addressed the latest review feedback"* || "${H}" == *"no changes needed"* || "${H}" == *"AutoFix could not start —"* || "${H}" == *"updated a stale base"* || "${H}" == *"deferred a stale-base refresh"* ]]; then
CONSEC_FAIL=1
# A push that turned the checks red is not progress.
# Full rationale → qwen-autofix.md#af-157
H_ROUND=''
[[ "${H}" =~ \(round\ ([0-9]+)/ ]] && H_ROUND="${BASH_REMATCH[1]}"
if [[ -n "${H_ROUND}" && " ${REGRESSED_ROUNDS} " == *" ${H_ROUND} "* ]]; then
CONSEC_FAIL=$(( CONSEC_FAIL + 1 ))
else
CONSEC_FAIL=1
fi
else
CONSEC_FAIL=$(( CONSEC_FAIL + 1 ))
fi
done <<< "${PRIOR_HEADS}"
if [[ "${CONSEC_FAIL}" -ge "${CONSECUTIVE_FAILURE_CAP}" ]]; then
MARK_ROUND="${MAX_ROUNDS}"
HEADLINE="🤖 AutoFix stopped after ${CONSEC_FAIL} consecutive rounds that pushed nothing (failed rounds, timeouts, gate rejections, or stops under instruction). Retrying at the same per-round budget is not converging — this usually means the PR is too large or conflicts with a fast-moving \`main\`. A human should rebase, split, or reduce it, then comment \`${RETRY_COMMAND}\` to re-arm. Until then future scans will skip this PR."
HEADLINE_ZH="🤖 AutoFix 已停止:连续 ${CONSEC_FAIL} 轮未能推送任何内容(失败轮次、超时、验证门拒绝或按指示停止)。以相同的单轮预算重试并不收敛 —— 这通常意味着 PR 过大,或与快速变动的 \`main\` 冲突。应由人工 rebase、拆分或缩减它,然后评论 \`${RETRY_COMMAND}\` 重新武装。在此之前,后续扫描将跳过本 PR。"
HEADLINE="🤖 AutoFix stopped after ${CONSEC_FAIL} consecutive rounds without progress (failed rounds, timeouts, gate rejections, stops under instruction, or a push that left the checks red). Retrying at the same per-round budget is not converging — this usually means the PR is too large or conflicts with a fast-moving \`main\`. A human should rebase, split, or reduce it, then comment \`${RETRY_COMMAND}\` to re-arm. Until then future scans will skip this PR."
HEADLINE_ZH="🤖 AutoFix 已停止:连续 ${CONSEC_FAIL} 轮没有进展(失败轮次、超时、验证门拒绝、按指示停止,或推送后检查转红)。以相同的单轮预算重试并不收敛 —— 这通常意味着 PR 过大,或与快速变动的 \`main\` 冲突。应由人工 rebase、拆分或缩减它,然后评论 \`${RETRY_COMMAND}\` 重新武装。在此之前,后续扫描将跳过本 PR。"
fi
# CUMULATIVE timeout breaker — the sibling of the consecutive
# one above, for the failure shape it cannot see: timeouts
@@ -6702,6 +6736,10 @@ jobs:
echo
echo "Run log: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
echo
if [[ -n "${REGRESSED_ROUND:-}" ]]; then
echo
echo "🩸 Regression charged to round ${REGRESSED_ROUND}: that round pushed onto a head whose checks were all green and left them red. It no longer counts as progress for the consecutive-failure brake. · 已将回归记在第 ${REGRESSED_ROUND} 轮:该轮在检查全绿的 head 上推送后检查转红,因此不再计入连续失败熔断的有进展判定。"
fi
echo "---"
echo "🧠 Handled by **Qwen Code** · model/模型 \`${MODEL_DISPLAY}\`${CLI_DISPLAY}"
echo
@@ -6723,6 +6761,11 @@ jobs:
if [[ "${MARK_TS}" != '9999-12-31T23:59:59Z' ]]; then
echo "<!-- autofix-redcheck head=${REPORT_HEAD} -->"
fi
# A failed round still records the PRIOR round's regression
# (af-157).
if [[ -n "${REGRESSED_ROUND:-}" ]]; then
echo "<!-- autofix-regression round=${REGRESSED_ROUND} key=${WINDOW:-none} -->"
fi
} > "${WORKDIR}/report.md"
gh pr comment "${PR}" --repo "${REPO}" --body-file "${WORKDIR}/report.md" || echo "::warning::Failed to post handoff comment on PR #${PR}"
fi
@@ -6847,22 +6890,9 @@ jobs:
HB_PID: '${{ steps.post_status.outputs.heartbeat_pid }}'
HB_START_TICKS: '${{ steps.post_status.outputs.heartbeat_start_ticks }}'
run: |-
# This step holds the PAT, and its shell inherits every
# $GITHUB_ENV plant earlier steps left — including
# BASH_FUNC_<name>%% imports, which bash loads as functions at
# STARTUP even under --norc, ahead of builtins and PATH, under
# attacker-chosen names no step-level pin can enumerate
# (probe-verified: plants shadowing set/export/builtin/gh each
# hijacked this step's former inline body). Close the class,
# not the enumeration: the whole PAT-touching body runs in the
# gate's env -i clean-child form, re-declaring only what it
# needs from the step-level pins above and expression context.
# PATH reaches the child through the allowlist, so the body
# carries no in-shell pin of its own; inside the clean child
# no import survives, so bare and builtin words resolve
# soundly again. The kill target travels through expression
# context — a pid read from a WORKDIR file would be an
# untrusted kill target (WORKDIR is sandbox-writable).
# This step holds the PAT: the body runs in the gate's env -i
# clean-child form (no $GITHUB_ENV plant survives it), and the
# kill target travels through expression context.
# Full rationale → qwen-autofix.md#af-149
LD_PRELOAD= LD_AUDIT= LD_LIBRARY_PATH= \
/usr/bin/env -i \
@@ -6884,18 +6914,8 @@ jobs:
PR="${PR}" \
bash --norc -c '
set -uo pipefail
# Stop the round heartbeat BEFORE flipping this comment
# to its terminal text, and DRAIN it first: killing the
# client cannot cancel a PATCH the server already
# ACCEPTED, so the terminal PATCH must wait the last
# in-flight tick request out (the stamp drain below).
# The kill is lifecycle-confirmed: the gate killed this
# loop long before this step, and by now the pid
# recorded at launch can be REUSED — TERMing it would
# kill an unrelated process, its group and session. A
# failed check means the loop is already gone, so
# killing nothing is right; the stop marker ends it on
# its next self-check even if the kills miss.
# Stop the round heartbeat BEFORE the terminal text, and
# DRAIN it first; the kill is lifecycle-confirmed.
# Full rationale → qwen-autofix.md#af-149
/usr/bin/touch "${WORKDIR}/heartbeat-stop" 2> /dev/null || true
HB_FIELDS=()
@@ -6909,20 +6929,10 @@ jobs:
builtin kill "${HB_PID}" 2>/dev/null || true
/usr/bin/pkill -TERM -s "${HB_PID}" 2>/dev/null || true
fi
# Drain the last in-flight tick before the terminal
# PATCH: each tick stamps its start epoch around its gh
# call (bounded to 60s), so a stamp older than 65s
# proves its request committed or died, and no stamp
# means nothing is in flight. The loop is dead by here,
# so no new stamp can appear. The read is bounded in
# time AND bytes (a planted FIFO must not stall this
# step, and a symlink to an endless non-NUL target —
# /dev/urandom or a fed FIFO — would otherwise stream
# into the substitution buffer GB-scale and kill this
# clean child before the terminal PATCH, R17-3); a
# valid stamp is ≤ ~20 bytes, so 64 covers it. A
# planted fresh stamp costs at most the 65s bound, a
# planted deletion reopens only the cosmetic overwrite.
# Drain the last in-flight tick before the terminal PATCH:
# a stamp older than 65s proves its request committed or
# died; the read is bounded in time AND bytes (R17-3).
# Full rationale → qwen-autofix.md#af-149
DRAIN_END=$(( $(date +%s) + 65 ))
while :; do
NOW_S="$(date +%s)"
@@ -7001,24 +7011,10 @@ jobs:
HB_PID: '${{ steps.post_status.outputs.heartbeat_pid }}'
HB_START_TICKS: '${{ steps.post_status.outputs.heartbeat_start_ticks }}'
run: |-
# Last heartbeat kill (the gate and finalize already did this on
# their paths) from the expression-context pid — never from a
# WORKDIR file, which is sandbox-writable. Lifecycle-confirmed
# like its twins and stalest of all: signal only a pid whose
# /proc/<pid>/stat start time still matches the launch
# capture, kill nothing otherwise. The session kill covers
# the mid-tick subtree, same as its twins.
# ABSOLUTE-PATH-ONLY command words, the gate twin's form:
# this block runs in the step's OUTER shell, which inherits
# every $GITHUB_ENV plant earlier steps left — a
# BASH_FUNC_cat%% import shadows cat at bash startup and
# forges the stat line (the matching start time sits in
# this step's own env), passing the lifecycle check on a
# REUSED pid so the real kill TERMs an unrelated process,
# its group and session; a planted kill or PATH-planted
# pkill no-ops the delivery instead, leaving the loop
# holding the PAT alive to its age cap (R17-4).
# Full rationale → qwen-autofix.md#af-149
# Last heartbeat kill from the expression-context pid —
# lifecycle-confirmed, session-wide, absolute-path command
# words only (this outer shell inherits every $GITHUB_ENV
# plant). Full rationale → qwen-autofix.md#af-149
HB_FIELDS=()
if [[ "${HB_PID:-}" =~ ^[0-9]+$ ]]; then
HB_STAT="$(/usr/bin/cat "/proc/${HB_PID}/stat" 2>/dev/null)" || HB_STAT=''
+32 -3
View File
@@ -489,9 +489,35 @@ Two boundaries hold regardless of what any feedback asks for:
- Deleting or weakening tests requires content evidence, not an author's
say-so: it is sound only when the pinned behavior itself is wrong (show the
probe that proves the correct behavior) or the coverage demonstrably
survives in a named surviving test. State that evidence in the summary —
the gate appends its own machine-measured advisory listing every deleted
test to the round report, and a maintainer will read the two side by side.
survives in a named surviving test. State that evidence in the summary AND
record it machine-readably: the gate parses every pre-existing
JavaScript/TypeScript test file (by name: `*.test.*`, `*.spec.*`) and
REJECTS the round when its declared test surface shrank — the file was
deleted, statement-level assertions were removed, a test or describe that
was enabled is now disabled by any spelling (`.skip`/`.todo`/`.fails`,
`xit`, a constant `skipIf(true)`/`runIf(false)`, `{ skip: true }` or any
truthy constant, an unconditional body-level `skip()`/`ctx.skip()`, a
wrapping `describe.skip`), or enabled tests were removed — an early
`return` planted ahead of a test's assertions counts as removing them —
unless each
such file is named in `<workdir>/test-weakening.json`, a JSON array of
`{"path": "<file>", "reason": "<evidence>"}` whose reason is at least 40
characters. The Python and Rust test-file shapes (`test_*.py`,
`tests/*.rs`, `*_test.rs`, `*_tests.rs`) are watched for DELETION alone —
their contents are not parsed, so only the file-deleted signal can charge
them. RENAMING a test file counts as deleting the old path: record
one entry naming it, with the new path as the evidence. Condition-valued
environment guards (`.skipIf(cond)`, `skip(cond, reason)`,
`if (cond) ctx.skip()`), snapshot churn, and a brand-new `it.todo` are
not
weakening and need no entry; an assertion moved WITHIN a file nets zero
and needs none either, while one moved to another file does (name its new
home as the evidence). Main's own changes crossing a merge are attributed
to main, never to the round. The gate checks that the claim EXISTS, not
that it is right — a maintainer reads each reason against the diff in the
round report, alongside the gate's own machine-measured advisory. Never
write an entry to buy silence for a weakening you cannot justify: restore
the assertion instead.
The gate also measures a deny-by-default FOOTPRINT: any area (declared
workspace, top-level directory, or root file) a round touches that the PR
@@ -647,6 +673,9 @@ Finish with exactly one outcome:
disposition and the reason in a sentence or two, plus the question you need
answered when you escalated. Each body is bilingual per GitHub Actions Rules.
Omit the file when every inline finding was resolved.
Also write `<workdir>/test-weakening.json` when this round deleted or
weakened any pre-existing test, per the test-evidence boundary above; omit
it otherwise.
- No change: write `<workdir>/no-action.md` (bilingual per GitHub Actions Rules).
- Stopped by the growth brake: write `<workdir>/handoff.md` per the
not-converging rule (English-only, no details block) — and commit nothing.
+89
View File
@@ -12,6 +12,95 @@ are listed; nightly and preview pre-releases are intentionally omitted.
> [GitHub Releases](https://github.com/QwenLM/qwen-code/releases). Do not edit it
> by hand — run `npm run changelog` to regenerate.
## [0.23.3](https://github.com/QwenLM/qwen-code/releases/tag/v0.23.3) - 2026-09-10
### Highlights
_See the complete change list below._
### Breaking Changes
No known breaking changes.
### Complete Change List
#### Features
- feat(core): expand Kimi, Qwen and DeepSeek reasoning presets ([#11349](https://github.com/QwenLM/qwen-code/pull/11349)) by @callmeYe
- feat(sessions): record what kind of session registered, and document the registry and wire contract ([#11463](https://github.com/QwenLM/qwen-code/pull/11463)) by @qqqys
- feat(web-shell): keep answered questions visible outside processed summaries ([#11461](https://github.com/QwenLM/qwen-code/pull/11461)) by @ytahdn
- feat(web-shell): improve session overview navigation and details ([#11238](https://github.com/QwenLM/qwen-code/pull/11238)) by @wenshao
- feat(core): add OpenAI Responses API content generator ([#8169](https://github.com/QwenLM/qwen-code/pull/8169)) by @netbrah
- feat(providers): show newest discovered models first ([#11449](https://github.com/QwenLM/qwen-code/pull/11449)) by @qqqys
- feat(web-shell): add unified session sources ([#11262](https://github.com/QwenLM/qwen-code/pull/11262)) by @callmeYe
- feat: delegate a subagent turn to an external agent over ACP (Claude Code first) ([#11003](https://github.com/QwenLM/qwen-code/pull/11003)) by @wenshao
- feat(web-shell): make the product name and logo configurable ([#11244](https://github.com/QwenLM/qwen-code/pull/11244)) by @wenshao
- feat(autofix): run one bounded self-review before an address-review commit (A/B) ([#11516](https://github.com/QwenLM/qwen-code/pull/11516)) by @wenshao
- feat(acp): register daemon-managed sessions in the session registry and let them send peer messages ([#11488](https://github.com/QwenLM/qwen-code/pull/11488)) by @qqqys
- feat(web-shell): render URLs in user messages as clickable links ([#11464](https://github.com/QwenLM/qwen-code/pull/11464)) by @wenshao
- feat(serve): support 256 workspaces by default ([#11515](https://github.com/QwenLM/qwen-code/pull/11515)) by @doudouOUC
#### Bug Fixes
- fix(goal): retry a checkpoint that overran the claim budget instead of spending a stall ([#11365](https://github.com/QwenLM/qwen-code/pull/11365)) by @qqqys
- fix(dws): forward Aone sandbox identifier ([#11462](https://github.com/QwenLM/qwen-code/pull/11462)) by @qqqys
- fix(cli): quiesce a fire-and-forget serve handler across tests (#11346) ([#11362](https://github.com/QwenLM/qwen-code/pull/11362)) by @qwen-code-dev-bot
- fix(serve): reconnect evicted SSE clients ([#11467](https://github.com/QwenLM/qwen-code/pull/11467)) by @yiliang114
- fix(ci): space PR creation from release to avoid secondary rate limit ([#11444](https://github.com/QwenLM/qwen-code/pull/11444)) by @yiliang114
- fix(cua): preserve degraded UIAccess responses ([#11479](https://github.com/QwenLM/qwen-code/pull/11479)) by @LaZzyMan
- fix(release): pin Debian 11 repositories to a coherent snapshot ([#11487](https://github.com/QwenLM/qwen-code/pull/11487)) by @LaZzyMan
- fix(web-shell): hide the sidebar version label at the compact footer breakpoint ([#11470](https://github.com/QwenLM/qwen-code/pull/11470)) by @yiliang114
- fix(permissions): cite matching deny rule for compound and virtual-op shell denials ([#11411](https://github.com/QwenLM/qwen-code/pull/11411)) by @yiliang114
- fix(core): reject pre-aborted queued tool requests ([#11483](https://github.com/QwenLM/qwen-code/pull/11483)) by @lorenzozanee
- fix(web-shell): avoid duplicate cold session restoration ([#11413](https://github.com/QwenLM/qwen-code/pull/11413)) by @wenshao
- fix(core): preserve every reasoning episode's signature during history consolidation ([#8260](https://github.com/QwenLM/qwen-code/pull/8260)) by @netbrah
- fix(dws): retry completed response delivery ([#11387](https://github.com/QwenLM/qwen-code/pull/11387)) by @qqqys
- fix(core): host Windows shell PTYs via bundled ConPTY to stop orphaning conhost.exe ([#11497](https://github.com/QwenLM/qwen-code/pull/11497)) by @yiliang114
- fix(web-shell): restore standalone header and scroll button styles ([#11517](https://github.com/QwenLM/qwen-code/pull/11517)) by @ytahdn
- fix(desktop): unblock macOS and Linux desktop packaging ([#11518](https://github.com/QwenLM/qwen-code/pull/11518)) by @yiliang114
- fix(review): reply carried findings into their thread, resolve fixed ones ([#9940](https://github.com/QwenLM/qwen-code/pull/9940)) by @wenshao
- fix(desktop): realign the release test with the new signing step ([#11522](https://github.com/QwenLM/qwen-code/pull/11522)) by @yiliang114
- fix(bridge): keep pending permission/question across refreshed session loads ([#11468](https://github.com/QwenLM/qwen-code/pull/11468)) by @wenshao
- fix(export): restore document runtime budget headroom ([#11372](https://github.com/QwenLM/qwen-code/pull/11372)) by @yiliang114
- fix(core): preserve Responses stream error details ([#11523](https://github.com/QwenLM/qwen-code/pull/11523)) by @tanzhenxin
- fix(core): release node-pty's conout worker after every PTY on Windows ([#11313](https://github.com/QwenLM/qwen-code/pull/11313)) by @yiliang114
- fix(web-shell): make the workspace provider guard self-diagnosing and reload on root retry ([#11421](https://github.com/QwenLM/qwen-code/pull/11421)) by @wenshao
- fix(web-shell): move scheduled task row icon into trailing meta slot ([#11356](https://github.com/QwenLM/qwen-code/pull/11356)) by @qqqys
- fix(vscode): restore pre-cutover conversation history in the panel ([#11495](https://github.com/QwenLM/qwen-code/pull/11495)) by @yiliang114
- fix(ci): record the qwen-autofix.yml size drift two in-allowance PRs accumulated (#11529) ([#11537](https://github.com/QwenLM/qwen-code/pull/11537)) by @qwen-code-dev-bot
- fix(web-shell): realign two suites with shipped behavior to unbreak main CI (#11525) ([#11530](https://github.com/QwenLM/qwen-code/pull/11530)) by @qwen-code-dev-bot
- fix(ci): grant the packaging check the permissions its callee declares ([#11532](https://github.com/QwenLM/qwen-code/pull/11532)) by @yiliang114
- fix(dev): install hooks during worktree bootstrap ([#11496](https://github.com/QwenLM/qwen-code/pull/11496)) by @yiliang114
- fix(review): dispatch review agents through parallel workflows ([#11524](https://github.com/QwenLM/qwen-code/pull/11524)) by @wenshao
- fix(vscode): preserve active file exclusion ([#11568](https://github.com/QwenLM/qwen-code/pull/11568)) by @yiliang114
- fix(cli): drain notification queue when a turn errors out ([#11559](https://github.com/QwenLM/qwen-code/pull/11559)) by @yiliang114
- fix(review): keep host-trusted state out of the container's writable surface ([#9983](https://github.com/QwenLM/qwen-code/pull/9983)) by @wenshao
- fix(acp): pass shell execution config to tool invocations ([#11102](https://github.com/QwenLM/qwen-code/pull/11102)) by @yiliang114
#### Performance
- perf(export): split the transcript renderer's embedded CSS into a versioned asset ([#11485](https://github.com/QwenLM/qwen-code/pull/11485)) by @yiliang114
#### Documentation
- docs: highlight Qwen Code interfaces in the README ([#11491](https://github.com/QwenLM/qwen-code/pull/11491)) by @pomelo-nwu
- docs(daemon): add a REST integration entry point, guarded against the route surface ([#11477](https://github.com/QwenLM/qwen-code/pull/11477)) by @yiliang114
#### Internal Changes
- refactor(cli): drop the legacy Goal Stop-hook plumbing ([#11459](https://github.com/QwenLM/qwen-code/pull/11459)) by @qqqys
- refactor(core): stop routing Goal turns through the legacy Stop hook ([#11458](https://github.com/QwenLM/qwen-code/pull/11458)) by @qqqys
- test(channels): canonicalize restored worktree fixture paths ([#11476](https://github.com/QwenLM/qwen-code/pull/11476)) by @yiliang114
- ci(desktop): exercise desktop packaging on a schedule ([#11519](https://github.com/QwenLM/qwen-code/pull/11519)) by @yiliang114
- test(integration): deflake the /compress E2E event budget (#11088) ([#11094](https://github.com/QwenLM/qwen-code/pull/11094)) by @qwen-code-dev-bot
- test(core): cover bundled ConPTY fallback and terminal replies ([#11555](https://github.com/QwenLM/qwen-code/pull/11555)) by @yiliang114
### New Contributors
- @lorenzozanee made their first contribution in [#11483](https://github.com/QwenLM/qwen-code/pull/11483)
**Full Changelog**: https://github.com/QwenLM/qwen-code/compare/v0.23.2...v0.23.3
## [0.23.2](https://github.com/QwenLM/qwen-code/releases/tag/v0.23.2) - 2026-09-09
### Highlights
@@ -0,0 +1,49 @@
# Daemon UserPromptSubmit provenance
[English](daemon-user-prompt-submit-provenance.md) | [简体中文](daemon-user-prompt-submit-provenance.zh-CN.md)
## Problem and evidence
A normal daemon prompt on main `1919ff97f5` runs the configured UserPromptSubmit hook but omits `submitted_prompt`, so Mem0 Auto Recall returns `{}` without searching. The initial fix inferred provenance from request text on fresh non-channel ACP turns. Review at `2b4f46a` and a failing Session/Hook probe showed that scheduled tasks, sub-session spawns, and Live task dispatches also satisfy that condition. Their machine-composed text must not acquire provenance by default.
## Scope and ownership
Preserve the owning session's configuration, message bus, cwd, registration, and environment. Add explicit submission propagation across the existing client, daemon admission, bridge, and ACP child boundaries. Do not change legacy hook invocation, recording, provider configuration, credentials, default extension registration, or managed memory recall.
REST prompt admission is live-session-owner scoped; it uses the already-resolved owner bridge and runtime. ACP HTTP/WebSocket admission uses its bound session and bridge. No new route or primary-runtime fallback is introduced.
## Design
A supported client declares the original submitted text in `_meta["qwen.submittedPrompt"]`. Web Shell captures its composer text before host `prepareSubmit`, slash-command rewriting, and attachment expansion. Ordinary queues store this original declaration independently of the prepared payload. Generic actions, manual scheduled runs, retries, and server-restored queues do not create declarations. Re-submitting restored content from the editor is a new Web Shell submission; this change does not add core TUI-style editor provenance tracking. The existing core TUI producer is unchanged. Both ordinary and queued Web Shell submissions retain their existing admission behavior. Clients must omit the declaration for machine-generated input. This is an explicit per-request contract, not proof of human authorship, authentication, or DLP.
The REST and ACP transport routes read the declaration and pass it as `submittedPrompt` in the bridge context. REST channel-worker requests, including requests whose worker authorization is no longer valid, do not gain provenance through that route. The bridge strips both public and private submission keys from the request and re-injects only context-declared text as `qwen.daemon.submittedPrompt`; channel and promoted mid-turn dispatches omit it. Internal scheduled, sub-session, Live task, and other automated dispatchers provide no declaration. Realtime voice handoffs also omit the declaration: their request text comes from model-generated tool arguments rather than an independently verified user transcript.
At ACP process admission, a trusted parent can supply the private key; a direct ACP client can opt in with the public declaration but cannot forge the private key. The public key is consumed at admission and does not pass through to Session. A trusted parent cannot use a public key as a fallback when the private declaration is missing.
Session emits `submitted_prompt` only for fresh non-channel turns with a nonblank string declaration. Missing, invalid, empty, or whitespace-only values omit the field without falling back to request text or `promptDisplayText`. Preserve the declared whitespace. Display projections retain their existing recording consumers but no longer establish submission provenance. Channel markers cover both automated events and human messages; both remain excluded in this version.
Keep `isFreshUserTurn` and managed memory recall unchanged. Retry can still invoke legacy hooks but omits submission provenance; continue, restored-question, and runtime-goal turns retain their existing hook exclusions. Tool-result and other internal re-entry loops do not create a declaration. Local-only slash commands that return before model execution remain outside this hook path.
The initial ACP `prompt` remains the request's pre-expansion text-block join, not the complete expanded model input. TUI-specific Vim, paste, history, undo, and rewind rules do not automatically apply to ACP clients; each client owns the provenance of its declared text.
## Consumers and compatibility
The hook pipeline preserves `submitted_prompt` while adding session/cwd metadata. Auto Recall uses it as the search query; every configured hook can receive it. The default Mem0 extension remains MCP-only and Auto Recall still requires explicit v3 configuration and registration.
Web Shell supplies declarations for user submissions. Other ACP and daemon SDK clients must opt in per eligible request; existing clients without declarations continue invoking legacy hooks but do not trigger provenance-gated retrieval. Do not add a declaration globally to an SDK transport or automated dispatcher. Older daemon versions may ignore this optional metadata, so absence must remain normal for consumers. This changes eligibility from the earlier unmerged implementation, not the released default MCP behavior.
Newly eligible ACP/daemon payloads include `submitted_prompt`. Administrators whose hooks reject unknown fields, for example through `additionalProperties: false`, must test the deployed hook against the new payload before rollout because rejection can fail open or closed. See [UserPromptSubmit](../users/features/hooks.md#userpromptsubmit) for current semantics and the predecessor design's [Compatibility and migration](submitted-prompt-provenance.md#compatibility-and-migration) for the strict-decoder note only. Its producer table predates headless and ACP support and is not the current eligibility list.
The Direct Profile's managed launcher remains TTY-only; broader field producers do not expand that deployment contract. A Mem0 v3 profile remains bound to one canonical repository root and scope. Other workspaces skip retrieval; no per-workspace profile routing is added. Sanitization, bounded timeouts, fail-open output, and untrusted context wrapping are unchanged.
## Validation and acceptance
- Reproduce the undeclared non-channel failure before editing and preserve the failing assertion.
- Test explicit, missing, invalid, empty, whitespace-only, retry, channel, and model-only cases at Session. Remove the new declaration gate and confirm omission cases fail.
- Test spoofed private keys and public opt-in at admission; verify bridge requests without trusted context cannot acquire provenance. Preserve the raw original text through attachment expansion; model-only content must not replace the declaration.
- Run a real local daemon with an observing hook and controlled model. Explicit ordinary submissions must publish the declared text; marker-less machine submissions must not. Validate the rebuilt bundle, not an older installed binary.
- Run affected package tests, build, bundle, typecheck, formatting, lint, and two full self-audit passes. Record evidence without credentials under `.qwen/e2e-tests/`.
## Status
The original implementation at `4fb7d2f0a9` passed 869 Session tests and four local plus four real Holo scenarios. Those results predate the explicit-declaration correction and must not be presented as validation of it. Both synthetic Holo records were removed. Records were seeded/deleted directly through Holo, the model was controlled locally, and workspace B tested exclusion rather than a second profile. The review correction's reproduction and verification are tracked separately in `.qwen/issues/pr-11455-provenance.md`.
@@ -0,0 +1,49 @@
# Daemon UserPromptSubmit 来源信息
[English](daemon-user-prompt-submit-provenance.md) | [简体中文](daemon-user-prompt-submit-provenance.zh-CN.md)
## 问题与证据
main `1919ff97f5` 上的普通 daemon 提问会执行已配置的 UserPromptSubmit hook,但不携带 `submitted_prompt`,因此 Mem0 Auto Recall 返回 `{}` 而不检索。首轮修复从全新非 channel ACP 回合的请求文本推断来源。针对 `2b4f46a` 的评审和失败的 Session/Hook 探针证实,定时任务、子会话派生和 Live task 派发也满足该条件。这些机器组合文本不能默认获得提交来源。
## 范围与归属
保留所属会话的配置、消息总线、cwd、注册信息与环境。在现有客户端、daemon 准入、bridge 与 ACP 子进程边界之间显式传递提交文本。不改变旧 hook 调用策略、录制、provider 配置、凭证、默认扩展注册或托管记忆召回。
REST prompt 准入属于 live-session-owner 范围,使用已解析的 owner bridge 和 runtime。ACP HTTP/WebSocket 准入使用已绑定的会话与 bridge。不增加路由,也不引入 primary-runtime 回退。
## 设计
受支持的客户端通过 `_meta["qwen.submittedPrompt"]` 声明原始提交文本。Web Shell 在宿主 `prepareSubmit`、斜杠命令改写及附件展开之前捕获输入框文本。普通队列独立保存原始声明与准备后的 payload。通用 action、定时任务手动运行、重试及服务端恢复的队列不会生成声明。用户从输入框重新提交恢复内容属于新的 Web Shell 提交;本次不增加 core TUI 式编辑器来源跟踪。既有 core TUI 生产方保持不变。Web Shell 普通提交与排队提交均保留既有准入行为。客户端必须在机器生成输入上省略声明。这是逐请求的显式契约,不证明由人撰写,不是身份认证或 DLP。
REST 与 ACP 传输路由读取声明,通过 bridge context 的 `submittedPrompt` 传递。REST channel-worker 请求不会通过该路由获得来源,包括 worker 授权已失效的请求。bridge 从请求中删除公开与私有提交键,仅将 context 中声明的文本重新注入为 `qwen.daemon.submittedPrompt`;channel 和提升为普通回合的 mid-turn 派发省略该字段。内部定时任务、子会话、Live task 及其他自动派发不提供声明。实时语音委派同样省略声明:其请求文本来自模型生成的工具参数,而非独立核实的用户转写。
在 ACP 进程准入处,可信父进程可以提供私有键;直接 ACP 客户端可以通过公开声明逐请求启用,但不能伪造私有键。公开键在准入处消费,不继续传入 Session。可信父进程缺少私有声明时,不能回退到公开键。
Session 仅在全新非 channel 回合且声明为非空白字符串时发出 `submitted_prompt`。缺失、非法、空串或纯空白值均省略字段,不回退到请求文本或 `promptDisplayText`。保留声明中的原始空白。显示投影保留已有录制消费方,但不再建立提交来源。channel 标记同时覆盖自动事件与人工消息,本版本继续排除二者。
保持 `isFreshUserTurn` 和托管记忆召回不变。retry 仍可调用旧 hook,但省略提交来源;continue、恢复提问和 runtime-goal 保留既有 hook 排除条件。工具结果和其他内部重入循环不会创建声明。在模型执行前返回的纯本地 slash command 仍不经过该 hook 路径。
ACP 初始 `prompt` 仍为请求展开前的文本块拼接,并非完整展开后的模型输入。TUI 专属的 Vim、粘贴、历史、撤销与回退规则不会自动适用于 ACP 客户端;各客户端负责所声明文本的来源。
## 消费方与兼容性
hook 管道补充 session/cwd 元数据时保留 `submitted_prompt`。Auto Recall 将其作为查询文本,每个已配置 hook 都可能收到它。默认 Mem0 扩展仍仅提供 MCP,Auto Recall 仍需显式 v3 配置和注册。
Web Shell 为用户提交提供声明。其他 ACP 和 daemon SDK 客户端必须在符合条件的请求上逐次显式启用;没有声明的现有客户端继续调用旧 hook,但不会触发依赖来源的检索。不得在 SDK 传输层或自动派发器中全局添加声明。旧版 daemon 可能忽略这个可选元数据,因此消费方必须继续将缺失视为正常状态。此处调整的是此前未合并实现的资格规则,不改变已发布的默认 MCP 行为。
新获得资格的 ACP/daemon payload 包含 `submitted_prompt`。如果管理员的 hook 会拒绝未知字段,例如使用 `additionalProperties: false`,必须在上线前用新 payload 测试已部署 hook,因为拒绝可能导致失败放行或失败关闭。当前语义见 [UserPromptSubmit](../users/features/hooks.md#userpromptsubmit);仅为严格解码器说明引用前序设计的 [Compatibility and migration](submitted-prompt-provenance.md#compatibility-and-migration)。其生产方表早于 headless 与 ACP 支持,不是当前资格清单。
Direct Profile 的托管启动器仍仅支持 TTY;字段生产方扩展不会扩大该部署契约。Mem0 v3 profile 仍绑定一个规范化仓库根和 scope。其他 workspace 跳过检索,不增加按 workspace 路由 profile 的功能。清洗、有限超时、失败放行输出和不可信上下文包装保持不变。
## 验证与验收
- 修改前复现无声明非 channel 回合的问题,保留失败断言。
- 在 Session 测试显式、缺失、非法、空串、纯空白、retry、channel 与模型专用输入;移除新的声明判定后,确认省略用例失败。
- 测试准入处对伪造私有键和公开显式启用的处理;确认没有可信 context 的 bridge 请求不能获得来源。附件展开须保留原始文本;模型专用内容不得替换声明。
- 使用真实本地 daemon、观察 hook 和受控模型。显式普通提交必须发布声明文本;无标记的机器提交不得发布。验证重新构建的 bundle,不使用旧安装版作为修复证据。
- 执行相关包测试、构建、打包、类型检查、格式检查、lint 和两轮完整自审。在 `.qwen/e2e-tests/` 记录证据,不保存凭证。
## 状态
原实现 `4fb7d2f0a9` 通过了 869 项 Session 测试,以及本地与真实 Holo 各四项场景。这些结果早于显式声明修正,不能用作该修正的验证证据。两条合成 Holo 记录均已删除。记录直接通过 Holo 创建和删除,模型由本地控制,workspace B 验证的是排除而非第二个 profile。评审修正的复现与验证单独记录在 `.qwen/issues/pr-11455-provenance.md`。
-20
View File
@@ -111,26 +111,6 @@ Background Agent output is sent as soon as each response segment is available.
Every message is labeled with the Agent name so concurrent work remains
attributable.
To buffer each Agent's notification turn and send it as one labeled message,
enable aggregation for the DingTalk channel in `settings.json`:
```json
{
"channels": {
"my-dingtalk": {
"type": "dingtalk",
"clientId": "$DINGTALK_CLIENT_ID",
"clientSecret": "$DINGTALK_CLIENT_SECRET",
"aggregateBackgroundAgentResponses": true
}
}
}
```
Aggregation is disabled by default. A partial labeled message is sent if the
Agent turn is interrupted, fails before producing a final response, or does
not finish within ten minutes.
## Running
```bash
-5
View File
@@ -63,7 +63,6 @@ Channels are configured under the `channels` key in `settings.json`. Each channe
| `allowedUsers` | No | List of user IDs allowed to use the bot (used by `allowlist` and `pairing` policies) |
| `sessionScope` | No | How sessions are scoped: `user` (default), `chat_thread`, or `single`. Legacy `thread` remains compatible when already configured but is not offered for new Web Shell configurations |
| `multiSession` | No | Retain up to eight owner-scoped named tasks in one chat. Requires daemon-managed mode, `sessionScope: "user"`, no webhooks or group-history backfill, and no enabled Channel loops |
| `messagePrefix` | No | Only dispatch user messages that begin with this exact, case-sensitive prefix after any leading `@mentions`; the prefix and following whitespace are removed before dispatch |
| `cwd` | No | Working directory for the agent. Defaults to the current directory |
| `approvalMode` | No | Tool approval mode for channel sessions. Unattended webhook tasks require `yolo`; the setting applies to every session on the channel |
| `instructions` | No | Custom instructions prepended to the first message of each session |
@@ -74,10 +73,6 @@ Channels are configured under the `channels` key in `settings.json`. Each channe
| `groups` | No | Per-group settings. Keys are group chat IDs or `"*"` for defaults. See [Group Chats](#group-chats) |
| `dispatchMode` | No | What happens when you send a message while the bot is busy: `steer` (default), `collect`, or `followup`. See [Dispatch Modes](#dispatch-modes) |
When `messagePrefix` is set, every user-authored message must begin with the prefix and a non-empty payload, for example `/review inspect #123`. Only the prefix and the mentions ahead of it are removed; a mention the user typed after the prefix reaches the agent unchanged. Shared and agent commands use the same rule (`/review /help`, `/review /clear`, and so on). Telegram's registered command-menu actions remain available without the prefix — unless the configured prefix is itself one of them, in which case the prefix wins and that command has to be sent prefixed too (`/new /new`). Attachments need a matching caption when the platform supports one; captionless Telegram, Feishu, WeChat, DingTalk and WeCom media messages continue to run, and their placeholder text is never quoted back as group history. Native todos, webhooks, and provider-generated assignment or review-request events also continue to run without a prefix because they are system events rather than chat messages.
Two behaviours are deliberate and worth knowing before you turn the prefix on. A voice message whose transcript DingTalk or WeCom fills in counts as text the user spoke, so it must carry the prefix like any other message and is dropped otherwise — only an untranscribed voice note runs as captionless media. And the prefix is checked before pairing, so first contact from an unknown sender or an unapproved group has to carry the prefix as well; without that ordering every unprefixed message in a busy group would draw a pairing reply, which is exactly the noise the prefix exists to suppress. Tell new users the prefix out of band, or leave pairing channels unprefixed.
### Sender Policy
Controls who can interact with the bot:
+14 -13
View File
@@ -610,24 +610,28 @@ For `"ask"`, the TUI displays `permissionDecisionReason` as literal text rather
#### UserPromptSubmit
**Purpose**: Executed before supported model invocations to validate, block, or enrich the current model-bound prompt. The event currently covers `UserQuery`, `ToolResult`, and `Hook` sends, while `Retry`, `Steer`, `Cron`, `Notification`, and `Teammate` sends are skipped. It can therefore occur on continuation paths, and `prompt` must not be assumed to be raw user input.
**Purpose**: Executed before supported model invocations to validate, block, or enrich their input. On the core/headless path, the event currently covers `UserQuery`, `ToolResult`, and `Hook` sends, while `Retry`, `Steer`, `Cron`, `Notification`, and `Teammate` sends are skipped. It can therefore occur on continuation paths, and `prompt` must not be assumed to be raw user input. The ACP session path has its own invocation policy: retries and newly dispatched background tasks can still invoke legacy hooks; continue, restored-question, and runtime-goal turns do not.
**Event-specific fields**:
```json
{
"prompt": "current model-bound prompt for this hook invocation",
"submitted_prompt": "optional user text captured at a supported interactive TUI submission boundary"
"prompt": "legacy prompt for this invocation; semantics depend on the execution path",
"submitted_prompt": "optional user text captured at a supported submission boundary"
}
```
`submitted_prompt` is optional. It is present only when Qwen can carry provenance from a supported interactive TUI submission to a fresh `UserQuery`. It is omitted for unsupported producers and machine-driven paths such as same-turn steering, tool-result continuations, retries, cron, notifications, and teammate traffic. ACP, headless, `serve`, SDK, and remote-input paths do not produce it in this version.
`submitted_prompt` is optional. It is present on supported interactive TUI submissions and first-turn headless `UserQuery` sends. On the ACP session path used by ACP clients, `serve`, and daemon hosts, a fresh turn must carry an explicit submission declaration. Missing, non-string, empty, or whitespace-only declarations omit the field; the value is never reconstructed from `prompt` or a display label. Retries, continuations, and channel-classified turns omit it. The channel exclusion includes both automated events and human messages relayed through channel adapters.
Deferred input can retain the field when its provenance remains complete. A combined batch retains provenance only when every constituent item has it; edited, partially known, or otherwise ambiguous input omits the field. Prompt, command, and shell-history navigation or selected search matches, cross-restart stash restores, and conversation rewind restores also omit it because those paths can surface model-bound text without its original provenance. Consumers that require user-submitted text should treat absence as unavailable rather than falling back to `prompt`.
Web Shell provides the original composer text at its submission boundary. Realtime voice handoffs do not declare provenance because their request text comes from model-generated tool arguments. Other ACP/daemon SDK clients can opt in per request with `_meta: { "qwen.submittedPrompt": "original submitted text" }`, captured before resource or model-only expansion. Existing clients without this declaration continue running legacy hooks but do not trigger provenance-gated Auto Recall. Do not add the declaration globally to an SDK transport: scheduled tasks, Live task runs, sub-session spawns, model-authored cross-session messages, and promoted mid-turn messages must not acquire it automatically. The private `qwen.daemon.submittedPrompt` key is reserved for the daemon-to-child hop and is stripped from external callers. These declarations are caller-supplied provenance, not proof of human authorship or authorization.
On the ACP path, the initial legacy `prompt` is the request's text blocks joined with a space before resource, attachment, slash-command, or model-only expansion. It does not expose the complete expanded model input. `submitted_prompt` can equal that text, but comes only from the explicit declaration and preserves its original whitespace. On the core/headless path, legacy `prompt` represents the current model-bound text for the hook invocation. Neither field is a complete DLP inspection surface.
The following composer rules apply to the interactive TUI, not to ACP clients. Deferred input can retain the field when its provenance remains complete. A combined batch retains provenance only when every constituent item has it; edited, partially known, or otherwise ambiguous input omits the field. Prompt, command, and shell-history navigation or selected search matches, cross-restart stash restores, and conversation rewind restores also omit it because those paths can surface model-bound text without its original provenance. Consumers that require user-submitted text should treat absence as unavailable rather than falling back to `prompt`.
After restored or provenance-unavailable model-bound input is cleared or submitted, the composer also clears its undo and redo history. This prevents undo from restoring expanded text after its marker or sidecar has been consumed.
Large-paste placeholders remain compact in `submitted_prompt`; the expanded pasted content appears only in `prompt`. Consumers should treat the field as a TUI text projection rather than a byte-for-byte record of clipboard input.
Large-paste placeholders remain compact in `submitted_prompt`; the expanded pasted content appears only in `prompt`. On that TUI path, consumers should treat the field as a text projection rather than a byte-for-byte record of clipboard input. ACP clients have no equivalent built-in Vim, paste-placeholder, history, or rewind provenance tracking; they own whether restored or edited text retains a valid submission declaration.
Any non-empty input present while Vim mode is enabled omits `submitted_prompt`, including after Vim is disabled, because Vim registers do not carry provenance in this version. This conservative rule also covers drafts entered before enabling Vim. Clearing the composer starts a new eligible input.
@@ -665,10 +669,7 @@ This two-field payload is written only for this kind of user-prompt record.
`hookContext` intentionally duplicates the tagged part so offline and
third-party consumers can identify its provenance without parsing model text.
`displayText` is the pre-hook display projection and never includes the hook
context. For a supported interactive TUI submission it is the raw composer
projection carried by `submitted_prompt`; ACP, headless, `serve`, SDK, remote
input, and other paths without that provenance record the expanded pre-hook
prompt instead.
context. On the core/headless path it is the submitted projection when available, otherwise the expanded pre-hook prompt. ACP records the trusted display projection or raw request text before expansion when a projection or attachment references require a payload; otherwise it records the user message without `systemPayload` or `displayText`.
Transcript display consumers treat `displayText` as this user-prompt projection
when `systemPayload.hookContext` is a string. For compatibility with released
@@ -1495,11 +1496,11 @@ A PostToolUse HTTP hook that sends all tool execution records to a remote audit
}
```
### Example 3: Interactive TUI Submitted Prompt Validation Hook
### Example 3: Submitted Prompt Validation Hook
To inspect the current model-bound content instead, read `prompt`. That field can include generated or expanded content, is not the original user input, and does not imply that `UserPromptSubmit` covers every model send. Do not silently fall back from `submitted_prompt` to `prompt` when source provenance is required.
On the core/headless path, `prompt` can include generated or expanded content rather than original user input. On ACP it starts with the pre-expansion request text, so reading it does not inspect attachment bodies or the complete model input. `UserPromptSubmit` does not cover every model send. Do not silently fall back from `submitted_prompt` to `prompt` when source provenance is required.
A UserPromptSubmit hook that validates supported interactive TUI submissions for sensitive information and provides context for long prompts. It skips invocations where source provenance is unavailable. The keyword check is illustrative and is not a complete DLP policy:
A UserPromptSubmit hook that validates supported submitted text and provides context for long prompts. It also runs on headless submissions and explicitly declared ACP/daemon submissions; it is not TUI-only. It skips invocations where source provenance is unavailable. A blocking result stops the affected invocation, including on these non-TUI paths. The keyword check is illustrative and is not a complete DLP policy:
**prompt_validator.py**
+3 -6
View File
@@ -260,12 +260,9 @@ could send two searches for one turn. Use the v2 Extension profile for
on-demand `context_search`, or the v3 Hook-only profile for Auto Recall, never
both in one Qwen process.
The Hook requires a non-empty `submitted_prompt` captured before prompt
expansion. This includes supported interactive TUI submissions and headless
CLI user turns (`qwen -p` and stream-json input, including SDK clients using
that path). The field establishes prompt provenance, not a TUI-only origin.
The Hook never falls back to the expanded `prompt`; events without
`submitted_prompt` do not trigger retrieval.
The Hook requires a non-empty `submitted_prompt` captured before prompt expansion. This includes supported interactive TUI submissions, first-turn headless CLI `UserQuery` sends (`qwen -p` and stream-json input, including SDK clients using that path), and explicitly declared fresh non-channel turns on the ACP session path used by ACP clients, `serve`, and daemon hosts. ACP/daemon clients must provide `_meta: { "qwen.submittedPrompt": "original submitted text" }` per eligible request; Web Shell does so at its submission boundary. Realtime voice handoffs omit declarations because their text comes from model-generated tool arguments. Existing ACP clients without a declaration continue to omit the field. Internal background dispatches and all channel messages, including human messages, are excluded. See [UserPromptSubmit](../../docs/users/features/hooks.md#userpromptsubmit).
The field establishes prompt provenance, not a TUI-only origin or proof of human authorship. The Hook never falls back to `prompt`; events without `submitted_prompt` do not trigger retrieval.
Register this profile only in launchers where automatic retrieval is intended
for all eligible inputs. To disable every Hook for an automation run, use
@@ -1,6 +1,6 @@
{
"name": "@qwen-code/external-context-mem0",
"version": "0.23.2",
"version": "0.23.3",
"description": "Configurable Mem0-compatible External Context Extension",
"repository": {
"type": "git",
@@ -8,7 +8,7 @@
"en": "Configurable retrieval from an administrator-bound Mem0-compatible service",
"zh": "从管理员绑定的 Mem0 兼容服务检索外部上下文"
},
"version": "0.23.2",
"version": "0.23.3",
"mcpServers": {
"external-context-mem0": {
"command": "node",
+5 -7
View File
@@ -237,13 +237,11 @@ write. Cancellation therefore does not prove that no memory was created.
### Auto-recall profile
Auto-recall sends a sanitized best-effort query to the external provider for
each eligible ordinary interactive prompt. It requires a non-empty
`submitted_prompt` captured by the supported interactive TUI before reminders,
file and resource expansion, extension output, and vision expansion. It never
falls back to the legacy model-bound `prompt`. Missing or invalid provenance
fails closed before configuration or credentials are read. Common credential
shapes are removed from the submitted text, but this is not DLP.
This managed Auto Profile supports a fresh interactive TTY launcher, as constrained below. It sends a sanitized best-effort query to the external provider for eligible submissions. The Hook requires a non-empty `submitted_prompt` and never falls back to legacy `prompt`.
The field itself has a broader producer contract than this managed launcher: supported TUI and first-turn headless submissions, and explicitly declared fresh non-channel turns on the ACP path used by ACP clients, `serve`, and daemon hosts. ACP declarations are opt-in per request; ordinary clients without them, internal background dispatches, and all channel messages (including human messages) do not trigger recall. See [UserPromptSubmit](../../docs/users/features/hooks.md#userpromptsubmit) for the current field contract. This does not expand this Direct Profile's supported launchers. The original [Direct Auto Recall design](../../docs/design/direct-external-context-auto-recall.md) describes its TTY deployment constraints; its producer enumeration predates subsequent headless and ACP support.
Missing or invalid provenance fails closed before configuration or credentials are read. Common credential shapes are removed from the submitted text, but this is not DLP.
1. Copy `examples/auto-recall-mem0.json` or
`examples/auto-recall-generic-http.json` to an
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@qwen-code/external-context",
"version": "0.23.2",
"version": "0.23.3",
"private": true,
"description": "Provider-bound external context for Qwen Code",
"type": "module",
+32 -43
View File
@@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.23.2",
"version": "0.23.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
"version": "0.23.2",
"version": "0.23.3",
"hasInstallScript": true,
"workspaces": [
"packages/*",
@@ -96,7 +96,7 @@
},
"integrations/external-context": {
"name": "@qwen-code/external-context",
"version": "0.23.2",
"version": "0.23.3",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
"undici": "^7.28.0",
@@ -114,7 +114,7 @@
},
"integrations/external-context-mem0": {
"name": "@qwen-code/external-context-mem0",
"version": "0.23.2",
"version": "0.23.3",
"devDependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
"@types/node": "^22.0.0",
@@ -16959,7 +16959,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -16981,7 +16980,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -17003,7 +17001,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -17025,7 +17022,6 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -17047,7 +17043,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -17069,7 +17064,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -17091,7 +17085,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -17113,7 +17106,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -17135,7 +17127,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -17157,7 +17148,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -17179,7 +17169,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -27049,7 +27038,7 @@
},
"packages/acp-bridge": {
"name": "@qwen-code/acp-bridge",
"version": "0.23.2",
"version": "0.23.3",
"dependencies": {
"@agentclientprotocol/sdk": "^0.14.1",
"@qwen-code/qwen-code-core": "file:../core"
@@ -27065,7 +27054,7 @@
},
"packages/audio-capture": {
"name": "@qwen-code/audio-capture",
"version": "0.23.2",
"version": "0.23.3",
"hasInstallScript": true,
"dependencies": {
"node-gyp-build": "^4.8.4"
@@ -27082,7 +27071,7 @@
},
"packages/channels/base": {
"name": "@qwen-code/channel-base",
"version": "0.23.2",
"version": "0.23.3",
"dependencies": {
"@agentclientprotocol/sdk": "^0.14.1"
},
@@ -27092,9 +27081,9 @@
},
"packages/channels/dingtalk": {
"name": "@qwen-code/channel-dingtalk",
"version": "0.23.2",
"version": "0.23.3",
"dependencies": {
"@qwen-code/channel-base": "0.23.2",
"@qwen-code/channel-base": "0.23.3",
"dingtalk-stream-sdk-nodejs": "^2.0.4"
},
"devDependencies": {
@@ -27103,9 +27092,9 @@
},
"packages/channels/dws": {
"name": "@qwen-code/channel-dws",
"version": "0.23.2",
"version": "0.23.3",
"dependencies": {
"@qwen-code/channel-base": "0.23.2"
"@qwen-code/channel-base": "0.23.3"
},
"devDependencies": {
"typescript": "^5.0.0"
@@ -27113,10 +27102,10 @@
},
"packages/channels/feishu": {
"name": "@qwen-code/channel-feishu",
"version": "0.23.2",
"version": "0.23.3",
"dependencies": {
"@larksuiteoapi/node-sdk": "^1.73.0",
"@qwen-code/channel-base": "0.23.2"
"@qwen-code/channel-base": "0.23.3"
},
"devDependencies": {
"typescript": "^5.0.0"
@@ -27139,10 +27128,10 @@
},
"packages/channels/github": {
"name": "@qwen-code/channel-github",
"version": "0.23.2",
"version": "0.23.3",
"dependencies": {
"@octokit/rest": "^21.1.1",
"@qwen-code/channel-base": "0.23.2",
"@qwen-code/channel-base": "0.23.3",
"https-proxy-agent": "^7.0.6"
},
"devDependencies": {
@@ -27151,10 +27140,10 @@
},
"packages/channels/gitlab": {
"name": "@qwen-code/channel-gitlab",
"version": "0.23.2",
"version": "0.23.3",
"dependencies": {
"@gitbeaker/rest": "^42.5.0",
"@qwen-code/channel-base": "0.23.2",
"@qwen-code/channel-base": "0.23.3",
"zod": "^3.25.0"
},
"devDependencies": {
@@ -27163,7 +27152,7 @@
},
"packages/channels/plugin-example": {
"name": "@qwen-code/channel-plugin-example",
"version": "0.23.2",
"version": "0.23.3",
"dependencies": {
"@qwen-code/channel-base": "file:../base",
"ws": "^8.18.0"
@@ -27177,9 +27166,9 @@
},
"packages/channels/qqbot": {
"name": "@qwen-code/channel-qqbot",
"version": "0.23.2",
"version": "0.23.3",
"dependencies": {
"@qwen-code/channel-base": "0.23.2",
"@qwen-code/channel-base": "0.23.3",
"@tencent-connect/qqbot-connector": "^1.1.0",
"ws": "^8.18.0"
},
@@ -27189,9 +27178,9 @@
},
"packages/channels/telegram": {
"name": "@qwen-code/channel-telegram",
"version": "0.23.2",
"version": "0.23.3",
"dependencies": {
"@qwen-code/channel-base": "0.23.2",
"@qwen-code/channel-base": "0.23.3",
"grammy": "^1.41.1",
"https-proxy-agent": "^7.0.6",
"telegram-markdown-formatter": "^0.1.2"
@@ -27202,9 +27191,9 @@
},
"packages/channels/wecom": {
"name": "@qwen-code/channel-wecom",
"version": "0.23.2",
"version": "0.23.3",
"dependencies": {
"@qwen-code/channel-base": "0.23.2",
"@qwen-code/channel-base": "0.23.3",
"@wecom/aibot-node-sdk": "^1.0.7"
},
"devDependencies": {
@@ -27213,9 +27202,9 @@
},
"packages/channels/weixin": {
"name": "@qwen-code/channel-weixin",
"version": "0.23.2",
"version": "0.23.3",
"dependencies": {
"@qwen-code/channel-base": "0.23.2"
"@qwen-code/channel-base": "0.23.3"
},
"devDependencies": {
"typescript": "^5.0.0"
@@ -27223,7 +27212,7 @@
},
"packages/chrome-extension": {
"name": "@qwen-code/chrome-bridge",
"version": "0.23.2",
"version": "0.23.3",
"license": "Apache-2.0",
"devDependencies": {
"@types/chrome": "^0.1.32",
@@ -27251,7 +27240,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
"version": "0.23.2",
"version": "0.23.3",
"dependencies": {
"@a2a-js/sdk": "^1.1.0",
"@agentclientprotocol/sdk": "^0.14.1",
@@ -27704,7 +27693,7 @@
},
"packages/core": {
"name": "@qwen-code/qwen-code-core",
"version": "0.23.2",
"version": "0.23.3",
"hasInstallScript": true,
"dependencies": {
"@anthropic-ai/sdk": "^0.36.1",
@@ -30793,7 +30782,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
"version": "0.23.2",
"version": "0.23.3",
"license": "LICENSE",
"dependencies": {
"@agentclientprotocol/sdk": "^0.14.1",
@@ -30861,7 +30850,7 @@
},
"packages/web-shell": {
"name": "@qwen-code/web-shell",
"version": "0.23.2",
"version": "0.23.3",
"dependencies": {
"@codemirror/autocomplete": "^6.18.0",
"@codemirror/commands": "^6.7.0",
@@ -31587,7 +31576,7 @@
},
"packages/web-templates": {
"name": "@qwen-code/web-templates",
"version": "0.23.2",
"version": "0.23.3",
"devDependencies": {
"@qwen-code/sdk": "file:../sdk-typescript",
"@qwen-code/web-shell": "file:../web-shell",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.23.2",
"version": "0.23.3",
"packageManager": "[email protected]+sha512.bd27e345e976dcb0be0b7a1228217b049a817e21b1f355c90dbe7dc46671895a8bc1e6d06c24554505ea93ea0b45f489a27ec1bfbc8de6a9659fca0f16fa0000",
"engines": {
"node": ">=22.0.0"
@@ -29,7 +29,7 @@
"url": "git+https://github.com/QwenLM/qwen-code.git"
},
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.23.2"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.23.3"
},
"scripts": {
"start": "node scripts/start.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@qwen-code/acp-bridge",
"version": "0.23.2",
"version": "0.23.3",
"description": "Shared ACP bridge core (createHttpAcpBridge factory, BridgeClient, defaultSpawnChannelFactory, BridgeFileSystem injection seam) + primitives (EventBus, AcpChannel, in-memory channel, PermissionMediator interface) used by qwen serve, channels, IDE, TUI, and remote-control adapters.",
"repository": {
"type": "git",
+35
View File
@@ -15892,6 +15892,41 @@ describe('createAcpSessionBridge', () => {
await bridge.shutdown();
});
it('forwards only explicitly declared submission text from trusted context', async () => {
const handle = makeChannel();
const bridge = makeBridge({ channelFactory: async () => handle.channel });
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const req = {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'machine wrapper' }],
_meta: {
'qwen.submittedPrompt': 'forged public declaration',
'qwen.daemon.submittedPrompt': 'forged private declaration',
},
} as PromptRequest;
await bridge.sendPrompt(session.sessionId, req);
expect(
handle.agent.promptCalls[0]?._meta?.['qwen.daemon.submittedPrompt'],
).toBeUndefined();
expect(
handle.agent.promptCalls[0]?._meta?.['qwen.submittedPrompt'],
).toBeUndefined();
await bridge.sendPrompt(session.sessionId, req, undefined, {
submittedPrompt: ' original question\n',
});
expect(
handle.agent.promptCalls[1]?._meta?.['qwen.daemon.submittedPrompt'],
).toBe(' original question\n');
await bridge.sendPrompt(session.sessionId, req, undefined, {
submittedPrompt: 'human channel message',
channelPrompt: true,
});
expect(
handle.agent.promptCalls[2]?._meta?.['qwen.daemon.submittedPrompt'],
).toBeUndefined();
await bridge.shutdown();
});
it('strips spoofed channel-prompt classification and injects only trusted context', async () => {
// `qwen.channel.prompt` opts a turn out of loop-detected rejection,
// so a forged key must not reach the child; only the authenticated
+12
View File
@@ -168,6 +168,8 @@ import {
DAEMON_ATTACHMENT_REFERENCES_META_KEY,
DAEMON_MODEL_PROMPT_META_KEY,
DAEMON_PROMPT_DISPLAY_TEXT_META_KEY,
DAEMON_SUBMITTED_PROMPT_META_KEY,
SUBMITTED_PROMPT_META_KEY,
DAEMON_RESTORE_ASK_USER_QUESTION_META_KEY,
DAEMON_SUPPRESS_RESTORE_ASK_USER_QUESTION_META_KEY,
DAEMON_SUPPRESS_WORKTREE_CONTEXT_RESTORE_META_KEY,
@@ -10536,6 +10538,16 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
// agent post under another's name.
delete meta[DAEMON_AGENT_RUN_META_KEY];
delete meta[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY];
delete meta[SUBMITTED_PROMPT_META_KEY];
delete meta[DAEMON_SUBMITTED_PROMPT_META_KEY];
if (
typeof context?.submittedPrompt === 'string' &&
!isPromotedMidTurn &&
context?.channelPrompt !== true
) {
meta[DAEMON_SUBMITTED_PROMPT_META_KEY] =
context.submittedPrompt;
}
delete meta[DAEMON_MODEL_PROMPT_META_KEY];
delete meta[DAEMON_ATTACHMENT_REFERENCES_META_KEY];
// Channel classification is authenticated channel-worker
+4
View File
@@ -976,6 +976,8 @@ export interface BridgeClientRequestContext {
* unchanged. HTTP routes never populate this from request input.
*/
modelPrompt?: string;
/** Original text explicitly declared by a supported submission producer. */
submittedPrompt?: string;
/** User-facing projection supplied by an authenticated channel worker. */
promptDisplayText?: string;
/**
@@ -1081,6 +1083,8 @@ export const DAEMON_CHANNEL_DELIVERY_META_KEY = 'qwen.daemon.channelDelivery';
* caller that could set it could make one agent post as another.
*/
export const DAEMON_AGENT_RUN_META_KEY = 'qwen.daemon.agentRun';
export const SUBMITTED_PROMPT_META_KEY = 'qwen.submittedPrompt';
export const DAEMON_SUBMITTED_PROMPT_META_KEY = 'qwen.daemon.submittedPrompt';
export const DAEMON_PROMPT_DISPLAY_TEXT_META_KEY =
'qwen.daemon.promptDisplayText';
// Wire twin of channel-base's CHANNEL_PROMPT_META_KEY; the packages have no
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@qwen-code/audio-capture",
"version": "0.23.2",
"version": "0.23.3",
"description": "Native microphone capture backend for Qwen Code voice input",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@qwen-code/channel-base",
"version": "0.23.2",
"version": "0.23.3",
"description": "Base channel infrastructure for Qwen Code",
"repository": {
"type": "git",
+38 -83
View File
@@ -722,31 +722,34 @@ describe('ChannelBase', () => {
});
describe('gate integration', () => {
it('filters and strips configured message prefixes before dispatch', async () => {
const ch = createChannel({ messagePrefix: '/review' });
it.each(['hello', '/review /new', '@Qwen /review inspect this'])(
'preserves %s when an old config still contains messagePrefix',
async (text) => {
const legacyConfig = { ...defaultConfig(), messagePrefix: '/review' };
const ch = createChannel(legacyConfig);
await ch.handleInbound(envelope({ text: 'hello' }));
await ch.handleInbound(envelope({ text: '@Qwen /review inspect this' }));
await ch.handleInbound(envelope({ text }));
expect(bridge.prompt).toHaveBeenCalledTimes(1);
expect(bridge.prompt).toHaveBeenCalledWith(
expect.any(String),
'inspect this',
expect.any(Object),
);
});
expect(bridge.prompt).toHaveBeenCalledWith(
expect.any(String),
text,
expect.any(Object),
);
expect(bridge.discardSession).not.toHaveBeenCalled();
},
);
it('checks a prepared envelope once and rejects before preparation', async () => {
const ch = createChannel({ messagePrefix: '/review' });
it('rejects disallowed groups before preparing media', async () => {
const ch = createChannel();
const prepare = vi.fn(async () => {});
const rejected = envelope({ text: 'hello' });
const rejected = envelope({ isGroup: true });
await ch.handlePreparedInbound(rejected, prepare);
await ch.handlePreparedInbound(rejected, prepare);
expect(prepare).not.toHaveBeenCalled();
await ch.handlePreparedInbound(
envelope({ text: '/review inspect this' }),
envelope({ text: 'inspect this' }),
prepare,
);
expect(prepare).toHaveBeenCalledTimes(1);
@@ -757,23 +760,22 @@ describe('ChannelBase', () => {
);
});
it('documents the prefix on shared command replies', async () => {
const ch = createChannel({ messagePrefix: '/review' });
it('documents direct shared commands', async () => {
const ch = createChannel();
await ch.handleInbound(envelope({ text: '/review /help' }));
await ch.handleInbound(envelope({ text: '/help' }));
expect(ch.sent[0]?.text).toContain('/review /help — Show this help');
expect(ch.sent[0]?.text).toContain('/help — Show this help');
expect(ch.sent[0]?.text).toContain(
'/review /approve [request-id] — Approve a pending permission request',
'/approve [request-id] — Approve a pending permission request',
);
});
it('keeps permission and shared-clear instructions usable with a prefix', async () => {
it('keeps permission and shared-clear instructions directly usable', async () => {
const ch = createChannel({
messagePrefix: '/review',
sessionScope: 'single',
});
await ch.handleInbound(envelope({ text: '/review start' }));
await ch.handleInbound(envelope({ text: 'start' }));
ch.sent = [];
for (const requestId of ['req-1', 'req-2']) {
await ch.dispatchPermissionRequest({
@@ -790,74 +792,27 @@ describe('ChannelBase', () => {
}
expect(ch.sent).toHaveLength(2);
expect(ch.sent[0]?.text).toContain('/review /approve');
expect(ch.sent[0]?.text).toContain('/review /deny');
expect(ch.sent[0]?.text).toContain('/approve');
expect(ch.sent[0]?.text).toContain('/deny');
ch.sent = [];
await ch.handleInbound(envelope({ text: '/review /approve' }));
expect(ch.sent[0]?.text).toContain('/review /approve <request-id>');
await ch.handleInbound(envelope({ text: '/approve' }));
expect(ch.sent[0]?.text).toContain('/approve <request-id>');
ch.sent = [];
await ch.handleInbound(envelope({ text: '/review /clear' }));
expect(ch.sent[0]?.text).toContain('/review /clear confirm');
await ch.handleInbound(envelope({ text: '/clear' }));
expect(ch.sent[0]?.text).toContain('/clear confirm');
});
it('logs prefix mismatches for DMs but not ambient group traffic', async () => {
it('offers pairing on first contact with ordinary text', async () => {
const ch = createChannel({
messagePrefix: '/review',
groupPolicy: 'open',
});
const writeSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
await ch.handleInbound(
envelope({
text: 'ambient',
isGroup: true,
isMentioned: false,
isReplyToBot: false,
}),
);
expect(
writeSpy.mock.calls.some(([message]) =>
String(message).includes('message_prefix_mismatch'),
),
).toBe(false);
await ch.handleInbound(envelope({ text: 'direct' }));
expect(
writeSpy.mock.calls.some(([message]) =>
String(message).includes('message_prefix_mismatch'),
),
).toBe(true);
});
it('requires the prefix on a pairing first contact too', async () => {
// Deliberate ordering: the prefix gate runs ahead of the pairing
// gates. A pairing code is a reply, and replying to every unprefixed
// message is exactly the traffic the prefix suppresses.
const ch = createChannel({
messagePrefix: '/review',
senderPolicy: 'pairing',
allowedUsers: [],
});
await ch.handleInbound(envelope({ text: 'hello' }));
expect(ch.sent).toEqual([]);
await ch.handleInbound(envelope({ text: '/review hello' }));
expect(ch.sent[0]?.text).toContain('pairing code');
});
it('allows explicitly marked system envelopes through', async () => {
const ch = createChannel({ messagePrefix: '/review' });
await ch.handleInbound(
envelope({ text: 'system event', bypassMessagePrefix: true }),
);
expect(bridge.prompt).toHaveBeenCalled();
expect(bridge.prompt).not.toHaveBeenCalled();
});
it('silently drops group messages when groupPolicy=disabled', async () => {
@@ -2586,11 +2541,11 @@ describe('ChannelBase', () => {
});
it('requires card-presented questions to be submitted or denied', async () => {
const ch = createChannel({ messagePrefix: '/review' });
const ch = createChannel();
ch.userInputPresentationResult = { kind: 'presented' };
const active = await startActiveSession(ch, {
senderId: 'owner-1',
text: '/review run tests',
text: 'run tests',
});
emitUserQuestion(active.sessionId, 'req-card-command');
await vi.waitFor(() => expect(ch.userInputPresentations).toHaveLength(1));
@@ -2600,7 +2555,7 @@ describe('ChannelBase', () => {
await ch.handleInbound(
envelope({
senderId: 'owner-1',
text: '/review /approve req-card-command',
text: '/approve req-card-command',
}),
);
@@ -2608,12 +2563,12 @@ describe('ChannelBase', () => {
expect(ch.sent.at(-1)?.text).toContain(
'Submit this question through its interactive card',
);
expect(ch.sent.at(-1)?.text).toContain('/review /deny [request-id]');
expect(ch.sent.at(-1)?.text).toContain('/deny [request-id]');
await ch.handleInbound(
envelope({
senderId: 'owner-1',
text: '/review /deny req-card-command',
text: '/deny req-card-command',
}),
);
+34 -83
View File
@@ -73,7 +73,6 @@ import type {
} from './ChannelAgentBridge.js';
import type { ChannelLoop, ChannelLoopInput } from './ChannelLoopStore.js';
import { ChannelLoopSkippedError } from './ChannelLoopScheduler.js';
import { applyMessagePrefix } from './message-prefix.js';
import {
buildChannelWebhookDisplayText,
buildChannelWebhookPrompt,
@@ -488,9 +487,6 @@ export abstract class ChannelBase {
private readonly observedContacts?: ChannelBaseOptions['observedContacts'];
private readonly namedSessions?: NamedSessionManager;
private readonly observedContactEnvelopes = new WeakSet<Envelope>();
private readonly messagePrefix?: string;
private readonly messagePrefixCheckedEnvelopes = new WeakSet<Envelope>();
private readonly messagePrefixRejectedEnvelopes = new WeakSet<Envelope>();
private instructedSessions: Set<string> = new Set();
private unattendedMemorySessions: Set<string> = new Set();
private channelMemoryReads = new Map<string, ChannelMemoryReadState>();
@@ -698,7 +694,7 @@ export abstract class ChannelBase {
await this.sendThreadMessage(
envelope.chatId,
envelope.threadId,
`Could not resolve the current task for ${this.prefixedCommand('/btw')}.`,
`Could not resolve the current task for /btw.`,
sourceLabel,
);
return;
@@ -1371,17 +1367,8 @@ export abstract class ChannelBase {
bridge: ChannelAgentBridge,
options?: ChannelBaseOptions,
) {
if (
config.messagePrefix !== undefined &&
typeof config.messagePrefix !== 'string'
) {
throw new Error(
`Channel "${name}" field "messagePrefix" must be a string.`,
);
}
this.name = name;
this.config = config;
this.messagePrefix = config.messagePrefix?.trim() || undefined;
this.bridge = bridge;
this.locale = options?.locale ?? 'en';
this.proxy = options?.proxy;
@@ -3318,13 +3305,13 @@ export abstract class ChannelBase {
? { approve: ' ', always: ' ', deny: ' ' }
: { approve: ' ', always: ' ', deny: ' ' };
const replies = [
`${this.prefixedCommand(`/approve${requestSuffix}`)}${replyPadding.approve}${approveLabel}`,
`/approve${requestSuffix}${replyPadding.approve}${approveLabel}`,
...(alwaysOption
? [
`${this.prefixedCommand(`/approve-always${requestSuffix}`)}${replyPadding.always}${alwaysOption.label}`,
`/approve-always${requestSuffix}${replyPadding.always}${alwaysOption.label}`,
]
: []),
`${this.prefixedCommand(`/deny${requestSuffix}`)}${replyPadding.deny}${denyLabel}`,
`/deny${requestSuffix}${replyPadding.deny}${denyLabel}`,
];
return [
copy.required,
@@ -3339,14 +3326,6 @@ export abstract class ChannelBase {
].join('\n');
}
protected prefixedCommand(command: string): string {
return this.messagePrefix ? `${this.messagePrefix} ${command}` : command;
}
protected configuredMessagePrefix(): string | undefined {
return this.messagePrefix;
}
private permissionTitle(
toolCall: PermissionRequestEvent['request']['toolCall'],
): string {
@@ -3574,7 +3553,7 @@ export abstract class ChannelBase {
await this.sendThreadMessage(
envelope.chatId,
envelope.threadId,
`Multiple permission requests are pending for this chat. Reply with ${this.prefixedCommand(`/${decision} <request-id>`)}.\n${requestList}`,
`Multiple permission requests are pending for this chat. Reply with /${decision} <request-id>.\n${requestList}`,
);
return true;
}
@@ -3607,7 +3586,7 @@ export abstract class ChannelBase {
await this.sendThreadMessage(
envelope.chatId,
envelope.threadId,
`Submit this question through its interactive card, or use ${this.prefixedCommand('/deny [request-id]')} to cancel it.`,
`Submit this question through its interactive card, or use /deny [request-id] to cancel it.`,
pending.sourceLabel,
);
return true;
@@ -3869,7 +3848,7 @@ export abstract class ChannelBase {
const remedy =
safeTaskName === undefined
? 'select this task first or close it'
: `select this task first or close it with ${this.prefixedCommand(`/session close ${safeTaskName}`)}`;
: `select this task first or close it with /session close ${safeTaskName}`;
return `${subject} ${problem}. Its files were not changed. Clearing now would reset the selected task instead, so ${remedy}.`;
}
@@ -3918,7 +3897,7 @@ export abstract class ChannelBase {
await this.sendThreadMessage(
envelope.chatId,
envelope.threadId,
`Usage: ${this.prefixedCommand('/sessions [all]')}`,
`Usage: /sessions [all]`,
);
return true;
}
@@ -3971,7 +3950,7 @@ export abstract class ChannelBase {
envelope.threadId,
current
? `Current task: ${current.name} (${current.isolation})`
: `No task is currently selected. Use ${this.prefixedCommand('/session new <name>')} or ${this.prefixedCommand('/session use <name>')}.`,
: `No task is currently selected. Use /session new <name> or /session use <name>.`,
);
return true;
}
@@ -4081,7 +4060,7 @@ export abstract class ChannelBase {
await this.sendThreadMessage(
envelope.chatId,
envelope.threadId,
`Usage: ${this.prefixedCommand('/session current')} | ${this.prefixedCommand('/session new <name> [--worktree]')} | ${this.prefixedCommand('/session use <name>')} | ${this.prefixedCommand('/session close <name>')} | ${this.prefixedCommand('/session cancel [<name>]')}`,
`Usage: /session current | /session new <name> [--worktree] | /session use <name> | /session close <name> | /session cancel [<name>]`,
);
return true;
}
@@ -4280,7 +4259,7 @@ export abstract class ChannelBase {
await this.sendThreadMessage(
envelope.chatId,
envelope.threadId,
`This clears the shared session for everyone who shares it. Re-send with "confirm" (e.g. ${this.prefixedCommand('/clear confirm')}) to proceed.`,
`This clears the shared session for everyone who shares it. Re-send with "confirm" (e.g. /clear confirm) to proceed.`,
);
return true;
}
@@ -4368,24 +4347,24 @@ export abstract class ChannelBase {
this.registerCommand('help', async (envelope) => {
const lines = [
'Commands:',
`${this.prefixedCommand('/help')} — Show this help`,
`/help — Show this help`,
this.isSharedSession(envelope)
? `${this.prefixedCommand('/clear confirm')} — Clear the shared session (aliases: ${this.prefixedCommand('/reset')}, ${this.prefixedCommand('/new')})`
: `${this.prefixedCommand('/clear')} — Clear your session (aliases: ${this.prefixedCommand('/reset')}, ${this.prefixedCommand('/new')})`,
`${this.prefixedCommand('/who')} — Show current session & workspace`,
`${this.prefixedCommand('/status')} — Show session info`,
`${this.prefixedCommand('/approve [request-id]')} — Approve a pending permission request`,
`${this.prefixedCommand('/approve-always [request-id]')} — Always approve a pending permission request`,
`${this.prefixedCommand('/deny [request-id]')} — Deny a pending permission request`,
? `/clear confirm — Clear the shared session (aliases: /reset, /new)`
: `/clear — Clear your session (aliases: /reset, /new)`,
`/who — Show current session & workspace`,
`/status — Show session info`,
`/approve [request-id] — Approve a pending permission request`,
`/approve-always [request-id] — Always approve a pending permission request`,
`/deny [request-id] — Deny a pending permission request`,
...(this.bridge.btw
? [
`${this.prefixedCommand('/btw <question>')} — Ask a side question without interrupting the current task`,
`/btw <question> — Ask a side question without interrupting the current task`,
]
: []),
...(this.namedSessions
? [
`${this.prefixedCommand('/sessions [all]')} — List your named tasks`,
`${this.prefixedCommand('/session current|new|use|close|cancel')} — Manage your named tasks`,
`/sessions [all] — List your named tasks`,
`/session current|new|use|close|cancel — Manage your named tasks`,
]
: []),
];
@@ -4413,7 +4392,7 @@ export abstract class ChannelBase {
);
if (platformCmds.length > 0) {
for (const cmd of platformCmds) {
lines.push(this.prefixedCommand(`/${cmd}`));
lines.push(`/${cmd}`);
}
}
@@ -4433,18 +4412,11 @@ export abstract class ChannelBase {
if (agentCommands.length > 0) {
lines.push('', 'Agent commands (forwarded to Qwen Code):');
for (const cmd of agentCommands) {
lines.push(
`${this.prefixedCommand(`/${cmd.name}`)} — ${cmd.description}`,
);
lines.push(`/${cmd.name} — ${cmd.description}`);
}
}
lines.push(
'',
this.messagePrefix
? `Start each message with ${this.messagePrefix} to chat with the agent.`
: 'Send any text to chat with the agent.',
);
lines.push('', 'Send any text to chat with the agent.');
await this.sendThreadMessage(
envelope.chatId,
envelope.threadId,
@@ -4532,7 +4504,7 @@ export abstract class ChannelBase {
await this.sendThreadMessage(
envelope.chatId,
envelope.threadId,
`Usage: ${this.prefixedCommand('/loop add "<cron>" <prompt>')} | ${this.prefixedCommand('/loop list')} | ${this.prefixedCommand('/loop inspect <id>')} | ${this.prefixedCommand('/loop cancel <id>')}`,
`Usage: /loop add "<cron>" <prompt> | /loop list | /loop inspect <id> | /loop cancel <id>`,
);
return true;
}
@@ -4565,7 +4537,7 @@ export abstract class ChannelBase {
await this.sendThreadMessage(
envelope.chatId,
envelope.threadId,
`Usage: ${this.prefixedCommand('/loop add "<cron>" <prompt>')}`,
`Usage: /loop add "<cron>" <prompt>`,
);
return true;
}
@@ -4792,7 +4764,7 @@ export abstract class ChannelBase {
await this.sendThreadMessage(
envelope.chatId,
envelope.threadId,
`Usage: ${this.prefixedCommand('/loop inspect <id>')}`,
`Usage: /loop inspect <id>`,
);
return true;
}
@@ -4872,7 +4844,7 @@ export abstract class ChannelBase {
await this.sendThreadMessage(
envelope.chatId,
envelope.threadId,
`Usage: ${this.prefixedCommand('/loop cancel <id>')}`,
`Usage: /loop cancel <id>`,
);
return true;
}
@@ -6175,23 +6147,6 @@ export abstract class ChannelBase {
envelope: Envelope,
options: PreflightInboundOptions = {},
): boolean | Promise<boolean> {
// Ahead of both pairing gates on purpose: a pairing request is a reply,
// and replying to every unprefixed message would be exactly the traffic
// the prefix exists to suppress. First contact carries the prefix too.
if (this.messagePrefixRejectedEnvelopes.has(envelope)) return false;
if (!this.messagePrefixCheckedEnvelopes.has(envelope)) {
this.messagePrefixCheckedEnvelopes.add(envelope);
if (!applyMessagePrefix(envelope, this.messagePrefix)) {
this.messagePrefixRejectedEnvelopes.add(envelope);
if (
!(envelope.isGroup && !envelope.isMentioned && !envelope.isReplyToBot)
) {
this.logPreflightRejected('message_prefix_mismatch');
}
return false;
}
}
const groupResult = this.groupGate.check(envelope, {
createPairingRequest: !options.deferPairingRequests,
});
@@ -6286,10 +6241,6 @@ export abstract class ChannelBase {
);
}
protected wasMessagePrefixRejected(envelope: Envelope): boolean {
return this.messagePrefixRejectedEnvelopes.has(envelope);
}
protected logDebugPayload(platform: string, payload: unknown): void {
if (!isDebugPayloadEnabled(this.name)) return;
const prefix = `[${sanitizeLogText(platform, 40)}:${sanitizeLogText(
@@ -6622,7 +6573,7 @@ export abstract class ChannelBase {
await this.sendThreadMessage(
envelope.chatId,
envelope.threadId,
`Only authorized members can use ${this.prefixedCommand('/btw')} in this shared session.`,
`Only authorized members can use /btw in this shared session.`,
);
return;
}
@@ -6631,7 +6582,7 @@ export abstract class ChannelBase {
await this.sendThreadMessage(
envelope.chatId,
envelope.threadId,
`Usage: ${this.prefixedCommand('/btw <question>')}`,
`Usage: /btw <question>`,
);
return;
}
@@ -6647,7 +6598,7 @@ export abstract class ChannelBase {
await this.sendThreadMessage(
envelope.chatId,
envelope.threadId,
`${this.prefixedCommand('/btw')} supports text-only questions.`,
`/btw supports text-only questions.`,
);
return;
}
@@ -6751,7 +6702,7 @@ export abstract class ChannelBase {
await this.sendThreadMessage(
envelope.chatId,
envelope.threadId,
`No task is currently selected. Use ${this.prefixedCommand('/session new <name>')} or ${this.prefixedCommand('/session use <name>')}.`,
`No task is currently selected. Use /session new <name> or /session use <name>.`,
);
return;
}
@@ -6773,7 +6724,7 @@ export abstract class ChannelBase {
await this.sendThreadMessage(
envelope.chatId,
envelope.threadId,
`Could not identify the selected task. Use ${this.prefixedCommand('/sessions')}, select it again, and retry.`,
`Could not identify the selected task. Use /sessions, select it again, and retry.`,
);
return;
}
-4
View File
@@ -40,10 +40,6 @@ export type {
DaemonPermissionResolvedEvent,
} from './DaemonChannelBridge.js';
export { ChannelBase, CLEAR_CANCEL_TIMEOUT_MS } from './ChannelBase.js';
export {
startsWithMessagePrefix,
stripMessagePrefix,
} from './message-prefix.js';
export {
CHANNEL_PROACTIVE_DELIVERY_ERROR_CODE,
ChannelProactiveDeliveryError,
@@ -1,240 +0,0 @@
import { describe, expect, it } from 'vitest';
import { applyMessagePrefix, stripMessagePrefix } from './message-prefix.js';
import type { Envelope } from './types.js';
function envelope(overrides: Partial<Envelope> = {}): Envelope {
return {
channelName: 'test',
senderId: 'user',
senderName: 'User',
chatId: 'chat',
text: 'hello',
isGroup: false,
isMentioned: false,
isReplyToBot: false,
...overrides,
};
}
describe('stripMessagePrefix', () => {
it.each([
['/review 123', '123'],
[' /review 123 ', '123'],
['@Qwen /review 123', '123'],
['@Qwen @Code\n/review 123', '123'],
['<@ABC123DEF456> /review 123', '123'],
])('accepts %j', (text, expected) => {
expect(stripMessagePrefix(text, '/review')).toBe(expected);
});
it.each([
['@Qwen @bot hello', '@bot', 'hello'],
['@botswana @bot do X', '@bot', 'do X'],
['<@U1> <@BOT> hi', '<@BOT>', 'hi'],
['@bot hello', '@bot', 'hello'],
])('accepts %j under the @-leading prefix %j', (text, prefix, expected) => {
// Nothing rejects a prefix that itself starts with `@`, so the
// mention loop must stop at the prefix rather than eat it as one
// more mention token.
expect(stripMessagePrefix(text, prefix)).toBe(expected);
});
it.each([
'please review 123',
'/review',
'/review ',
'/reviewer 123',
'please /review 123',
'/Review 123',
'@Qwen/review 123',
'@Qwen@Code /review 123',
'@Alice please inspect /review 123',
])('rejects %j', (text) => {
expect(stripMessagePrefix(text, '/review')).toBeUndefined();
});
it('preserves text when no prefix is configured', () => {
expect(stripMessagePrefix(' hello ', undefined)).toBe(' hello ');
});
});
describe('applyMessagePrefix', () => {
it('updates both the display text and a self-prefixed model prompt', () => {
const input = envelope({
text: '[atMention=true] [User]: review: inspect this',
displayText: 'review: inspect this',
alreadyPrefixed: true,
});
expect(applyMessagePrefix(input, 'review:')).toBe(true);
expect(input.displayText).toBe('inspect this');
expect(input.text).toBe('[atMention=true] [User]: inspect this');
});
it('preserves adapter context around the display text', () => {
const input = envelope({
text: '[atMention=false] [Alice(abc)]: review: inspect this\n机器人 OPENID: BOT',
displayText: 'review: inspect this',
alreadyPrefixed: true,
});
expect(applyMessagePrefix(input, 'review:')).toBe(true);
expect(input.displayText).toBe('inspect this');
expect(input.text).toBe(
'[atMention=false] [Alice(abc)]: inspect this\n机器人 OPENID: BOT',
);
});
it('strips the user segment, not a sender tag that repeats it', () => {
// Both the nick and the body are attacker-controlled on QQ, so a nick
// equal to the message body puts the first occurrence of displayText
// inside the sender tag. Splicing there would leave the prefix on the
// dispatched message and corrupt the tag.
const input = envelope({
text: '[atMention=false] [review: inspect this(abc)]: review: inspect this\n机器人 OPENID: BOT',
displayText: 'review: inspect this',
displayTextOffset: '[atMention=false] [review: inspect this(abc)]: '
.length,
alreadyPrefixed: true,
});
expect(applyMessagePrefix(input, 'review:')).toBe(true);
expect(input.text).toBe(
'[atMention=false] [review: inspect this(abc)]: inspect this\n机器人 OPENID: BOT',
);
});
it('refuses to guess when the display text appears twice and no offset is given', () => {
// Same shape without the adapter-supplied position: two candidate
// locations and no way to tell them apart, so the message is refused
// rather than rewritten at the wrong one.
const input = envelope({
text: '[atMention=false] [review: inspect this(abc)]: review: inspect this\n机器人 OPENID: BOT',
displayText: 'review: inspect this',
alreadyPrefixed: true,
});
expect(applyMessagePrefix(input, 'review:')).toBe(false);
});
it('uses the trailing display segment without an adapter offset', () => {
const input = envelope({
text: '[atMention=true] [review: inspect]: review: inspect',
displayText: 'review: inspect',
alreadyPrefixed: true,
});
expect(applyMessagePrefix(input, 'review:')).toBe(true);
expect(input.text).toBe('[atMention=true] [review: inspect]: inspect');
});
it('uses the adapter-normalized prefix text without guessing mention boundaries', () => {
const input = envelope({
text: '@Alice Smith /review inspect this',
displayText: '@Alice Smith /review inspect this',
messagePrefixText: '/review inspect this',
});
expect(applyMessagePrefix(input, '/review')).toBe(true);
expect(input.text).toBe('inspect this');
expect(input.displayText).toBe('inspect this');
expect(input.messagePrefixText).toBe('inspect this');
});
it('matches an unsanitized prefix source while splicing sanitized display text', () => {
const input = envelope({
text: '[atMention=true] [Alice]: review hello',
displayText: 'review hello',
displayTextOffset: '[atMention=true] [Alice]: '.length,
messagePrefixText: '[review] hello',
alreadyPrefixed: true,
});
expect(applyMessagePrefix(input, '[review]')).toBe(true);
expect(input.text).toBe('[atMention=true] [Alice]: hello');
expect(input.displayText).toBe('hello');
});
it('rejects a prefix manufactured only by display sanitization', () => {
const input = envelope({
text: '[atMention=true] [Alice]: review hello',
displayText: 'review hello',
displayTextOffset: '[atMention=true] [Alice]: '.length,
messagePrefixText: '[r]eview hello',
alreadyPrefixed: true,
});
expect(applyMessagePrefix(input, 'review')).toBe(false);
expect(input.text).toBe('[atMention=true] [Alice]: review hello');
});
it('strips a reconciled slash envelope without restoring removed mentions', () => {
const input = envelope({
text: '!ai /compact',
displayText: '!ai /compact',
messagePrefixText: '!ai /compact',
});
expect(applyMessagePrefix(input, '!ai')).toBe(true);
expect(input.text).toBe('/compact');
expect(input.displayText).toBe('/compact');
});
it('falls back to the search when the adapter offset does not fit', () => {
// A stale or forged offset must not splice the payload at the wrong
// range: the guard demotes it to the search path, which locates the
// one real occurrence.
const input = envelope({
text: '[atMention=false] [Alice]: review: inspect this\n机器人 OPENID: BOT',
displayText: 'review: inspect this',
displayTextOffset: 3,
alreadyPrefixed: true,
});
expect(applyMessagePrefix(input, 'review:')).toBe(true);
expect(input.text).toBe(
'[atMention=false] [Alice]: inspect this\n机器人 OPENID: BOT',
);
});
it('refuses an absent display segment without mutating the envelope', () => {
const input = envelope({
text: '!ai /compact',
displayText: '!ai /compact <@other>',
messagePrefixText: '!ai /compact <@other>',
});
expect(applyMessagePrefix(input, '!ai')).toBe(false);
expect(input.text).toBe('!ai /compact');
expect(input.displayText).toBe('!ai /compact <@other>');
expect(input.messagePrefixText).toBe('!ai /compact <@other>');
});
it('uses an authoritative offset before a matching trailing segment', () => {
const input = envelope({
text: '[atMention=false] [Alice]: OPENID: BOT1D\n机器人 OPENID: BOT1D',
displayText: 'OPENID: BOT1D',
displayTextOffset: '[atMention=false] [Alice]: '.length,
alreadyPrefixed: true,
});
expect(applyMessagePrefix(input, 'OPENID:')).toBe(true);
expect(input.text).toBe(
'[atMention=false] [Alice]: BOT1D\n机器人 OPENID: BOT1D',
);
});
it('lets adapter-synthesized media placeholders bypass the filter', () => {
const input = envelope({ text: '(image)', syntheticText: true });
expect(applyMessagePrefix(input, '/review')).toBe(true);
expect(input.text).toBe('(image)');
});
it('lets system envelopes bypass the filter', () => {
const input = envelope({ bypassMessagePrefix: true });
expect(applyMessagePrefix(input, '/review')).toBe(true);
expect(input.text).toBe('hello');
});
});
@@ -1,95 +0,0 @@
import type { Envelope } from './types.js';
export function startsWithMessagePrefix(text: string, prefix: string): boolean {
return (
text.startsWith(prefix) &&
(text.length === prefix.length || /\s/u.test(text.charAt(prefix.length)))
);
}
export function stripMessagePrefix(
text: string,
prefix: string | undefined,
): string | undefined {
if (!prefix) return text;
let candidate = text.trim();
// The prefix is re-checked every iteration, not once before the loop: a
// configured prefix may itself start with `@` or `<@` (nothing rejects
// `@qwen_bot` as a prefix), and consuming it as a mention token would
// reject every correctly-prefixed message that carries a leading
// mention of someone else.
while (
!startsWithMessagePrefix(candidate, prefix) &&
(candidate.startsWith('@') || candidate.startsWith('<@'))
) {
const mention = candidate.match(/^(?:@[^@\s]+|<@[^>]{1,64}>)\s+/u)?.[0];
if (!mention) return undefined;
candidate = candidate.slice(mention.length);
}
if (!startsWithMessagePrefix(candidate, prefix)) return undefined;
const suffix = candidate.slice(prefix.length);
if (!/^\s+\S[\s\S]*$/u.test(suffix)) return undefined;
return suffix.trim();
}
/**
* Where to splice the stripped payload into an adapter-composed `text`.
*
* An adapter-supplied `displayTextOffset` is authoritative, and validated
* before use so a stale one cannot corrupt the text. Without an offset, a
* trailing segment is preferred for adapters that append the user body after
* sender context. Other duplicate occurrences are ambiguous: on QQ both the
* sender nick and the body are attacker-controlled, so guessing could splice
* inside the sender tag. Ambiguity fails closed rather than picking one.
*/
function locateDisplayText(
envelope: Envelope,
displayText: string,
): number | 'ambiguous' {
const offset = envelope.displayTextOffset;
if (offset !== undefined && envelope.text.startsWith(displayText, offset)) {
return offset;
}
if (offset === undefined && envelope.text.endsWith(displayText)) {
return envelope.text.length - displayText.length;
}
const first = envelope.text.indexOf(displayText);
if (first === -1) return -1;
return envelope.text.indexOf(displayText, first + 1) === -1
? first
: 'ambiguous';
}
export function applyMessagePrefix(
envelope: Envelope,
prefix: string | undefined,
): boolean {
if (!prefix || envelope.bypassMessagePrefix || envelope.syntheticText) {
return true;
}
const displayText = envelope.displayText;
const prefixText = envelope.messagePrefixText;
const sourceText = prefixText ?? displayText ?? envelope.text;
const stripped = stripMessagePrefix(sourceText, prefix);
if (stripped === undefined) return false;
if (displayText === undefined) {
if (prefixText !== undefined) envelope.messagePrefixText = stripped;
envelope.text = stripped;
return true;
}
const at = locateDisplayText(envelope, displayText);
if (at === 'ambiguous' || at === -1) return false;
if (prefixText !== undefined) envelope.messagePrefixText = stripped;
envelope.displayText = stripped;
envelope.text =
envelope.text.slice(0, at) +
stripped +
envelope.text.slice(at + displayText.length);
return true;
}
+2 -26
View File
@@ -54,8 +54,6 @@ export interface ChannelConfig {
cwd: string;
approvalMode?: string;
instructions?: string;
/** Only dispatch user messages beginning with this exact prefix. */
messagePrefix?: string;
identity?: ChannelIdentityConfig;
memoryScope?: ChannelMemoryScopeConfig;
webhooks?: ChannelWebhookConfig;
@@ -94,34 +92,12 @@ export interface Envelope {
text: string;
/** User-authored text to display when `text` contains model-only context. */
displayText?: string;
/**
* Where `displayText` begins inside `text`, for adapters that compose
* the two.
*
* The prefix filter rewrites the user-authored segment in place. Both
* the sender nick and the message body are attacker-controlled on some
* platforms, so a nick equal to the body would make a search for
* `displayText` land in the sender tag and leave the prefix on the
* dispatched message. An adapter that knows where it put the segment
* says so here; without it the filter refuses to guess between two
* occurrences.
*/
displayTextOffset?: number;
/**
* The user-authored text with the leading mention run removed, for
* adapters whose mention markers the shared prefix matcher cannot read as
* one token. Mentions after the prefix stay in place.
*/
messagePrefixText?: string;
/** System event, or adapter input whose prefix was already checked. */
bypassMessagePrefix?: true;
/**
* `text` is an adapter-synthesized placeholder (`(image)`, `(voice
* message)`, `(file: …)`) rather than something the user typed.
*
* No user action can put the configured prefix on it, so it bypasses the
* prefix filter -- and it is never recorded as quoted group history,
* where it would reach the next prompt as if a member had typed it.
* It is never recorded as quoted group history, where it would reach
* the next prompt as if a member had typed it.
*/
syntheticText?: true;
threadId?: string;
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@qwen-code/channel-dingtalk",
"version": "0.23.2",
"version": "0.23.3",
"description": "DingTalk channel adapter for Qwen Code",
"repository": {
"type": "git",
@@ -25,7 +25,7 @@
"test:ci": "vitest run"
},
"dependencies": {
"@qwen-code/channel-base": "0.23.2",
"@qwen-code/channel-base": "0.23.3",
"dingtalk-stream-sdk-nodejs": "^2.0.4"
},
"devDependencies": {
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,11 @@
import { expect, it } from 'vitest';
import { plugin } from './index.js';
it('exposes only credentials and interactive cards in channel management', () => {
expect(plugin.requiredConfigFields).toEqual(['clientId', 'clientSecret']);
expect(plugin.management?.fields.map((field) => field.key)).toEqual([
'clientId',
'clientSecret',
'interactiveCards',
]);
});
-7
View File
@@ -25,13 +25,6 @@ export const plugin: ChannelPlugin = {
required: true,
envResolvable: true,
},
{
key: 'aggregateBackgroundAgentResponses',
label: 'Aggregate Background Agent Responses',
kind: 'boolean',
description:
'Buffer each background Agent turn and send one labeled result instead of labeled segments',
},
{
key: 'interactiveCards',
label: 'Interactive Cards',
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@qwen-code/channel-dws",
"version": "0.23.2",
"version": "0.23.3",
"description": "DingTalk Workspace channel adapter for Qwen Code",
"repository": {
"type": "git",
@@ -25,7 +25,7 @@
"test:ci": "vitest run"
},
"dependencies": {
"@qwen-code/channel-base": "0.23.2"
"@qwen-code/channel-base": "0.23.3"
},
"devDependencies": {
"typescript": "^5.0.0"
+20 -104
View File
@@ -1410,80 +1410,34 @@ describe('DwsChannel', () => {
expect(client.sendImMessage).not.toHaveBeenCalled();
});
it('only dispatches complete commands matching the configured message prefix', async () => {
it('dispatches ordinary text and preserves literal slash-prefixed text', async () => {
const client = new FakeDwsClient();
const channel = await readyChannel(
client,
makeConfig({ messagePrefix: '/review' }),
);
for (const [messageId, content] of [
const channel = await readyChannel(client);
const messages = [
['plain', 'please review 123'],
['empty', '/review'],
['whitespace-only', '/review '],
['command', '/review'],
['similar', '/reviewer 123'],
['embedded', 'please /review 123'],
['wrong-case', '/Review 123'],
['joined', '@Qwen/review 123'],
['malformed-mention', '@Qwen@Other /review 123'],
]) {
['multiple-mentions', '@Qwen@Other /review 123'],
];
for (const [messageId, content] of messages) {
await client.emit(
1,
message('user_im_message_receive_o2o_all', messageId, content),
);
}
await client.emit(
1,
message(
'user_im_message_receive_o2o_all',
'direct',
' /review 456 ',
{ referencedText: '/review should not affect matching' },
),
);
await client.emit(
0,
message(
'user_im_message_receive_at',
'valid',
'@Qwen @Code\n/review https://github.com/QwenLM/qwen-code/pull/123',
),
);
expect(channel.inbound).toEqual([
expect.objectContaining({
messageId: 'direct',
text: '456',
bypassMessagePrefix: true,
}),
expect.objectContaining({
messageId: 'valid',
text: 'https://github.com/QwenLM/qwen-code/pull/123',
bypassMessagePrefix: true,
}),
]);
expect(channel.processedMessageIds()).toEqual(
expect.arrayContaining(
[
'plain',
'empty',
'whitespace-only',
'similar',
'embedded',
'wrong-case',
'joined',
'malformed-mention',
].map((messageId) => `cid-1\0${messageId}`),
expect(channel.inbound).toEqual(
messages.map(([messageId, text]) =>
expect.objectContaining({ messageId, text }),
),
);
});
it('lets provider-generated document notifications bypass the prefix', async () => {
it('dispatches provider-generated document notifications', async () => {
const client = new FakeDwsClient();
const channel = await readyChannel(
client,
makeConfig({ messagePrefix: '/review' }),
);
const channel = await readyChannel(client, makeConfig());
await client.emit(
1,
@@ -1502,42 +1456,6 @@ describe('DwsChannel', () => {
expect.objectContaining({
chatId: 'doc-prefixed',
threadId: '1786589783750e2a797d2c2c141c295519dbcb07f2274',
bypassMessagePrefix: true,
}),
]);
});
it('parses a prefixed single-line document link after the strip', async () => {
// The anchored link patterns only match a line that is nothing but the
// link, so a prefixed link parses only on the second pass over the
// stripped text.
const client = new FakeDwsClient();
const channel = await readyChannel(
client,
makeConfig({ messagePrefix: '/review' }),
);
const link = documentMentionCard('doc-prefixed-link', 'comment-link')
.split('\n')
.find((line) => line.startsWith('[https://alidocs.dingtalk.com/'))!;
await client.emit(
1,
message(
'user_im_message_receive_o2o_all',
'document-with-prefix',
`/review ${link}`,
),
);
expect(client.readDocument).toHaveBeenCalledWith(
'doc-prefixed-link',
expect.any(AbortSignal),
);
expect(channel.inbound).toEqual([
expect.objectContaining({
chatId: 'doc-prefixed-link',
threadId: 'comment-link',
bypassMessagePrefix: true,
}),
]);
});
@@ -2576,19 +2494,18 @@ describe('DwsChannel', () => {
);
});
it('does not retain prefix-filtered ambient group history', async () => {
it('retains eligible ambient group history', async () => {
const client = new FakeDwsClient();
const { bridge } = await readyPolicyChannel(
client,
makeConfig({
groupHistoryLimit: 5,
messagePrefix: '/review',
groups: {
'*': { requireMention: false },
'conversation-shadowed': { dispatchMode: 'followup' },
},
}),
'prefix-filtered-group-history-dws',
'ambient-group-history-dws',
{ groupHistoryPath: join(qwenHome, 'group-history.json') },
);
@@ -2596,8 +2513,8 @@ describe('DwsChannel', () => {
1,
message(
'user_im_message_receive_group_all',
'unprefixed-ambient',
'unprefixed ambient chatter',
'ambient-message',
'ambient chatter',
{ conversationId: 'conversation-shadowed' },
),
);
@@ -2605,14 +2522,14 @@ describe('DwsChannel', () => {
0,
message(
'user_im_message_receive_at',
'prefixed-mention',
'@QwenBot /review summarize',
'direct-mention',
'@QwenBot summarize',
{ conversationId: 'conversation-shadowed' },
),
);
const prompt = String(vi.mocked(bridge.prompt).mock.calls[0]?.[1]);
expect(prompt).not.toContain('unprefixed ambient chatter');
expect(prompt).toContain('ambient chatter');
});
it('does not add an @ message twin to its own group history', async () => {
@@ -4750,7 +4667,7 @@ describe('DwsChannel', () => {
client.todoTasks = [todoTask('task-existing', 'Historical task')];
const channel = await readyChannel(
client,
makeConfig({ watchTodos: true, messagePrefix: '/review' }),
makeConfig({ watchTodos: true }),
);
await channel.poll();
@@ -4769,7 +4686,6 @@ describe('DwsChannel', () => {
senderId: 'alice',
displayText: 'Investigate the new failure',
text: expect.stringContaining('Investigate the new failure'),
bypassMessagePrefix: true,
metadata: expect.stringContaining('DWS native todo ID: task-new'),
}),
]);
+20 -56
View File
@@ -10,7 +10,6 @@ import {
isTerminalTaskLifecycleType,
PollingChannelBase,
sanitizeLogText,
stripMessagePrefix,
truncateCodePoints,
type ChannelAgentBridge,
type ChannelBaseOptions,
@@ -607,7 +606,6 @@ export class DwsChannel extends PollingChannelBase<DwsCursor> {
private readonly userInstructions?: string;
private readonly client: DwsClientLike;
private readonly imStates: ImSubscriptionState[];
private readonly dwsMessagePrefix?: string;
private readonly startReactionName: string;
private readonly endReactionName?: string;
private readonly watchTodos: boolean;
@@ -652,10 +650,6 @@ export class DwsChannel extends PollingChannelBase<DwsCursor> {
client?: DwsClientLike,
) {
const profile = configuredString(config.profile, 'profile');
const messagePrefix = configuredString(
config.messagePrefix,
'messagePrefix',
);
const startReactionName =
configuredString(config.startReaction, 'startReaction') ??
DEFAULT_START_REACTION;
@@ -713,7 +707,6 @@ export class DwsChannel extends PollingChannelBase<DwsCursor> {
}));
this.startReactionName = startReactionName;
this.endReactionName = endReactionName;
this.dwsMessagePrefix = messagePrefix;
this.watchTodos = watchTodos;
}
@@ -1723,7 +1716,6 @@ export class DwsChannel extends PollingChannelBase<DwsCursor> {
messageId: `todo-${fingerprint}`,
text: `Process this DingTalk todo:\n${truncateCodePoints(title, MAX_COMMENT_CHARS)}`,
displayText: title,
bypassMessagePrefix: true,
isGroup: true,
isMentioned: true,
isReplyToBot: false,
@@ -1974,21 +1966,15 @@ export class DwsChannel extends PollingChannelBase<DwsCursor> {
}
if (this.shouldFilterImMessage(source, message)) {
if (source.kind === 'group' || source.kind === 'group-all') {
const text = stripMessagePrefix(
message.content.trim(),
this.dwsMessagePrefix,
);
if (!this.dwsMessagePrefix || text) {
const envelope = this.createImEnvelope(source, message, text);
if (
this.groupGate.check(envelope, { createPairingRequest: false })
.reason === 'mention_required' &&
!this.queuedMessages.has(key) &&
!this.cursor.processedMessages.includes(key) &&
!this.hasPendingMessage(key)
) {
this.recordPendingGroupHistory(envelope);
}
const envelope = this.createImEnvelope(source, message);
if (
this.groupGate.check(envelope, { createPairingRequest: false })
.reason === 'mention_required' &&
!this.queuedMessages.has(key) &&
!this.cursor.processedMessages.includes(key) &&
!this.hasPendingMessage(key)
) {
this.recordPendingGroupHistory(envelope);
}
}
this.removePersistedPendingMessageForSource(key, source);
@@ -2277,7 +2263,7 @@ export class DwsChannel extends PollingChannelBase<DwsCursor> {
// on both the at stream and its group stream under a single dedup key, and
// only when a mention is required is the at stream the sole deliverer, so
// the normalized text cannot depend on which copy won the race.
const rawText =
const text =
source.kind === 'at' && this.requiresMention(message.conversationId)
? message.content
.replace(
@@ -2286,18 +2272,6 @@ export class DwsChannel extends PollingChannelBase<DwsCursor> {
)
.trim()
: message.content.trim();
const providerDocumentNotification =
source.kind === 'direct'
? parseDocumentMentionNotification(rawText)
: undefined;
const text = providerDocumentNotification
? rawText
: stripMessagePrefix(rawText, this.dwsMessagePrefix);
if (this.dwsMessagePrefix && !text) {
this.markProcessedMessage(key);
this.saveCursor();
return;
}
const target: DwsImTarget =
source.kind === 'direct'
@@ -2312,10 +2286,9 @@ export class DwsChannel extends PollingChannelBase<DwsCursor> {
}
const documentNotification =
providerDocumentNotification ??
(source.kind === 'direct'
source.kind === 'direct'
? parseDocumentMentionNotification(text)
: undefined);
: undefined;
if (documentNotification) {
await this.processDocumentNotification(
message,
@@ -2370,7 +2343,6 @@ export class DwsChannel extends PollingChannelBase<DwsCursor> {
chatName: message.conversationId,
messageId: message.messageId,
text,
bypassMessagePrefix: true,
...(message.referencedText
? { referencedText: message.referencedText }
: {}),
@@ -2631,7 +2603,6 @@ export class DwsChannel extends PollingChannelBase<DwsCursor> {
threadId: notification.commentKey,
messageId: message.messageId,
text: truncateCodePoints(notification.request, MAX_COMMENT_CHARS),
bypassMessagePrefix: true,
isGroup: true,
isMentioned: true,
isReplyToBot: false,
@@ -2718,22 +2689,15 @@ export class DwsChannel extends PollingChannelBase<DwsCursor> {
pending.source.kind === 'group' ||
pending.source.kind === 'group-all'
) {
const text = stripMessagePrefix(
pending.message.content.trim(),
this.dwsMessagePrefix,
const envelope = this.createImEnvelope(
pending.source,
pending.message,
);
if (!this.dwsMessagePrefix || text) {
const envelope = this.createImEnvelope(
pending.source,
pending.message,
text,
);
if (
this.groupGate.check(envelope, { createPairingRequest: false })
.reason === 'mention_required'
) {
this.recordPendingGroupHistory(envelope);
}
if (
this.groupGate.check(envelope, { createPairingRequest: false })
.reason === 'mention_required'
) {
this.recordPendingGroupHistory(envelope);
}
}
this.removePersistedPendingMessageForSource(key, pending.source);
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@qwen-code/channel-feishu",
"version": "0.23.2",
"version": "0.23.3",
"description": "Feishu (Lark) channel adapter for Qwen Code",
"repository": {
"type": "git",
@@ -26,7 +26,7 @@
},
"dependencies": {
"@larksuiteoapi/node-sdk": "^1.73.0",
"@qwen-code/channel-base": "0.23.2"
"@qwen-code/channel-base": "0.23.3"
},
"devDependencies": {
"typescript": "^5.0.0"
+1 -71
View File
@@ -11,7 +11,6 @@ import {
isChannelProactiveDeliveryError,
isTerminalTaskLifecycleType,
sanitizeSenderName,
startsWithMessagePrefix,
} from '@qwen-code/channel-base';
import {
buildCardContent,
@@ -142,39 +141,6 @@ const escapeFeishuMarkdown = (value: string) =>
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replace(/([\\`*_[\]{}()#+.!|>~-])/gu, '\\$1');
/**
* Consume the leading `@name` mention run so prefix matching starts at the
* payload.
*
* Only the leading run: a mention the user typed after the prefix is part of
* the message and has to survive into the dispatched prompt. Display names
* are matched literally because Feishu renders them verbatim -- a name
* containing spaces is one token here, which the shared mention skip in
* `stripMessagePrefix` cannot recognize. The loop stops as soon as the
* remainder starts with the configured prefix, so a prefix that itself
* begins with `@` is never eaten as a mention.
*/
function stripLeadingMentionNames(
text: string,
names: readonly string[],
prefix: string | undefined,
): string {
let rest = text.trimStart();
const tokens = [
...new Set(names.filter(Boolean).map((name) => `@${name}`)),
].sort((a, b) => b.length - a.length);
let consumed = true;
while (consumed && !(prefix && startsWithMessagePrefix(rest, prefix))) {
consumed = false;
for (const token of tokens) {
if (!rest.startsWith(token)) continue;
rest = rest.slice(token.length).trimStart();
consumed = true;
break;
}
}
return rest;
}
const FEISHU_STATUS_LABELS = `(?:${FEISHU_STATUS_STRINGS.map(escapeRegExp).join('|')})`;
/** A rendered status block: `---` divider line + `*label*` line,
* at line granularity anywhere in the joined card text. */
@@ -2654,7 +2620,6 @@ export class FeishuChannel extends ChannelBase {
// Check @mention
let isMentioned = false;
let cleanText = content.text;
const mentionNames = [...(content.mentionNames ?? [])];
if (msg.mentions && msg.mentions.length > 0) {
const mentionReplacements = new Map<string, string>();
for (const mention of msg.mentions) {
@@ -2671,7 +2636,6 @@ export class FeishuChannel extends ChannelBase {
mention.key,
isBotMention ? '' : `@${mention.name}`,
);
if (!isBotMention && mention.name) mentionNames.push(mention.name);
}
const mentionKeys = [...mentionReplacements.keys()].sort(
(a, b) => b.length - a.length,
@@ -2694,15 +2658,6 @@ export class FeishuChannel extends ChannelBase {
return;
}
// Matching-only text: the prefix follows the leading mention run, and
// only that run is consumed. Mentions inside the payload survive into
// the dispatched prompt, as they do with no prefix configured.
const messagePrefixText = stripLeadingMentionNames(
cleanText,
mentionNames,
this.configuredMessagePrefix(),
);
// Parent authorship is resolved under the named-session preparation lock;
// replies run the full preflight again before they can be processed.
const envelope: Envelope = {
@@ -2712,11 +2667,7 @@ export class FeishuChannel extends ChannelBase {
chatId,
...(chatName ? { chatName } : {}),
text: cleanText,
// A media message carries only an adapter-synthesized placeholder,
// which no user action can prefix -- gating it would drop every
// image, file, audio and video with the prefix configured.
...(!content.userAuthoredText ? { syntheticText: true as const } : {}),
messagePrefixText: messagePrefixText.trim(),
messageId: msgId,
threadId: msg.root_id || undefined,
isGroup,
@@ -2916,25 +2867,7 @@ export class FeishuChannel extends ChannelBase {
imageKey?: string;
fileKey?: string;
fileName?: string;
/**
* Display names this method rendered as `@name` mention markers.
*
* A `post` message carries its mentions as at-nodes, so the message-level
* `mention.key` tokens never appear in `text` and stripping them for
* prefix matching is a no-op. Reporting the rendered names lets the
* caller consume the leading mention run the same way.
*/
mentionNames?: string[];
/**
* Whether `text` is something the user typed.
*
* Feishu delivers media as its own message type with no caption
* field, so an image or file carries only an adapter-synthesized
* placeholder. Gating that on `messagePrefix` would drop every media
* message with no action the user could take, so the caller bypasses
* the filter when this is false -- the same contract DingTalk and
* WeCom already implement.
*/
/** Whether text came from the user rather than a media placeholder. */
userAuthoredText: boolean;
} {
try {
@@ -2950,7 +2883,6 @@ export class FeishuChannel extends ChannelBase {
case 'post': {
// Rich text (post) format: extract text from nested structure
const lines: string[] = [];
const mentionNames: string[] = [];
const post = content as Record<string, unknown>;
// Post can have multiple language versions like {"zh_cn": {title, content}}
// or be directly {title, content} (no language wrapper).
@@ -2979,7 +2911,6 @@ export class FeishuChannel extends ChannelBase {
];
if (typeof userName === 'string' && userName) {
parts.push(`@${userName}`);
mentionNames.push(userName);
}
}
}
@@ -2988,7 +2919,6 @@ export class FeishuChannel extends ChannelBase {
}
return {
text: lines.join('\n').trim() || '',
mentionNames,
userAuthoredText: true,
};
}
+23 -69
View File
@@ -722,18 +722,9 @@ describe('FeishuChannel', () => {
}
});
it('keeps media messages running when a prefix is configured', async () => {
// Feishu delivers media as its own message type with no caption
// field, so an image carries only the adapter's `(image)`
// placeholder. Gating that would drop every media message with no
// action the user could take to get past it -- while text with no
// prefix must still be gated.
it('dispatches both media and ordinary text', async () => {
const bridge = createMockBridge();
const channel = new FeishuChannel(
'test',
createConfig({ messagePrefix: '/review' }),
bridge,
);
const channel = new FeishuChannel('test', createConfig(), bridge);
const onMessage = getPrivateMethod<(data: unknown) => void>(
channel,
'onMessage',
@@ -756,19 +747,13 @@ describe('FeishuChannel', () => {
onMessage(event('media-image', 'image', { image_key: 'img_1' }));
await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(1));
// The control: ordinary text with no prefix stays gated.
onMessage(event('plain-text', 'text', { text: 'no prefix here' }));
await new Promise<void>((resolve) => setImmediate(resolve));
expect(bridge.prompt).toHaveBeenCalledTimes(1);
onMessage(event('plain-text', 'text', { text: 'inspect this' }));
await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(2));
});
it('matches prefixes after platform-normalized mentions with spaced names', async () => {
it('preserves text after platform-normalized mentions with spaced names', async () => {
const bridge = createMockBridge();
const channel = new FeishuChannel(
'test',
createConfig({ messagePrefix: '/review' }),
bridge,
);
const channel = new FeishuChannel('test', createConfig(), bridge);
Object.assign(channel as unknown as Record<string, unknown>, {
botOpenId: 'ou_bot',
});
@@ -814,8 +799,8 @@ describe('FeishuChannel', () => {
expect.stringContaining('inspect this'),
expect.anything(),
);
expect(vi.mocked(bridge.prompt).mock.calls[0]?.[1]).not.toContain(
'/review',
expect(vi.mocked(bridge.prompt).mock.calls[0]?.[1]).toContain(
'@Alice Smith /review inspect this',
);
onMessage(
@@ -824,17 +809,12 @@ describe('FeishuChannel', () => {
'@_user_1 @_user_2 inspect without prefix',
),
);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(bridge.prompt).toHaveBeenCalledTimes(1);
await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(2));
});
it('matches after a mentioned member whose name extends the bot name', async () => {
it('preserves a mentioned member whose name extends the bot name', async () => {
const bridge = createMockBridge();
const channel = new FeishuChannel(
'test',
createConfig({ messagePrefix: '/review' }),
bridge,
);
const channel = new FeishuChannel('test', createConfig(), bridge);
Object.assign(channel as unknown as Record<string, unknown>, {
botOpenId: 'ou_bot',
});
@@ -866,16 +846,12 @@ describe('FeishuChannel', () => {
await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(1));
const prompt = String(vi.mocked(bridge.prompt).mock.calls[0]?.[1]);
expect(prompt).toContain('inspect this');
expect(prompt).not.toContain('/review');
expect(prompt).toContain('/review');
});
it('removes the structured bot mention without corrupting a longer member name', async () => {
const bridge = createMockBridge();
const channel = new FeishuChannel(
'test',
createConfig({ messagePrefix: '/review' }),
bridge,
);
const channel = new FeishuChannel('test', createConfig(), bridge);
Object.assign(channel as unknown as Record<string, unknown>, {
botOpenId: 'ou_bot',
});
@@ -907,16 +883,12 @@ describe('FeishuChannel', () => {
await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(1));
const prompt = String(vi.mocked(bridge.prompt).mock.calls[0]?.[1]);
expect(prompt).toContain('inspect this');
expect(prompt).not.toContain('/review');
expect(prompt).toContain('/review');
});
it('does not mistake an extending mention name for an at-sign prefix', async () => {
it('preserves a mentioned member alongside literal at-sign text', async () => {
const bridge = createMockBridge();
const channel = new FeishuChannel(
'test',
createConfig({ messagePrefix: '@bot' }),
bridge,
);
const channel = new FeishuChannel('test', createConfig(), bridge);
Object.assign(channel as unknown as Record<string, unknown>, {
botOpenId: 'ou_bot',
});
@@ -948,19 +920,12 @@ describe('FeishuChannel', () => {
await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(1));
const prompt = String(vi.mocked(bridge.prompt).mock.calls[0]?.[1]);
expect(prompt).toContain('do X');
expect(prompt).not.toContain('@bot');
expect(prompt).toContain('@botswana @bot do X');
});
it('keeps a mention the user typed after the prefix', async () => {
// The matching text consumes only the leading mention run, so a
// mention inside the payload reaches the agent exactly as it does
// with no prefix configured.
it('keeps a member mention within slash-prefixed text', async () => {
const bridge = createMockBridge();
const channel = new FeishuChannel(
'test',
createConfig({ messagePrefix: '/review' }),
bridge,
);
const channel = new FeishuChannel('test', createConfig(), bridge);
Object.assign(channel as unknown as Record<string, unknown>, {
botOpenId: 'ou_bot',
});
@@ -989,20 +954,12 @@ describe('FeishuChannel', () => {
await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(1));
const prompt = String(vi.mocked(bridge.prompt).mock.calls[0]?.[1]);
expect(prompt).toContain('please talk to @Alice Smith');
expect(prompt).not.toContain('/review');
expect(prompt).toContain('/review');
});
it('matches the prefix on a rich-text post behind a spaced mention', async () => {
// A post carries its mentions as at-nodes, so the message-level keys
// never appear in the text: without normalizing the rendered name, a
// display name with a space leaves a token the shared mention skip
// cannot consume and the message is dropped.
it('preserves a rich-text post behind a spaced mention', async () => {
const bridge = createMockBridge();
const channel = new FeishuChannel(
'test',
createConfig({ messagePrefix: '/review' }),
bridge,
);
const channel = new FeishuChannel('test', createConfig(), bridge);
Object.assign(channel as unknown as Record<string, unknown>, {
botOpenId: 'ou_bot',
});
@@ -1038,9 +995,7 @@ describe('FeishuChannel', () => {
});
it('keeps a media placeholder out of the next prompt as group history', async () => {
// The placeholder bypasses the prefix gate, so without the synthetic
// marking it would be recorded as unmentioned group traffic and quoted
// back to the model as if a member had typed `(image)`.
// Synthetic media placeholders must not be quoted as user-authored history.
const previousQwenHome = process.env['QWEN_HOME'];
const qwenHome = mkdtempSync(join(tmpdir(), 'feishu-history-'));
process.env['QWEN_HOME'] = qwenHome;
@@ -1048,7 +1003,6 @@ describe('FeishuChannel', () => {
const channel = new FeishuChannel(
'test',
createConfig({
messagePrefix: '/review',
groupHistoryLimit: 10,
groups: { '*': { requireMention: true } },
}),
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@qwen-code/channel-github",
"version": "0.23.2",
"version": "0.23.3",
"description": "GitHub polling channel adapter for Qwen Code",
"repository": {
"type": "git",
@@ -25,7 +25,7 @@
"test:ci": "vitest run"
},
"dependencies": {
"@qwen-code/channel-base": "0.23.2",
"@qwen-code/channel-base": "0.23.3",
"@octokit/rest": "^21.1.1",
"https-proxy-agent": "^7.0.6"
},
@@ -1459,7 +1459,6 @@ describe('GithubChannel', () => {
await initWithoutLoop({
senderPolicy: 'allowlist',
allowedUsers: ['maintainer', 'bob'],
messagePrefix: '/review',
});
channel.usePreflight = true;
mockOctokit.paginate
@@ -1506,12 +1505,11 @@ describe('GithubChannel', () => {
senderId: 'maintainer',
threadId: 'pr:99',
isMentioned: true,
bypassMessagePrefix: true,
});
expect(channel.inboundEnvelopes[1]).toMatchObject({
senderId: 'bob',
threadId: 'pr:99',
text: 'check this review note',
text: ' /review check this review note',
isMentioned: true,
});
expect(channel.inboundEnvelopes[0]!.metadata).toContain(
@@ -1719,8 +1717,8 @@ describe('GithubChannel', () => {
},
);
it('filters each aggregated comment and consumes unmatched comments', async () => {
await initWithoutLoop({ messagePrefix: '/review' });
it('aggregates ordinary comments and preserves literal slash-prefixed text', async () => {
await initWithoutLoop();
channel.usePreflight = true;
mockOctokit.paginate
.mockResolvedValueOnce([
@@ -1743,8 +1741,7 @@ describe('GithubChannel', () => {
expect(channel.inboundEnvelopes).toHaveLength(1);
expect(channel.inboundEnvelopes[0]).toMatchObject({
displayText: '- @bob: inspect this',
bypassMessagePrefix: true,
displayText: '- @alice: ignore this\n- @bob: /review inspect this',
});
expect(channel.cursor.dispatchedComments).toEqual(['C_1001', 'C_1002']);
});
+1 -18
View File
@@ -28,7 +28,6 @@ import {
sanitizeDisplayText,
sanitizeLogText,
sanitizePromptText,
stripMessagePrefix,
truncateCodePoints,
} from '@qwen-code/channel-base';
import { testBotMention, stripBotMention } from './mention.js';
@@ -386,8 +385,6 @@ function isInboundEnvelope(value: unknown): value is Envelope | undefined {
Array.isArray(envelope.attachments)) &&
(envelope.metadata === undefined ||
typeof envelope.metadata === 'string') &&
(envelope.bypassMessagePrefix === undefined ||
envelope.bypassMessagePrefix === true) &&
(envelope.alreadyPrefixed === undefined ||
envelope.alreadyPrefixed === true)
);
@@ -1444,7 +1441,6 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
reason === 'review_requested'
? `Review requested: ${displayTitle}`
: `Issue assigned: ${displayTitle}`,
bypassMessagePrefix: true,
isGroup: true,
isMentioned: true,
isReplyToBot: false,
@@ -1475,20 +1471,8 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
for (const comment of newComments) {
this.recordDispatchedComment(comment.node_id || String(comment.id));
}
const messagePrefix = this.configuredMessagePrefix();
const comments = newComments
.flatMap((comment) => {
const rawBody = comment.body || '';
const body = messagePrefix
? stripMessagePrefix(
this.botUsername
? stripBotMention(rawBody, this.botUsername)
: rawBody,
messagePrefix,
)
: rawBody.trim();
return body === undefined ? [] : [{ comment, body }];
})
.map((comment) => ({ comment, body: (comment.body || '').trim() }))
.slice(-MAX_AGGREGATE_COMMENTS);
if (comments.length === 0) return;
@@ -1508,7 +1492,6 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
messageId: String(first.id),
text: `Review these new comments and output exactly ${NO_REPLY_SENTINEL} if no public reply is needed:\n${summary}`,
displayText: summary,
bypassMessagePrefix: true,
isGroup: true,
isMentioned: true,
isReplyToBot: false,
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@qwen-code/channel-gitlab",
"version": "0.23.2",
"version": "0.23.3",
"description": "GitLab polling channel adapter for Qwen Code",
"type": "module",
"main": "dist/index.js",
@@ -21,7 +21,7 @@
},
"dependencies": {
"@gitbeaker/rest": "^42.5.0",
"@qwen-code/channel-base": "0.23.2",
"@qwen-code/channel-base": "0.23.3",
"zod": "^3.25.0"
},
"devDependencies": {
@@ -378,7 +378,6 @@ describe('GitlabChannel', () => {
expect(env.senderId).toBe('alice');
expect(env.isMentioned).toBe(true);
expect(env.text).toContain('please fix this');
expect(env.bypassMessagePrefix).toBeUndefined();
expect(env.metadata).toContain('Project: owner/repo');
});
@@ -400,11 +399,10 @@ describe('GitlabChannel', () => {
expect(channel.inboundEnvelopes[0]!.text).toContain(
'Full issue description',
);
expect(channel.inboundEnvelopes[0]!.bypassMessagePrefix).toBeUndefined();
expect(mockApi.Issues.show).toHaveBeenCalled();
});
it('bypasses the prefix for provider-generated assignment todos', async () => {
it('dispatches provider-generated assignment todos', async () => {
const configured = makeConfig({
action_prompt_template: {
mentioned: 'Mentioned: %description%',
@@ -429,7 +427,7 @@ describe('GitlabChannel', () => {
await pollOnce();
expect(channel.inboundEnvelopes[0]!.bypassMessagePrefix).toBe(true);
expect(channel.inboundEnvelopes[0]!.text).toContain('Please fix this');
});
it('skips todo authored by bot', async () => {
@@ -353,12 +353,6 @@ export class GitlabChannel extends PollingChannelBase<GitlabCursor> {
),
true,
);
const isMentionTrigger =
todo.action_name === 'mentioned' ||
todo.action_name === 'directly_addressed';
if (!isNoteMention && !isMentionTrigger) {
envelope.bypassMessagePrefix = true;
}
if (isNoteMention) {
this.reactions.set(messageId, {
@@ -1,6 +1,6 @@
{
"name": "@qwen-code/channel-plugin-example",
"version": "0.23.2",
"version": "0.23.3",
"private": true,
"type": "module",
"main": "dist/index.js",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@qwen-code/channel-qqbot",
"version": "0.23.2",
"version": "0.23.3",
"description": "QQ Bot (QQ机器人) channel adapter for Qwen Code",
"repository": {
"type": "git",
@@ -25,7 +25,7 @@
"test:ci": "vitest run"
},
"dependencies": {
"@qwen-code/channel-base": "0.23.2",
"@qwen-code/channel-base": "0.23.3",
"@tencent-connect/qqbot-connector": "^1.1.0",
"ws": "^8.18.0"
},
+7 -79
View File
@@ -19,7 +19,6 @@ import {
sanitizeSenderName,
sanitizePromptText,
sanitizeLogText,
stripMessagePrefix,
truncateCodePoints,
} from '@qwen-code/channel-base';
import type {
@@ -2445,8 +2444,6 @@ export class QQChannel extends ChannelBase {
commandText: string;
text: string;
displayText: string;
displayTextOffset?: number;
messagePrefixText?: string;
senderName: string;
} | null {
// Keep identity values out of the display-name position. In particular,
@@ -2490,22 +2487,8 @@ export class QQChannel extends ChannelBase {
const effectiveIsAtBot = forceAtMention ?? isAtBot;
const configuredPrefix = this.configuredMessagePrefix();
// Keep prefix matching on the pre-sanitized text: prompt sanitization can
// peel a leading bracket tag and must neither create nor destroy a match.
// Slash commands still discard mention tokens before dispatch.
const prefixSourceText =
this.qqConfig.allowMention !== false ? safeDisplayText : safeCleanText;
const strippedCommandText = configuredPrefix
? stripMessagePrefix(prefixSourceText, configuredPrefix)
: safeCleanText;
const rawCommandText = (strippedCommandText ?? safeCleanText)
.replace(/<@[^>]{1,64}>/g, '')
.trim();
const isSlash =
effectiveIsAtBot &&
strippedCommandText !== undefined &&
rawCommandText.startsWith('/');
const rawCommandText = safeCleanText.replace(/<@[^>]{1,64}>/g, '').trim();
const isSlash = effectiveIsAtBot && rawCommandText.startsWith('/');
const commandText = sanitizePromptText(rawCommandText);
// Deliberately NOT hard-blocking bot messages — QQ Bot API may deliver
@@ -2573,36 +2556,12 @@ export class QQChannel extends ChannelBase {
? `(${truncateCodePoints(sanitizeSenderName(senderIdentity), 8)}…)`
: '';
const head = `[atMention=${effectiveIsAtBot}]${openIdSuffix} [${safeName}${senderTag}]: `;
// The prompt body and `displayText` are the same string by
// construction. The base prefix filter rewrites the user-authored
// segment inside `text`, which it can only do if it can find it
// there -- and deriving the two from different mention-stripping
// passes made `<@other> <@bot> /review hi` unlocatable, costing the
// whole `[atMention=…] [sender]:` wrapper and the OPENID suffix.
// With `allowMention` off, every mention token is dropped from both
// rather than leaving raw openids in the prompt.
// Prefix matching uses `messagePrefixText` below while `displayText`
// remains the sanitized segment that is safe to splice into the prompt.
const payloadText = isSlash
? commandText
: sanitizePromptText(strippedCommandText ?? prefixSourceText);
const messagePrefixText =
configuredPrefix && strippedCommandText !== undefined
? `${configuredPrefix} ${payloadText}`
: configuredPrefix
? prefixSourceText
: undefined;
const displayText = sanitizePromptText(
isSlash && messagePrefixText ? messagePrefixText : prefixSourceText,
this.qqConfig.allowMention !== false ? safeDisplayText : safeCleanText,
);
const text = isSlash
? sanitizePromptText(messagePrefixText ?? safeCleanText)
? sanitizePromptText(safeCleanText)
: `${head}${displayText}${suffixFromBotOpenId}`;
// Where that segment sits, so the filter splices at an exact range
// instead of searching: both the nick and the body are
// attacker-controlled here, and a nick equal to the body would
// otherwise put the first match inside the sender tag.
const displayTextOffset = isSlash ? undefined : head.length;
return {
isAtBot: effectiveIsAtBot,
@@ -2612,8 +2571,6 @@ export class QQChannel extends ChannelBase {
commandText,
text,
displayText,
...(displayTextOffset !== undefined ? { displayTextOffset } : {}),
...(messagePrefixText !== undefined ? { messagePrefixText } : {}),
senderName,
};
}
@@ -2654,20 +2611,8 @@ export class QQChannel extends ChannelBase {
.replace(/\[atMention=[^\]]*]/g, '')
.replace(/\[botOpenId:[^\]]*]/g, '')
.replace(/\[bot]/g, '');
const configuredPrefix = this.configuredMessagePrefix();
const strippedCommandText = configuredPrefix
? stripMessagePrefix(safeContent, configuredPrefix)
: safeContent;
const rawCommandText = strippedCommandText ?? safeContent;
const isSlash = rawCommandText.startsWith('/');
const commandText = sanitizePromptText(rawCommandText);
const isSlash = safeContent.startsWith('/');
const displayText = sanitizePromptText(safeContent);
const messagePrefixText =
configuredPrefix && strippedCommandText !== undefined
? `${configuredPrefix} ${commandText}`
: configuredPrefix
? safeContent
: undefined;
const text = isSlash
? displayText
: `[atMention=true] [${safeName}]: ${displayText}`;
@@ -2678,7 +2623,6 @@ export class QQChannel extends ChannelBase {
chatId,
text,
displayText,
...(messagePrefixText !== undefined ? { messagePrefixText } : {}),
messageId: event.id,
isGroup: false,
isMentioned: true,
@@ -2731,15 +2675,8 @@ export class QQChannel extends ChannelBase {
forceAtMention: true,
});
if (!result) return;
const {
isSlash,
text,
displayText,
displayTextOffset,
commandText,
senderName,
safeName,
} = result;
const { isSlash, text, displayText, commandText, senderName, safeName } =
result;
// Deduplicate before handleInbound — prepareGroupMessage already ran
// so side effects (extractBotOpenId) are applied regardless of dedup.
@@ -2774,10 +2711,6 @@ export class QQChannel extends ChannelBase {
chatId,
text,
displayText,
...(displayTextOffset !== undefined ? { displayTextOffset } : {}),
...(result.messagePrefixText !== undefined
? { messagePrefixText: result.messagePrefixText }
: {}),
messageId: event.id,
isGroup: true,
isMentioned: true,
@@ -2827,7 +2760,6 @@ export class QQChannel extends ChannelBase {
isSlash,
text,
displayText,
displayTextOffset,
commandText,
senderName,
isAtBot,
@@ -2931,10 +2863,6 @@ export class QQChannel extends ChannelBase {
chatId,
text,
displayText,
...(displayTextOffset !== undefined ? { displayTextOffset } : {}),
...(result.messagePrefixText !== undefined
? { messagePrefixText: result.messagePrefixText }
: {}),
senderId,
senderName,
messageId: event.id,
+13 -87
View File
@@ -77,13 +77,6 @@ vi.mock('@qwen-code/channel-base', () => ({
mockHandleInbound(env);
return Promise.resolve();
}
// QQChannel reads this to decide `isSlash` from the payload after the
// configured prefix. Mirrors the real accessor, which reads the same
// config key.
protected configuredMessagePrefix(): string | undefined {
const value = this.config['messagePrefix'];
return typeof value === 'string' && value ? value : undefined;
}
protected onSessionDied(_sessionId: string): void {
// no-op in mock
}
@@ -120,38 +113,10 @@ vi.mock('@qwen-code/channel-base', () => ({
const cp = Array.from(str);
return cp.length > max ? cp.slice(0, max).join('') : str;
},
// QQChannel decides `isSlash` from the payload *after* the configured
// prefix, so it imports the real helper. This mirrors the shared
// implementation rather than stubbing it away: a fake that answered
// `undefined` for every prefixed text would make `isSlash` read the
// raw content and quietly invert the behaviour under test.
stripMessagePrefix: (text: string, prefix: string | undefined) => {
if (!prefix) return text;
let candidate = text.trim();
while (
!candidate.startsWith(prefix) &&
(candidate.startsWith('@') || candidate.startsWith('<@'))
) {
const mention = candidate.match(/^(?:@[^@\s]+|<@[^>]{1,64}>)\s+/u)?.[0];
if (!mention) return undefined;
candidate = candidate.slice(mention.length);
}
if (!candidate.startsWith(prefix)) return undefined;
const suffix = candidate.slice(prefix.length);
if (!/^\s+\S[\s\S]*$/u.test(suffix)) return undefined;
return suffix.trim();
},
}));
const { QQChannel } = await import('./QQChannel.js');
// The real filter, not the bare-factory mock above: this file needs to
// run a produced envelope through the base's actual prefix rewrite.
// Reached by path rather than through the package: the barrel does not
// re-export it, and the package alias resolves to the barrel.
type Envelope = import('@qwen-code/channel-base').Envelope;
const { applyMessagePrefix } = (await vi.importActual(
'../../base/src/message-prefix.js',
)) as { applyMessagePrefix: (e: Envelope, p?: string) => boolean };
import type {
QQMessageEvent,
QQGroupMessageEvent,
@@ -380,33 +345,15 @@ describe('handleC2C', () => {
expect(mockHandleInbound).not.toHaveBeenCalled();
});
it('keeps the sender wrapper on a prefixed C2C message', async () => {
// `isSlash` reads the payload after the prefix. Deciding it from the
// raw content instead would classify `/review hello` as a command and
// drop the `[atMention=true] [sender]:` wrapper.
const ch = makeChannel({ messagePrefix: '/review' });
it('runs a C2C slash command without the wrapper', async () => {
const ch = makeChannel();
const pvt = ch as unknown as QQChannelRaw;
pvt['handleC2C'](makeC2CEvent({ content: '/review hello' }));
pvt['handleC2C'](makeC2CEvent({ content: '/status' }));
await vi.advanceTimersByTimeAsync(600);
const env = mockHandleInbound.mock.calls[0][0] as unknown as Envelope;
expect(env.text).toBe('[atMention=true] [Alice]: /review hello');
expect(env.alreadyPrefixed).toBe(true);
expect(applyMessagePrefix(env, '/review')).toBe(true);
expect(env.text).toBe('[atMention=true] [Alice]: hello');
});
it('runs a prefixed C2C slash command without the wrapper', async () => {
const ch = makeChannel({ messagePrefix: '/review' });
const pvt = ch as unknown as QQChannelRaw;
pvt['handleC2C'](makeC2CEvent({ content: '/review /status' }));
await vi.advanceTimersByTimeAsync(600);
const env = mockHandleInbound.mock.calls[0][0] as unknown as Envelope;
expect(env.text).toBe('/review /status');
expect(env.alreadyPrefixed).toBeUndefined();
expect(applyMessagePrefix(env, '/review')).toBe(true);
expect(env.text).toBe('/status');
expect(env.alreadyPrefixed).toBeUndefined();
});
it('drops bot C2C messages', async () => {
@@ -589,30 +536,16 @@ describe('handleGroup', () => {
expect(env['text']).toBe('/status');
});
it('keeps the sender wrapper when the prefix filter rewrites the body', async () => {
// The three fields have to agree or the base filter cannot find the
// user segment inside `text` and falls back to replacing the whole
// message with the stripped payload -- losing the
// `[atMention=…] [sender]:` wrapper and the OPENID suffix, with
// `alreadyPrefixed` stopping the base from re-attributing it.
//
// A member mentioned before the bot is the natural ordering that
// exposed it: the bot's token sat between the two, so `displayText`
// was not a substring of `text`.
//
// The bot OPENID is well-formed so it reaches the prompt as a suffix,
// and the sender nick repeats the body: `text` no longer ends with
// `displayText` and the body occurs twice, so only the adapter-supplied
// `displayTextOffset` can locate the segment.
const ch = makeChannel({ messagePrefix: '/review' });
it('keeps the sender wrapper and member mentions when the body repeats the name', async () => {
const ch = makeChannel();
const pvt = ch as unknown as QQChannelRaw;
pvt['handleGroup'](
makeGroupEvent({
content: '<@OPENID_OTHER> <@OPENID_BOT> /review hello',
content: '<@OPENID_OTHER> <@OPENID_BOT> please review hello',
author: {
member_openid: 'ABCDEF0123456789ABCDEF0123456789',
user_openid: 'ABCDEF0123456789ABCDEF0123456789',
username: '<@OPENID_OTHER> /review hello',
username: '<@OPENID_OTHER> please review hello',
},
mentions: [
{ member_openid: 'other-openid', is_you: false },
@@ -623,12 +556,10 @@ describe('handleGroup', () => {
await vi.advanceTimersByTimeAsync(600);
const env = mockHandleInbound.mock.calls[0][0] as unknown as Envelope;
expect(env.displayTextOffset).toBeGreaterThan(0);
expect(env.displayText).toBe('<@OPENID_OTHER> /review hello');
expect(env.displayText).toBe('<@OPENID_OTHER> please review hello');
expect(env.text.split(env.displayText!)).toHaveLength(3);
expect(applyMessagePrefix(env, '/review')).toBe(true);
expect(env.text).toContain('[atMention=');
expect(env.text).toContain('[<@OPENID_OTHER> /review hello(');
expect(env.text).toContain('[<@OPENID_OTHER> please review hello(');
expect(env.text).toContain(
'机器人 OPENID: 0123456789ABCDEF0123456789ABCDEF',
);
@@ -639,15 +570,12 @@ describe('handleGroup', () => {
).toBe(true);
});
it('keeps a mention the user typed after the prefix', async () => {
// Matching runs on the display text, where only the bot's own token is
// removed, so another member's `<@...>` in the payload survives into
// the dispatched prompt exactly as it does with no prefix configured.
const ch = makeChannel({ messagePrefix: '/review' });
it('keeps a member mention in the message body', async () => {
const ch = makeChannel();
const pvt = ch as unknown as QQChannelRaw;
pvt['handleGroup'](
makeGroupEvent({
content: '<@OPENID_BOT> /review ask <@OPENID_OTHER> about the deploy',
content: '<@OPENID_BOT> ask <@OPENID_OTHER> about the deploy',
mentions: [
{ member_openid: '0123456789ABCDEF0123456789ABCDEF', is_you: true },
{ member_openid: 'other-openid', is_you: false },
@@ -657,9 +585,7 @@ describe('handleGroup', () => {
await vi.advanceTimersByTimeAsync(600);
const env = mockHandleInbound.mock.calls[0][0] as unknown as Envelope;
expect(applyMessagePrefix(env, '/review')).toBe(true);
expect(env.text).toContain('ask <@OPENID_OTHER> about the deploy');
expect(env.text).not.toContain('/review');
});
it('其他成员 mention 后的斜杠命令仍被识别', async () => {
+5 -149
View File
@@ -117,9 +117,6 @@ vi.mock('@qwen-code/channel-base', async () => {
protected getResponseSourceLabel(_sessionId: string): undefined {
return undefined;
}
protected configuredMessagePrefix(): string | undefined {
return this.config['messagePrefix'] as string | undefined;
}
protected formatMarkdownAttributedText(
text: string,
sourceLabel?: string,
@@ -147,7 +144,6 @@ vi.mock('@qwen-code/channel-base', async () => {
sanitizeSenderName: real.sanitizeSenderName,
sanitizePromptText: real.sanitizePromptText,
sanitizeLogText: real.sanitizeLogText,
stripMessagePrefix: real.stripMessagePrefix,
truncateCodePoints: real.truncateCodePoints,
};
});
@@ -313,7 +309,7 @@ describe('session persistence paths', () => {
// newlines, Unicode line separators, and BiDi overrides. The tests below
// validate each threat class individually.
describe('group sender-name sanitization', () => {
function makeChannel(messagePrefix?: string) {
function makeChannel() {
return new QQChannel(
'qq-bot',
{
@@ -328,7 +324,6 @@ describe('group sender-name sanitization', () => {
groups: {},
appID: 'test-app-id',
appSecret: 'test-secret',
...(messagePrefix ? { messagePrefix } : {}),
},
{} as unknown as ChannelAgentBridge,
);
@@ -484,117 +479,9 @@ describe('group sender-name sanitization', () => {
expect(env.alreadyPrefixed).toBe(true);
});
it('recognizes a slash command after the configured message prefix', () => {
it('audits the command that ran', () => {
vi.useFakeTimers();
const ch = makeChannel('review:');
const inbound = vi.fn().mockResolvedValue(undefined);
(ch as unknown as { handleInbound: typeof inbound }).handleInbound =
inbound;
(ch as unknown as { saveQQState: () => void }).saveQQState = () => {};
(ch as unknown as { handleGroup: (event: unknown) => void }).handleGroup({
id: 'evt-prefixed-slash',
group_openid: 'grp-1',
content: 'review: /help',
author: { username: 'Alice', id: 'uid', user_openid: 'uo' },
});
const env = inbound.mock.calls[0][0] as {
text: string;
alreadyPrefixed?: boolean;
};
expect(env.text).toBe('review: /help');
expect(env.alreadyPrefixed).toBeUndefined();
});
it('keeps a tag-shaped group prefix authoritative across prompt sanitization', () => {
vi.useFakeTimers();
const ch = makeChannel('[review]');
const inbound = vi.fn().mockResolvedValue(undefined);
(ch as unknown as { handleInbound: typeof inbound }).handleInbound =
inbound;
(ch as unknown as { saveQQState: () => void }).saveQQState = () => {};
(ch as unknown as { handleGroup: (event: unknown) => void }).handleGroup({
id: 'evt-tag-prefix-group',
group_openid: 'grp-1',
content: '[review] hello',
author: { username: 'Alice', id: 'uid', user_openid: 'uo' },
});
const env = inbound.mock.calls[0][0] as Envelope;
expect(env.displayText).toBe('review hello');
expect(env.messagePrefixText).toBe('[review] hello');
expect(env.text).toMatch(/: review hello$/);
});
it('keeps a tag-shaped C2C prefix authoritative across prompt sanitization', () => {
vi.useFakeTimers();
const ch = makeChannel('[review]');
const inbound = vi.fn().mockResolvedValue(undefined);
(ch as unknown as { handleInbound: typeof inbound }).handleInbound =
inbound;
(ch as unknown as { handleC2C: (event: unknown) => void }).handleC2C({
id: 'evt-tag-prefix-c2c',
content: '[review] hello',
author: { username: 'Alice', id: 'uid', user_openid: 'user-openid' },
});
const env = inbound.mock.calls[0][0] as Envelope;
expect(env.displayText).toBe('review hello');
expect(env.messagePrefixText).toBe('[review] hello');
expect(env.text).toMatch(/: review hello$/);
});
it('does not accept a group prefix manufactured by prompt sanitization', () => {
vi.useFakeTimers();
const ch = makeChannel('review');
const inbound = vi.fn().mockResolvedValue(undefined);
(ch as unknown as { handleInbound: typeof inbound }).handleInbound =
inbound;
(ch as unknown as { saveQQState: () => void }).saveQQState = () => {};
(ch as unknown as { handleGroup: (event: unknown) => void }).handleGroup({
id: 'evt-sanitized-prefix-group',
group_openid: 'grp-1',
content: '[r]eview hello',
author: { username: 'Alice', id: 'uid', user_openid: 'uo' },
});
const env = inbound.mock.calls[0][0] as Envelope;
expect(env.displayText).toBe('review hello');
expect(env.messagePrefixText).toBe('[r]eview hello');
});
it('does not restore another member mention in a prefixed group slash command', () => {
vi.useFakeTimers();
const ch = makeChannel('!ai');
const inbound = vi.fn().mockResolvedValue(undefined);
(ch as unknown as { handleInbound: typeof inbound }).handleInbound =
inbound;
(ch as unknown as { saveQQState: () => void }).saveQQState = () => {};
(ch as unknown as { handleGroup: (event: unknown) => void }).handleGroup({
id: 'evt-prefixed-compact',
group_openid: 'grp-1',
content: '!ai /compact <@OPENID_OTHER>',
author: { username: 'Alice', id: 'uid', user_openid: 'uo' },
});
const env = inbound.mock.calls[0][0] as Envelope;
expect(env.text).toBe('!ai /compact');
expect(env.displayText).toBe('!ai /compact');
expect(env.messagePrefixText).toBe('!ai /compact');
expect(env.text).not.toContain('OPENID_OTHER');
});
it('audits the command that ran, not the configured prefix', () => {
// The audit log exists to identify which command reset a session. With
// a prefix configured every command shares the same leading token, so
// the log has to read the payload after the prefix.
vi.useFakeTimers();
const ch = makeChannel('/review');
const ch = makeChannel();
(ch as unknown as { handleInbound: () => Promise<void> }).handleInbound =
() => Promise.resolve();
(ch as unknown as { saveQQState: () => void }).saveQQState = () => {};
@@ -608,9 +495,9 @@ describe('group sender-name sanitization', () => {
});
(ch as unknown as { handleGroup: (event: unknown) => void }).handleGroup({
id: 'evt-prefixed-audit',
id: 'evt-command-audit',
group_openid: 'grp-1',
content: '/review /clear',
content: '/clear',
author: { username: 'Alice', id: 'uid', user_openid: 'uo' },
mentions: [{ is_you: true, member_openid: 'bot-openid' }],
});
@@ -620,40 +507,9 @@ describe('group sender-name sanitization', () => {
const audit = writes.find((w) => w.includes('Slash cmd from'));
expect(audit).toBeDefined();
expect(audit).toContain('/clear');
expect(audit).not.toContain('/review');
expect(audit!.split('\n')).toHaveLength(2);
});
it('does not audit a slash command rejected by the configured prefix', () => {
vi.useFakeTimers();
const ch = makeChannel('/review');
(ch as unknown as { handleInbound: () => Promise<void> }).handleInbound =
() => Promise.resolve();
(ch as unknown as { saveQQState: () => void }).saveQQState = () => {};
const writes: string[] = [];
const spy = vi
.spyOn(process.stderr, 'write')
.mockImplementation((chunk: unknown) => {
writes.push(String(chunk));
return true;
});
(ch as unknown as { handleGroup: (event: unknown) => void }).handleGroup({
id: 'evt-unprefixed-audit',
group_openid: 'grp-1',
content: '/clear',
author: { username: 'Alice', id: 'uid', user_openid: 'uo' },
mentions: [{ is_you: true, member_openid: 'bot-openid' }],
});
spy.mockRestore();
expect(writes.some((write) => write.includes('Slash cmd from'))).toBe(
false,
);
});
it('sanitizes the sender name AND command text in the slash-command audit log (no log forging)', () => {
vi.useFakeTimers();
const ch = makeChannel();
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@qwen-code/channel-telegram",
"version": "0.23.2",
"version": "0.23.3",
"description": "Telegram channel adapter for Qwen Code",
"repository": {
"type": "git",
@@ -25,7 +25,7 @@
"test:ci": "vitest run"
},
"dependencies": {
"@qwen-code/channel-base": "0.23.2",
"@qwen-code/channel-base": "0.23.3",
"grammy": "^1.41.1",
"https-proxy-agent": "^7.0.6",
"telegram-markdown-formatter": "^0.1.2"
@@ -49,7 +49,6 @@ class TestTelegramChannel extends TelegramChannel {
msg: TestTelegramMessage,
text: string,
entities?: TestTelegramEntity[],
allowRegisteredCommandBypass = false,
): Envelope {
return (
this as unknown as {
@@ -57,10 +56,9 @@ class TestTelegramChannel extends TelegramChannel {
msg: TestTelegramMessage,
text: string,
entities?: TestTelegramEntity[],
allowRegisteredCommandBypass?: boolean,
) => Envelope;
}
).buildEnvelope(msg, text, entities, allowRegisteredCommandBypass);
).buildEnvelope(msg, text, entities);
}
pushTestProactive(
@@ -378,8 +376,8 @@ describe('TelegramChannel', () => {
expect(processOnceSpy).toHaveBeenCalled();
});
it('does not restore a stripped prefix while preparing a document', async () => {
const channel = createChannel({ messagePrefix: '/review' });
it('preserves the document caption when the download fails', async () => {
const channel = createChannel();
const bot = installFakeBot(channel);
vi.spyOn(process, 'once').mockReturnValue(process);
await channel.connect();
@@ -404,17 +402,16 @@ describe('TelegramChannel', () => {
reply: vi.fn(),
});
const preparation = channel.inboundPreparations[0]!;
preparation.envelope.text = 'inspect this';
await preparation.prepare();
expect(preparation.envelope.text).toBe(
'inspect this\n\n(User sent a file "input.txt" but download failed)',
'/review inspect this\n\n(User sent a file "input.txt" but download failed)',
);
});
it('keeps stripped document captions after a successful download', async () => {
const channel = createChannel({ messagePrefix: '/review' });
it('keeps document captions after a successful download', async () => {
const channel = createChannel();
const bot = installFakeBot(channel);
bot.api.getFile.mockResolvedValue({ file_path: 'input.txt' });
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
@@ -443,10 +440,9 @@ describe('TelegramChannel', () => {
reply: vi.fn(),
});
const preparation = channel.inboundPreparations[0]!;
preparation.envelope.text = 'inspect this';
await preparation.prepare();
expect(preparation.envelope.text).toBe('inspect this');
expect(preparation.envelope.text).toBe('/review inspect this');
expect(preparation.envelope.attachments?.[0]).toMatchObject({
type: 'file',
fileName: 'input.txt',
@@ -456,8 +452,8 @@ describe('TelegramChannel', () => {
});
});
it('keeps stripped voice captions after a successful download', async () => {
const channel = createChannel({ messagePrefix: '/review' });
it('keeps voice captions after a successful download', async () => {
const channel = createChannel();
const bot = installFakeBot(channel);
bot.api.getFile.mockResolvedValue({ file_path: 'voice.ogg' });
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
@@ -482,10 +478,9 @@ describe('TelegramChannel', () => {
reply: vi.fn(),
});
const preparation = channel.inboundPreparations[0]!;
preparation.envelope.text = 'inspect this';
await preparation.prepare();
expect(preparation.envelope.text).toBe('inspect this');
expect(preparation.envelope.text).toBe('/review inspect this');
expect(preparation.envelope.attachments?.[0]).toMatchObject({
type: 'audio',
mimeType: 'audio/ogg',
@@ -495,13 +490,8 @@ describe('TelegramChannel', () => {
});
});
it('runs a captionless voice message despite a configured prefix', async () => {
// Standard Telegram clients cannot caption a voice message, so the
// envelope text is always the `(voice message)` placeholder -- there
// is no action the user could take to get it past the prefix gate.
// The bypass skips stripping entirely, so the placeholder also has to
// be cleared or it reaches the model as prompt text.
const channel = createChannel({ messagePrefix: '/review' });
it('clears a captionless voice placeholder after download', async () => {
const channel = createChannel();
const bot = installFakeBot(channel);
bot.api.getFile.mockResolvedValue({ file_path: 'voice.ogg' });
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
@@ -538,7 +528,7 @@ describe('TelegramChannel', () => {
});
it('clears a captionless document placeholder after a successful download', async () => {
const channel = createChannel({ messagePrefix: '/review' });
const channel = createChannel();
const bot = installFakeBot(channel);
bot.api.getFile.mockResolvedValue({ file_path: 'report.pdf' });
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
@@ -595,34 +585,28 @@ describe('TelegramChannel', () => {
},
},
},
])(
'runs a captionless $label despite a configured prefix',
async ({ event, message }) => {
// Without the synthetic marking, the `(image)` / `(file: …)`
// placeholder is gated like user text and the media is dropped with
// no action the sender could take.
const channel = createChannel({ messagePrefix: '/review' });
const bot = installFakeBot(channel);
vi.spyOn(process, 'once').mockReturnValue(process);
await channel.connect();
const handler = bot.on.mock.calls.find(
([registered]) => registered === event,
)?.[1] as ((ctx: unknown) => Promise<void>) | undefined;
])('marks a captionless $label as synthetic', async ({ event, message }) => {
const channel = createChannel();
const bot = installFakeBot(channel);
vi.spyOn(process, 'once').mockReturnValue(process);
await channel.connect();
const handler = bot.on.mock.calls.find(
([registered]) => registered === event,
)?.[1] as ((ctx: unknown) => Promise<void>) | undefined;
await handler!({
message: {
message_id: 1,
from: { id: 1, first_name: 'User' },
chat: { id: 1, type: 'private' },
...message,
},
api: bot.api,
reply: vi.fn(),
});
await handler!({
message: {
message_id: 1,
from: { id: 1, first_name: 'User' },
chat: { id: 1, type: 'private' },
...message,
},
api: bot.api,
reply: vi.fn(),
});
expect(channel.inboundPreparations[0]?.envelope.syntheticText).toBe(true);
},
);
expect(channel.inboundPreparations[0]?.envelope.syntheticText).toBe(true);
});
it.each([
{
@@ -655,10 +639,7 @@ describe('TelegramChannel', () => {
])(
'drops the $label placeholder from the prompt when the download fails',
async ({ event, message, placeholder, note }) => {
// The synthetic envelope skips stripping, so the placeholder is still
// in `envelope.text` at catch time -- keying on the caption keeps it
// out of the prompt, as the success branch already does.
const channel = createChannel({ messagePrefix: '/review' });
const channel = createChannel();
const bot = installFakeBot(channel);
vi.spyOn(process, 'once').mockReturnValue(process);
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
@@ -685,10 +666,8 @@ describe('TelegramChannel', () => {
},
);
it('still gates a captioned photo on the configured prefix', async () => {
// The narrowness control: a caption IS user-authored, so it must
// still carry the prefix.
const channel = createChannel({ messagePrefix: '/review' });
it('keeps a photo caption as user-authored text', async () => {
const channel = createChannel();
const bot = installFakeBot(channel);
vi.spyOn(process, 'once').mockReturnValue(process);
await channel.connect();
@@ -701,13 +680,16 @@ describe('TelegramChannel', () => {
message_id: 1,
from: { id: 1, first_name: 'User' },
chat: { id: 1, type: 'private' },
caption: 'no prefix here',
caption: 'inspect this photo',
photo: [{ file_id: 'photo-1' }],
},
api: bot.api,
reply: vi.fn(),
});
expect(channel.inboundPreparations[0]?.envelope.text).toBe(
'inspect this photo',
);
expect(
channel.inboundPreparations[0]?.envelope.syntheticText,
).toBeUndefined();
@@ -795,119 +777,8 @@ describe('TelegramChannel', () => {
);
});
it.each(['start', 'help', 'new', 'cancel', 'status'])(
'lets the Telegram command menu invoke /%s without the message prefix',
(command) => {
const channel = createChannel({ messagePrefix: '/review' });
const text = `/${command}`;
const built = channel.buildTestEnvelope(
{
from: { id: 1, first_name: 'User' },
chat: { id: 1, type: 'private' },
},
text,
[{ type: 'bot_command', offset: 0, length: text.length }],
true,
);
expect(built.bypassMessagePrefix).toBe(true);
},
);
it.each([
['/cancel@qwen_bot', true, 'addressed to us'],
['/cancel@OtherBot', false, 'addressed to another bot'],
['/notregistered', false, 'not a registered menu command'],
['please /cancel later', false, 'no bot_command entity at offset 0'],
])(
'grants the command-menu bypass for %j only when %s',
(text, expected, _why) => {
// `parseCommand` strips the `@suffix`, so a command addressed to a
// different bot would otherwise skip the prefix gate and run here
// -- cancelling our own request on someone else's instruction.
const channel = createChannel({ messagePrefix: '/review' });
(channel as unknown as { botUsername: string }).botUsername = 'qwen_bot';
const entities = text.startsWith('/')
? [
{
type: 'bot_command',
offset: 0,
length: text.split(' ')[0].length,
},
]
: [];
const built = channel.buildTestEnvelope(
{
from: { id: 1, first_name: 'User' },
chat: { id: 1, type: 'private' },
},
text,
entities,
true,
);
expect(built.bypassMessagePrefix).toBe(expected ? true : undefined);
},
);
it('does not grant the bypass for a bot_command entity past offset 0', () => {
// Telegram attaches `bot_command` entities anywhere in a message. Only
// a leading one is a menu action; anything else is prose that has to
// stay behind the prefix gate.
const channel = createChannel({ messagePrefix: '/review' });
const built = channel.buildTestEnvelope(
{
from: { id: 1, first_name: 'User' },
chat: { id: 1, type: 'private' },
},
'hi /cancel',
[{ type: 'bot_command', offset: 3, length: 7 }],
true,
);
expect(built.bypassMessagePrefix).toBeUndefined();
});
it('keeps a prefix that collides with a menu command a prefix', () => {
// Nothing rejects `/new` as a prefix. Without the precedence rule the
// command-menu bypass would run the clear command on every prefixed
// message instead of stripping and dispatching it.
const channel = createChannel({ messagePrefix: '/new' });
const built = channel.buildTestEnvelope(
{
from: { id: 1, first_name: 'User' },
chat: { id: 1, type: 'private' },
},
'/new fix the bug',
[{ type: 'bot_command', offset: 0, length: 4 }],
true,
);
expect(built.bypassMessagePrefix).toBeUndefined();
});
it('keeps a longer registered command eligible for the menu bypass', () => {
const channel = createChannel({ messagePrefix: '/ne' });
const built = channel.buildTestEnvelope(
{
from: { id: 1, first_name: 'User' },
chat: { id: 1, type: 'private' },
},
'/new',
[{ type: 'bot_command', offset: 0, length: 4 }],
true,
);
expect(built.bypassMessagePrefix).toBe(true);
});
it('handles the Telegram Start button when a prefix is configured', async () => {
const channel = createChannel({ messagePrefix: '/review' });
it('handles the Telegram Start button', async () => {
const channel = createChannel();
const bot = installFakeBot(channel);
vi.spyOn(process, 'once').mockReturnValue(process);
await channel.connect();
@@ -929,15 +800,12 @@ describe('TelegramChannel', () => {
await vi.waitFor(() => expect(bot.api.sendMessage).toHaveBeenCalled());
expect(bot.api.sendMessage).toHaveBeenCalledWith(
'1',
expect.stringContaining('Use /review /help'),
expect.stringContaining('Use /help'),
{ parse_mode: 'HTML' },
);
// The greeting must not invite the very messages the gate drops.
const greeting = String(bot.api.sendMessage.mock.calls[0]?.[1]);
expect(greeting).toContain(
'Start each message with /review to chat with Qwen Code.',
expect(String(bot.api.sendMessage.mock.calls[0]?.[1])).toContain(
'Send any message to chat with Qwen Code.',
);
expect(greeting).not.toContain('Send any message');
});
it('sends command replies back to the Telegram forum topic', async () => {
@@ -12,7 +12,6 @@ import {
import {
ChannelBase,
isTerminalTaskLifecycleType,
startsWithMessagePrefix,
} from '@qwen-code/channel-base';
import type {
ChannelAgentBridge,
@@ -115,7 +114,7 @@ export class TelegramChannel extends ChannelBase {
const msg = ctx.message;
const text = msg.text;
const envelope = this.buildEnvelope(msg, text, msg.entities, true);
const envelope = this.buildEnvelope(msg, text, msg.entities);
// Don't await — long prompts would block the update loop
this.handleInbound(envelope).catch((err) => {
@@ -132,7 +131,6 @@ export class TelegramChannel extends ChannelBase {
msg,
msg.caption || '(image)',
msg.caption_entities,
false,
!msg.caption,
);
@@ -173,7 +171,6 @@ export class TelegramChannel extends ChannelBase {
msg,
msg.caption || `(file: ${fileName})`,
msg.caption_entities,
false,
!msg.caption,
);
@@ -194,12 +191,7 @@ export class TelegramChannel extends ChannelBase {
);
writeFileSync(filePath, buf);
// Cleared when the caption is absent too: the bypass above
// skips stripping entirely, so the `(file: …)` placeholder
// would otherwise reach the model as prompt text.
if (!this.configuredMessagePrefix() || !msg.caption) {
envelope.text = msg.caption || '';
}
envelope.text = msg.caption || '';
envelope.attachments = [
{
type: 'file',
@@ -214,10 +206,7 @@ export class TelegramChannel extends ChannelBase {
);
// Mirrors the success branch: the placeholder is adapter text, so
// only a real caption may survive into the prompt.
const promptText =
this.configuredMessagePrefix() && msg.caption
? envelope.text
: msg.caption || '';
const promptText = msg.caption || '';
envelope.text = `${promptText}\n\n(User sent a file "${fileName}" but download failed)`;
}
}).catch((err) => {
@@ -237,7 +226,6 @@ export class TelegramChannel extends ChannelBase {
msg,
msg.caption || '(voice message)',
msg.caption_entities,
false,
// Standard Telegram clients cannot caption a voice message, so
// this is effectively always synthetic.
!msg.caption,
@@ -257,12 +245,7 @@ export class TelegramChannel extends ChannelBase {
const filePath = join(dir, fileName);
writeFileSync(filePath, buf);
// Cleared when the caption is absent too: the bypass above
// skips stripping entirely, so the `(file: …)` placeholder
// would otherwise reach the model as prompt text.
if (!this.configuredMessagePrefix() || !msg.caption) {
envelope.text = msg.caption || '';
}
envelope.text = msg.caption || '';
envelope.attachments = [
{
type: 'audio',
@@ -277,10 +260,7 @@ export class TelegramChannel extends ChannelBase {
);
// Mirrors the success branch: the placeholder is adapter text, so
// only a real caption may survive into the prompt.
const promptText =
this.configuredMessagePrefix() && msg.caption
? envelope.text
: msg.caption || '';
const promptText = msg.caption || '';
envelope.text = `${promptText}\n\n(User sent a voice message but download failed)`;
}
}).catch((err) => {
@@ -314,16 +294,13 @@ export class TelegramChannel extends ChannelBase {
}
private startMessage(): string {
const prefix = this.configuredMessagePrefix();
return [
'Qwen Code Telegram bot',
'',
prefix
? `Start each message with ${prefix} to chat with Qwen Code.`
: 'Send any message to chat with Qwen Code.',
`Use ${this.prefixedCommand('/new')} to start a fresh conversation.`,
`Use ${this.prefixedCommand('/cancel')} to stop a running request.`,
`Use ${this.prefixedCommand('/help')} to see available commands.`,
'Send any message to chat with Qwen Code.',
'Use /new to start a fresh conversation.',
'Use /cancel to stop a running request.',
'Use /help to see available commands.',
].join('\n');
}
@@ -525,14 +502,7 @@ export class TelegramChannel extends ChannelBase {
},
text: string,
entities?: Array<{ type: string; offset: number; length: number }>,
allowRegisteredCommandBypass = false,
/**
* True when `text` is an adapter-synthesized placeholder rather than
* something the user typed. Media with no caption can never carry the
* configured prefix -- voice messages cannot carry one at all -- so
* gating it would drop the message with no action the user could
* take. Same contract DingTalk and WeCom already implement.
*/
/** Whether the text is an adapter-generated media placeholder. */
syntheticText = false,
): Envelope {
const isGroup = msg.chat.type === 'group' || msg.chat.type === 'supergroup';
@@ -565,37 +535,6 @@ export class TelegramChannel extends ChannelBase {
// Extract referenced message text (when user replies to a message)
const referencedText = msg.reply_to_message?.text || undefined;
// A configured prefix wins over the command-menu bypass: nothing rejects
// `/new` as a prefix, and without this every prefixed message would run
// the colliding command instead of being stripped and dispatched. Bare
// menu commands that do not start with the prefix keep the bypass.
const configuredPrefix = this.configuredMessagePrefix();
const bypassMessagePrefix =
allowRegisteredCommandBypass &&
!(
configuredPrefix &&
startsWithMessagePrefix(text.trim(), configuredPrefix)
) &&
(entities?.some((entity) => {
if (entity.type !== 'bot_command' || entity.offset !== 0) return false;
const value = text.slice(0, entity.length).toLowerCase();
const command = value.slice(1).split('@', 1)[0];
// `/cancel@OtherBot` is addressed to a different bot. `parseCommand`
// strips the suffix, so without this check the bypass would let a
// command meant for someone else past the prefix gate and run it
// here -- cancelling our own request, for instance.
const atIndex = value.indexOf('@', 1);
if (
atIndex !== -1 &&
value.slice(atIndex + 1) !== this.botUsername.toLowerCase()
) {
return false;
}
return TELEGRAM_BOT_COMMANDS.some(
(registered) => registered.command === command,
);
}) ??
false);
return {
channelName: this.name,
@@ -611,7 +550,6 @@ export class TelegramChannel extends ChannelBase {
: undefined,
text: cleanText,
...(syntheticText ? { syntheticText: true as const } : {}),
...(bypassMessagePrefix ? { bypassMessagePrefix: true as const } : {}),
isGroup,
isMentioned,
isReplyToBot,
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@qwen-code/channel-wecom",
"version": "0.23.2",
"version": "0.23.3",
"description": "WeCom channel adapter for Qwen Code",
"repository": {
"type": "git",
@@ -25,7 +25,7 @@
"test:ci": "vitest run"
},
"dependencies": {
"@qwen-code/channel-base": "0.23.2",
"@qwen-code/channel-base": "0.23.3",
"@wecom/aibot-node-sdk": "^1.0.7"
},
"devDependencies": {
@@ -1676,11 +1676,7 @@ describe('WeComChannel', () => {
mocks.httpResponse.headers = {
'content-disposition': 'attachment; filename="../secret.png"',
};
const channel = new TestWeComChannel(
'bot',
makeConfig({ messagePrefix: '/review' }),
makeBridge(),
);
const channel = new TestWeComChannel('bot', makeConfig(), makeBridge());
await channel.connect();
const client = lastClient();
@@ -1699,35 +1695,34 @@ describe('WeComChannel', () => {
it.each([
{
label: 'a transcribed voice message stays gated on the prefix',
label: 'a transcribed voice message carries user-authored text',
event: 'message.voice',
payload: {
msgtype: 'voice',
voice: { content: 'please look at the build' },
},
dispatched: false,
text: 'please look at the build',
synthetic: undefined,
},
{
label: 'a prefixed transcript is dispatched and stripped',
label: 'a slash-prefixed transcript is preserved',
event: 'message.voice',
payload: {
msgtype: 'voice',
voice: { content: '/review please look at the build' },
},
dispatched: true,
text: 'please look at the build',
text: '/review please look at the build',
synthetic: undefined,
},
{
label: 'an untranscribed voice message runs as media',
event: 'message.voice',
payload: { msgtype: 'voice', voice: {} },
dispatched: true,
text: '(voice)',
synthetic: true,
},
{
label: 'a mixed message carrying text stays gated on the prefix',
label: 'a mixed message carries user-authored text',
event: 'message.mixed',
payload: {
msgtype: 'mixed',
@@ -1738,7 +1733,8 @@ describe('WeComChannel', () => {
],
},
},
dispatched: false,
text: 'inspect this',
synthetic: undefined,
},
{
label: 'a mixed message with no text runs as media',
@@ -1747,40 +1743,26 @@ describe('WeComChannel', () => {
msgtype: 'mixed',
mixed: { msg_item: [{ msgtype: 'image', image: {} }] },
},
dispatched: true,
text: '',
synthetic: true,
},
])(
'under a configured prefix, $label',
async ({ event, payload, dispatched, text, synthetic }) => {
// A transcript is the user's own words, so it carries the prefix like
// any other message; only the adapter's own placeholder is exempt.
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const channel = new TestWeComChannel(
'bot',
makeConfig({ messagePrefix: '/review' }),
makeBridge(),
);
await channel.connect();
])('$label', async ({ event, payload, text, synthetic }) => {
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const channel = new TestWeComChannel('bot', makeConfig(), makeBridge());
await channel.connect();
lastClient().emit(event, {
msgid: `msg-${event}-${String(dispatched)}`,
chattype: 'single',
from: { userid: 'alice' },
...payload,
});
await new Promise<void>((resolve) => setImmediate(resolve));
lastClient().emit(event, {
msgid: `msg-${event}`,
chattype: 'single',
from: { userid: 'alice' },
...payload,
});
await new Promise<void>((resolve) => setImmediate(resolve));
if (!dispatched) {
expect(channel.envelopes).toHaveLength(0);
return;
}
await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1));
expect(channel.envelopes[0]?.text).toBe(text);
expect(channel.envelopes[0]?.syntheticText).toBe(synthetic);
},
);
await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1));
expect(channel.envelopes[0]?.text).toBe(text);
expect(channel.envelopes[0]?.syntheticText).toBe(synthetic);
});
it('logs sanitized payloads only when debug payload logging is enabled', async () => {
const oldDebugPayload = process.env['QWEN_CHANNEL_DEBUG_PAYLOAD'];
@@ -2044,42 +2026,6 @@ describe('WeComChannel', () => {
stderr.mockRestore();
});
it('consumes prefix mismatches without repeatedly reconsidering them', async () => {
const stderr = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
const bridge = makeBridge();
const channel = new WeComChannel(
'bot',
makeConfig({ messagePrefix: '/review' }),
bridge,
);
await channel.connect();
const client = lastClient();
const payload = {
msgid: 'msg-prefix-mismatch',
msgtype: 'text',
chattype: 'single',
from: { userid: 'alice' },
text: { content: 'hello' },
};
client.emit('message.text', payload);
await vi.waitFor(() =>
expect(stderr).toHaveBeenCalledWith(
'[Channel:bot] preflight rejected reason=message_prefix_mismatch\n',
),
);
client.emit('message.text', payload);
await vi.waitFor(() =>
expect(stderr).toHaveBeenCalledWith(
'[WeCom:bot] dropping duplicate message msg-prefix-mismatch (already seen).\n',
),
);
expect(bridge.prompt).not.toHaveBeenCalled();
stderr.mockRestore();
});
it('allows messages interrupted during attachment download to be retried', async () => {
const stderr = vi
.spyOn(process.stderr, 'write')
@@ -2855,11 +2801,7 @@ describe('WeComChannel', () => {
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
mocks.httpResponse.statusCode = 500;
const channel = new TestWeComChannel(
'bot',
makeConfig({ messagePrefix: '/review' }),
makeBridge(),
);
const channel = new TestWeComChannel('bot', makeConfig(), makeBridge());
await channel.connect();
const client = lastClient();
@@ -463,9 +463,6 @@ export class WeComChannel extends ChannelBase {
let processStarted = false;
try {
if (!(await this.preflightInbound(envelope))) {
if (rawMessageId && this.wasMessagePrefixRejected(envelope)) {
this.seenMessages.set(rawMessageId, Date.now());
}
process.stderr.write(
`[WeCom:${this.name}] dropping message ${logMessageId}: preflight rejected.\n`,
);
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@qwen-code/channel-weixin",
"version": "0.23.2",
"version": "0.23.3",
"description": "WeChat (Weixin) channel adapter for Qwen Code",
"repository": {
"type": "git",
@@ -33,7 +33,7 @@
"test:ci": "vitest run"
},
"dependencies": {
"@qwen-code/channel-base": "0.23.2"
"@qwen-code/channel-base": "0.23.3"
},
"devDependencies": {
"typescript": "^5.0.0"
@@ -156,26 +156,26 @@ describe('WeixinChannel', () => {
msg: { text: '/review inspect this' },
synthetic: undefined,
},
])('marks $label for the message-prefix gate', async ({ msg, synthetic }) => {
// The placeholder is the adapter's own text: no user action can put
// the configured prefix on it, so gating it would drop every WeChat
// media message.
const channel = createChannel({ messagePrefix: '/review' });
await channel.connect();
const onMessage = monitorMocks.startPollLoop.mock.calls[0]?.[0]
?.onMessage as (parsed: ParsedMessage) => Promise<void>;
])(
'preserves the synthetic marker for $label',
async ({ msg, synthetic }) => {
const channel = createChannel();
await channel.connect();
const onMessage = monitorMocks.startPollLoop.mock.calls[0]?.[0]
?.onMessage as (parsed: ParsedMessage) => Promise<void>;
await onMessage({ fromUserId: 'user-1', messageId: 'm-1', ...msg });
await onMessage({ fromUserId: 'user-1', messageId: 'm-1', ...msg });
expect(channel.inboundEnvelopes[0]?.syntheticText).toBe(synthetic);
});
expect(channel.inboundEnvelopes[0]?.syntheticText).toBe(synthetic);
},
);
it('replaces a captionless image placeholder when the download fails', async () => {
mediaMocks.downloadAndDecrypt.mockRejectedValue(
new Error('download unavailable'),
);
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const channel = createChannel({ messagePrefix: '/review' });
const channel = createChannel();
await channel.connect();
const onMessage = monitorMocks.startPollLoop.mock.calls[0]?.[0]
?.onMessage as (parsed: ParsedMessage) => Promise<void>;
@@ -138,9 +138,6 @@ export class WeixinChannel extends ChannelBase {
senderName: msg.fromUserId,
chatId: msg.fromUserId,
text: msg.text,
// A caption-less image or file carries only the placeholder above,
// which no user action can prefix -- gating it on `messagePrefix`
// would drop every WeChat media message.
...(msg.syntheticText ? { syntheticText: true as const } : {}),
isGroup: false,
isMentioned: false,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@qwen-code/chrome-bridge",
"version": "0.23.2",
"version": "0.23.3",
"description": "Chrome extension bridge for Qwen CLI - enables AI-powered browser interactions",
"private": true,
"repository": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.23.2",
"version": "0.23.3",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -37,7 +37,7 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.23.2"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.23.3"
},
"dependencies": {
"@a2a-js/sdk": "^1.1.0",
@@ -2972,6 +2972,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
'qwen-code/private-parent-capability': 'forged-capability',
'qwen.daemon.modelPrompt': 'forged model-only prompt',
'qwen.daemon.promptDisplayText': 'forged display text',
'qwen.daemon.submittedPrompt': 'forged submission',
},
});
@@ -2989,6 +2990,57 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
await agentPromise;
});
it('admits an explicit public submission without trusting private ACP metadata', async () => {
await setupSessionMocks('untrusted-session');
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
await agent.initialize({ clientCapabilities: {} });
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
await agent.prompt({
sessionId: 'untrusted-session',
prompt: [{ type: 'text', text: 'hello' }],
_meta: {
keep: true,
'qwen.submittedPrompt': ' original question\n',
'qwen-code/invocation': {
version: 1,
sessionId: 'forged-session',
promptId: 'forged-prompt',
},
'qwen-code/private-parent-capability': 'forged-capability',
'qwen.daemon.modelPrompt': 'forged model-only prompt',
'qwen.daemon.promptDisplayText': 'forged display text',
'qwen.daemon.submittedPrompt': 'forged submission',
},
});
expect(lastSessionMock?.prompt).toHaveBeenCalledWith(
{
sessionId: 'untrusted-session',
prompt: [{ type: 'text', text: 'hello' }],
_meta: {
keep: true,
'qwen.daemon.submittedPrompt': ' original question\n',
},
},
undefined,
expect.any(AbortSignal),
undefined,
);
mockConnectionState.resolve();
await agentPromise;
});
it('returns the startup profile only when initialize metadata requests v1', async () => {
initializeAcpStartupProfiler();
const mockSettings = {
@@ -433,6 +433,8 @@ import {
DAEMON_CHANNEL_DELIVERY_META_KEY,
DAEMON_MODEL_PROMPT_META_KEY,
DAEMON_PROMPT_DISPLAY_TEXT_META_KEY,
DAEMON_SUBMITTED_PROMPT_META_KEY,
SUBMITTED_PROMPT_META_KEY,
DAEMON_RESTORE_ASK_USER_QUESTION_META_KEY,
DAEMON_SUPPRESS_RESTORE_ASK_USER_QUESTION_META_KEY,
DAEMON_SUPPRESS_WORKTREE_CONTEXT_RESTORE_META_KEY,
@@ -6339,12 +6341,21 @@ class QwenAgent implements Agent {
const suppliedContext = meta[INVOCATION_CONTEXT_META_KEY];
const suppliedModelPrompt = meta[DAEMON_MODEL_PROMPT_META_KEY];
const suppliedPromptDisplayText = meta[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY];
const submittedPrompt =
this.privateParentState === 'trusted'
? meta[DAEMON_SUBMITTED_PROMPT_META_KEY]
: meta[SUBMITTED_PROMPT_META_KEY];
const suppliedChannelPrompt = meta[CHANNEL_PROMPT_META_KEY];
const suppliedChannelDelivery = meta[DAEMON_CHANNEL_DELIVERY_META_KEY];
delete meta[INVOCATION_CONTEXT_META_KEY];
delete meta[DAEMON_MODEL_PROMPT_META_KEY];
delete meta[PRIVATE_PARENT_CAPABILITY_META_KEY];
delete meta[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY];
delete meta[SUBMITTED_PROMPT_META_KEY];
delete meta[DAEMON_SUBMITTED_PROMPT_META_KEY];
if (typeof submittedPrompt === 'string' && suppliedChannelPrompt !== true) {
meta[DAEMON_SUBMITTED_PROMPT_META_KEY] = submittedPrompt;
}
delete meta[CHANNEL_PROMPT_META_KEY];
delete meta[DAEMON_CHANNEL_DELIVERY_META_KEY];
// The user-facing display projection is caller-controlled metadata; honor
@@ -31059,6 +31059,182 @@ describe('Session', () => {
);
});
it.each<{
name: string;
prompt: PromptRequest['prompt'];
submitted?: string;
declared?: unknown;
displayText?: string;
modelPrompt?: string;
retry?: boolean;
metaRetry?: boolean;
channel?: boolean;
}>([
{
name: 'text blocks without resource bodies',
prompt: [
{ type: 'text', text: ' check' },
{
type: 'resource',
resource: {
uri: 'file:///notes.txt',
text: 'PRIVATE RESOURCE',
},
},
{ type: 'text', text: 'this\n' },
],
submitted: ' check this\n',
declared: ' check this\n',
},
{
name: 'explicit submission overrides unrelated display text',
prompt: [{ type: 'text', text: 'internal channel instructions' }],
displayText: 'display label',
submitted: 'original question',
declared: 'original question',
},
{
name: 'display projection cannot declare submission provenance',
prompt: [{ type: 'text', text: 'internal channel instructions' }],
displayText: 'display text without a declaration',
},
{
name: 'model-only delegation excluded',
prompt: [{ type: 'text', text: 'original question' }],
modelPrompt:
'<realtime_delegation>private model context</realtime_delegation>',
submitted: 'original question',
declared: 'original question',
},
{ name: 'blank text', prompt: [{ type: 'text', text: ' \n ' }] },
{
name: 'resource-only submission',
prompt: [
{
type: 'resource',
resource: {
uri: 'file:///notes.txt',
text: 'PRIVATE RESOURCE',
},
},
],
},
{
name: 'legacy retry',
prompt: [{ type: 'text', text: 'retry question' }],
retry: true,
declared: 'retry question',
},
{
name: 'daemon retry',
prompt: [{ type: 'text', text: 'retry question' }],
metaRetry: true,
declared: 'retry question',
},
{
name: 'channel turn with display projection',
prompt: [{ type: 'text', text: 'composed channel wrapper' }],
displayText: 'Issue assigned: broken build',
channel: true,
declared: 'human channel message',
},
{
name: 'channel turn without display projection',
prompt: [{ type: 'text', text: 'composed channel wrapper' }],
channel: true,
declared: 'human channel message',
},
{
name: 'machine dispatch without a declaration',
prompt: [{ type: 'text', text: 'Run this scheduled task now' }],
},
{
name: 'empty declaration never falls back to request text',
prompt: [{ type: 'text', text: 'internal wrapper' }],
declared: '',
},
{
name: 'blank declaration',
prompt: [{ type: 'text', text: 'internal wrapper' }],
declared: ' \n',
},
{
name: 'invalid declaration',
prompt: [{ type: 'text', text: 'internal wrapper' }],
declared: { text: 'not a string' },
},
])(
'preserves submission provenance: $name',
async ({
prompt,
submitted,
declared,
displayText,
modelPrompt,
retry,
metaRetry,
channel,
}) => {
const messageBus = {
request: vi.fn().mockResolvedValue({ success: true, output: {} }),
};
mockConfig.getMessageBus = vi.fn().mockReturnValue(messageBus);
mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(false);
mockConfig.hasHooksForEvent = vi
.fn()
.mockImplementation(
(eventName: string) => eventName === 'UserPromptSubmit',
);
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValue(createEmptyStream());
await session.prompt(
{
sessionId: 'test-session-id',
prompt,
...(retry ? { retry: true } : {}),
_meta: {
...(declared !== undefined
? { 'qwen.daemon.submittedPrompt': declared }
: {}),
...(channel ? { [CHANNEL_PROMPT_META_KEY]: true } : {}),
...(displayText !== undefined
? { 'qwen.daemon.promptDisplayText': displayText }
: {}),
...(metaRetry ? { 'qwen.daemon.retry': true } : {}),
},
} as PromptRequest,
modelPrompt === undefined
? undefined
: {
version: 1,
sessionId: 'test-session-id',
promptId: 'daemon-prompt-id',
},
undefined,
modelPrompt,
);
expect(mockChat.sendMessageStream).toHaveBeenCalledOnce();
expect(messageBus.request).toHaveBeenCalledWith(
expect.objectContaining({
eventName: 'UserPromptSubmit',
input: {
prompt: prompt
.filter((block) => block.type === 'text')
.map((block) => (block.type === 'text' ? block.text : ''))
.join(' '),
...(submitted === undefined
? {}
: { submitted_prompt: submitted }),
},
}),
expect.anything(),
);
},
);
it('blocks prompt when UserPromptSubmit hook returns blocking decision', async () => {
const messageBus = {
request: vi.fn().mockResolvedValue({
@@ -262,6 +262,7 @@ import {
DAEMON_ATTACHMENT_REFERENCES_META_KEY,
DAEMON_PERMISSION_CANCEL_REASON_META_KEY,
DAEMON_PROMPT_DISPLAY_TEXT_META_KEY,
DAEMON_SUBMITTED_PROMPT_META_KEY,
DAEMON_RESTORE_ASK_USER_QUESTION_META_KEY,
MID_TURN_QUEUE_DRAIN_METHOD,
isValidTrustedModelPrompt,
@@ -5335,6 +5336,10 @@ export class Session implements SessionContext {
typeof promptDisplayTextValue === 'string'
? promptDisplayTextValue
: undefined;
const declaredSubmission =
promptMetadata?.[DAEMON_SUBMITTED_PROMPT_META_KEY];
const submittedPrompt =
typeof declaredSubmission === 'string' ? declaredSubmission : '';
const modelPromptBlocks: PromptRequest['prompt'] =
modelPrompt === undefined
? params.prompt
@@ -5679,6 +5684,10 @@ export class Session implements SessionContext {
!isContinue &&
!isRestoreAskUserQuestion &&
!isRuntimeContinuation;
// Channel markers cover both automated and human messages. Keep
// that class excluded until its producers distinguish them; see
// docs/design/daemon-user-prompt-submit-provenance.md.
const isUserSubmissionTurn = isFreshUserTurn && !channelTurn;
if (
!isContinue &&
!isRestoreAskUserQuestion &&
@@ -5696,6 +5705,10 @@ export class Session implements SessionContext {
eventName: 'UserPromptSubmit',
input: {
prompt: promptText,
...(isUserSubmissionTurn &&
submittedPrompt.trim().length > 0
? { submitted_prompt: submittedPrompt }
: {}),
},
signal: pendingSend.signal,
},
@@ -121,7 +121,6 @@ describe('built-in channel registry', () => {
});
expect(entry?.fields.map((field) => field.key)).toEqual([
'settings',
'messagePrefix',
'senderPolicy',
'allowedUsers',
'groupPolicy',
@@ -814,7 +814,7 @@ describe('channel registry', () => {
);
for (const type of ['dingtalk', 'wecom', 'feishu'] as const) {
const fields = catalog.find((entry) => entry.type === type)?.fields;
expect(fields).toContainEqual(
expect(fields).not.toContainEqual(
expect.objectContaining({
key: 'messagePrefix',
kind: 'string',
@@ -855,7 +855,7 @@ describe('channel registry', () => {
}
for (const type of ['github', 'gitlab'] as const) {
const fields = catalog.find((entry) => entry.type === type)?.fields;
expect(fields).toContainEqual(
expect(fields).not.toContainEqual(
expect.objectContaining({
key: 'messagePrefix',
kind: 'string',
@@ -32,13 +32,6 @@ const FIELD_KINDS: ReadonlySet<ChannelConfigFieldKind> = new Set([
]);
const SHARED_ACCESS_FIELDS: readonly ChannelConfigFieldDescriptor[] = [
{
key: 'messagePrefix',
label: 'Message Prefix',
kind: 'string',
description:
'Only dispatch user messages that start with this exact prefix after any leading @mentions. The prefix is removed before the task runs',
},
{
key: 'senderPolicy',
label: 'Sender Policy',
@@ -178,23 +178,22 @@ describe('parseChannelConfig', () => {
expect(result.cwd).toBe(process.cwd());
expect(result.groupPolicy).toBe('disabled');
expect(result.dmPolicy).toBe('open');
expect(result.messagePrefix).toBeUndefined();
expect(result.groups).toEqual({});
expect(result.identity).toBeUndefined();
expect(result.memoryScope).toBeUndefined();
});
it('validates and normalizes the shared message prefix', async () => {
const result = await parseChannelConfig('bot', {
type: 'bare',
messagePrefix: ' /review ',
});
it.each([' /review ', false])(
'treats an old messagePrefix value as unknown configuration data: %s',
async (messagePrefix) => {
const result = await parseChannelConfig('bot', {
type: 'bare',
messagePrefix,
});
expect(result.messagePrefix).toBe('/review');
await expect(
parseChannelConfig('bot', { type: 'bare', messagePrefix: false }),
).rejects.toThrow('field "messagePrefix" must be a string');
});
expect(result).toMatchObject({ type: 'bare', messagePrefix });
},
);
it('resolves env vars in token, clientId, clientSecret', async () => {
process.env['TEST_TOKEN'] = 'tok123';
@@ -229,22 +229,6 @@ function optionalBooleanField(
return value;
}
function optionalPlainStringField(
channelName: string,
path: string,
value: unknown,
): string | undefined {
if (value === undefined || value === null || value === '') {
return undefined;
}
if (typeof value !== 'string') {
throw new Error(
`Channel "${channelName}" field "${path}" must be a string.`,
);
}
return value.trim() || undefined;
}
function requireObjectField(
channelName: string,
path: string,
@@ -529,11 +513,6 @@ export async function parseChannelConfig(
'multiSession',
rawConfig['multiSession'],
);
const messagePrefix = optionalPlainStringField(
name,
'messagePrefix',
rawConfig['messagePrefix'],
);
const groups = (rawConfig['groups'] as ChannelConfig['groups']) || {};
const webhooks = parseWebhookConfig(name, rawConfig);
@@ -561,7 +540,6 @@ export async function parseChannelConfig(
cwd: resolveChannelCwd(rawConfig['cwd'] as string | undefined, defaultCwd),
approvalMode: parseApprovalModeConfig(name, rawConfig),
instructions: rawConfig['instructions'] as string | undefined,
messagePrefix,
identity: parseObjectStringFields(name, rawConfig, 'identity', [
'id',
'displayName',
@@ -7342,6 +7342,18 @@ describe('composeReview — fixedFindings', () => {
// An inline link's DESTINATION is raw characters, so a backtick in it
// is not a delimiter either.
['see [x](a`b) <details>` here', 'see [x](a`b) &lt;details>` here'],
// A reference link's LABEL is not: MEASURED against cmark-gfm, a
// backtick inside `[a`b][c]`, `[x][a`b]`, `[a`b]` or a link
// reference definition's own label is an ordinary code-span
// delimiter, and the tag it pairs over is code. Ordinary text is
// what this pass already reads them as, so nothing special is
// needed — the rows are here because two audits reasoned the other
// way and neither could build a counterexample (#9940 review,
// rounds 11 and 12).
['see [a`b][c] and <details>` here', 'see [a`b][c] and <details>` here'],
['see [x][a`b] <details>` here', 'see [x][a`b] <details>` here'],
['see [a`b] <details>` here', 'see [a`b] <details>` here'],
['[a`b]: /u <details>` here', '[a`b]: /u <details>` here'],
// …and the `_` rule reaches the LAST label too, not only the one
// before it: `http://a.b_c` does not link, so the backticks pair and
// the tag between them is live.
@@ -7413,6 +7425,183 @@ describe('composeReview — fixedFindings', () => {
);
});
it('every model-written channel reaches the escape as ONE line — the invariant the per-line escape rests on (#9940 review, round 12 reverse audit)', () => {
// `escapeTagOpeners` models CommonMark INLINE structure only. That is
// correct exactly while every channel folds its text to ONE LINE before
// posting it — and the fold is not one primitive. `collapseEntry` and
// `collapseToLine` carry the entry, disclosure and duplicate-drop
// legs; the downgrade reasons have their own `\s+` pass; the deferral
// leg is held TWICE, by a `collapse` pass and by `mdField`'s
// line-ending strip (lib/md-field.ts), and either alone holds it —
// MEASURED: break only one and the row is still folded; the bare CR
// reaches the POSTED LINE only with both broken. Two more legs are
// held twice: cannot-tell by `collapseEntry` AND `collapseToLine`, and
// the RELOCATION exit THREE times — `collapseToLine` at
// `toDeferredEntries` and again inside `boundDeferredLine`, then
// `mdField`. Breaking any two of the three still leaves it folded.
//
// So THREE rows cannot redden on a SINGLE-site regression — a property
// of the code, not a gap here — and the other five do redden on theirs
// (all eight measured). Do not read a double-held row as a sentinel
// for its own folds; it is here for the channel, not for them.
//
// The two rows asserted inert as a CODE SPAN get there differently.
// The deferral LIST line is `- ${mdField(entry)}` and never reaches
// `escapeTagOpeners` at all; the relocation line does reach it, and
// comes back unchanged because the escape reads `mdField`'s backtick
// span correctly. The fold is what all eight share, and it is what
// this case is about.
//
// Three producers push into `bodyCriticals` AFTER `ingestEntryList`
// has run, and that is how a channel gets missed: `scriptLintGate`'s
// push, which for a while did not fold; a Critical deferral's
// RELOCATION exit, which a `Suggestion` row never reaches (armed
// below); and the non-convergence Critical, which is this module's own
// fixed prose and carries no line break to fold. The gate's push is
// the one this case does not arm — it needs a report fixture beside
// the plan — and it is pinned by `folds its own entry`. A producer
// outside these still owes a case.
const multiline = [
'the retry helper\n<div class="w"> and `x\nnever clears it',
'the retry helper\r\n<div class="w"> and `x\r\nnever clears it',
'the retry helper\r<div class="w"> and `x\rnever clears it',
];
const carries = (body: string, where: string, escapes = true) => {
// Split on `\n` ONLY, so a surviving bare `\r` stays INSIDE the
// matched line where the next assertion can see it. Splitting on the
// folds' own set here would consume the very break under test, and
// counting lines proves nothing on its own: the filter keeps the
// entry's first physical line whatever happened to the rest.
const lines = body
.split('\n')
.filter(
(l) =>
l.includes('retry helper') && !l.includes('qwen-review-ledger'),
);
expect([where, lines.length]).toEqual([where, 1]);
// The head and the TAIL of the entry on the SAME line, and no break
// left inside it. That is the fold, stated as the reader sees it: an
// LF that survives puts the tail on another line, a bare CR that
// survives stays in this one — and the per-line escape then decides
// each half on its own and can post a tag the renderer holds live
// (compose-review.ts records that happening). `&amp;lt;` was asserted
// here before and could not fail: the escape only ever writes
// `&lt;`, so the only body that trips it is one a model wrote with
// those characters already in it.
expect([where, lines[0]!.includes('never clears it')]).toEqual([
where,
true,
]);
expect([where, /[\r\n]/.test(lines[0]!)]).toEqual([where, false]);
// Inert by ESCAPE on the prose channels; the deferral line is a code
// span, where the renderer already holds the tag and an escape would
// show the reader a literal `&lt;`.
expect([
where,
escapes
? lines[0]!.includes('&lt;div class="w">')
: /^[^`]*`[^`]*<div class="w">[^`]*`[^`]*$/.test(lines[0]!),
]).toEqual([where, true]);
};
for (const text of multiline) {
carries(
composeReview(base({ bodyCriticals: [text, 'blocker two'] })).body,
'bodyCriticals',
);
carries(
composeReview(base({ cannotTellCriticals: [text] })).body,
'cannotTellCriticals',
);
carries(
composeReview(
base({
suggestionsInline: 1,
suggestionsDroppedAsDuplicates: [text],
}),
).body,
'suggestionsDroppedAsDuplicates',
);
carries(
composeReview(base({ uncoverableChunks: [text] })).body,
'uncoverableChunks',
);
carries(
composeReview(base({ unreviewedDimensions: [text] })).body,
'unreviewedDimensions',
);
carries(
composeReview(
base({
criticalsInline: 1,
presubmit: {
downgradeRequestChanges: true,
downgradeReasons: [text],
},
}),
).body,
'downgradeReasons',
);
carries(
composeReview(
base({
suggestionsInline: 1,
deferredSuggestions: [
{
file: 'a.ts',
line: 1,
source: 'review',
severity: 'Suggestion',
title: text,
},
],
}),
).body,
'deferredSuggestions',
false,
);
// The deferral channel has a SECOND exit: a Critical relocates into
// `bodyCriticals` — another producer that joins after
// `ingestEntryList` has run, and the one this case would otherwise
// miss, since a `Suggestion` never reaches it.
carries(
composeReview(
base({
suggestionsInline: 1,
deferredSuggestions: [
{
file: 'a.ts',
line: 1,
source: 'review',
severity: 'Critical',
title: text,
},
],
}),
).body,
'deferredSuggestions (relocated)',
false,
);
// A ruling note's `by` does not fold — it REFUSES, because the note
// is one posted line and a folded `by` would misquote the author.
expect(() =>
composeReview(base({ fixedFindings: [{ id: 'R1-2', by: text }] })),
).toThrow(/must be ONE non-empty line/);
}
// A quoted FENCE is refused rather than folded on the two Critical
// channels: folding it would join the delimiters onto one line and
// silently change what the author quoted.
const fenced =
'the retry helper\n```\n<div class="w">\n```\nnever clears it';
for (const input of [
{ bodyCriticals: [fenced, 'blocker two'] },
{ cannotTellCriticals: [fenced] },
]) {
expect(() => composeReview(base(input))).toThrow(
/quotes a code fence its one-line render cannot carry/,
);
}
});
it('a raw `<![CDATA[ … > … <details> … ]]>` does not fold the rest of the body away (#9940 review, round 10 reverse audit)', () => {
// The unit rows pin the escape; this pins what the escape is FOR. The
// HTML parser closes the bogus comment at the first `>`, so under the
@@ -3673,11 +3673,20 @@ export function escapeTagOpeners(text: string): string {
// each closing one hole and opening the next; a line is the unit the
// rule below can decide exactly (#9940 review, round 31 reverse audit).
//
// The channels are folded at four sites, not one: `ingestEntryList` for
// the entry channels, the `\s+` normalisation for downgrade reasons,
// `collapseEntry` for the `Not reviewed:` disclosures, and
// `scriptLintGate`'s own push — that last one joins `bodyCriticals`
// after `ingestEntryList` has run, so the shared fold never sees it.
// The channels are folded at several sites, not one: `collapseEntry` for
// the entry channels and the `Not reviewed:` disclosures (the `\r\n?`
// normalisation `ingestEntryList` does first folds nothing on its own),
// the `\s+` pass for downgrade reasons, `collapseToLine` for the
// duplicate-drop leg and — at `toDeferredEntries` and again through
// `boundDeferredLine` — for a Critical deferral's relocation exit, and
// `scriptLintGate`'s own push. The
// relocation exit and the gate push join `bodyCriticals` AFTER
// `ingestEntryList` has run, so the shared fold never sees them. (The
// deferral LIST line is folded by `mdField` and posts without coming
// here at all.) `every model-written channel reaches the escape as ONE
// line` in the tests drives every one of these EXCEPT the gate push,
// which needs a report fixture and is pinned by `folds its own entry`;
// it also says which legs are held more than once.
// Per-line is NOT a conservative fallback — it is the model that the
// fold makes correct. Handed a multi-line string anyway it differs from
// the renderer in BOTH directions: it pairs backticks the renderer keeps
@@ -1078,9 +1078,26 @@ describe('the review footer and the regex that strips it', () => {
'q <!--the auth check at line 40 is missing',
'q <![CDATA[the auth check at line 40 is missing',
'q <?the auth check at line 40 is missing',
// A `<?` whose span leaves prose behind it: what it hides ends at
// the first `>`, so the sentence after that still counts.
'q <?x?> the auth check at line 40 is missing',
'<?x> the auth check at line 40 is missing',
]) {
expect([body, rendersAsNothing(body)]).toEqual([body, false]);
}
// These arms are POSITION-BLIND on purpose, and `&nbsp;<?x>` is the
// shape where that shows: mid-line a `<?` with no `?>` forms nothing
// in the renderer, so its characters are VISIBLE and calling the
// body empty is wrong. The split that would fix it was measured and
// REVERTED — being right about it needs the block structure this
// module does not model, and the half-measure cost more bodies than
// it saved. Pinned so the boundary is a decision on the record
// rather than something the next audit rediscovers; the numbers are
// on `rendersAsNothing` (#9940 review, round 12 reverse audit).
for (const body of ['&nbsp;<?x>', '<?x>', '<?php echo 1 ?>']) {
expect([body, rendersAsNothing(body)]).toEqual([body, true]);
}
expect(rendersAsNothing('q <?x>')).toBe(false);
});
it('still counts real content wearing the same shapes', () => {
@@ -902,6 +902,48 @@ function linkRefDefLines(lines: string[], i: number): number {
* toward the verdict, and re-promotes as an unanswerable blocker. This is a
* judgment projection, not a sanitizer, so it is deliberately fence-blind: a
* quotation of scaffolding is still not a finding.
*
* Two further blind spots are DECISIONS, not gaps, and both were measured
* against cmark-gfm and an HTML parser before being left alone (#9940
* review, round 12 reverse audit):
*
* FOLDS. A body whose prose sits inside a `<details>` renders as a
* collapsed triangle — one click from readable, not invisible — and an arm
* that deleted the fold's content would delete a quoted `` `<details>` ``
* too, which is the shape a review OF THIS FILE writes. That trade loses a
* real finding to catch a readable one, so a fold that hides an inline
* comment's whole text posts, and counts.
*
* BLOCK CONTEXT. The `<!` and `<?` arms hide the same span wherever the
* opener stands, though the renderer does not: mid-line a `<?` without its
* `?>` forms nothing and its characters are VISIBLE, while at a line's
* content start it is an HTML block. Splitting the two BY HAND was tried
* and REVERTED. Measured on a prose-weighted corpus of 30 000 bodies: the
* split alone traded 121 → 116 bodies wrongly called empty for 1426 → 1730
* wrongly called visible, and teaching it the container prefixes (so
* `><?…` reads as a block) then needs the container's EXTENT as well —
* without that, bodies wrongly called EMPTY went 121 → 3594. (A one-off
* corpus, not kept: the numbers are the shape of the trade, not a
* benchmark to re-run.)
*
* The parse is not what stops it, and neither is the parser. `BLOCK_PARSER`
* ABOVE hands every BLOCK token its line span, and this path already pays
* for a run of it: `stripReviewFooter` opens `canProjectFooterMarker` on a
* `<`, which every body these arms judge carries by construction, and
* reaches `scanLines`. So the parse is already in the bill — on the body
* shapes these arms fire on, adding one costs about what this leg already
* spends in `stripReviewFooter`, and the two move together across shapes
* (a directional measurement, not a benchmark: the absolute figures swing
* more than twentyfold with the body's structure).
*
* What stops it is the PROJECTION. `scanLines` flattens `token.map` into a
* per-line kind and returns `{line, kind, depth, content}` — the extent is
* discarded, and `depth` counts block-quote markers only, so a list item
* reads 0. Extent is the exact fact the numbers above say the split needs:
* a container prefix tells you an opener is at a line's content start, and
* only the container's END tells you how far the block it opens reaches.
* So the arms stay position-blind until something here surfaces that, and
* the price is the `&nbsp;<?x>` miscall pinned in the tests.
*/
/**
* The `<!` family and `<?`, taken as ONE left-to-right decision.
+2 -2
View File
@@ -852,8 +852,8 @@ export default {
"L'entrada a l'ordre és JSON amb tool_name, tool_input, tool_use_id, error, error_type, is_interrupt i is_timeout.",
'Input to command is JSON with notification message and type.':
"L'entrada a l'ordre és JSON amb el missatge de notificació i el tipus.",
'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the supported interactive TUI text projection).':
'L’entrada de l’ordre és JSON amb "prompt" (el prompt actual vinculat al model) i el camp opcional "submitted_prompt" (la projecció de text de la TUI interactiva compatible).',
'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the text projection captured at a supported submission boundary).':
'L’entrada de l’ordre és JSON amb "prompt" (el prompt actual vinculat al model) i el camp opcional "submitted_prompt" (la projecció de text capturada en un límit d’enviament compatible).',
'Input to command is JSON with command_name, command_args, and expanded prompt text.':
"L'entrada a l'ordre és JSON amb command_name, command_args i el text del missatge expandit.",
'Input to command is JSON with session start source.':
+2 -2
View File
@@ -798,8 +798,8 @@ export default {
'Die Eingabe an den Befehl ist JSON mit tool_name, tool_input, tool_use_id, error, error_type, is_interrupt und is_timeout.',
'Input to command is JSON with notification message and type.':
'Die Eingabe an den Befehl ist JSON mit Benachrichtigungsnachricht und -typ.',
'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the supported interactive TUI text projection).':
'Die Eingabe für den Befehl ist JSON mit "prompt" (dem aktuellen modellgebundenen Prompt) und optional "submitted_prompt" (der Textprojektion der unterstützten interaktiven TUI).',
'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the text projection captured at a supported submission boundary).':
'Die Eingabe für den Befehl ist JSON mit "prompt" (dem aktuellen modellgebundenen Prompt) und optional "submitted_prompt" (der an einer unterstützten Übermittlungsgrenze erfassten Textprojektion).',
'Input to command is JSON with command_name, command_args, and expanded prompt text.':
'Die Eingabe an den Befehl ist JSON mit command_name, command_args und erweitertem Prompt-Text.',
'Input to command is JSON with session start source.':
+2 -2
View File
@@ -1155,8 +1155,8 @@ export default {
'Input to command is JSON with tool_name, tool_input, tool_use_id, error, error_type, is_interrupt, and is_timeout.',
'Input to command is JSON with notification message and type.':
'Input to command is JSON with notification message and type.',
'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the supported interactive TUI text projection).':
'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the supported interactive TUI text projection).',
'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the text projection captured at a supported submission boundary).':
'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the text projection captured at a supported submission boundary).',
'Input to command is JSON with command_name, command_args, and expanded prompt text.':
'Input to command is JSON with command_name, command_args, and expanded prompt text.',
'Input to command is JSON with session start source.':
+2 -2
View File
@@ -867,8 +867,8 @@ export default {
"L'entrée de la commande est du JSON avec tool_name, tool_input, tool_use_id, error, error_type, is_interrupt et is_timeout.",
'Input to command is JSON with notification message and type.':
"L'entrée de la commande est du JSON avec le message et le type de notification.",
'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the supported interactive TUI text projection).':
'L’entrée de la commande est un JSON avec "prompt" (l’invite actuelle liée au modèle) et, facultativement, "submitted_prompt" (la projection textuelle de l’interface TUI interactive prise en charge).',
'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the text projection captured at a supported submission boundary).':
'L’entrée de la commande est un JSON avec "prompt" (l’invite actuelle liée au modèle) et, facultativement, "submitted_prompt" (la projection textuelle capturée à une frontière de soumission prise en charge).',
'Input to command is JSON with command_name, command_args, and expanded prompt text.':
"L'entrée de la commande est du JSON avec command_name, command_args et le texte d'invite développé.",
'Input to command is JSON with session start source.':
+2 -2
View File
@@ -583,8 +583,8 @@ export default {
'コマンドへの入力は tool_name、tool_input、tool_use_id、error、error_type、is_interrupt、is_timeout を持つ JSON です。',
'Input to command is JSON with notification message and type.':
'コマンドへの入力は通知メッセージとタイプを持つ JSON です。',
'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the supported interactive TUI text projection).':
'コマンド入力は、"prompt"(現在のモデル向けプロンプト)と、オプションの "submitted_prompt"(サポート対象の対話型 TUI で入力されたテキストの投影)を含む JSON です。',
'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the text projection captured at a supported submission boundary).':
'コマンド入力は、"prompt"(現在のモデル向けプロンプト)と、オプションの "submitted_prompt"(サポート対象の送信境界でキャプチャされたテキスト投影)を含む JSON です。',
'Input to command is JSON with command_name, command_args, and expanded prompt text.':
'コマンドへの入力は command_name、command_args、展開後のプロンプトテキストを持つ JSON です。',
'Input to command is JSON with session start source.':
+2 -2
View File
@@ -804,8 +804,8 @@ export default {
'A entrada para o comando é JSON com tool_name, tool_input, tool_use_id, error, error_type, is_interrupt e is_timeout.',
'Input to command is JSON with notification message and type.':
'A entrada para o comando é JSON com mensagem e tipo de notificação.',
'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the supported interactive TUI text projection).':
'A entrada para o comando é JSON com "prompt" (o prompt atual vinculado ao modelo) e o campo opcional "submitted_prompt" (a projeção de texto da TUI interativa compatível).',
'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the text projection captured at a supported submission boundary).':
'A entrada para o comando é JSON com "prompt" (o prompt atual vinculado ao modelo) e o campo opcional "submitted_prompt" (a projeção de texto capturada em um ponto de envio compatível).',
'Input to command is JSON with command_name, command_args, and expanded prompt text.':
'A entrada para o comando é JSON com command_name, command_args e o texto do prompt expandido.',
'Input to command is JSON with session start source.':
+2 -2
View File
@@ -811,8 +811,8 @@ export default {
'Ввод в команду — это JSON с tool_name, tool_input, tool_use_id, error, error_type, is_interrupt и is_timeout.',
'Input to command is JSON with notification message and type.':
'Ввод в команду — это JSON с сообщением уведомления и типом.',
'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the supported interactive TUI text projection).':
'Ввод команды — JSON с полем "prompt" (текущий промпт, отправляемый модели) и необязательным "submitted_prompt" (текстовая проекция поддерживаемого интерактивного TUI).',
'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the text projection captured at a supported submission boundary).':
'Ввод команды — JSON с полем "prompt" (текущий промпт, отправляемый модели) и необязательным "submitted_prompt" (текстовая проекция, захваченная на поддерживаемой границе отправки).',
'Input to command is JSON with command_name, command_args, and expanded prompt text.':
'Ввод в команду — это JSON с command_name, command_args и развернутым текстом промпта.',
'Input to command is JSON with session start source.':
+2 -2
View File
@@ -1064,8 +1064,8 @@ export default {
'命令輸入為包含 tool_name、tool_input、tool_use_id、error、error_type、is_interrupt 和 is_timeout 的 JSON。',
'Input to command is JSON with notification message and type.':
'命令輸入為包含通知消息和類型的 JSON。',
'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the supported interactive TUI text projection).':
'命令輸入為 JSON,其中包含 "prompt"(目前模型側提示)以及選用的 "submitted_prompt"(受支援互動式 TUI 的提交文字投影)。',
'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the text projection captured at a supported submission boundary).':
'命令輸入為 JSON,其中包含 "prompt"(目前模型側提示)以及選用的 "submitted_prompt"(在受支援的提交邊界擷取的文字投影)。',
'Input to command is JSON with command_name, command_args, and expanded prompt text.':
'命令輸入為包含 command_name、command_args 和展開後提示文本的 JSON。',
'Input to command is JSON with session start source.':
+2 -2
View File
@@ -1124,8 +1124,8 @@ export default {
'命令输入为包含 tool_name、tool_input、tool_use_id、error、error_type、is_interrupt 和 is_timeout 的 JSON。',
'Input to command is JSON with notification message and type.':
'命令输入为包含通知消息和类型的 JSON。',
'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the supported interactive TUI text projection).':
'命令输入为 JSON,其中包含 "prompt"(当前模型侧提示)以及可选的 "submitted_prompt"(受支持交互式 TUI 的提交文本投影)。',
'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the text projection captured at a supported submission boundary).':
'命令输入为 JSON,其中包含 "prompt"(当前模型侧提示)以及可选的 "submitted_prompt"(在受支持的提交边界捕获的文本投影)。',
'Input to command is JSON with command_name, command_args, and expanded prompt text.':
'命令输入为包含 command_name、command_args 和展开后提示文本的 JSON。',
'Input to command is JSON with session start source.':
+15 -1
View File
@@ -53,10 +53,14 @@ import {
} from '../auth/device-flow.js';
import {
REQUESTED_SESSION_ID_META_KEY,
SUBMITTED_PROMPT_META_KEY,
CHANNEL_PROMPT_META_KEY,
DAEMON_PROMPT_DISPLAY_TEXT_META_KEY,
type BridgeBranchedSession,
type BridgeRestoredSession,
type HttpAcpBridge,
} from '@qwen-code/acp-bridge/bridgeTypes';
import { CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY } from '../channel-worker-prompt-authorization.js';
import { parseSessionSource } from '@qwen-code/acp-bridge';
import { restoreRetryAfterSeconds } from '@qwen-code/acp-bridge/sessionRestoreTimeout';
import {
@@ -5851,6 +5855,8 @@ export class AcpDispatcher {
binding.promptAbort?.abort();
const abort = new AbortController();
binding.promptAbort = abort;
const metadata = params['_meta'] as Record<string, unknown> | undefined;
const submittedPrompt = metadata?.[SUBMITTED_PROMPT_META_KEY];
try {
const result = await this.bridge.sendPrompt(
sessionId,
@@ -5862,7 +5868,15 @@ export class AcpDispatcher {
// sessionId, prompt }`) so it can't become client-controlled.
params as unknown as Parameters<HttpAcpBridge['sendPrompt']>[1],
abort.signal,
this.sessionCtx(conn, sessionId, fromLoopback),
{
...this.sessionCtx(conn, sessionId, fromLoopback),
...(typeof submittedPrompt === 'string' &&
metadata?.[CHANNEL_PROMPT_META_KEY] === undefined &&
metadata?.[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY] === undefined &&
metadata?.[CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY] === undefined
? { submittedPrompt }
: {}),
},
);
if (id !== undefined) this.replySession(conn, sessionId, id, result);
} catch (err) {
@@ -1862,6 +1862,54 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
});
});
it.each([
{ meta: undefined, context: {} },
{ meta: { 'qwen.daemon.submittedPrompt': 'forged' }, context: {} },
{
meta: {
'qwen.submittedPrompt': 'label',
'qwen.daemon.channelPromptAuthorization': 'revoked-worker',
},
context: {},
},
{
meta: { 'qwen.submittedPrompt': ' original text\n' },
context: { submittedPrompt: ' original text\n' },
},
])(
'admits only public submission declarations over ACP HTTP: $meta',
async ({ meta, context }) => {
const send = vi.spyOn(bridge, 'sendPrompt');
const connId = await initialize();
await newSession(connId);
const ack = await post(connId, {
jsonrpc: '2.0',
id: 5,
method: 'session/prompt',
params: {
sessionId: 'sess-1',
prompt: [{ type: 'text', text: 'wrapper' }],
...(meta ? { _meta: meta } : {}),
},
});
expect(ack.status).toBe(202);
await vi.waitFor(() => expect(send).toHaveBeenCalled());
expect(send).toHaveBeenCalledWith(
'sess-1',
expect.anything(),
expect.any(AbortSignal),
expect.objectContaining({
...context,
}),
);
if (Object.keys(context).length === 0) {
expect((send.mock.calls[0] as unknown[])[3]).not.toHaveProperty(
'submittedPrompt',
);
}
},
);
it('prompt streams session/update then the final result', async () => {
bridge.promptBehavior = async (_s, q) => {
q.push({
@@ -228,30 +228,43 @@ describe('WorkspaceChannelSettingsStore', () => {
).toBe('chat_thread');
});
it('round-trips a managed message prefix', async () => {
it('preserves a stored legacy messagePrefix when saving other settings', async () => {
const settings = readWorkspaceSettings();
const channels = settings['channels'] as Record<
string,
Record<string, unknown>
>;
channels['bot']!['messagePrefix'] = '/review';
writeWorkspaceSettings(JSON.stringify(settings));
const store = new WorkspaceChannelSettingsStore(workspace);
const config = {
type: 'management-validation-test',
clientId: 'client-id',
messagePrefix: '/review',
instructions: 'Use concise replies.',
};
const next = await store.upsert('bot', {
expectedRevision: store.snapshot().revision,
config: {
type: 'management-validation-test',
clientId: 'client-id',
messagePrefix: '/review',
},
config,
});
expect(next.channels['bot']?.['messagePrefix']).toBe('/review');
expect(
(
readWorkspaceSettings()['channels'] as Record<
string,
Record<string, unknown>
>
)['bot']?.['messagePrefix'],
).toBe('/review');
expect(next.channels['bot']).toMatchObject({
messagePrefix: '/review',
instructions: 'Use concise replies.',
});
await expect(
store.upsert('bot', {
expectedRevision: next.revision,
config: { ...config, messagePrefix: '/changed' },
}),
).rejects.toMatchObject({
code: 'channel_settings_invalid_config',
message: 'Channel field "messagePrefix" is not manageable.',
});
});
it('rejects a non-string managed message prefix', async () => {
it('rejects adding the removed messagePrefix setting', async () => {
const store = new WorkspaceChannelSettingsStore(workspace);
await expect(
@@ -260,12 +273,12 @@ describe('WorkspaceChannelSettingsStore', () => {
config: {
type: 'management-validation-test',
clientId: 'client-id',
messagePrefix: 42,
} as never,
messagePrefix: '/review',
},
}),
).rejects.toMatchObject({
code: 'channel_settings_invalid_config',
message: 'Channel field "messagePrefix" must be a string.',
message: 'Channel field "messagePrefix" is not manageable.',
});
});
@@ -145,11 +145,7 @@ function assertSharedField(
}
return true;
}
if (
['model', 'cwd', 'approvalMode', 'instructions', 'messagePrefix'].includes(
key,
)
) {
if (['model', 'cwd', 'approvalMode', 'instructions'].includes(key)) {
if (typeof value !== 'string') {
throw invalidConfig(`Channel field "${key}" must be a string.`);
}
+11
View File
@@ -50,6 +50,8 @@ import type { SessionArtifactInput } from '@qwen-code/acp-bridge/sessionArtifact
import {
CHANNEL_PROMPT_META_KEY,
DAEMON_PROMPT_DISPLAY_TEXT_META_KEY,
DAEMON_SUBMITTED_PROMPT_META_KEY,
SUBMITTED_PROMPT_META_KEY,
type BridgeBranchedSession,
} from '@qwen-code/acp-bridge/bridgeTypes';
import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus';
@@ -6915,6 +6917,7 @@ export function registerSessionRoutes(
!Array.isArray(forwardedBody['_meta'])
? { ...(forwardedBody['_meta'] as Record<string, unknown>) }
: undefined;
const submittedPrompt = forwardedMeta?.[SUBMITTED_PROMPT_META_KEY];
const promptAuthorization =
forwardedMeta?.[CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY];
const promptDisplayText =
@@ -6923,6 +6926,8 @@ export function registerSessionRoutes(
if (forwardedMeta) {
delete forwardedMeta[CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY];
delete forwardedMeta[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY];
delete forwardedMeta[SUBMITTED_PROMPT_META_KEY];
delete forwardedMeta[DAEMON_SUBMITTED_PROMPT_META_KEY];
delete forwardedMeta[CHANNEL_PROMPT_META_KEY];
if (Object.keys(forwardedMeta).length > 0) {
forwardedBody['_meta'] = forwardedMeta;
@@ -6993,6 +6998,12 @@ export function registerSessionRoutes(
...(trustedPromptDisplayText !== undefined
? { promptDisplayText: trustedPromptDisplayText }
: {}),
...(typeof submittedPrompt === 'string' &&
channelPrompt === undefined &&
promptAuthorization === undefined &&
promptDisplayText === undefined
? { submittedPrompt }
: {}),
...(trustedChannelPrompt ? { channelPrompt: true } : {}),
...(delivery !== undefined
? {
+56
View File
@@ -18757,6 +18757,62 @@ describe('createServeApp', () => {
}
});
it('requires explicit submission provenance and never upgrades a rejected worker', async () => {
const bridge = fakeBridge();
const app = createServeApp(baseOpts, undefined, { bridge });
const cases = [
{ meta: undefined, expected: undefined },
{
meta: { 'qwen.daemon.submittedPrompt': 'forged' },
expected: undefined,
},
{
meta: { 'qwen.submittedPrompt': ' original question\n' },
expected: ' original question\n',
},
{ meta: { 'qwen.submittedPrompt': 42 }, expected: undefined },
{
meta: {
'qwen.submittedPrompt': 'label',
[CHANNEL_PROMPT_META_KEY]: true,
},
expected: undefined,
},
{
meta: {
'qwen.submittedPrompt': 'label',
[CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY]: 'revoked-worker',
},
expected: undefined,
},
{
meta: {
'qwen.submittedPrompt': 'label',
'qwen.daemon.promptDisplayText': 'label',
},
expected: undefined,
},
];
for (const { meta, expected } of cases) {
const result = await request(app)
.post('/session/session-A/prompt')
.set('Host', `127.0.0.1:${baseOpts.port}`)
.send({
prompt: [{ type: 'text', text: 'request wrapper' }],
...(meta ? { _meta: meta } : {}),
});
expect(result.status).toBe(202);
const call = bridge.promptCalls.at(-1);
expect(call?.context?.submittedPrompt).toBe(expected);
expect(call?.req._meta ?? {}).not.toHaveProperty(
'qwen.submittedPrompt',
);
expect(call?.req._meta ?? {}).not.toHaveProperty(
'qwen.daemon.submittedPrompt',
);
}
});
it('accepts channel-prompt classification only from the workspace worker', async () => {
// `qwen.channel.prompt` opts a turn out of loop-detected rejection;
// a forged key from an unauthorized caller must be dropped at the
@@ -235,7 +235,7 @@ describe('hooks constants', () => {
expect(desc).toContain('"prompt"');
expect(desc).toContain('model-bound');
expect(desc).toContain('"submitted_prompt"');
expect(desc).toContain('interactive TUI');
expect(desc).toContain('submission boundary');
});
it('should return description for PostCompact', () => {
@@ -244,7 +244,7 @@ export function getHookDescription(eventName: string): string {
'Input to command is JSON with file_path, memory_type, load_reason, and optional trigger_file_path and parent_file_path.',
),
[HookEventName.UserPromptSubmit]: t(
'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the supported interactive TUI text projection).',
'Input to command is JSON with "prompt" (the current model-bound prompt) and optional "submitted_prompt" (the text projection captured at a supported submission boundary).',
),
[HookEventName.UserPromptExpansion]: t(
'Input to command is JSON with command_name, command_args, and expanded prompt text.',
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-core",
"version": "0.23.2",
"version": "0.23.3",
"description": "Qwen Code Core",
"repository": {
"type": "git",
@@ -441,8 +441,9 @@ export interface NotificationRecordPayload {
export interface UserPromptRecordPayload {
/**
* TUI submittedPrompt projection when available; otherwise the expanded
* pre-hook prompt.
* Core/headless: submitted projection, otherwise expanded pre-hook text.
* ACP: display projection or raw request text before expansion. ACP omits
* this payload when neither a projection nor attachment references exist.
*/
displayText: string;
/** Sanitized hook context duplicated from the tagged model-bound part. */
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "qwen-code-vscode-ide-companion",
"displayName": "Qwen Code Companion",
"description": "Enable Qwen Code with direct access to your VS Code workspace.",
"version": "0.23.2",
"version": "0.23.3",
"publisher": "qwenlm",
"icon": "assets/icon.png",
"repository": {
+41 -15
View File
@@ -20534,7 +20534,7 @@ describe('App session callbacks', () => {
});
expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith(
'resolved',
expect.objectContaining({ inputAnnotations }),
expect.objectContaining({ inputAnnotations, submittedPrompt: 'hello' }),
);
expect(onSessionChange).toHaveBeenCalledWith({
type: 'submit',
@@ -20967,6 +20967,8 @@ describe('App session callbacks', () => {
undefined,
undefined,
undefined,
undefined,
'',
);
expect(onSessionChange).toHaveBeenCalledWith({
type: 'submit',
@@ -24674,6 +24676,8 @@ describe('App session callbacks', () => {
undefined,
undefined,
undefined,
undefined,
'queued',
);
expect(onSessionChange).toHaveBeenCalledWith({
type: 'submit',
@@ -24726,6 +24730,8 @@ describe('App session callbacks', () => {
undefined,
undefined,
inputAnnotations,
undefined,
'queued',
);
expect(onSessionChange).toHaveBeenCalledWith({
type: 'submit',
@@ -26296,20 +26302,36 @@ describe('App session callbacks', () => {
},
);
it('converts /skills arguments to a direct skill command', async () => {
const { container } = renderApp();
await flush();
testState.prompt = '/skills bugfix';
await clickSubmit(container);
await flush();
expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith(
'/bugfix',
expect.any(Object),
);
expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull();
});
it.each(['idle', 'responding'] as const)(
'preserves the original /skills submission when %s',
async (streamingState) => {
testState.streamingState = streamingState;
const { container } = renderApp();
await flush();
testState.prompt = '/skills bugfix';
await clickSubmit(container);
await flush();
if (streamingState === 'idle') {
expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith(
'/bugfix',
expect.objectContaining({ submittedPrompt: '/skills bugfix' }),
);
} else {
expect(rawEnqueuePrompt).toHaveBeenCalledWith(
'/bugfix',
undefined,
undefined,
undefined,
undefined,
undefined,
'/skills bugfix',
);
}
expect(
container.querySelector('[data-testid="inline-panel"]'),
).toBeNull();
},
);
it('opens plugin management tabs from the sidebar', async () => {
mockWorkspaceActions.loadMcpStatus.mockResolvedValue({
@@ -35867,6 +35889,10 @@ describe('App manual-run orchestration (scheduled tasks)', () => {
await act(async () => {
await expect(run('do the thing', null)).resolves.toBeUndefined();
});
expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith(
'do the thing',
expect.not.objectContaining({ submittedPrompt: expect.anything() }),
);
});
it('rejects an unbound run that settles without admitting (cancel path)', async () => {
+11
View File
@@ -616,6 +616,7 @@ function resolvePreparedSubmit(
}
interface SendPromptOptionsWithRetry {
submittedPrompt?: string;
optimisticUserMessage?: boolean;
images?: PromptImage[];
files?: PromptFile[];
@@ -9540,6 +9541,7 @@ export function App({
// by the failed-prompt retry, whose user message was never
// recorded.
skipPrepareSubmit?: boolean;
submittedPrompt?: string;
inputAnnotations?: DaemonInputAnnotation[];
clearComposerOnPromptStart?: boolean;
commitComposerAccepted?: ComposerSubmitCommit;
@@ -9752,6 +9754,9 @@ export function App({
let admissionStarted = false;
let admitted = false;
const promptOptions: SendPromptOptionsWithRetry = {
...(opts?.submittedPrompt !== undefined
? { submittedPrompt: opts.submittedPrompt }
: {}),
images,
files,
inputAnnotations:
@@ -10304,6 +10309,7 @@ export function App({
onComplete?: () => void,
commitComposerAccepted?: ComposerSubmitCommit,
inputAnnotations?: DaemonInputAnnotation[],
submittedPrompt = text,
) => {
const normalizedInputAnnotations = inputAnnotations
? [...inputAnnotations]
@@ -10330,6 +10336,8 @@ export function App({
files,
onComplete,
annotations,
undefined,
submittedPrompt,
);
if (result !== false) {
if (commitComposerAccepted) {
@@ -13971,6 +13979,7 @@ export function App({
undefined,
commitComposerAccepted,
metadata?.inputAnnotations,
text,
);
};
const submitPromptFromEditor = (
@@ -14013,6 +14022,7 @@ export function App({
let admissionStarted = false;
let admissionSessionId: string | undefined;
sendPrompt(promptText, promptImages, promptFiles, {
submittedPrompt: text,
ownerRef: admissionAttachment,
...sendOptions,
clearComposerOnPromptStart,
@@ -14489,6 +14499,7 @@ export function App({
writeBlockGeneration
) {
return sendPrompt(prompt, images, files, {
submittedPrompt: text,
clearComposerOnPromptStart: true,
inputAnnotations: metadata?.inputAnnotations,
});
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff. Show more