Merge branch 'main' into feat/openai-responses-api

This commit is contained in:
Dinesh Palanisamy authored and GitHub committed 2026-08-06 15:03:12 -04:00
commit 770146526f
172 files changed
+23892 -1479

No files matched your search

@@ -84,6 +84,21 @@ if [[ -n "$(git status --porcelain)" ]]; then
fi
VERIFICATION_HEAD="$(git rev-parse HEAD)"
# The schema generator resolves '@qwen-code/qwen-code-core' to core's DIST
# entry point, which the CLI bundle restored from the TRUSTED BASE. When the
# branch itself changed core's sources, that base-built dist can disagree
# with the branch's committed schema (changed runtime constants) or crash
# the generator (changed exports) — the same false "settings schema is
# stale" rejection class this gate exists to prevent. Rebuild core from
# branch sources in that case: the gate already runs a full `npm run build`
# on branch sources for every commit path, so this widens no trust surface,
# and the build's git-ignored output cannot trip the dirty-tree asserts.
if git diff --name-only "origin/main...${BRANCH}" \
| grep -Eq '^packages/core/(src/|index\.ts$)'; then
run_check 'core rebuild failed on the agent-committed fix' \
npm run build --workspace packages/core
fi
# Settings-schema freshness is a STRUCTURAL guard, checked BEFORE the
# no-op/unchanged return: on a stale-schema PR the agent can wrongly
# write no-action.md, and without this the no-op path would report the
+4
View File
@@ -353,6 +353,10 @@ jobs:
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
run: 'npm run check:desktop-isolation'
- name: 'Check voice guard mirror sync'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
run: 'npm run check:voice-guard-sync'
- name: 'Install linters'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
run: 'node scripts/lint.js --setup'
+2 -1
View File
@@ -148,8 +148,9 @@ jobs:
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$keychain_password" "$keychain"
identity="$(security find-identity -v -p codesigning "$keychain" | sed -n 's/.*"\(Developer ID Application:.*\)"/\1/p' | head -n 1)"
if [ -z "$identity" ]; then echo '::error::Developer ID Application identity was not found.'; exit 1; fi
identity_name="${identity#Developer ID Application: }"
{
echo "CSC_NAME=$identity"
echo "CSC_NAME=$identity_name"
echo 'CSC_IDENTITY_AUTO_DISCOVERY=true'
} >> "$GITHUB_ENV"
+471 -39
View File
@@ -658,21 +658,141 @@ jobs:
needs.route.outputs.do_issue == 'true' &&
(github.event_name != 'schedule' || (needs.review-scan.result == 'success' && needs.review-scan.outputs.has_targets != 'true'))
}}
runs-on: 'ubuntu-latest'
# Secret-bearing and executes agent-driven code, but the agent runs inside
# the docker sandbox image and only ever writes a new branch as the
# dev-bot — it never executes a foreign author's code. Forks of this repo
# (and MAINTAINER_ECS_RUNNER_DISABLED) fall back to hosted. On
# pull_request / pull_request_review events the ECS route additionally
# needs a same-repo head or a write+ author (ci.yml's pick_runner form);
# the other triggers skip that clause and rely on their own gates
# instead: issues / schedule require autofix/approved plus
# status/ready-for-agent on the issue, and workflow_dispatch rides the
# actor's own write access. Docker availability on this pool is proven
# in-repo by qwen-triage's container jobs, which run on the same
# runner labels.
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event_name != ''pull_request'' && github.event_name != ''pull_request_review'' || github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON(''["OWNER","MEMBER","COLLABORATOR"]''), github.event.pull_request.author_association))) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}'
timeout-minutes: 180
# route.issue_number is only set for forced dispatches; label events carry
# the issue in the payload, and scan-and-pick runs (cron, unforced
# dispatch) share one 'scheduled' group. The old github.run_id fallback
# made every scan-and-pick run its own group, so two overlapping scans
# (cron fires every 40-70min, this job runs up to 180) could double-claim
# the same issue — the claim recheck runs after assess and only narrows
# the race to the short gap between the recheck and the claim's label
# write; it does not close it. Queued (never cancelled) so the newest
# pending tick still runs after a long scan if targets remain;
# intermediate ticks are superseded, which is fine because each run
# rescans from scratch.
#
# GitHub evaluates concurrency before the job `if`, but after `needs`, so
# the group is gated on the same runnability predicate as the `if` above,
# plus a dry-run exclusion: runs whose issue phase will not execute
# (do_issue=false takeover, review, and command events; label events
# failing the decide gates; scheduled ticks whose review-scan still has
# targets) and dry runs (if-runnable, but their Claim/Publish steps are
# gated off) get a run-unique group instead — a run that never claims
# entering a target-keyed group would replace the single pending run
# there and silently cancel it. Same precedent as qwen-triage.yml's
# triage/tmux jobs.
concurrency:
group: 'qwen-autofix-issue-${{ needs.route.outputs.issue_number || github.run_id }}'
group: >-
${{ needs.route.outputs.do_issue == 'true' && needs.route.outputs.dry_run != 'true' && (github.event_name != 'schedule' || (needs.review-scan.result == 'success' && needs.review-scan.outputs.has_targets != 'true')) && format('qwen-autofix-issue-{0}', needs.route.outputs.issue_number || github.event.issue.number || 'scheduled') || format('qwen-autofix-issue-run-{0}', github.run_id) }}
cancel-in-progress: false
permissions:
contents: 'read'
env:
REPO: '${{ github.repository }}'
WORKDIR: '/tmp/autofix'
# Per-run private dir: this pool carries many registrations sharing one
# OS /tmp, and issue-phase runs never serialize against each other, so
# a fixed path let concurrent runs clobber each other's decision files.
WORKDIR: '/tmp/autofix-${{ github.run_id }}'
EVENT_NAME: '${{ github.event_name }}'
READY_FOR_AGENT_LABEL: 'status/ready-for-agent'
AUTOFIX_APPROVED_LABEL: 'autofix/approved'
AUTOFIX_ISSUE_EXCLUDES: 'no:assignee -linked:pr -label:autofix/skip -label:autofix/in-progress -label:status/need-information -label:status/need-retesting sort:created-desc'
steps:
# Self-hosted runners reuse the workspace; a prior containerised job
# can leave root-owned, read-only files anywhere in it. Restore
# ownership and write permission unconditionally before checkout.
- name: 'Restore workspace ownership'
run: |-
set -uo pipefail
RUNNER_UID="$(id -u)"
RUNNER_GID="$(id -g)"
if [ "$RUNNER_UID" != "0" ]; then
chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files"
fi
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files"
# Self-hosted runners keep the workspace between runs, and other pool
# jobs execute human-authored code as the runner user, so a prior job
# can plant git exec knobs (core.fsmonitor, filter.*.smudge,
# diff.external, includeIf, hooks) in the local config that would fire
# inside THIS job's PAT-bearing git steps. It keeps
# a known-safe allowlist and unsets everything else, hardened against
# the worktree-config and global-hooksPath bypasses verified in
# qwen-triage on this pool. No-op on a fresh hosted runner.
- name: 'Sanitize workspace git config'
run: |-
set -uo pipefail
# `.git` is a directory in a normal checkout but a gitlink file in
# a worktree; -e covers both, and a missing .git (first run) too.
if [ ! -e .git ]; then
echo "no prior workspace; nothing to sanitize"
exit 0
fi
# Worktree-scoped config FIRST: `extensions.worktreeConfig=true` is
# on the allowlist below (it carries no command itself), but it
# activates `.git/config.worktree` — a second config file that
# `git config --local` neither lists nor unsets, and that CAN carry
# core.hooksPath. Verified in qwen-triage: a prior run can set
# `--worktree core.hooksPath=/`, survive the sweep untouched, and
# make the hooks deletion below walk /. Delete the file outright,
# then drop the extension.
rm -f "$(git rev-parse --git-path config.worktree 2>/dev/null || echo /nonexistent)" 2>/dev/null || true
git config --local --unset-all extensions.worktreeConfig 2>/dev/null || true
# Rather than denylist each exec-vector family (which kept missing
# new ones), KEEP a known-safe allowlist and --unset-all everything
# else: this closes the whole class, including knobs not yet
# enumerated. The kept set is only plumbing that carries no command
# — repo format, remote, branch, fetch/gc/pack/index, safe.directory,
# extensions, and submodule url/active/branch (NOT
# submodule.*.update, which can be `!cmd`). actions/checkout
# re-establishes remote/auth afterward. `|| true` on the grep: no
# non-allowlisted keys (the steady state on an already-sanitized
# runner) means grep exits 1, which would kill the step exactly
# when there is nothing to clean.
git config --local --name-only --list 2>/dev/null \
| { grep -ivE '^(core\.(repositoryformatversion|bare|filemode|symlinks|ignorecase|precomposeunicode|logallrefupdates|worktree|hidedotfiles|protecthfs|protectntfs)|remote\.[^.]+\.(url|fetch|pushurl)|branch\.|extensions\.|gc\.|pack\.|fetch\.|index\.|safe\.|submodule\.[^.]+\.(url|active|branch))' || true; } \
| while IFS= read -r key; do git config --local --unset-all "$key" 2>/dev/null || true; done
# Belt and braces after the config scrub: only delete inside the
# repository's own git dir. A hooks path resolving anywhere else is
# unlinked, never swept — a recursive delete of a planted path is
# far worse than a stale hook on a runner the pool re-cleans.
# Resolve hooks with global/system config OUT of the way. Verified
# in qwen-triage: with a global core.hooksPath set, `git rev-parse
# --git-path hooks` returns that path, the guard below sees
# "outside the git dir", and a planted `.git/hooks` symlink
# survives untouched. Keep this resolution AFTER the sweep above.
GIT_DIR_ABS="$(git rev-parse --absolute-git-dir 2>/dev/null || echo '')"
HOOKS_DIR="$(GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git rev-parse --git-path hooks 2>/dev/null || echo .git/hooks)"
HOOKS_ABS="$(cd "$HOOKS_DIR" 2>/dev/null && pwd -P || echo '')"
if [ -n "$GIT_DIR_ABS" ] && [ -n "$HOOKS_ABS" ] && [ "${HOOKS_ABS#"$GIT_DIR_ABS"/}" != "$HOOKS_ABS" ]; then
# Match -type f OR -type l: a symlinked hook survives a bare
# `-type f` sweep and still fires on the next checkout.
find "$HOOKS_ABS" \( -type f -o -type l \) ! -name '*.sample' -delete 2>/dev/null || true
else
# Resolves outside the git dir (or not at all). Warning and
# walking away would leave a live hook directory that the next
# git command executes, so unlink the ENTRY without descending
# into it and put an empty hooks directory back.
RAW_HOOKS="$(GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git rev-parse --git-path hooks 2>/dev/null || echo .git/hooks)"
echo "::warning::hooks path did not resolve inside the git dir (${HOOKS_ABS:-unresolved}); unlinking it."
rm -f "$RAW_HOOKS" 2>/dev/null || echo "::warning::refusing to recursively delete planted hooks path '$RAW_HOOKS' (a hooksPath resolving to the git dir itself would otherwise wipe .git); leaving it to the pool re-clean."
mkdir -p "${GIT_DIR_ABS:-.git}/hooks" 2>/dev/null || true
git config --local --unset-all core.hooksPath 2>/dev/null || true
fi
- name: 'Checkout'
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
@@ -682,7 +802,34 @@ jobs:
- name: 'Reset autofix workspace'
run: |-
rm -rf "${WORKDIR}"
mkdir -p "${WORKDIR}"
# 0700: the dir holds agent transcripts and decision files; the
# sandbox container runs as this same user, so the tighter mode
# costs the job nothing. umask at creation, not mkdir-then-chmod —
# the chmod form leaves a world-readable window on this shared /tmp.
(umask 077; mkdir -p "${WORKDIR}")
# Age-sweep abandoned run-scoped dirs on this shared /tmp: a hard
# runner kill skips the always() teardown and run_id never repeats,
# so nothing else ever reclaims them.
find /tmp -maxdepth 1 -name 'autofix*' -mmin +1440 -exec rm -rf {} + 2>/dev/null || true
# The reused workspace's .git accumulates unreferenced objects
# across fetch runs on this persistent pool; prune them.
git -c gc.autoDetach=false gc --auto --prune=now --quiet 2>/dev/null || true
# Self-hosted runners keep the workspace's .git across runs, so a
# failed earlier attempt's local branch survives here: the agent's
# branch create then dies "branch already exists", or an adaptation
# checks out the stale line and pushes the failed attempt's commits
# into the new PR. Drop them deterministically (refs survive
# actions/checkout's untracked-file clean).
- name: 'Drop stale autofix branches'
run: |-
# Detach first: `git branch -D` refuses the currently checked-out
# branch, so a stale autofix branch holding HEAD would otherwise
# silently survive the sweep (actions/checkout normally leaves
# HEAD on the default branch; this makes the sweep unconditional).
git checkout --detach 2>/dev/null || true
git for-each-ref --format='%(refname:short)' "refs/heads/${BRANCH_PREFIX}*" \
| xargs -r -n 1 git branch -D 2>/dev/null || true
# Same staging as the review-address job: the verify gate always runs the
# trusted checkout's copy of the schema gate, never a working-tree copy.
@@ -717,18 +864,39 @@ jobs:
- name: 'Check runner environment'
env:
RUNNER_ENVIRONMENT: '${{ runner.environment }}'
RUNNER_NAME: '${{ runner.name }}'
run: |-
case "${RUNNER_ENVIRONMENT}" in
github-hosted) ;;
github-hosted|self-hosted) ;;
*)
echo "::error::Unsupported runner environment: ${RUNNER_ENVIRONMENT:-unset}."
exit 1
;;
esac
# The label routing pins ecs-qwen, but a mis-labelled registration
# must not silently claim a PAT-bearing 300-minute job — assert the
# pool by name on the self-hosted branch too.
if [[ "${RUNNER_ENVIRONMENT}" == 'self-hosted' ]]; then
case "${RUNNER_NAME}" in
ecs-qwen-*) ;;
*)
echo "::error::self-hosted runner '${RUNNER_NAME}' is not an ecs-qwen pool member; refusing to run here."
exit 1
;;
esac
fi
# Capability preflight for the persistent pool: this job's agent
# runs inside the docker sandbox, and a missing daemon otherwise
# surfaces only at 'Resolve sandbox image' — after npm ci/build
# has already burned tens of minutes. Fail in seconds instead.
# Hosted runners ship docker; the ECS pool's docker is proven by
# qwen-triage's container jobs on the same labels.
if ! docker info > /dev/null 2>&1; then
echo "::error::docker daemon is not reachable on this runner; the sandboxed agent cannot start."
exit 1
fi
- name: 'Set up Node.js (hosted)'
if: |-
${{ runner.environment == 'github-hosted' }}
- name: 'Set up Node.js'
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '22.x'
@@ -740,8 +908,13 @@ jobs:
if command -v tmux > /dev/null 2>&1; then
tmux -V
elif command -v sudo > /dev/null 2>&1 && command -v apt-get > /dev/null 2>&1; then
sudo apt-get update -qq
sudo apt-get install -y -qq tmux
# sudo -n: a host without passwordless sudo must fail fast with a
# clear message, not die on a password prompt (the pr-review pool
# steps make the same assumption with `sudo -n ... || ::warning`).
sudo -n apt-get update -qq && sudo -n apt-get install -y -qq tmux || {
echo '::error::tmux is required on the autofix runner and passwordless install failed.'
exit 1
}
else
echo '::error::tmux is required on the autofix runner.'
exit 1
@@ -786,6 +959,8 @@ jobs:
id: 'scan'
env:
GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}'
# Must resolve to the same issue as this job's concurrency group
# expression; a test pins the two equal.
FORCED_ISSUE: '${{ needs.route.outputs.issue_number || github.event.issue.number }}'
run: |-
mkdir -p "${WORKDIR}"
@@ -1260,7 +1435,7 @@ jobs:
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
with:
name: 'autofix-issue-artifacts'
path: '/tmp/autofix/'
path: '${{ env.WORKDIR }}/'
if-no-files-found: 'ignore'
- name: 'Publish PR'
@@ -1297,9 +1472,14 @@ jobs:
fi
BRANCH="autofix/issue-${ISSUE}"
git config --local --unset-all http.https://github.com/.extraheader || true
git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${REPO}.git"
git config core.hooksPath /dev/null
git push --no-verify origin "${BRANCH}"
# Authenticate the push with a one-shot, host-scoped credential
# helper via `git -c`: nothing is written to the reused
# workspace's .git/config (no error path can strand it there),
# argv holds only the literal ${GITHUB_TOKEN} reference, and the
# host scope means it cannot answer a non-GitHub URL.
git -c credential."https://github.com".helper='!f(){ echo username=x-access-token; echo "password=${GITHUB_TOKEN}"; };f' \
push --no-verify "https://github.com/${REPO}.git" "${BRANCH}"
PR_URL="$(gh pr create --repo "${REPO}" \
--base main --head "${BRANCH}" \
@@ -1372,6 +1552,16 @@ jobs:
gh api -X DELETE "/repos/${REPO}/issues/comments/${COMMENT_ID}" || true
fi
# Nothing else removes the per-run WORKDIR; on the persistent pool
# every run would leave its transcripts and decision files behind
# forever. Last step, after every reader including the artifact
# upload. always() covers cancellation too; only a hard runner kill
# abandons the dir, and run_id never repeats — the age sweep in
# Reset autofix workspace reclaims it.
- name: 'Clean up autofix workdir'
if: 'always()'
run: 'rm -rf "${WORKDIR}"'
# ===========================================================================
# TAKEOVER COMMAND — the accepted comment command's side effects are the
# label toggle plus its own acknowledgements: add on an unlabeled PR
@@ -2604,13 +2794,91 @@ jobs:
needs: ['route', 'review-scan']
if: |-
${{ needs.review-scan.outputs.has_targets == 'true' }}
runs-on: 'ubuntu-latest'
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event_name != ''pull_request'' && github.event_name != ''pull_request_review'' || github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON(''["OWNER","MEMBER","COLLABORATOR"]''), github.event.pull_request.author_association))) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}'
timeout-minutes: 30
permissions:
contents: 'read'
outputs:
base_sha: '${{ steps.meta.outputs.base_sha }}'
steps:
# Self-hosted runners reuse the workspace; a prior containerised job
# can leave root-owned, read-only files anywhere in it. Restore
# ownership and write permission unconditionally before checkout.
- name: 'Restore workspace ownership'
run: |-
set -uo pipefail
RUNNER_UID="$(id -u)"
RUNNER_GID="$(id -g)"
if [ "$RUNNER_UID" != "0" ]; then
chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files"
fi
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files"
# Same sweep as the agent jobs: this checkout runs in a workspace
# other pool jobs have written, and a surviving filter.*.smudge or
# hook fires during it — in the job that builds the CLI bundle every
# review-address leg then executes with a PAT in env.
- name: 'Sanitize workspace git config'
run: |-
set -uo pipefail
# `.git` is a directory in a normal checkout but a gitlink file in
# a worktree; -e covers both, and a missing .git (first run) too.
if [ ! -e .git ]; then
echo "no prior workspace; nothing to sanitize"
exit 0
fi
# Worktree-scoped config FIRST: `extensions.worktreeConfig=true` is
# on the allowlist below (it carries no command itself), but it
# activates `.git/config.worktree` — a second config file that
# `git config --local` neither lists nor unsets, and that CAN carry
# core.hooksPath. Verified in qwen-triage: a prior run can set
# `--worktree core.hooksPath=/`, survive the sweep untouched, and
# make the hooks deletion below walk /. Delete the file outright,
# then drop the extension.
rm -f "$(git rev-parse --git-path config.worktree 2>/dev/null || echo /nonexistent)" 2>/dev/null || true
git config --local --unset-all extensions.worktreeConfig 2>/dev/null || true
# Rather than denylist each exec-vector family (which kept missing
# new ones), KEEP a known-safe allowlist and --unset-all everything
# else: this closes the whole class, including knobs not yet
# enumerated. The kept set is only plumbing that carries no command
# — repo format, remote, branch, fetch/gc/pack/index, safe.directory,
# extensions, and submodule url/active/branch (NOT
# submodule.*.update, which can be `!cmd`). actions/checkout
# re-establishes remote/auth afterward. `|| true` on the grep: no
# non-allowlisted keys (the steady state on an already-sanitized
# runner) means grep exits 1, which would kill the step exactly
# when there is nothing to clean.
git config --local --name-only --list 2>/dev/null \
| { grep -ivE '^(core\.(repositoryformatversion|bare|filemode|symlinks|ignorecase|precomposeunicode|logallrefupdates|worktree|hidedotfiles|protecthfs|protectntfs)|remote\.[^.]+\.(url|fetch|pushurl)|branch\.|extensions\.|gc\.|pack\.|fetch\.|index\.|safe\.|submodule\.[^.]+\.(url|active|branch))' || true; } \
| while IFS= read -r key; do git config --local --unset-all "$key" 2>/dev/null || true; done
# Belt and braces after the config scrub: only delete inside the
# repository's own git dir. A hooks path resolving anywhere else is
# unlinked, never swept — a recursive delete of a planted path is
# far worse than a stale hook on a runner the pool re-cleans.
# Resolve hooks with global/system config OUT of the way. Verified
# in qwen-triage: with a global core.hooksPath set, `git rev-parse
# --git-path hooks` returns that path, the guard below sees
# "outside the git dir", and a planted `.git/hooks` symlink
# survives untouched. Keep this resolution AFTER the sweep above.
GIT_DIR_ABS="$(git rev-parse --absolute-git-dir 2>/dev/null || echo '')"
HOOKS_DIR="$(GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git rev-parse --git-path hooks 2>/dev/null || echo .git/hooks)"
HOOKS_ABS="$(cd "$HOOKS_DIR" 2>/dev/null && pwd -P || echo '')"
if [ -n "$GIT_DIR_ABS" ] && [ -n "$HOOKS_ABS" ] && [ "${HOOKS_ABS#"$GIT_DIR_ABS"/}" != "$HOOKS_ABS" ]; then
# Match -type f OR -type l: a symlinked hook survives a bare
# `-type f` sweep and still fires on the next checkout.
find "$HOOKS_ABS" \( -type f -o -type l \) ! -name '*.sample' -delete 2>/dev/null || true
else
# Resolves outside the git dir (or not at all). Warning and
# walking away would leave a live hook directory that the next
# git command executes, so unlink the ENTRY without descending
# into it and put an empty hooks directory back.
RAW_HOOKS="$(GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git rev-parse --git-path hooks 2>/dev/null || echo .git/hooks)"
echo "::warning::hooks path did not resolve inside the git dir (${HOOKS_ABS:-unresolved}); unlinking it."
rm -f "$RAW_HOOKS" 2>/dev/null || echo "::warning::refusing to recursively delete planted hooks path '$RAW_HOOKS' (a hooksPath resolving to the git dir itself would otherwise wipe .git); leaving it to the pool re-clean."
mkdir -p "${GIT_DIR_ABS:-.git}/hooks" 2>/dev/null || true
git config --local --unset-all core.hooksPath 2>/dev/null || true
fi
- name: 'Checkout trusted base'
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
@@ -2618,9 +2886,7 @@ jobs:
fetch-depth: 1
persist-credentials: false
- name: 'Set up Node.js (hosted)'
if: |-
${{ runner.environment == 'github-hosted' }}
- name: 'Set up Node.js'
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '22.x'
@@ -2664,17 +2930,27 @@ jobs:
PATH="${qwen_bin}:${PATH}"
qwen --version
# Only the repo-root dist/ is shipped: copy_bundle_assets.js already
# gathers every runtime asset (chunks, vendor, web-shell, locales)
# under it. packages/*/dist would triple the artifact size and is
# never read by the legs — the verify gate's full `npm run build`
# wipes and rebuilds each package's dist from branch sources
# (build_package.js rms it first, so no staleness leaks through).
# The repo-root dist/ plus packages/core/dist are shipped:
# copy_bundle_assets.js already gathers every runtime asset (chunks,
# vendor, web-shell, locales) under the root dist/, and the remaining
# packages/*/dist would triple the artifact size without ever being
# read by the legs — the verify gate's full `npm run build` wipes and
# rebuilds each package's dist from branch sources (build_package.js
# rms it first, so no staleness leaks through). packages/core/dist is
# the exception: the settings-schema check runs BEFORE any build (on
# every path, including no-action), and its generator — tsx run from
# the repo root, whose tsconfig has NO `paths` — imports cli sources
# that resolve '@qwen-code/qwen-code-core' through the workspace
# symlink to core's dist entry point. Without it the generator crashes
# with ERR_MODULE_NOT_FOUND and the gate misreports a deterministic
# "settings schema is stale" rejection. The i18n check needs no dist:
# it runs with cwd packages/cli, whose tsconfig `paths` map the
# specifier to core's sources instead.
- name: 'Upload CLI bundle'
id: 'meta'
run: |-
echo "base_sha=$(git rev-parse HEAD)" >> "${GITHUB_OUTPUT}"
tar -czf "${RUNNER_TEMP}/qwen-cli-dist.tar.gz" dist
tar -czf "${RUNNER_TEMP}/qwen-cli-dist.tar.gz" dist packages/core/dist
- name: 'Publish CLI bundle artifact'
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
@@ -2692,7 +2968,22 @@ jobs:
needs: ['route', 'review-scan', 'build-cli']
if: |-
${{ needs.review-scan.outputs.has_targets == 'true' }}
runs-on: 'ubuntu-latest'
# Secret-bearing and executes PR code, but every target is live-gated to
# write+ (internal) authors at scan AND address time. That is an
# author-permission gate by design, not a head-repository gate: takeover
# engages maintainer fork PRs, and the pattern matches qwen-code-pr-review,
# whose ECS-routed review job also rides its upstream write+ check. The
# job therefore runs host-side (no `container:`): the branch code it
# executes is collaborator-authored — the same trust class ci.yml's
# pick_runner routes onto this pool — and persistent-workspace residue is
# scrubbed by the hygiene steps below. On pull_request /
# pull_request_review events the ECS route additionally needs a same-repo
# head or a write+ author (ci.yml's pick_runner form); issue_comment and
# the other triggers skip that clause and rely on the live write+ gates.
# Forks of this repo (and MAINTAINER_ECS_RUNNER_DISABLED) fall back to
# hosted. Docker availability on this pool is proven in-repo by
# qwen-triage's container jobs, which run on the same runner labels.
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event_name != ''pull_request'' && github.event_name != ''pull_request_review'' || github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON(''["OWNER","MEMBER","COLLABORATOR"]''), github.event.pull_request.author_association))) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}'
timeout-minutes: 300
permissions:
contents: 'read'
@@ -2778,6 +3069,88 @@ jobs:
exit 1
fi
# Self-hosted runners reuse the workspace; a prior containerised job
# can leave root-owned, read-only files anywhere in it. Restore
# ownership and write permission unconditionally before checkout.
- name: 'Restore workspace ownership'
run: |-
set -uo pipefail
RUNNER_UID="$(id -u)"
RUNNER_GID="$(id -g)"
if [ "$RUNNER_UID" != "0" ]; then
chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files"
fi
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files"
# Self-hosted runners keep the workspace between runs, and other pool
# jobs execute human-authored code as the runner user, so a prior job
# can plant git exec knobs (core.fsmonitor, filter.*.smudge,
# diff.external, includeIf, hooks) in the local config that would fire
# inside THIS job's PAT-bearing git steps. It keeps
# a known-safe allowlist and unsets everything else, hardened against
# the worktree-config and global-hooksPath bypasses verified in
# qwen-triage on this pool. No-op on a fresh hosted runner.
- name: 'Sanitize workspace git config'
run: |-
set -uo pipefail
# `.git` is a directory in a normal checkout but a gitlink file in
# a worktree; -e covers both, and a missing .git (first run) too.
if [ ! -e .git ]; then
echo "no prior workspace; nothing to sanitize"
exit 0
fi
# Worktree-scoped config FIRST: `extensions.worktreeConfig=true` is
# on the allowlist below (it carries no command itself), but it
# activates `.git/config.worktree` — a second config file that
# `git config --local` neither lists nor unsets, and that CAN carry
# core.hooksPath. Verified in qwen-triage: a prior run can set
# `--worktree core.hooksPath=/`, survive the sweep untouched, and
# make the hooks deletion below walk /. Delete the file outright,
# then drop the extension.
rm -f "$(git rev-parse --git-path config.worktree 2>/dev/null || echo /nonexistent)" 2>/dev/null || true
git config --local --unset-all extensions.worktreeConfig 2>/dev/null || true
# Rather than denylist each exec-vector family (which kept missing
# new ones), KEEP a known-safe allowlist and --unset-all everything
# else: this closes the whole class, including knobs not yet
# enumerated. The kept set is only plumbing that carries no command
# — repo format, remote, branch, fetch/gc/pack/index, safe.directory,
# extensions, and submodule url/active/branch (NOT
# submodule.*.update, which can be `!cmd`). actions/checkout
# re-establishes remote/auth afterward. `|| true` on the grep: no
# non-allowlisted keys (the steady state on an already-sanitized
# runner) means grep exits 1, which would kill the step exactly
# when there is nothing to clean.
git config --local --name-only --list 2>/dev/null \
| { grep -ivE '^(core\.(repositoryformatversion|bare|filemode|symlinks|ignorecase|precomposeunicode|logallrefupdates|worktree|hidedotfiles|protecthfs|protectntfs)|remote\.[^.]+\.(url|fetch|pushurl)|branch\.|extensions\.|gc\.|pack\.|fetch\.|index\.|safe\.|submodule\.[^.]+\.(url|active|branch))' || true; } \
| while IFS= read -r key; do git config --local --unset-all "$key" 2>/dev/null || true; done
# Belt and braces after the config scrub: only delete inside the
# repository's own git dir. A hooks path resolving anywhere else is
# unlinked, never swept — a recursive delete of a planted path is
# far worse than a stale hook on a runner the pool re-cleans.
# Resolve hooks with global/system config OUT of the way. Verified
# in qwen-triage: with a global core.hooksPath set, `git rev-parse
# --git-path hooks` returns that path, the guard below sees
# "outside the git dir", and a planted `.git/hooks` symlink
# survives untouched. Keep this resolution AFTER the sweep above.
GIT_DIR_ABS="$(git rev-parse --absolute-git-dir 2>/dev/null || echo '')"
HOOKS_DIR="$(GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git rev-parse --git-path hooks 2>/dev/null || echo .git/hooks)"
HOOKS_ABS="$(cd "$HOOKS_DIR" 2>/dev/null && pwd -P || echo '')"
if [ -n "$GIT_DIR_ABS" ] && [ -n "$HOOKS_ABS" ] && [ "${HOOKS_ABS#"$GIT_DIR_ABS"/}" != "$HOOKS_ABS" ]; then
# Match -type f OR -type l: a symlinked hook survives a bare
# `-type f` sweep and still fires on the next checkout.
find "$HOOKS_ABS" \( -type f -o -type l \) ! -name '*.sample' -delete 2>/dev/null || true
else
# Resolves outside the git dir (or not at all). Warning and
# walking away would leave a live hook directory that the next
# git command executes, so unlink the ENTRY without descending
# into it and put an empty hooks directory back.
RAW_HOOKS="$(GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git rev-parse --git-path hooks 2>/dev/null || echo .git/hooks)"
echo "::warning::hooks path did not resolve inside the git dir (${HOOKS_ABS:-unresolved}); unlinking it."
rm -f "$RAW_HOOKS" 2>/dev/null || echo "::warning::refusing to recursively delete planted hooks path '$RAW_HOOKS' (a hooksPath resolving to the git dir itself would otherwise wipe .git); leaving it to the pool re-clean."
mkdir -p "${GIT_DIR_ABS:-.git}/hooks" 2>/dev/null || true
git config --local --unset-all core.hooksPath 2>/dev/null || true
fi
- name: 'Checkout trusted base'
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
@@ -2788,7 +3161,18 @@ jobs:
- name: 'Reset autofix workspace'
run: |-
rm -rf "${WORKDIR}"
mkdir -p "${WORKDIR}"
# 0700: the dir holds agent transcripts and decision files; the
# sandbox container runs as this same user, so the tighter mode
# costs the job nothing. umask at creation, not mkdir-then-chmod —
# the chmod form leaves a world-readable window on this shared /tmp.
(umask 077; mkdir -p "${WORKDIR}")
# Age-sweep abandoned run-scoped dirs on this shared /tmp: a hard
# runner kill skips the always() teardown and run_id never repeats,
# so nothing else ever reclaims them.
find /tmp -maxdepth 1 -name 'autofix*' -mmin +1440 -exec rm -rf {} + 2>/dev/null || true
# The reused workspace's .git accumulates unreferenced objects
# across fetch runs on this persistent pool; prune them.
git -c gc.autoDetach=false gc --auto --prune=now --quiet 2>/dev/null || true
# Stage the schema gate script from the TRUSTED BASE checkout before
# "Prepare branch and feedback" switches the working tree to the PR
@@ -2820,18 +3204,39 @@ jobs:
- name: 'Check runner environment'
env:
RUNNER_ENVIRONMENT: '${{ runner.environment }}'
RUNNER_NAME: '${{ runner.name }}'
run: |-
case "${RUNNER_ENVIRONMENT}" in
github-hosted) ;;
github-hosted|self-hosted) ;;
*)
echo "::error::Unsupported runner environment: ${RUNNER_ENVIRONMENT:-unset}."
exit 1
;;
esac
# The label routing pins ecs-qwen, but a mis-labelled registration
# must not silently claim a PAT-bearing 300-minute job — assert the
# pool by name on the self-hosted branch too.
if [[ "${RUNNER_ENVIRONMENT}" == 'self-hosted' ]]; then
case "${RUNNER_NAME}" in
ecs-qwen-*) ;;
*)
echo "::error::self-hosted runner '${RUNNER_NAME}' is not an ecs-qwen pool member; refusing to run here."
exit 1
;;
esac
fi
# Capability preflight for the persistent pool: this job's agent
# runs inside the docker sandbox, and a missing daemon otherwise
# surfaces only at 'Resolve sandbox image' — after npm ci/build
# has already burned tens of minutes. Fail in seconds instead.
# Hosted runners ship docker; the ECS pool's docker is proven by
# qwen-triage's container jobs on the same labels.
if ! docker info > /dev/null 2>&1; then
echo "::error::docker daemon is not reachable on this runner; the sandboxed agent cannot start."
exit 1
fi
- name: 'Set up Node.js (hosted)'
if: |-
${{ runner.environment == 'github-hosted' }}
- name: 'Set up Node.js'
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '22.x'
@@ -2843,8 +3248,13 @@ jobs:
if command -v tmux > /dev/null 2>&1; then
tmux -V
elif command -v sudo > /dev/null 2>&1 && command -v apt-get > /dev/null 2>&1; then
sudo apt-get update -qq
sudo apt-get install -y -qq tmux
# sudo -n: a host without passwordless sudo must fail fast with a
# clear message, not die on a password prompt (the pr-review pool
# steps make the same assumption with `sudo -n ... || ::warning`).
sudo -n apt-get update -qq && sudo -n apt-get install -y -qq tmux || {
echo '::error::tmux is required on the autofix runner and passwordless install failed.'
exit 1
}
else
echo '::error::tmux is required on the autofix runner.'
exit 1
@@ -2853,7 +3263,7 @@ jobs:
# npm ci only — the build itself ran ONCE in build-cli. The legs still
# need node_modules: the agent builds/tests the PR branch, and the
# verify gate rebuilds from branch sources (wiping the restored
# packages/*/dist via build_package.js, so no base staleness leaks
# packages/core/dist via build_package.js, so no base staleness leaks
# into branch verification). The retry recipe (and the shim below) is
# duplicated in issue-autofix and build-cli; the workflow contract
# tests pin every copy in lockstep — edit them together.
@@ -2882,6 +3292,13 @@ jobs:
run: |-
tar -xzf "${RUNNER_TEMP}/cli-dist/qwen-cli-dist.tar.gz"
test -f dist/cli.js
# The verify gate's pre-build settings-schema check resolves
# '@qwen-code/qwen-code-core' through the workspace symlink to
# this entry point (the root tsconfig has no `paths`; the i18n
# check instead resolves core to sources via the packages/cli
# `paths` map); a bundle without it makes the schema generator
# crash with ERR_MODULE_NOT_FOUND.
test -f packages/core/dist/index.js
- name: 'Prepare Qwen Code CLI'
run: |-
@@ -3006,7 +3423,8 @@ jobs:
# and fine-grained PATs are documented as NOT receiving it.
# Prove push access NOW, before an agent round is spent, instead
# of 403ing at the report step after the work is done.
if ! git push --no-verify --dry-run "https://x-access-token:${GITHUB_TOKEN}@github.com/${HEAD_REPO}.git" HEAD:"${BRANCH}" > /dev/null 2>&1; then
if ! git -c credential."https://github.com".helper='!f(){ echo username=x-access-token; echo "password=${GITHUB_TOKEN}"; };f' \
push --no-verify --dry-run "https://github.com/${HEAD_REPO}.git" HEAD:"${BRANCH}" > /dev/null 2>&1; then
echo "🫥 fork push preflight failed for ${HEAD_REPO} (allow-edits grant or PAT type) — discarding without action or marker"
{
echo "stale=true"
@@ -3900,19 +4318,22 @@ jobs:
if [[ "${OUTCOME}" == "fixed" ]]; then
NEXT_ROUND="$(( ROUND + 1 ))"
git config --local --unset-all http.https://github.com/.extraheader || true
git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${REPO}.git"
# This step carries the PAT; the branch carries PR-controlled
# .husky hooks (hooksPath was pointed there so the AGENT's
# commits get checked). A pre-push hook would execute that code
# with the PAT in env — sever hooks entirely before pushing.
git config core.hooksPath /dev/null
# Authenticate push/fetch with a one-shot, host-scoped credential
# helper via a git_auth wrapper (see Publish PR) — nothing lands
# in .git/config, argv holds only the ${GITHUB_TOKEN} reference.
git_auth() { git -c credential."https://github.com".helper='!f(){ echo username=x-access-token; echo "password=${GITHUB_TOKEN}"; };f' "$@"; }
if [[ "${HEAD_REPO:-${REPO}}" != "${REPO}" ]]; then
# Push back to the FORK branch via allow-edits (PAT has push
# rights on the upstream, which GitHub extends to the fork's
# PR branch when the author ticked the box).
PUSH_URL="https://x-access-token:${GITHUB_TOKEN}@github.com/${HEAD_REPO}.git"
PUSH_URL="https://github.com/${HEAD_REPO}.git"
else
PUSH_URL="https://x-access-token:${GITHUB_TOKEN}@github.com/${REPO}.git"
PUSH_URL="https://github.com/${REPO}.git"
fi
# Salvage a race-lost push instead of discarding the run. The
# per-PR head-write concurrency group serialises THIS repo's
@@ -3931,7 +4352,7 @@ jobs:
# through to the existing failure path — same as today.
PUSH_RACE_MERGED='false'
for push_attempt in 1 2 3; do
if git push --no-verify "${PUSH_URL}" HEAD:"${BRANCH}"; then
if git_auth push --no-verify "${PUSH_URL}" HEAD:"${BRANCH}"; then
break
fi
if [[ "${push_attempt}" == 3 ]]; then
@@ -3939,7 +4360,7 @@ jobs:
exit 1
fi
echo "⚠️ push rejected (attempt ${push_attempt}) — branch moved during the run; merging the new head and retrying"
if ! git fetch "${PUSH_URL}" "refs/heads/${BRANCH}"; then
if ! git_auth fetch "${PUSH_URL}" "refs/heads/${BRANCH}"; then
echo "::error::could not fetch the moved head (attempt ${push_attempt}) — cannot salvage this push"
exit 1
fi
@@ -4788,3 +5209,14 @@ jobs:
gh api --method PATCH "repos/${REPO}/issues/comments/${STATUS_ID}" \
-f body="${BODY}" > /dev/null ||
echo "::warning::Failed to finalize the autofix status comment on PR #${PR}; continuing."
# Nothing else removes the per-target WORKDIR; PR numbers only
# increase, so on the persistent pool every addressed PR would leave
# its transcripts and decision files behind forever. Last step, after
# every reader including the artifact upload. always() covers
# cancellation too; a dir abandoned by a hard runner kill is reclaimed
# by the next same-PR run's reset, or by the age sweep in Reset
# autofix workspace if that PR is never addressed again.
- name: 'Clean up autofix workdir'
if: 'always()'
run: 'rm -rf "${WORKDIR}"'
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
if: "${{ github.repository == 'QwenLM/qwen-code' }}"
strategy:
matrix:
runner: ['ecs-update-sg', 'ecs-update-64c']
runner: ['ecs-update-sg', 'ecs-update-64c', 'ecs-update-hk-1', 'ecs-update-hk-2']
fail-fast: false
runs-on: ['self-hosted', 'linux', 'x64', '${{ matrix.runner }}']
concurrency:
@@ -0,0 +1,117 @@
# Secondary artifact workspace ownership
Issue: https://github.com/QwenLM/qwen-code/issues/8494
## Baseline
- Global CLI: `qwen 0.21.3`
- `qwen serve --help` confirms repeated `--workspace` registration and Web
Shell serving are available.
- Current component behavior is captured failure-first: an artifact tab with
no owner uses root workspace actions, and scheduled-task detail omits the
secondary `workspaceId`.
## Local setup
1. Create isolated primary and secondary temporary workspaces.
2. Put the same relative sentinel filename in both workspaces with different
contents.
3. Create distinct durable scheduled-task fixtures for both runtimes.
4. Start the built `dist/cli.js serve` on loopback with both `--workspace`
flags and the production Web Shell bundle.
## Scenarios
### Secondary file action
Open a secondary-session artifact and download/preview its sentinel.
Expected:
- The request targets `/workspaces/<secondary>/file...`.
- The returned bytes are the secondary sentinel, never the primary sentinel.
### Secondary scheduled task
Open the secondary durable task, toggle it, edit it, and delete the test copy.
Expected:
- Every request targets `/workspaces/<secondary>/scheduled-tasks...`.
- The primary task and primary task file are unchanged.
### Ownership loss
Keep an artifact tab open, then remove or mark the secondary workspace
unavailable in the capability fixture before a delayed read resolves.
Expected:
- The panel displays the localized workspace-unavailable state.
- The delayed response is ignored.
- No request is retried against the primary route.
## Evidence
- Focused test output for resolver, turn outputs, artifact panel, nested
subagent, and app tab propagation.
- Production build/typecheck/lint output.
- Captured two-workspace HTTP route log and sentinel hashes.
- Browser screenshot of the secondary artifact panel and the fail-closed
ownership-loss state when the local browser harness can represent it.
## Results (2026-08-04)
Result: PASS
### Automated verification
- Failure-first run: 3 expected failures and 29 existing passes. The primary
sentinel leaked into an ownerless tab, the secondary qualified client was
unused, and scheduled-task list omitted `workspaceId`.
- Focused ownership suite: 413/413 tests passed across the six affected test
files.
- Full Web Shell suite: 167 files and 2,777 tests passed.
- Web Shell lint and TypeScript typecheck passed.
- Package Web Shell build and repository root production build passed.
- The changed files pass Prettier. The package-wide format check still reports
five unchanged baseline files:
`BranchPickerPopover.module.css`, `GitModePopover.module.css`,
`GitDialog.module.css`, `PlanExecutionView.module.css`, and `index.html`.
### Production two-workspace verification
The built CLI served the copied production bundle with two trusted workspaces.
`GET /capabilities` advertised distinct primary and secondary runtime IDs, and
the served JavaScript contained the new stale-owner guard.
The same relative file was read through both routes:
- `GET /file?path=artifact-owner.txt` returned
`PRIMARY_WORKSPACE_SENTINEL_8494`, SHA-256
`818d5f4f1fb9c7e3f9bdfd9a3ad39361c93a23ca3f3d0ba5e4a7c889b33b9127`.
- `GET /workspaces/<secondary-id>/file?path=artifact-owner.txt` returned
`SECONDARY_WORKSPACE_SENTINEL_8494`, SHA-256
`0e9fac7909b16ff8b17014ea103ed5018c8b4e9ad21d2f95e128fef3a3544a12`.
- The secondary bytes route returned only the secondary sentinel bytes.
Durable task CRUD was exercised against isolated primary and secondary task
fixtures:
- Secondary list, update, and delete requests all used
`/workspaces/<secondary-id>/scheduled-tasks...`.
- The secondary update changed only the secondary name/enabled state.
- The primary task remained present, enabled, and unchanged after that update.
- Deleting the secondary task emptied only the secondary list; the primary
task was still present. Both test tasks were then removed.
The daemon request log independently recorded the qualified file, bytes,
scheduled-task POST/GET/PATCH/DELETE routes and their successful statuses.
### Visual evidence
The in-app browser runtime reported no available browser instance, so a UI
screenshot could not be captured in this environment. No synthetic screenshot
was substituted. The production server returned the current Web Shell bundle
with HTTP 200, and the DOM behavior is covered by the focused and full suites
above.
+97
View File
@@ -0,0 +1,97 @@
# Terminal Inline Images E2E Plan
## Baseline
1. Start the interactive CLI from `main`.
2. Return an assistant response containing
`text -> inlineData(image/png) -> text`.
3. Run a tool whose `functionResponse.parts` contains an `image/png`.
4. Confirm that `main` omits the assistant image and reduces tool media to
text, while the separate `display_image` tool from #8217 can display a
workspace PNG path.
The global `qwen` executable is unavailable in the current environment, so the
baseline is grounded in issue #8090 and the unchanged `main` event mapping.
## Verification
### Shared renderer regression
1. Ask the model to call `display_image` for a workspace PNG.
2. In direct Kitty or Ghostty, confirm the existing native preview still
renders.
3. In a non-native terminal with `chafa` installed, confirm the existing ANSI
preview still renders.
4. Confirm the file-path tool retains its workspace, file-size, and PNG
validation behavior.
### Inline assistant and tool images
1. Return a 1x1 PNG between two assistant text parts.
2. Confirm the transcript order is `text -> image -> text`.
3. Return a PNG in a successful tool's top-level and nested
`functionResponse.parts`.
4. Confirm the successful tool row retains its images.
5. Open Ctrl+O and resume the session; confirm successful tool image order is
reconstructed from persisted parts. Assistant output resumes its persisted
text; assistant inline images are not persisted by the current Core recorder.
6. Return six images in one assistant output and one tool response; confirm the
first four render and the row ends with `[+2 more images]`.
### Kitty/Ghostty
1. Start the CLI in direct Kitty or Ghostty without tmux or SSH.
2. Repeat the assistant and tool cases.
3. Confirm inline PNGs use the same virtual placement and Unicode placeholders
as `display_image`.
4. Confirm remounting a history row does not retransmit an already-written
payload.
### chafa
1. Start the CLI in Warp, iTerm2, tmux, SSH, or another non-native environment
with `chafa` installed.
2. Repeat the assistant and tool cases.
3. Confirm PNG bytes render as ANSI symbol rows and stay aligned during normal
scrolling.
4. Confirm image data is supplied through stdin and no model-controlled value
is used as a command argument.
### Placeholders and accessibility
1. Repeat without `chafa` and outside direct Kitty/Ghostty.
2. Confirm a valid PNG displays `[image: <width>x<height> png]`.
3. Repeat with malformed base64, a payload above 8 MiB, invalid IHDR
dimensions, and a non-PNG MIME type.
4. Confirm no raw image sequence is written. Confirm the oversized payload is
dropped before UI history, while admitted malformed/non-PNG data uses a
deterministic placeholder.
5. Repeat with `INK_SCREEN_READER=true`; confirm only the placeholder is
emitted.
### Stream lifecycle
1. Exercise a fresh retry, continuation retry, model fallback, cancellation,
stream boundary, tool call boundary, and displayed goal-state event.
2. Confirm fresh attempts discard all staged image/text runs from the failed
attempt.
3. Confirm continuations preserve partial output.
4. Confirm every normal boundary commits earlier runs before later status or
tool rows.
## Automated Evidence
Record final results for:
- focused CLI renderer, component, stream, tool, resume, and compaction tests;
- Core `Turn`, `display_image`, config, scheduler, and tool tests;
- `npm run lint:ci`;
- `npm run typecheck`;
- `npm run build`;
- `npm run check:serve-fast-path-bundle`.
Real-terminal output remains a reviewer hardware step when the local environment
has no Kitty/Ghostty session. Reusing #8217's renderer means its existing manual
Kitty, Ghostty, cmux, and Warp evidence continues to cover the terminal protocol
layer; this plan focuses new manual verification on the inline-data entry point
and transcript lifecycle.
@@ -0,0 +1,140 @@
# SSE Stream and Client Observability
## Context
`GET /session/:id/events` currently exposes a long-lived REST/SSE stream but
does not give an operator enough stable identity to reconstruct a connection
timeline. The daemon logs stream open and close, while EventBus logs slow
subscriber warnings and evictions independently. Those records cannot be
joined to a particular physical stream, and an SDK reconnect does not explain
why it opened a replacement stream.
This design adds diagnostic-only identity and lifecycle records. It does not
change replay, reconnect, backpressure, subscriber eviction, or stream
supersession behavior.
## Wire contract
The REST/SSE subscription accepts the existing `X-Qwen-Client-Id` request
header plus two optional query parameters:
- `connectReason`: `initial`, `resume`, `prompt_restart`, `stream_end`,
`transport_error`, `state_resync`, or `unknown`.
- `previousStreamId`: the UUID of the previous accepted REST/SSE stream as
reported by the client.
A successful subscription returns `X-Qwen-SSE-Stream-Id`, a daemon-generated
UUID. CORS exposes this response header. Invalid diagnostic values are ignored
or normalized to `unknown`; they never reject a subscription. Connect reason
and predecessor are untrusted diagnostic hints and must never drive auth,
eviction, deduplication, or supersession.
Old daemons ignore the new query parameters. Old clients omit them and ignore
the response header. No capability negotiation is required.
## EventBus diagnostics
`SubscribeOptions` gains a synchronous `onSubscriberDiagnostic` callback. It
receives a discriminated `slow_client_warning` or `client_evicted` record with
the existing queue measurements plus the type and serialized size of the event
that crossed the threshold. It never receives the event payload.
The callback returns whether it emitted the human-facing diagnostic. A false
return or exception preserves the existing EventBus stderr line. Callback
failures remain inside EventBus so `publish()` keeps its never-throws contract.
EventBus remains unaware of session, client, and stream identity; the SSE route
adds that context in its callback closure.
## Lifecycle telemetry
Each accepted stream produces `opened` and `closed` lifecycle records. Slow
subscriber warnings, evictions, and `state_resync_required` frames produce
additional records. Their OTel event names are:
- `qwen-code.daemon.sse.opened`
- `qwen-code.daemon.sse.slow_client_warning`
- `qwen-code.daemon.sse.client_evicted`
- `qwen-code.daemon.sse.state_resync_required`
- `qwen-code.daemon.sse.closed`
The route captures its request telemetry context before installing the
EventBus callback. Every lifecycle log runs under that captured context so a
warning emitted from a model/publisher call is parented to the correct
long-lived SSE request span.
The common attributes are `session.id`, `qwen-code.client_id`,
`qwen-code.daemon.sse.stream_id`,
`qwen-code.daemon.sse.client_reported_connect_reason`, and
`qwen-code.daemon.sse.client_reported_previous_stream_id`. Stream/client ids
remain span and log attributes only; they are not metric labels.
The close record and request span also receive stream duration, settled event
frame count, last written event id, backpressure count, maximum drain wait,
maximum live publish-to-write-settled time, slow-warning count, EventBus
eviction reason, terminal event type, and close reason. The duration attribute
and all other close attributes are fully namespaced. In particular, duration
must not use the special bare `duration_ms` attribute interpreted by the
Log-to-Span bridge as a span start duration.
The exact close attributes use the `qwen-code.daemon.sse.*` namespace:
`duration_ms`, `event_frames_write_settled`, `last_event_id_written`,
`backpressure_count`, `max_drain_wait_ms`,
`max_live_publish_to_write_settled_ms`, `slow_warning_count`,
`event_bus_eviction_reason`, `terminal_event_type`, and `close_reason`. Close
reason is one of `writer_idle_timeout`, `socket_error`, `iterator_error`,
`event_bus_evicted`, `session_terminal`, `source_complete`, or
`client_disconnect`.
Replay timestamps are excluded from live lag. A resumed stream begins live
lag measurement only after its `replay_complete` frame settles; a fresh stream
starts immediately. The measurement describes daemon write settlement, not
browser consumption.
## SDK ownership
Raw SDK subscription options expose client identity, connect reason,
predecessor, and an accepted-stream callback. `DaemonSessionClient` owns the
identity and lineage fields for session-scoped callers. It tracks successful
REST handshake state separately from the latest response stream id so a
successful old-daemon handshake without the new header is followed by
`resume`, not another `initial`.
Lineage covers adjacent accepted REST/SSE streams on the same session client.
Switching to ACP HTTP or WebSocket clears REST lineage. WebUI supplies only the
exceptional reasons it can prove (`prompt_restart`, `stream_end`,
`transport_error`, `state_resync`); the session client infers ordinary
`initial` and `resume` connections.
## Gateway and rollout
A gateway must preserve `X-Qwen-Client-Id`, both query parameters, existing
trace context, `X-Qwen-SSE-Stream-Id`, and the exposed-header list. It must
continue unbuffered, uncompressed event-stream forwarding. Gateway indexes
should cover session, client, stream, predecessor, connect reason, close
reason, queue/backpressure measurements, and bounded trigger event fields.
They must not contain authorization data, tokens, or SSE payloads.
Operational queries apply these interpretations:
- Consecutive `prompt_restart` streams with complete predecessor links and no
backpressure indicate client-driven restart churn.
- Warnings or evictions accompanied by high drain wait indicate a slow client
or buffering proxy.
- A byte threshold with a large trigger event points to a large-frame problem;
a frame threshold with small events points to many-frame accumulation.
- `state_resync_required` is a replay/ring gap and is not evidence of live
network lag.
- A long request span with no warning or backpressure is a normal quiet
long-lived connection.
Roll out daemon/EventBus support first, gateway forwarding second, and
SDK/WebUI attribution last. This yields useful server-side lifecycle records
for old clients while retaining full cross-version compatibility.
## Non-goals
- Active-stream lists in daemon status.
- Host or WebUI telemetry callbacks.
- Per-event logs or high-cardinality metric labels.
- Automatic replacement of overlapping streams.
- ACP HTTP or WebSocket stream observability.
File diff suppressed because it is too large. Load diff
+52
View File
@@ -0,0 +1,52 @@
# Review repository context
## Problem
The review pipeline needs a bounded way for repositories to declare review guidance without teaching shared roster, prompt, coverage, and composition code about individual projects. Repository metadata is security-sensitive for pull request reviews because the reviewed branch must not be able to opt into or remove trusted context.
## Manifest
A repository may provide strict JSON at `.qwen/review-context.json`:
```json
{
"version": 1,
"label": "Example repository",
"rules": [
{
"paths": ["packages/*/src/**"],
"relatedPaths": ["packages/cli/src/commands/review/**"],
"domains": ["runtime"],
"recommendedTests": ["test:runtime"],
"requiredConfigurations": ["debug"],
"requiredAgents": ["test-matrix"],
"unverifiedDimensions": ["Alternate configuration"],
"verificationNotes": ["Run the repository-native focused tests"]
}
]
}
```
The top-level fields are exactly `version`, `label`, and `rules`. Each rule requires `paths`; all other rule fields are optional. Unknown or missing required fields, comments, unsupported versions, oversized values, control characters, and duplicate array entries are rejected. Arrays are human-authored and may be written in any order; values from all matching rules are merged, deduplicated, and returned sorted and unique (the internal wire format keeps the strict sorted-and-unique check). Rule order is preserved. The total `paths` globs across all rules, the merged `relatedPaths` glob list, and every merged field are capped at the wire bounds and rejected fail-closed, so a matching burst cannot stall the step or outgrow the contract. Note the example's `relatedPaths` wildcard is scoped to one subsystem on purpose: wildcard `relatedPaths` are subject to the 128 resolved-file bound below, and a repository-wide scope like `packages/*/src/**` exceeds it on a repository this size.
`paths` and `relatedPaths` use repository-relative `/`-separated globs. Matching is case-sensitive on every platform and `?` consumes one UTF-16 code unit. The supported metacharacters are `*`, `?`, and a complete `**` path segment. Absolute paths, backslashes, empty or `.`/`..` segments, negation, brace expansion, character classes, and extended glob syntax are rejected.
A rule matches when any changed path matches one of its `paths` globs. If no rule matches, the provider returns no context. A matching rule's deduplicated `relatedPaths` globs are expanded from the worktree with dot files enabled, directory results disabled, symlink traversal disabled, and case-sensitive matching. Related globs containing wildcards must start with a non-wildcard directory segment so expansion cannot begin with a repository-wide wildcard; a completely static entry resolves to itself when it exists as a regular file. Globs whose path enters a dependency or build-output directory at any depth are rejected at validation (compared case-insensitively, on every platform), so the never-descend invariant holds for scan roots as well as recursion. Changed paths are removed from the result. Resolved files must remain inside the worktree. Expansion never descends into dependency and build-output trees (`node_modules`, `dist`, and the other conventional names the scan skips) and fails closed when any limit is exceeded: 16384 visited entries across the scan (files and directories, matching or not — calibrated on this repository's installed checkout, so a honestly scoped subtree, including all of `packages/`, never trips it), 128 resolved files in the result, and a matching-work budget charged per attempted pattern match (pattern length times path length) in both the rule filter and the expansion, which reports the matching-work limit and keeps a matching burst from stalling the step.
## Trust boundary
`repo-context` reads the fixed manifest path through `RepositoryContextProviderInput.readIdentityFile`. For pull request plans, the manifest therefore comes only from the trusted merge-base commit recorded by the fetch stage. The pull request head cannot opt in, opt out, or change the rules. For local plans, the manifest comes from the current worktree after safe-relative-path validation and realpath containment.
Identity reads return the same shape in both modes (CRLF normalised to LF, surrounding whitespace trimmed) and are capped at one megabyte, fail closed: an absent file yields `null`, but a file that is present, unreadable, or oversized throws rather than masquerading as "not this repository". Both modes follow a symlinked identity file itself under the same containment rule, and a directory yields nothing in both. A pull request plan whose merge base never resolved (`mergeBaseSha: null`) — or whose base fetch failed, leaving the recorded sha possibly stale — writes a `null` artifact without consulting the worktree at all: falling back to the worktree would read the manifest from the PR head, the exact read this boundary forbids, and a possibly stale sha is not a trusted source either.
Three residuals are recorded so the guarantee is not overstated. First, for pull request plans the RULES come from the merge base, but `relatedPaths` globs are expanded against the head worktree, so the head still decides which files the base's globs resolve to; impact is low because reviewers read the head tree anyway. Second, local reviews read the manifest from the current worktree, so reviewing an untrusted repository lets that repository put one bounded, control-character-free block of guidance — the label plus six capped arrays — into every code-reviewing brief; the one-megabyte read ceiling, the validation bounds, and inert rendering are the mitigation. Third, the two modes resolve identity symlinks with different engines: the pull request reader never descends through a symlinked intermediate path COMPONENT (the worktree reader does), and it caps identity symlink chains at 16 hops where the kernel resolves up to ~40 — throwing at the cap rather than degrading. A repository committing `.qwen` itself as a symlink to an in-tree directory therefore attaches context in local reviews and never in pull request reviews; the direction is fail-safe (strictly less, never more), but an operator diagnosing "context attaches locally but never on PRs" should know the asymmetry is by construction.
The manifest provider is statically registered in-process and returns the generic `RepositoryContext` shape with provider `manifest`. Its complete output passes through the shared `validateRepositoryContext` validator before downstream consumers use it. No dynamic plugin registry, shell execution, templates, or opaque payloads are supported.
## Review workflow
Medium- and high-effort local and same-repository pull request reviews invoke `repo-context` after the review plan is captured. The command receives absolute plan, worktree, and output paths. Low-effort reviews and cross-repository lightweight reviews skip repository context because they do not run the full local-tree workflow.
Code-review agents receive the generic context headed by its label. The build-and-test role receives recommended tests, required configurations, and verification notes. Required roles are merged into the normal roster without duplication, and only when the review's effort, topology, and mode already permit them — a manifest cannot inflate a medium review with the adversarial personas, re-add whole-diff walkers to a chunked fan-out, or demand a tree-grepping role from a review with no tree. Composition discloses unverified dimensions as non-blocking proof boundaries; a present-but-invalid context fails every consumer closed rather than being silently dropped anywhere.
Status: this is the foundation — the contract, the command, and the downstream consumers, exercised by unit tests and the review skill. No `.qwen/review-context.json` ships with this change, so nothing beyond tests runs end to end until a repository adopts one.
+138
View File
@@ -0,0 +1,138 @@
# Terminal Inline Images
## Problem
The interactive CLI drops model `inlineData` image parts at the
`Turn`-to-TUI boundary. Images nested in tool `functionResponse.parts`
survive in model history, but the tool display reduces them to text. As a
result, image-generating models and screenshot-producing tools cannot show
their output in the conversation.
PR #8217 introduced the path-based `display_image` tool and established the
project's terminal image infrastructure: `TerminalImage`,
`terminal-image-renderer`, native Kitty/Ghostty placement, and `chafa`
symbol output. This change extends that infrastructure to in-memory model and
tool image parts instead of adding another renderer.
## Scope
This is the render-and-forget slice requested by issue #8090:
- preserve ordered text and image parts on content events without changing the
existing concatenated `value` contract;
- render live assistant PNGs and restored successful tool PNGs through the
#8217 component and renderer;
- render PNGs nested in successful tool responses;
- keep text/image ordering across retry, model fallback, cancellation, stream
boundaries, and goal-state events;
- bound retained image payloads during UI history compaction;
- render at most four images per assistant output or tool row and collapse the
remainder into a `[+K more images]` marker;
- show a deterministic text placeholder when an image cannot be rendered.
Kitty deletion, resize-driven replacement, terminal cell pixel queries, and
global scroll lifecycle ownership remain out of scope and are tracked in
#8520.
## Data Flow
### Model output
`ServerGeminiContentEvent.value` remains the concatenated text consumed by
existing clients. When a response chunk contains image `inlineData`, the
event also carries an optional ordered `parts` field containing displayable
non-thought text and image parts.
Only the interactive TUI reads `parts`. It stages text and image history
items in their original order. A fresh retry or model fallback discards the
failed attempt's staged output, while a normal response boundary commits it.
The TUI admits at most four images for one assistant output and represents any
remaining image parts with a small overflow marker. Visible status rows end the
current assistant display block, so later text starts with the normal assistant
prefix instead of being attached to the status row as a continuation.
Text-only events keep their existing runtime shape, so non-interactive output,
SDK, ACP, daemon, channel, Web UI, and VS Code consumers continue using
`value` unchanged.
After a thrown stream, staged output remains transient so an explicit retry can
discard the failed attempt. If an out-of-band shell or slash-command item is
added before the next model submit, that item can enter committed history before
the staged output. The next model submit commits the staged output; history
clear, resume, branch, restore, rewind, and Ctrl+L paths discard it instead.
Resume logic reconstructs ordered text/image runs when persisted parts contain
them. Tool responses retain nested image parts in the session record. The
current Core recorder flattens assistant output to text, so live assistant
images are not restored by `--continue`; assistant-image persistence is outside
this slice and tracked in #8521.
### Tool output
Tool media is stored in `functionResponse.parts`. A CLI extractor reads image
`inlineData` from top-level and nested response parts. Live scheduler mapping
and resume mapping attach the images to the existing
`IndividualToolCallDisplay`. Each successful tool row keeps the first four
images and an overflow count for the rest. Failed and cancelled tool records
currently do not carry inline image parts from Core, so their image handling is
defensive rather than a supported output path in this slice.
Tools carrying images render individually even when their text-only form would
normally collapse into a read/search summary. `ToolMessage` routes the images
through the same `TerminalImage` component used by assistant messages.
## Rendering
The existing #8217 file-path entry point is unchanged. The shared renderer
adds an in-memory PNG entry point that:
1. validates bounded base64 before decoding;
2. verifies the PNG signature and IHDR dimensions;
3. rejects payloads above 8 MiB, dimensions above 1,000,000 pixels, or images
above 64 million total pixels;
4. reuses the existing terminal sizing and bounded render cache;
5. uses native Kitty placement in direct Kitty/Ghostty sessions;
6. passes PNG bytes to `chafa` over stdin in other supported environments;
7. returns a text placeholder when rendering is unavailable.
No temporary file is created. The inline payload is never used as a command
argument, and `chafa` receives the same allowlisted environment as the
path-based renderer.
The fallback format is `[image: <width>x<height> png]`. Invalid PNG data
becomes `[image: png]`; unsupported image MIME types retain their sanitized
format label, such as `[image: jpeg]`. Screen-reader mode always uses the text
placeholder and emits no raw image sequence.
The same encoded-length limit is applied before inline data enters CLI history
or tool-display state. Payloads that exceed the renderer's 8 MiB decoded-image
budget are dropped before rendering and do not produce a placeholder.
The first slice renders validated PNG data only. Other image MIME types remain
visible as deterministic placeholders rather than entering a second protocol
or decoding path.
## Memory
Encoded images are much larger than ordinary history text. UI compaction drops
payloads from old assistant image items while retaining the 20 most recent
items. Cleared images leave a visible marker instead of becoming blank rows.
Tool image payloads participate in the existing tool-result compaction limit.
The four-image admission cap also bounds synchronous Kitty/chafa rendering for
each assistant output and tool row.
## Test Plan
- Keep every #8217 renderer and `display_image` test green.
- Verify inline PNG validation, Kitty rendering, `chafa` stdin rendering,
screen-reader output, and unavailable-renderer placeholders.
- Verify `Turn` preserves mixed `text -> image -> text` ordering while
retaining the old `value` and text-only event shape.
- Verify live TUI ordering across retry, fallback, cancellation, stream
boundaries, and goal-state events.
- Verify live and restored successful tool responses expose nested images.
- Verify the resume parser preserves assistant text/image ordering for records
that already contain persisted parts; the current recorder's assistant-image
persistence gap remains outside this slice.
- Verify live assistant and successful tool output enforce the image cap and
expose the overflow count; oversized payloads are dropped before UI history.
- Verify memory compaction clears old assistant and tool image payloads.
@@ -0,0 +1,60 @@
# Trusted private voice base URLs
## Status
Implemented for [#8286](https://github.com/QwenLM/qwen-code/issues/8286).
## Problem
Voice transcription rejects non-loopback HTTP endpoints and endpoints that resolve to private addresses. Those checks are safe defaults, but they also prevent managed deployments from routing ASR traffic through an isolated private gateway. Gateway URLs are deployment-specific, so vendor or region hostname lists would not scale.
## Design
Add `security.allowedInsecureVoiceBaseUrls`, an empty-by-default list of complete base URLs. Every entry must include an explicit `http://` or `https://` scheme and the full provider path. A configured voice provider receives the exception only when its normalized base URL exactly matches a list entry, including scheme, host, port, and path; URL serialization and trailing slashes are normalized, but missing schemes or path segments such as `/v1` are not inferred for custom or regional gateways. The pre-existing `/v1` inference is preserved, for provider entries, only for official DashScope compatible-mode endpoints; desktop OAuth- and environment-derived base URLs still pass through the same legacy inference for any host before matching. The CLI voice resolver performs no `/v1` inference at all, so a DashScope provider entry meant to resolve identically on both surfaces must carry the `/v1`-suffixed baseUrl; without it the CLI resolves the pre-`/v1` URL while desktop appends `/v1`, and each surface's allowlist entry must match its own resolved URL. Wildcards and hostname suffix matching are not supported.
The setting is trusted configuration. User, System, and SystemDefaults scopes may provide it; Workspace values are ignored and reported as a settings warning. This prevents a cloned repository from granting itself access to an insecure or private endpoint. Settings values pass through environment-variable interpolation before matching, so anything that controls the process environment can supply an interpolated allowlist entry or provider `baseUrl`; treat the process environment as part of the trusted configuration surface.
The exact-match result travels with the resolved voice configuration so every egress path applies the same decision:
- CLI batch transcription
- CLI and daemon streaming transcription
- Desktop batch and streaming transcription
An exact match permits cleartext transport and private RFC 1918, CGNAT, or IPv6 unique-local addresses. Loopback aliases, unspecified addresses, link-local ranges, and known cloud metadata addresses remain blocked. Explicit localhost behavior remains unchanged.
Streaming transports derive their WebSocket URL from the resolved base URL (`deriveWebSocketBase` drops a trailing `/v1` or `/compatible-mode/v1` and appends `/api-ws/v1/inference` or `/api-ws/v1/realtime`), so the wire path intentionally differs from the allowlisted path. The exact-match guarantee therefore covers the provider endpoint; the batch request path uses it verbatim, while the streaming wire path is derived from it rather than matched against the allowlist.
Desktop voice merges SystemDefaults, User, and System settings with the same trusted-scope precedence as the CLI; `modelProviders` deep-merges per provider-group key exactly like the CLI (the higher scope's array wins for the same key; disjoint keys all survive). It never reads Workspace settings for this exception. It resolves the selected voice model before credentials; same-ID provider entries are ambiguous unless they are exact `(id, baseUrl)` duplicates (where the first registered entry wins like the CLI model registry; `envKey` is not part of the composite key, so a differing `envKey` also keeps the first registration) or none of the matching entries needs a network-policy decision (in which case the whole set keeps the legacy fall-through, like a single public HTTPS entry), preventing an unrelated model or region from supplying the endpoint and API key. Public HTTPS providers do not require an insecure allowlist entry; cleartext or private-network providers still require an exact match.
Provider-group visibility intentionally differs between the surfaces in one narrow way. The CLI resolves voice models through the model registry, so entries in a custom provider group are only visible when the group id resolves to a protocol — a built-in group id or a `providerProtocol` mapping, exactly as in the rest of the CLI model surface. The desktop resolver reads trusted settings directly and scans provider entries across all groups, protocol-agnostic, because it has no model registry. A voice entry under a custom group without a `providerProtocol` mapping therefore resolves on desktop while the CLI reports it as not configured. The scan also admits entries the CLI registry filters out — voice entries under non-OpenAI protocol groups (for example `gemini`), `imageOnly` entries, and `qwen-oauth` groups resolve on desktop while the CLI rejects or never registers them — and it widens the ambiguity check: a same-ID entry with a differing baseUrl in any scanned group makes the model ambiguous on desktop when any matching entry needs a network-policy decision, hard-failing dictation even when the duplicate sits in a group the CLI never sees and the CLI resolves the model normally; duplicates that all keep the legacy fall-through (public HTTPS, unallowlisted) fail on neither surface. Every resolution path stays network-policy-checked on both surfaces; these divergences change which entries resolve, never the checks applied to them.
## Configuration ownership
The operator that provisions a regional gateway owns the allowlist entry. Managed deployments should render the provider `baseUrl` and the allowlist entry from the same declarative endpoint value. Adding a region therefore requires no Qwen Code change and cannot drift into a hostname-wide exception. An allowlisted hostname is only as trustworthy as its DNS — a later DNS record change redirects the exception (and the provider credentials) wherever the name points. Prefer IP-literal entries when the gateway address is stable.
## Failure and rollback behavior
Malformed entries and non-matches fail closed. Removing the entry immediately restores the existing HTTPS/public-network requirement after settings reload or process restart.
Desktop treats a provider whose ID exactly matches the selected voice model as authoritative only when the entry needs a network-policy decision — its base URL is allowlisted, cleartext HTTP, a private-network address, or loopback. Those entries resolve before OAuth credentials so a managed gateway wins for OAuth-signed-in users, and they fail closed on duplicate matches, unsupported schemes, always-blocked addresses, a missing allowlist match, or an unresolved `envKey`, preventing an accidental fallback to a different provider or region. Public HTTPS entries keep the legacy fall-through (OAuth, then the shared DashScope provider, then environment credentials), preserving the pre-allowlist credential precedence for existing installs; entries too incomplete to classify (a missing or unparseable base URL) fall through the same way. An entry without `envKey` resolves without an API key, matching the CLI for keyless local or private gateways.
Hostnames whose DNS records resolve to loopback addresses (for example `asr.localtest.me` or `/etc/hosts` aliases for a local ASR server) are always blocked, with or without an allowlist entry; the CLI previously allowed such DNS results. To reach a local endpoint, configure an explicit loopback baseUrl such as `http://localhost`, `http://127.0.0.1`, or `http://[::1]`, which remains allowed.
Two more behavior changes relative to the pre-allowlist guard:
- CLI: a voice model `baseUrl` with embedded credentials (`https://user:pass@host/...`) is rejected instead of proceeding with the credentials stripped — userinfo can make the URL parser resolve an attacker-controlled host.
- Desktop: IPv4-mapped IPv6 literals such as `::ffff:127.0.0.1` are classified by their embedded IPv4 address and no longer bypass the loopback block; configure an explicit loopback spelling instead.
## Verification
- Preserve default rejection for non-localhost HTTP and private endpoints.
- Require allowlist entries to include an explicit scheme and full provider path on both CLI and Desktop.
- Accept two unrelated regional private gateway URLs only when the selected URL exactly matches an entry.
- Reject scheme, port, host, or path mismatches.
- Reject non-HTTP(S) URL schemes even when exactly listed.
- Ignore and warn about Workspace-scoped entries.
- Continue rejecting link-local and cloud metadata addresses, including AWS IMDS IPv6, after an exact match.
- Decode IPv4-mapped, IPv4-compatible, and well-known-prefix NAT64 IPv6 literals consistently so trusted private addresses are accepted while embedded loopback and metadata addresses remain blocked.
- Reject local-use NAT64, IETF protocol-assignment/Teredo, and 6to4 transition prefixes on both trusted and default-deny paths.
- Match Desktop credentials to one unambiguous provider with the selected voice model ID.
- Exercise both CLI and Desktop resolution and DNS guard paths.
@@ -0,0 +1,101 @@
# Web Shell artifact workspace ownership
Status: implemented and verified
Issue: https://github.com/QwenLM/qwen-code/issues/8494
## Problem
The Web Shell has one app-level `DaemonWorkspaceProvider`. Its default
workspace actions target the primary workspace. Session providers can attach to
secondary workspaces, but artifact surfaces currently retain or fall back to
the app-level actions. A file preview, download, review, nested subagent
artifact, or scheduled-task mutation can therefore reach the primary runtime
even though the producing session belongs to a secondary workspace.
The unsafe fallback also survives ownership changes: action objects are stored
in right-panel tabs, so removing or distrusting a workspace does not invalidate
an already-open tab.
## Ownership contract
The producing session owns every turn output. Its `workspaceCwd`, resolved by
the session connection, is the source of the owner claim. The current daemon
capabilities are the authority that accepts or rejects that claim.
A target is usable only when one of these cases holds:
1. Exactly one advertised workspace has the same cwd and is trusted.
2. For a legacy single-workspace daemon with no workspace list, the cwd exactly
matches `capabilities.workspaceCwd`.
Unknown, duplicate, untrusted, removed, or identity-mismatched targets fail
closed. They never fall back to the primary workspace.
## Design
### Resolve at use time
Turn-output requests and right-panel tabs carry immutable owner identity
(`workspaceCwd` and the advertised `workspaceId`) instead of long-lived action
objects. `ArtifactPanel` resolves that identity against current capabilities on
every render. This makes workspace removal, trust loss, and runtime replacement
invalidate open tabs immediately.
The resolver returns a small artifact action surface only:
- `readWorkspaceFile`
- `readFileBytes`
- `stat`
- scheduled-task list/update/delete operations
Primary targets reuse the provider's primary actions. Trusted secondary
targets use `client.workspaceByCwd(cwd)` for file operations. Scheduled-task
operations still use the Web UI REST actions, but always receive the resolved
workspace id explicitly.
### Propagation
- `TurnOutputs` derives the owner from its session `workspaceCwd`, uses the
scoped file actions for direct downloads, and stamps owner identity onto
review, artifact, and scheduled-task open requests.
- `ChatPane` and `SubagentDetail` preserve the request identity and add only the
producing session id. They no longer stamp root workspace actions onto the
request or artifact snapshot.
- `App` stores owner identity on each right-panel tab. Pane artifact snapshots
contain artifacts only; they no longer retain workspace clients.
- `ArtifactPanel` renders the existing workspace-unavailable state when the
owner cannot be resolved or no longer matches. It never substitutes
`useWorkspaceActions()` for a missing owner.
- Durable scheduled-task snapshots carry `workspaceId`, and every list,
update, toggle, and delete request passes that id.
### Async invalidation
Scoped action wrappers check that their captured owner is still current before
starting an operation and again after each response. Effects and scheduled-task
mutations also ignore results after unmount or owner replacement. A response
started for one runtime cannot populate a tab after that runtime is removed or
replaced.
## Non-goals
- No daemon route or workspace-registry semantics change.
- No primary fallback for compatibility on multi-workspace daemons.
- No redesign of artifact storage, session attachment, or scheduled-task data.
- No attempt to make arbitrary app-level workspace actions session-scoped;
only the artifact surfaces in issue #8494 are changed.
## Verification
1. Failure-first component tests pin the current primary fallback and missing
scheduled-task workspace id.
2. Resolver tests cover primary, trusted secondary, unknown, duplicate,
untrusted, removed, and runtime-replaced targets.
3. Turn-output tests verify secondary downloads and open requests use the
secondary owner.
4. Artifact-panel tests verify missing/stale ownership fails closed and all
durable scheduled-task operations include `workspaceId`.
5. A production bundle is run against two registered local workspaces with
distinct sentinel files/tasks. Captured requests must use the secondary
workspace-qualified routes and leave the primary sentinels unchanged.
+12 -6
View File
@@ -29,12 +29,12 @@ Matched origins receive the standard CORS response headers on every request:
Access-Control-Allow-Origin: <echoed origin>
Vary: Origin
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type, X-Qwen-Client-Id, Last-Event-ID
Access-Control-Allow-Headers: Authorization, Content-Type, X-Qwen-Client-Id, Last-Event-ID, X-Qwen-Event-Epoch
Access-Control-Max-Age: 86400
Access-Control-Expose-Headers: Retry-After
Access-Control-Expose-Headers: Retry-After, X-Qwen-Event-Epoch, X-Qwen-SSE-Stream-Id
```
`Access-Control-Allow-Origin` echoes the request's origin verbatim (lowercase / uppercase as the browser sent it) rather than the literal `*`, even under the `*` pattern — browser caches key responses on it paired with `Vary: Origin`, and echoing leaves room to add `Access-Control-Allow-Credentials` in a later release without a schema change. `Access-Control-Expose-Headers: Retry-After` lets browser webuis honor daemon retry hints from `429` / `503` responses. `Access-Control-Allow-Credentials` is **NOT** sent today: the daemon authenticates via bearer-in-`Authorization`, which works cross-origin without `credentials: 'include'`.
`Access-Control-Allow-Origin` echoes the request's origin verbatim (lowercase / uppercase as the browser sent it) rather than the literal `*`, even under the `*` pattern — browser caches key responses on it paired with `Vary: Origin`, and echoing leaves room to add `Access-Control-Allow-Credentials` in a later release without a schema change. The exposed headers let browser webuis honor retry hints, retain the SSE epoch, and correlate accepted physical streams. `Access-Control-Allow-Credentials` is **NOT** sent today: the daemon authenticates via bearer-in-`Authorization`, which works cross-origin without `credentials: 'include'`.
OPTIONS preflight requests (OPTIONS with `Access-Control-Request-Method` or `Access-Control-Request-Headers`) short-circuit with `204 No Content` plus the headers above. This is the conventional CORS pattern and is safe — the preflight only confirms which methods/headers the daemon will accept; the actual subsequent request still runs the full chain (host allowlist → bearer auth → routes), so anti-DNS-rebinding and bearer enforcement still fire before any state is read or mutated. Plain OPTIONS requests from matched origins keep flowing downstream with CORS headers attached.
@@ -2771,13 +2771,19 @@ Headers:
```
Accept: text/event-stream
Last-Event-ID: 42 ← optional, replays from after id 42
X-Qwen-Event-Epoch: ... ← optional, pairs the cursor with its bus epoch
X-Qwen-Client-Id: ... ← optional client identity and diagnostic correlation
```
Query params:
| Param | Required | Notes |
| ----------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `maxQueued` | no | Per-subscriber **live frame backlog** cap. Range `[16, 2048]`, default 256. Replay frames force-pushed at subscribe time are exempt from the frame and byte caps; what actually consumes them is live events that arrive while the subscriber is still draining a large `Last-Event-ID: 0` replay. Bump for cold reconnects so the live tail doesn't trip the slow-client warning / eviction before the consumer catches up. The live serialized-byte cap is fixed daemon-side (default 2 MiB) and has no query parameter. Out-of-range / non-decimal / present-but-empty values return `400 invalid_max_queued` before the SSE handshake opens. Pre-flight `caps.features.slow_client_warning` — old daemons silently ignore the param. |
| Param | Required | Notes |
| ------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `maxQueued` | no | Per-subscriber **live frame backlog** cap. Range `[16, 2048]`, default 256. Replay frames force-pushed at subscribe time are exempt from the frame and byte caps; what actually consumes them is live events that arrive while the subscriber is still draining a large `Last-Event-ID: 0` replay. Bump for cold reconnects so the live tail doesn't trip the slow-client warning / eviction before the consumer catches up. The live serialized-byte cap is fixed daemon-side (default 2 MiB) and has no query parameter. Out-of-range / non-decimal / present-but-empty values return `400 invalid_max_queued` before the SSE handshake opens. Pre-flight `caps.features.slow_client_warning` — old daemons silently ignore the param. |
| `connectReason` | no | Client-reported diagnostic hint: `initial`, `resume`, `prompt_restart`, `stream_end`, `transport_error`, `state_resync`, or `unknown`. Invalid values normalize to `unknown` and never reject the handshake. The daemon does not use this field for auth, replay, eviction, deduplication, or stream replacement. |
| `previousStreamId` | no | UUID of the previous accepted REST/SSE stream reported by the client. Invalid values are ignored. This is best-effort lineage only and never changes stream behavior. |
A successful handshake includes `X-Qwen-SSE-Stream-Id: <uuid>`. Browser gateways must preserve that response header and expose it through `Access-Control-Expose-Headers`. Old daemons or intermediaries may omit it; clients must continue normally and treat lineage as unavailable. The id identifies this physical REST/SSE connection and correlates its daemon lifecycle, queue diagnostics, and request trace.
Frame format. The `data:` line is the **full event envelope**, JSON-stringified on a single line — `{id?, v, type, data, originatorClientId?}`. The ACP-specific payload (`sessionUpdate`, `requestPermission` arguments, etc.) sits under the envelope's `data` field; the envelope's own `type` matches the SSE `event:` line.
+19 -9
View File
@@ -213,7 +213,16 @@ Settings are organized into categories. Most settings should be placed within th
**timeout (request timeout):**
`timeout` is the per-request timeout in milliseconds (default `120000`). Set it to `0` to disable the request timeout — matching the `QWEN_STREAM_IDLE_TIMEOUT_MS=0` convention — rather than aborting the request. It can also be set via the `QWEN_CODE_API_TIMEOUT_MS` environment variable. This is distinct from `QWEN_STREAM_IDLE_TIMEOUT_MS`, which bounds inactivity _between_ streamed chunks.
`timeout` is the per-request timeout in milliseconds (default `120000`). Set it to `0` to disable the request timeout — matching the `QWEN_STREAM_IDLE_TIMEOUT_MS=0` convention — rather than aborting the request. It can also be set via the `QWEN_CODE_API_TIMEOUT_MS` environment variable. This is distinct from the two stream guards below.
**stream guards (OpenAI-compatible providers only):**
Two guards bound a streaming response, each accepting `0` to disable. Neither is implemented by the Anthropic/Gemini generators, which leave the drip-fed shape below unbounded.
- `QWEN_STREAM_IDLE_TIMEOUT_MS` (default `240000`) bounds inactivity _between_ streamed chunks: a stream that goes silent for this long is aborted as a retryable `ETIMEDOUT`.
- `QWEN_STREAM_MAX_LIFETIME_MS` (default `900000`) caps the _total_ upstream-wait time of one streaming response regardless of chunk flow — the bound a drip-fed stream that never completes cannot reset.
These are **environment variables (or, for embedders, `ContentGeneratorConfig.streamIdleTimeoutMs` / `streamMaxLifetimeMs`) only — there is no settings.json key**; writing `"streamMaxLifetimeMs"` into settings.json has no effect. Upgrade notes: a deployment that previously set `QWEN_STREAM_IDLE_TIMEOUT_MS=0` — or passed `streamIdleTimeoutMs: 0` in `ContentGeneratorConfig` — to opt out of stream aborts now also needs `QWEN_STREAM_MAX_LIFETIME_MS=0` (or `streamMaxLifetimeMs: 0`) to keep that; and the 15-minute lifetime cap bounds even a stream whose idle timeout you raised above it (e.g. `QWEN_STREAM_IDLE_TIMEOUT_MS=1800000`) — raise the cap likewise, or set it to `0`, if you rely on a longer window.
**max_tokens (output token limit):**
@@ -545,14 +554,15 @@ LSP server configuration is done through `.lsp.json` files in your project root
#### security
| Setting | Type | Description | Default |
| ------------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `security.folderTrust.enabled` | boolean | Setting to track whether Folder trust is enabled. | `false` |
| `security.auth.selectedType` | string | The currently selected authentication type. | `undefined` |
| `security.auth.enforcedType` | string | The required auth type (useful for enterprises). | `undefined` |
| `security.auth.useExternal` | boolean | Whether to use an external authentication flow. | `undefined` |
| `security.auth.apiKey` | string | **Deprecated.** API key for OpenAI-compatible authentication. Migrate to `modelProviders` with `envKey` instead — see [Model Providers](./model-providers). | `undefined` |
| `security.auth.baseUrl` | string | **Deprecated.** Base URL for the OpenAI-compatible API. Migrate to `modelProviders` instead — see [Model Providers](./model-providers). | `undefined` |
| Setting | Type | Description | Default |
| --------------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `security.folderTrust.enabled` | boolean | Setting to track whether Folder trust is enabled. | `false` |
| `security.auth.selectedType` | string | The currently selected authentication type. | `undefined` |
| `security.auth.enforcedType` | string | The required auth type (useful for enterprises). | `undefined` |
| `security.auth.useExternal` | boolean | Whether to use an external authentication flow. | `undefined` |
| `security.auth.apiKey` | string | **Deprecated.** API key for OpenAI-compatible authentication. Migrate to `modelProviders` with `envKey` instead — see [Model Providers](./model-providers). | `undefined` |
| `security.auth.baseUrl` | string | **Deprecated.** Base URL for the OpenAI-compatible API. Migrate to `modelProviders` instead — see [Model Providers](./model-providers). | `undefined` |
| `security.allowedInsecureVoiceBaseUrls` | array of strings | Complete voice provider base URLs that may use HTTP or resolve to private-network addresses. Each entry must include an explicit `http://` or `https://` scheme and the full path (for example, `/v1`); only URL serialization and trailing slashes are normalized. Wildcards are not supported; metadata, link-local, local-use NAT64, 6to4, and Teredo addresses remain blocked even when listed, as do hostnames that resolve to loopback; IPv4-mapped, IPv4-compatible, and well-known NAT64 (`64:ff9b::/96`) literals are classified by their embedded IPv4 address. Only User, System, and SystemDefaults scopes are honored. Use only for trusted endpoints in managed private networks. Cleartext HTTP also exposes the provider API key transmitted in the Authorization header. An allowlisted hostname is only as trustworthy as its DNS; prefer IP-literal entries when the gateway address is stable. The exact match covers the batch request URL; streaming transports connect to a WebSocket URL derived from it (same scheme, host, and port, `/api-ws/v1/...` path), not to the allowlisted path itself. | `[]` |
#### advanced
+27
View File
@@ -266,6 +266,33 @@ You can customize review criteria per project. `/review` reads rules from these
Rules are injected into the LLM review agents (0-6) as additional criteria. For PR reviews, rules are read from the **base branch** to prevent a malicious PR from injecting bypass rules.
## Repository Context
Repositories can hand the reviewers bounded, repository-specific guidance by committing a strict JSON manifest to `.qwen/review-context.json`. At medium or high effort, `/review` reads the manifest after capturing the plan and attaches the matching guidance before any agent launches:
```json
{
"version": 1,
"label": "Example repository",
"rules": [
{
"paths": ["packages/*/src/**"],
"domains": ["runtime"],
"relatedPaths": ["packages/runtime/src/**"],
"recommendedTests": ["npm run test:runtime"],
"requiredConfigurations": ["debug"],
"requiredAgents": ["test-matrix"],
"unverifiedDimensions": ["Alternate runtime was not exercised"],
"verificationNotes": ["Use the repository native test runner"]
}
]
}
```
A rule applies when any changed file matches one of its `paths` globs (`*`, `?`, and `**` segments; case-sensitive). All matching rules merge their guidance: domains and related files for the review agents, recommended tests and required configurations for the build-and-test agent, extra reviewer roles (honoured only when the chosen effort and topology run them), and proof boundaries the final review discloses as unverified dimensions. Arrays may be written in any order; duplicate entries are rejected.
For PR reviews the manifest is read from the merge base, so the PR under review cannot opt itself into or out of guidance; local reviews read it from the current worktree. Low-effort and cross-repository reviews skip repository context. The full contract and trust model live in the [design doc](../../design/review-repository-context.md).
## Issue Fidelity
For bugfix PRs, the Issue Fidelity agent fetches issue evidence directly instead of relying on PR description text. It uses `gh pr view <pr> --repo <owner/repo> --json closingIssuesReferences` for GitHub's strong closing-issue metadata, then `gh issue view <number> --repo <issue_owner>/<issue_repo> --json title,body,comments` for the original report and discussion — the `--json` form includes the issue **body** (the reporter's original repro), which `--comments` alone omits, and the issue's own repository is read from each reference (a PR can close an issue in a different repo). This agent runs only for PR targets; local-diff and file-path reviews skip it.
+1 -1
View File
@@ -408,7 +408,7 @@ Notes:
| `--http-bridge` | `true` | Stage 1 mode: production attempts to preheat one primary `qwen --acp` child for compatibility and retries on first use after failure, while each trusted secondary can start one child on demand. Sessions targeting a runtime multiplex onto its child via ACP `newSession()`; untrusted secondaries cannot start ACP. Stage 2 native in-process becomes available later. |
| `--initialize-timeout-ms <n>` | `10000` | ACP child request timeout, including the `initialize` handshake (ms). Must be a positive integer up to `2147483647`. Values above the JS timer ceiling (`2^31-1`) are rejected at boot because Node silently compresses them to 1 ms. Cold-container deployments that need extra headroom for child startup can raise this; the same value governs `newSession`, workspace-status polls, and other ACP ext-method deadlines. |
| `--allow-origin <pat>` | — | T2.4 ([#4514](https://github.com/QwenLM/qwen-code/issues/4514)). Cross-origin allowlist for browser webui clients. Repeatable. Each value is `*` (any origin — boot refuses if no bearer token is configured; `--require-auth` on loopback is recommended so `/health` and `/demo` are also bearer-gated, since both are pre-auth on loopback by default) or a canonical URL origin (`<scheme>://<host>[:<port>]`, no trailing slash / path / userinfo / query). **Subdomain wildcards (`https://*.example.com`) are intentionally unsupported** — list each subdomain explicitly, or use `*` with a configured token (and `--require-auth` for full hardening). Matched origins receive CORS response headers (`Access-Control-Allow-Origin`, `Vary: Origin`, methods, headers, max-age, and exposed `Retry-After`); unmatched origins still get a 403 with the same envelope as today's wall. `Origin: null` (sandboxed iframes, file:// docs) is always rejected, even under `*`. Pre-flight via `caps.features.allow_origin`. Loopback self-origin hits are unaffected. |
| `--web` / `--no-web` | `true` | Serve the built Web Shell SPA at the daemon root (`GET /`, `/assets/*`, and SPA deep-link fallback). The static shell is registered **before** the bearer-auth gate — a browser can't attach a token to a `<script>` subresource or an address-bar navigation, the shell carries no secrets, and every API route stays token-gated regardless. On non-loopback binds a one-line stderr warning notes the UI is reachable without auth. Use `--no-web` for an API-only daemon. No effect when the build omits the Web Shell assets (the daemon logs a breadcrumb and runs API-only). |
| `--web` / `--no-web` | `true` | Serve the built Web Shell SPA at the daemon root (`GET /`, `/assets/*`, and `GET /session/<id>` document navigations). These entry points are registered **before** the bearer-auth gate — a browser can't attach a token to a `<script>` subresource or an address-bar navigation, and the shell carries no secrets. Every API route stays token-gated regardless, and the SPA deep-link fallback for all other paths sits behind the bearer gate too. On non-loopback binds a one-line stderr warning notes the UI is reachable without auth. Use `--no-web` for an API-only daemon. No effect when the build omits the Web Shell assets (the daemon logs a breadcrumb and runs API-only). |
| `--open` | `false` | After the listener is up, open the Web Shell in your default browser at the daemon URL (with `#token=` appended as a URL fragment when a token is configured — a fragment is never sent to the server, keeping the token out of access logs and Referer headers). No-op with `--no-web`, or in headless / CI / SSH environments where no browser is available. |
> **Memory project scope caveats.**
@@ -49,7 +49,6 @@ const CLI_BIN =
process.env['TEST_CLI_PATH'] ??
path.resolve(__dirname, '../../packages/cli/dist/index.js');
const TOKEN = 'streaming-integ-secret';
const REPO_ROOT = path.resolve(__dirname, '../..');
// Windows: this suite shells out to `pgrep` / `kill -KILL` to simulate
// child-process crashes for the SIGKILL → `session_died` test, and those
@@ -78,6 +77,7 @@ let base = '';
let client: DaemonClient;
let fakeServer: FakeOpenAIServer;
let homeDir = '';
let workspaceDir = '';
let pendingWritePath = '';
beforeAll(async () => {
@@ -131,6 +131,7 @@ beforeAll(async () => {
ui: { enableFollowupSuggestions: false },
}),
);
workspaceDir = mkdtempSync(path.join(tmpdir(), 'qwen-serve-streaming-ws-'));
daemon = spawn(
process.execPath,
[
@@ -143,16 +144,19 @@ beforeAll(async () => {
'--hostname',
'127.0.0.1',
// Per #3803 §02 (1 daemon = 1 workspace), pin the bound
// workspace so every `createOrAttachSession({ workspaceCwd:
// REPO_ROOT })` below matches. Without this the daemon inherits
// the test runner's cwd (CI / IDE-launcher / direct vitest
// invocations all differ) and every session create returns
// 400 workspace_mismatch — the SSE / permission / Last-Event-ID
// tests below would all silently 404. Same fix the sibling routes test
// received earlier in this PR — missed in this file in the original §02
// pass.
// workspace so every `createOrAttachSession({ workspaceCwd })`
// below matches. Without this the daemon inherits the test
// runner's cwd (CI / IDE-launcher / direct vitest invocations
// all differ) and every session create returns 400
// workspace_mismatch — the SSE / permission / Last-Event-ID
// tests below would all silently 404. A scratch workspace (not
// the checkout) also keeps sessions hermetic: the daemon merges
// the workspace's `.qwen/settings.json` into every session, and
// a stray one on a shared runner (e.g. a `tools.sandbox` mode or
// a `tools.core` allowlist missing `todo_write`) silently breaks
// the Stop Guard flow below.
'--workspace',
REPO_ROOT,
workspaceDir,
],
{
stdio: ['ignore', 'pipe', 'pipe'],
@@ -211,6 +215,9 @@ afterAll(async () => {
if (homeDir) {
rmSync(homeDir, { recursive: true, force: true });
}
if (workspaceDir) {
rmSync(workspaceDir, { recursive: true, force: true });
}
}, 15_000);
/** Open an authenticated SSE stream and yield parsed frames. */
@@ -241,7 +248,7 @@ async function* sseFrames(
describePOSIX('qwen serve — child-crash recovery (real SIGKILL)', () => {
it('publishes session_died after the qwen --acp child is SIGKILL-ed', async () => {
const session = await client.createOrAttachSession({
workspaceCwd: REPO_ROOT,
workspaceCwd: workspaceDir,
});
// Find the daemon's direct `--acp` child PID.
@@ -295,7 +302,7 @@ describePOSIX('qwen serve — child-crash recovery (real SIGKILL)', () => {
);
// Listing must NOT show the dead session.
const remaining = await client.listWorkspaceSessions(REPO_ROOT);
const remaining = await client.listWorkspaceSessions(workspaceDir);
// Explicit `s` type for resilience against a stale dist .d.ts
// in the reviewer's tsc env (see same note in routes.test.ts).
expect(
@@ -306,7 +313,7 @@ describePOSIX('qwen serve — child-crash recovery (real SIGKILL)', () => {
// Retry must spawn fresh, not reuse the corpse.
const fresh = await client.createOrAttachSession({
workspaceCwd: REPO_ROOT,
workspaceCwd: workspaceDir,
});
expect(fresh.sessionId).not.toBe(session.sessionId);
expect(fresh.attached).toBe(false);
@@ -316,7 +323,7 @@ describePOSIX('qwen serve — child-crash recovery (real SIGKILL)', () => {
describePOSIX('qwen serve — multi-client first-responder permission', () => {
it('fans out permission_request to both subscribers; only one vote wins', async () => {
const session = await client.createOrAttachSession({
workspaceCwd: REPO_ROOT,
workspaceCwd: workspaceDir,
});
// Pin the session to `default` approval mode. The ACP child
@@ -448,7 +455,7 @@ describePOSIX('qwen serve — multi-client first-responder permission', () => {
describePOSIX('qwen serve — Last-Event-ID resume', () => {
it('reconnect with Last-Event-ID:N yields events with id > N', async () => {
const session = await client.createOrAttachSession({
workspaceCwd: REPO_ROOT,
workspaceCwd: workspaceDir,
});
// Fire a short prompt to populate the bus.
@@ -494,7 +501,7 @@ describePOSIX('qwen serve — Last-Event-ID resume', () => {
describePOSIX('qwen serve — daemon Todo Stop Guard replay', () => {
it('continues after prompt admission without an SSE client and replays the bounded attempts', async () => {
const session = await client.createOrAttachSession({
workspaceCwd: REPO_ROOT,
workspaceCwd: workspaceDir,
});
const requestStart = fakeServer.requests.length;
const guardMarker = `todo-guard-e2e-${requestStart}`;
+1
View File
@@ -89,6 +89,7 @@
"telemetry": "node scripts/telemetry.js",
"check:lockfile": "node scripts/check-lockfile.js",
"check:desktop-isolation": "node scripts/check-desktop-isolation.js",
"check:voice-guard-sync": "node scripts/check-voice-guard-sync.js",
"check:serve-fast-path-bundle": "node scripts/clean-package-build-artifacts.js && npm run build -- --cli-only && cross-env DEV=true npm run bundle && node scripts/check-serve-fast-path-bundle.js",
"desktop-openwork-sync": "bun run scripts/desktop-openwork-sync.ts",
"clean": "node scripts/clean.js",
+81
View File
@@ -11,6 +11,7 @@ import {
EventBus,
EVENT_SCHEMA_VERSION,
type BridgeEvent,
type EventBusSubscriberDiagnostic,
} from './eventBus.js';
async function collect(
@@ -218,6 +219,86 @@ describe('EventBus', () => {
abort.abort();
});
it('routes contextual subscriber diagnostics without duplicating legacy stderr', async () => {
const bus = new EventBus();
const diagnostics: EventBusSubscriberDiagnostic[] = [];
const iter = bus.subscribe({
maxQueued: 2,
onSubscriberDiagnostic: (diagnostic) => {
diagnostics.push(diagnostic);
return true;
},
});
bus.publish({ type: 'first', data: 1 });
const thresholdEvent = bus.publish({
type: 'threshold-crossing',
data: 2,
});
const overflowingEvent = bus.publish({ type: 'overflowing', data: 3 });
const thresholdJson = JSON.stringify(thresholdEvent);
const overflowingJson = JSON.stringify(overflowingEvent);
if (!thresholdJson || !overflowingJson) {
throw new Error('Expected serializable published events');
}
for await (const _event of iter) {
// Drain the terminal subscription.
}
expect(diagnostics).toHaveLength(2);
expect(diagnostics[0]).toMatchObject({
type: 'slow_client_warning',
data: {
triggerEventType: 'threshold-crossing',
triggerEventBytes: expect.any(Number),
},
});
expect(diagnostics[1]).toMatchObject({
type: 'client_evicted',
data: {
reason: 'queue_overflow',
triggerEventType: 'overflowing',
triggerEventBytes: expect.any(Number),
},
});
expect(diagnostics[0]?.data.triggerEventBytes).toBe(
new TextEncoder().encode(thresholdJson).byteLength,
);
expect(diagnostics[1]?.data.triggerEventBytes).toBe(
new TextEncoder().encode(overflowingJson).byteLength,
);
expect(process.stderr.write).not.toHaveBeenCalledWith(
expect.stringContaining('EventBus slow_client_warning'),
);
expect(process.stderr.write).not.toHaveBeenCalledWith(
expect.stringContaining('EventBus subscriber evicted'),
);
});
it.each([
['returns false', () => false],
[
'throws',
() => {
throw new Error('diagnostic sink failed');
},
],
])('preserves legacy stderr when the diagnostic callback %s', (_name, cb) => {
const bus = new EventBus();
bus.subscribe({ maxQueued: 1, onSubscriberDiagnostic: cb });
expect(() => {
bus.publish({ type: 'threshold', data: 1 });
bus.publish({ type: 'overflow', data: 2 });
}).not.toThrow();
expect(process.stderr.write).toHaveBeenCalledWith(
expect.stringContaining('EventBus slow_client_warning'),
);
expect(process.stderr.write).toHaveBeenCalledWith(
expect.stringContaining('EventBus subscriber evicted'),
);
});
it('emits slow_client_warning exactly once per overflow episode', async () => {
// Queue size 8; warn threshold = 75% = 6. Push to 6 → warning
// fires; push to 7 → no additional warning (sub.warned latched).
+80 -4
View File
@@ -102,8 +102,49 @@ export interface SubscribeOptions {
* with a final `client_evicted` event. Defaults to 256.
*/
maxQueued?: number;
/**
* Receives low-frequency, per-subscriber queue diagnostics. Return `true`
* after emitting the human-facing diagnostic; `false` (or a throw) keeps the
* EventBus legacy stderr fallback.
*/
onSubscriberDiagnostic?: (
diagnostic: EventBusSubscriberDiagnostic,
) => boolean;
}
export interface EventBusSlowClientWarningData {
queueSize: number;
maxQueued: number;
lastEventId: number;
queuedBytes: number;
maxQueuedBytes: number;
threshold: QueueWarningThreshold;
triggerEventType: string;
triggerEventBytes: number;
}
export interface EventBusClientEvictedData {
reason: 'queue_overflow' | 'queue_bytes_overflow';
droppedAfter: number;
queueSize: number;
maxQueued: number;
queuedBytes: number;
maxQueuedBytes: number;
eventBytes?: number;
triggerEventType: string;
triggerEventBytes: number;
}
export type EventBusSubscriberDiagnostic =
| {
type: 'slow_client_warning';
data: EventBusSlowClientWarningData;
}
| {
type: 'client_evicted';
data: EventBusClientEvictedData;
};
export interface EventBusOptions {
maxQueuedBytes?: number;
/**
@@ -227,7 +268,7 @@ function logSubscriberEvicted(data: Record<string, unknown>): void {
}
}
type QueueWarningThreshold = 'frames' | 'bytes' | 'frames_and_bytes';
export type QueueWarningThreshold = 'frames' | 'bytes' | 'frames_and_bytes';
function logSlowClientWarning(data: Record<string, unknown>): void {
try {
@@ -268,6 +309,9 @@ interface InternalSub {
* that recovers and then lags again gets a fresh warning.
*/
warned: boolean;
onSubscriberDiagnostic?: (
diagnostic: EventBusSubscriberDiagnostic,
) => boolean;
/**
* Note: cleanup hook for the eviction path (overflow → close queue
* → remove from `subs`). Without this, the abort listener registered
@@ -281,6 +325,17 @@ interface InternalSub {
dispose: () => void;
}
function subscriberDiagnosticHandled(
sub: InternalSub,
diagnostic: EventBusSubscriberDiagnostic,
): boolean {
try {
return sub.onSubscriberDiagnostic?.(diagnostic) === true;
} catch {
return false;
}
}
/**
* Thrown by `EventBus.subscribe()` when the per-bus subscriber cap
* has been reached. The SSE route catches this and surfaces a
@@ -491,7 +546,7 @@ export class EventBus {
// `id` is absent.
const evictionData = {
reason: pushResult.reason,
droppedAfter: event.id,
droppedAfter: event.id as number,
queueSize: pushResult.liveSize,
maxQueued: sub.maxQueued,
queuedBytes: pushResult.liveBytes,
@@ -500,7 +555,17 @@ export class EventBus {
? { eventBytes: pushResult.eventBytes }
: {}),
};
logSubscriberEvicted(evictionData);
const evictionDiagnostic: EventBusSubscriberDiagnostic = {
type: 'client_evicted',
data: {
...evictionData,
triggerEventType: event.type,
triggerEventBytes: eventBytes,
},
};
if (!subscriberDiagnosticHandled(sub, evictionDiagnostic)) {
logSubscriberEvicted(evictionData);
}
const evictionFrame: BridgeEvent = {
v: EVENT_SCHEMA_VERSION,
type: 'client_evicted',
@@ -580,7 +645,17 @@ export class EventBus {
maxQueuedBytes: sub.maxQueuedBytes,
threshold,
};
logSlowClientWarning(warningData);
const warningDiagnostic: EventBusSubscriberDiagnostic = {
type: 'slow_client_warning',
data: {
...warningData,
triggerEventType: event.type,
triggerEventBytes: eventBytes,
},
};
if (!subscriberDiagnosticHandled(sub, warningDiagnostic)) {
logSlowClientWarning(warningData);
}
const warningFrame: BridgeEvent = {
v: EVENT_SCHEMA_VERSION,
type: 'slow_client_warning',
@@ -641,6 +716,7 @@ export class EventBus {
warnBytesThreshold: WARN_THRESHOLD_RATIO * this.maxQueuedBytes,
warnBytesResetThreshold: WARN_RESET_RATIO * this.maxQueuedBytes,
warned: false,
onSubscriberDiagnostic: opts.onSubscriberDiagnostic,
dispose: () => {},
};
this.subs.add(sub);
+1
View File
@@ -44,6 +44,7 @@ describe('reviewCommand', () => {
'fetch-pr',
'capture-local',
'plan-diff',
'repo-context',
'pr-context',
'comment-status',
'load-rules',
+3 -1
View File
@@ -15,6 +15,7 @@ import { findingsCommand } from './review/findings.js';
import { fetchPrCommand } from './review/fetch-pr.js';
import { captureLocalCommand } from './review/capture-local.js';
import { planDiffCommand } from './review/plan-diff.js';
import { repoContextCommand } from './review/repo-context.js';
import { prContextCommand } from './review/pr-context.js';
import { commentStatusCommand } from './review/comment-status.js';
import { loadRulesCommand } from './review/load-rules.js';
@@ -49,6 +50,7 @@ export const reviewCommand: CommandModule = {
.command(fetchPrCommand)
.command(captureLocalCommand)
.command(planDiffCommand)
.command(repoContextCommand)
.command(prContextCommand)
.command(commentStatusCommand)
.command(loadRulesCommand)
@@ -74,7 +76,7 @@ export const reviewCommand: CommandModule = {
.command(cleanupCommand)
.demandCommand(
1,
'Specify a subcommand: run, parse-args, fetch-pr, capture-local, plan-diff, pr-context, comment-status, load-rules, agent-prompt, build-test, base-tree, test-delta, drive, mock-provider, extract-step, script-lint, resolve-anchors, check-coverage, cost-ledger, presubmit, test-efficacy, test-plan, findings, publish-assets, compose-review, save-artifact, submit, or cleanup.',
'Specify a subcommand: run, parse-args, fetch-pr, capture-local, plan-diff, repo-context, pr-context, comment-status, load-rules, agent-prompt, build-test, base-tree, test-delta, drive, mock-provider, extract-step, script-lint, resolve-anchors, check-coverage, cost-ledger, presubmit, test-efficacy, test-plan, findings, publish-assets, compose-review, save-artifact, submit, or cleanup.',
)
.version(false),
handler: () => {
@@ -34,8 +34,13 @@ import { dirname, join, resolve } from 'node:path';
vi.mock('../../utils/stdioHelpers.js', () => ({
writeStdoutLine: vi.fn(),
writeStderrLine: vi.fn(),
writeStderrLineSafe: vi.fn(),
}));
import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js';
import {
writeStdoutLine,
writeStderrLine,
writeStderrLineSafe,
} from '../../utils/stdioHelpers.js';
import {
DEADLINE_ENV,
RESERVE_ENV,
@@ -526,13 +531,24 @@ describe('--all-chunks — every auditor of a Step 5 round, in one call', () =>
)!;
expect(key).toMatch(/--[0-9a-f]{12}$/);
const rec = recorded.get(key)!;
// The record IS the printed block, identity line first, findings in.
// The record IS the printed block, identity line first, findings
// pointer in. The list itself rides the digest-named file — one per
// round, shared by every block — never the block (issue #8597).
expect(printed).toContain(rec);
expect(rec.startsWith('You are review agent `reverse-audit`')).toBe(
true,
);
expect(rec).toContain('- **[Critical]** x.ts:1 — y');
expect(rec).not.toContain('- **[Critical]** x.ts:1 — y');
expect(rec).toContain('.findings.md');
}
// The round's findings file holds the list every block points at.
const anyRec = recorded.get(keys[0])!;
const listPath = /read_file\(file_path="([^"]*\.findings\.md)"/.exec(
anyRec,
)![1];
expect(readFileSync(listPath, 'utf8')).toContain(
'- **[Critical]** x.ts:1 — y',
);
// Each block reads its OWN chunk's range — asserted on two different
// chunks, because checking only the first cannot see a batch that built
// every block from the same chunk.
@@ -1365,10 +1381,11 @@ describe('--roster — every prompt the plan requires, in one call', () => {
// Dogfooded on a real 3A review: the orchestrator delivered Step 3 prompts verbatim
// but PARAPHRASED the Step 4/5 ones — added "(round 2)", inserted its own summary,
// truncated the "nothing replaces the brief" line — because it hand-prepended the
// findings list. `--findings` removes that assembly step: the command folds the list
// in and prints one block. The record stays findings-free, so the shared key still
// matches by the add-only delivery rule.
describe('--findings — fold the list in, print one block, record EXACTLY that block', () => {
// findings list. `--findings` removes that assembly step: the command copies the
// list to a digest-named file and prints one block pointing at it. The record IS
// that block — pointer included, keyed per findings digest — so a launch that
// drops the pointer matches no record.
describe('--findings — point the block at the list file, record EXACTLY that block', () => {
// Every temp dir this block makes, cleaned up after each test — the rest of the
// file uses try/finally; a helper-based block tracks and sweeps instead.
let dirs: string[] = [];
@@ -1416,20 +1433,31 @@ describe('--findings — fold the list in, print one block, record EXACTLY that
return { printed, plan };
}
it('a verifier gets the findings folded beneath its identity line, and the record IS the printed prompt', () => {
it('a verifier gets the findings pointer beneath its identity line, and the record IS the printed prompt', () => {
const { printed, plan } = run({ role: 'verify' });
// Printed: the findings section AND the findings themselves — and NOT the
// reverse auditor's framing (a branch swap in findingsSection would pass both
// tests if each only asserted its own heading).
// Printed: the findings section AND the pointer to the digest-named list
// file — and NOT the reverse auditor's framing (a branch swap in
// findingsSection would pass both tests if each only asserted its own
// heading). The list itself is NOT in the block: inlined per block it
// made a 12-14-auditor launch one 65-82 KB assistant message, and the
// stream generating it never completed (issue #8597).
expect(printed).toContain('## The findings you are ruling on');
expect(printed).not.toContain('Already confirmed');
expect(printed).toContain('foo.ts:10 — the collision drops arguments');
expect(printed).not.toContain('foo.ts:10 — the collision drops arguments');
// and the line the orchestrator used to truncate away.
expect(printed).toContain('does not replace the brief; read it first');
// Recorded: EXACTLY what was printed, findings included, under a digest key.
// The findings-free record was a receipt a partial delivery could satisfy:
// launch the agent with only the recorded tail, let it open the brief, and
// the delivery check passed while no verifier ever saw a finding.
// The list is on disk, named by the same digest that keys the record,
// holding exactly what --findings was given.
const m = /read_file\(file_path="([^"]*\.findings\.md)"\)/.exec(printed);
expect(m).not.toBeNull();
expect(readFileSync(m![1], 'utf8')).toContain(
'foo.ts:10 — the collision drops arguments',
);
// Recorded: EXACTLY what was printed, pointer included, under a digest
// key. The findings-free record was a receipt a partial delivery could
// satisfy; the pointer keeps that guarantee — a launch that drops it
// matches no record, and the delivery floor counts the read it
// instructs (see the verificationGaps tests).
const recorded = recordByPrefix(plan, 'verify--');
expect(recorded).toBe(printed);
// The identity line leads the output — the one spot a real run edited on a
@@ -1456,7 +1484,12 @@ describe('--findings — fold the list in, print one block, record EXACTLY that
expect(printed).toContain('Already confirmed — do not re-report these');
// and NOT the verifier's framing — the mirror of the assertion above.
expect(printed).not.toContain('The findings you are ruling on');
expect(printed).toContain('foo.ts:10 — the collision drops arguments');
expect(printed).not.toContain('foo.ts:10 — the collision drops arguments');
const m = /read_file\(file_path="([^"]*\.findings\.md)"\)/.exec(printed);
expect(m).not.toBeNull();
expect(readFileSync(m![1], 'utf8')).toContain(
'foo.ts:10 — the collision drops arguments',
);
const recorded = recordByPrefix(plan, 'reverse-audit--');
expect(recorded).toBe(printed);
});
@@ -1464,8 +1497,8 @@ describe('--findings — fold the list in, print one block, record EXACTLY that
it('a Step 3B per-chunk reverse auditor takes --chunk and --findings together', () => {
// The one valid triple: reverse-audit declares both acceptsChunk and
// acceptsFindings, and Step 5 3B launches `--role reverse-audit --chunk N
// --findings <cumulative>` per chunk per round. The findings fold above the
// chunk-scoped prompt; the record is that chunk's block, findings-free, keyed by
// --findings <cumulative>` per chunk per round. The findings pointer folds
// above the chunk-scoped prompt; the record is that chunk's block, keyed by
// the chunk. (PLAN's chunks are 13/14/15 — chunk 14 is offset 4024, limit 176.)
const { printed, plan } = run({
role: 'reverse-audit',
@@ -1473,7 +1506,8 @@ describe('--findings — fold the list in, print one block, record EXACTLY that
round: 1,
});
expect(printed).toContain('Already confirmed — do not re-report these');
expect(printed).toContain('foo.ts:10 — the collision drops arguments');
expect(printed).not.toContain('foo.ts:10 — the collision drops arguments');
expect(printed).toContain('.findings.md');
expect(printed).toContain('offset=4024, limit=176'); // this chunk's range only
expect(printed).not.toContain('offset=3807'); // not chunk 13's
const recorded = recordByPrefix(plan, 'reverse-audit--chunk-14--');
@@ -1486,8 +1520,58 @@ describe('--findings — fold the list in, print one block, record EXACTLY that
// must fail loudly, not inherit the reverse auditor's "do not re-report" prose.
// Called directly with a role the function does not frame — the guards never let
// a non-findings role reach it in a real run.
expect(() => findingsSection('2', 'some findings')).toThrow(
/--findings has no framing for role "2"/,
expect(() =>
findingsSection('2', 'some findings', '/tmp/x.findings.md'),
).toThrow(/--findings has no framing for role "2"/);
});
it('inlines the list when the findings file could not be written', () => {
// A read-only tmp dir makes writeFindingsFile return null; the section
// must then fall back to the pre-#8597 inline shape rather than point
// the block at a file that does not exist — a whole round would run
// against the dead path before the delivery floor could fail it.
const list = '- **[Critical]** foo.ts:10 — the collision drops arguments';
const verify = findingsSection('verify', list, null);
expect(verify).toContain('## The findings you are ruling on');
expect(verify).toContain(list);
expect(verify).not.toContain('The list is a file');
expect(verify).not.toContain('.findings.md');
const audit = findingsSection('reverse-audit', list, null);
expect(audit).toContain('Already confirmed — do not re-report these');
expect(audit).toContain(list);
expect(audit).not.toContain('The list is a file');
});
it('a failed findings write builds with the list inlined, not a dead pointer', () => {
// End-to-end shape of the fallback: a FILE where the record directory
// must sit makes the findings write fail, and the printed block carries
// the list itself with no `.findings.md` pointer. The agents then read
// what they were launched with; the floor owes no findings read for a
// pointer-less prompt.
const dir = tmp('ap-ff-');
const plan = join(dir, 'plan.json');
writeFileSync(plan, JSON.stringify(PLAN));
writeFileSync(
join(dir, 'plan-prompts'),
'a file where the record dir would go',
);
const findings = join(dir, 'findings.md');
writeFileSync(
findings,
'- **[Critical]** foo.ts:10 — the collision drops arguments',
);
(writeStderrLineSafe as unknown as Mock).mockClear();
(agentPromptCommand.handler as (a: unknown) => void)({
plan,
role: 'verify',
findings,
});
const printed = (writeStdoutLine as unknown as Mock).mock
.calls[0][0] as string;
expect(printed).toContain('foo.ts:10 — the collision drops arguments');
expect(printed).not.toContain('.findings.md');
expect((writeStderrLineSafe as unknown as Mock).mock.calls[0][0]).toContain(
'inlining the list instead',
);
});
@@ -1542,8 +1626,8 @@ describe('--findings — fold the list in, print one block, record EXACTLY that
// The old shape shared one findings-free record across shards — a receipt a
// tail-only delivery could satisfy. Now each shard's record is its exact
// printed prompt under a findings-digest key: shard 2 does not overwrite
// shard 1, each launch is verified against its own list, and a launch
// carrying the wrong shard's list matches nothing.
// shard 1, each launch points at its own list file, and a launch carrying
// the wrong shard's pointer matches nothing.
const dir = tmp('ap-shards-');
const plan = join(dir, 'plan.json');
writeFileSync(plan, JSON.stringify(PLAN));
@@ -1575,10 +1659,17 @@ describe('--findings — fold the list in, print one block, record EXACTLY that
const records = verifyKeys.map((k) => recorded.get(k)!);
expect(records).toContain(printed1);
expect(records).toContain(printed2);
// Cross-delivery fails: shard 1's launch does not satisfy shard 2's record.
const rec2 = records.find((r) => r.includes('second shard'))!;
expect(wasDeliveredVerbatim(printed1, rec2)).toBe(false);
expect(wasDeliveredVerbatim(printed2, rec2)).toBe(true);
// Each shard's list file holds its own findings.
const listOf = (p: string) =>
readFileSync(
/read_file\(file_path="([^"]*\.findings\.md)"/.exec(p)![1],
'utf8',
);
expect(listOf(printed1)).toContain('first shard');
expect(listOf(printed2)).toContain('second shard');
// Cross-delivery fails: shard 1's launch does not satisfy shard 2's record
// (printed2 IS shard 2's record — asserted above).
expect(wasDeliveredVerbatim(printed1, printed2)).toBe(false);
});
it('refuses a findings-taking role launched without --findings', () => {
@@ -1630,12 +1721,12 @@ describe('--findings — fold the list in, print one block, record EXACTLY that
[
'a dimension role',
{ role: '2', findings: '/f' },
/--findings folds a findings list into the prompt, only for a role that takes one/,
/--findings hands a findings list to the printed block, only for a role that takes one/,
],
[
'no role',
{ findings: '/f' },
/--findings folds a findings list into a --role verify \/ --role reverse-audit/,
/--findings hands a findings list to a --role verify \/ --role reverse-audit/,
],
[
'whole-diff',
@@ -1830,6 +1921,103 @@ describe('buildRoleBrief — every agent, not just the territory ones', () => {
expect(p).not.toMatch(/If you find no issues, say/i);
});
it('injects generic repository context into reviewers and a narrow verification boundary into Agent 7', () => {
const contextPlan = {
...PR_PLAN,
repositoryContext: {
version: 1,
provider: 'fake-provider',
label: 'Example project',
domains: ['compiler', 'runtime'],
relatedPaths: ['src/compiler.ts', 'src/runtime.ts'],
recommendedTests: ['test:compiler'],
requiredConfigurations: ['debug', 'linux-x64'],
requiredAgents: ['test-matrix'],
unverifiedDimensions: ['Alternate runtime was not exercised'],
verificationNotes: ['Use the repository native test runner'],
},
};
// Negative pins: roles outside the code-reviewing set and outside the
// manifest's required agents get nothing. A `brief.reviewsCode ||` →
// `true ||` regression would hand Agent 0 (issue fidelity, not code
// review) the full block on every context-bearing plan, and would give
// it to a role the manifest did not require, with the suite green.
expect(buildRoleBrief(contextPlan, '0')).not.toContain(
'Example project repository context',
);
expect(
buildRoleBrief(
{
...contextPlan,
repositoryContext: {
...contextPlan.repositoryContext,
requiredAgents: [],
},
},
'test-matrix',
),
).not.toContain('Example project repository context');
const reviewerBrief = buildRoleBrief(contextPlan, '1a');
expect(reviewerBrief).toContain('Example project repository context');
expect(reviewerBrief).toContain('compiler, runtime');
expect(reviewerBrief).toContain('src/compiler.ts');
expect(reviewerBrief).toContain('test:compiler');
expect(reviewerBrief).toContain('debug, linux-x64');
expect(reviewerBrief).toContain('Alternate runtime was not exercised');
expect(reviewerBrief).toContain('Use the repository native test runner');
// Section adjacency: each field is pinned under ITS OWN label, or a
// rendering swap between two same-shaped arrays ships green while
// reviewers are told the repository's proof boundaries are its
// verification instructions — and vice versa.
expect(reviewerBrief).toContain(
'Related paths:\n- src/compiler.ts\n- src/runtime.ts',
);
expect(reviewerBrief).toContain(
'Unverified dimensions:\n- Alternate runtime was not exercised',
);
expect(reviewerBrief).toContain(
'Verification notes:\n- Use the repository native test runner',
);
const territoryBrief = buildChunkAgentPrompt(contextPlan, 13);
expect(territoryBrief).toContain('Example project repository context');
expect(territoryBrief).toContain('src/compiler.ts');
const requiredAgentBrief = buildRoleBrief(contextPlan, 'test-matrix');
expect(requiredAgentBrief).toContain('Example project repository context');
expect(requiredAgentBrief).toContain('src/compiler.ts');
expect(requiredAgentBrief).toContain('test:compiler');
// Positive pins for code-reviewing roles OUTSIDE the manifest
// allow-list that reach the block solely through `brief.reviewsCode`:
// a narrowing mutant that keeps every pinned role strips exactly these
// and ships green.
for (const role of ['verify', 'reverse-audit'] as const) {
expect(buildRoleBrief(contextPlan, role)).toContain(
'Example project repository context',
);
}
const buildBrief = buildRoleBrief(contextPlan, '7');
expect(buildBrief).not.toContain('Example project repository context');
expect(buildBrief).not.toContain('compiler, runtime');
expect(buildBrief).not.toContain('src/compiler.ts');
expect(buildBrief).not.toContain('Alternate runtime was not exercised');
expect(buildBrief).toContain('Repository-specific verification boundary');
expect(buildBrief).toContain('test:compiler');
expect(buildBrief).toContain('debug, linux-x64');
expect(buildBrief).toContain('Use the repository native test runner');
// The --whole-diff path builds Agent 8's briefs; it carries the same
// block, or the one finder launched for a dominant domain is the one
// reviewer denied that domain's guidance.
const wholeDiff = buildWholeDiffBlock(contextPlan);
expect(wholeDiff).toContain('Example project repository context');
expect(wholeDiff).toContain('src/compiler.ts');
});
it('carries the mutation-testing lens into Agent 5, equivalent-mutant escape hatch included', () => {
// The all-role test above proves every brief gets the diff and the format; it
// cannot see whether a *specific* lens reached its role. If prompt assembly
@@ -3006,11 +3194,16 @@ describe('per-chunk retirement — cold territories stop costing a round', () =>
* Write a transcript the way the harness writes one: the launch prompt as
* the first record, then `calls` successful reads of the diff, then the
* final text. `calls: 0` is the whiff shape — prose and nothing else.
* `readOverride` makes the auditor read THAT window instead of the one the
* launch bakes — the lazy-auditor shape the territory bar exists to catch.
*/
function auditorTranscript(
launchPrompt: string,
finalText: string,
opts: { calls?: number } = {},
opts: {
calls?: number;
readOverride?: { offset: number; limit: number };
} = {},
): void {
const id = `aud-${++seq}`;
const base = { agentId: id, agentName: 'general-purpose', sessionId: 'S1' };
@@ -3019,8 +3212,10 @@ describe('per-chunk retirement — cold territories stop costing a round', () =>
// record's own baked read, so a synthetic auditor must open the same
// window its prompt names.
const baked = /offset=(\d+), limit=(\d+)/.exec(launchPrompt);
const readOffset = baked ? Number(baked[1]) : 0;
const readLimit = baked ? Number(baked[2]) : 100;
const readOffset =
opts.readOverride?.offset ?? (baked ? Number(baked[1]) : 0);
const readLimit =
opts.readOverride?.limit ?? (baked ? Number(baked[2]) : 100);
const lines = [
JSON.stringify({
...base,
@@ -3136,21 +3331,27 @@ describe('per-chunk retirement — cold territories stop costing a round', () =>
});
it('findings quoting a read window cannot widen a territory', () => {
// The record is the FOLDED prompt — the cumulative findings list rides
// inside it verbatim. Prose quoting a read window used to inject the
// range into the baked territory, and any-overlap-passes meant an
// auditor that read only the diff's head could retire a chunk whose
// territory is thousands of lines below — the hole the territory check
// closed, reopened by honest findings. Chunk 13's territory is
// 3808-4024; the injected `offset=0, limit=50` is exactly the window
// the auditors below read.
// The findings list now rides a digest-named FILE the block points at
// (issue #8597), so its prose can no longer inject a range into the
// record at all — the territory scan only ever sees the builder's own
// diff-aimed read. The guard still matters one level down: the auditor
// READS that findings file, and a lazy auditor whose only diff read is
// the quoted head window must not retire a chunk whose territory sits
// thousands of lines below. Chunk 13's territory is 3808-4024; the
// auditors below read only the diff's head (offset=0, limit=50).
writeFileSync(
findings,
'- **File:** packages/cli/src/x.ts:12 — the earlier read used ' +
'offset=0, limit=50 — **Severity:** Suggestion\n',
);
answerRound(1, { 13: DRY, 14: YIELD, 15: YIELD });
answerRound(2, { 13: DRY, 14: YIELD, 15: YIELD });
for (const round of [1, 2]) {
runRound(round);
auditorTranscript(recordOf(round, 13), DRY, {
readOverride: { offset: 0, limit: 50 },
});
auditorTranscript(recordOf(round, 14), YIELD);
auditorTranscript(recordOf(round, 15), YIELD);
}
const out = runRound(3);
expect(out).toContain('3 auditors required this round');
+224 -44
View File
@@ -55,13 +55,25 @@ import {
chunkIdsProblem,
type DiffChunk,
} from './lib/diff-plan.js';
import { recordPrompt, writeBrief } from './lib/prompt-record.js';
import {
recordPrompt,
writeBrief,
writeFindingsFile,
} from './lib/prompt-record.js';
import {
REVERSE_AUDIT_MAX_ROUNDS,
scheduleReverseAuditRound,
type RoundSchedule,
} from './lib/retirement.js';
import { BRIEFS, type RoleId } from './lib/agent-briefs.js';
import {
BRIEFS,
isRepositoryContextRoleId,
type RoleId,
} from './lib/agent-briefs.js';
import {
repositoryContextOf,
type RepositoryContext,
} from './lib/repository-context.js';
import { pathRulesFor } from './lib/path-rules.js';
import {
requiredAgents,
@@ -86,10 +98,11 @@ interface AgentPromptArgs {
allChunks?: boolean;
rules?: string;
/**
* A file of findings to fold into a verify/reverse-audit prompt, so the caller
* pastes one block instead of hand-prepending the list. Folded into BOTH the
* printed prompt and the record (keyed per findings digest) — a launch that
* drops the list matches no record.
* A file of findings for a verify/reverse-audit prompt, so the caller
* pastes one block instead of hand-prepending the list. The list is copied
* to a digest-named file the printed block points at (keyed per findings
* digest, like the record) — a launch that drops the read matches no
* record, and the block stays small however long the list grows.
*/
findings?: string;
/**
@@ -113,6 +126,7 @@ interface PlanReport {
ownerRepo?: unknown;
worktreePath?: unknown;
mergeBaseSha?: unknown;
repositoryContext?: unknown;
}
/** A heavy file's entry, which is the only kind an invariant agent can be built from. */
@@ -366,6 +380,11 @@ export function buildChunkAgentPrompt(
parts.push('', '## Project rules', '', rules.trim());
}
const repositoryContext = repositoryContextOf(report);
if (repositoryContext) {
parts.push('', ...repositoryContextBlock(repositoryContext));
}
// Deliberately NOT included: a sentence for the agent to recite when it finds
// nothing. Every real launch handed the agent its own receipt text — `If you
// find no issues, say "No issues found — reviewed chunk 13 (...)"` — and an
@@ -483,7 +502,13 @@ export function buildWholeDiffBlock(
rules?: string,
): string {
const diffPath = requireDiffPath(report);
return [...diffReadingBlock(report, diffPath), ...tail(rules)].join('\n');
const parts = [...diffReadingBlock(report, diffPath)];
const repositoryContext = repositoryContextOf(report);
if (repositoryContext) {
parts.push('', ...repositoryContextBlock(repositoryContext));
}
parts.push(...tail(rules));
return parts.join('\n');
}
/** The diff path, or the error this whole command exists to make impossible. */
@@ -724,6 +749,42 @@ function invariantFileBlock(
return parts;
}
function contextList(values: string[]): string[] {
return values.length > 0 ? values.map((value) => `- ${value}`) : ['- (none)'];
}
function repositoryContextBlock(context: RepositoryContext): string[] {
return [
`## ${context.label} repository context`,
'',
`Domains: ${context.domains.join(', ') || '(none)'}`,
'',
'Related paths:',
...contextList(context.relatedPaths),
'',
`Recommended tests: ${context.recommendedTests.join(', ') || '(none)'}`,
`Required configurations: ${context.requiredConfigurations.join(', ') || '(none)'}`,
'',
'Unverified dimensions:',
...contextList(context.unverifiedDimensions),
'',
'Verification notes:',
...contextList(context.verificationNotes),
];
}
function repositoryBuildBoundary(context: RepositoryContext): string[] {
return [
'## Repository-specific verification boundary',
'',
`Recommended tests: ${context.recommendedTests.join(', ') || '(none)'}`,
`Required configurations: ${context.requiredConfigurations.join(', ') || '(none)'}`,
'',
'Verification notes:',
...contextList(context.verificationNotes),
];
}
/**
* The launch prompt for any role that is not a territory agent.
*
@@ -769,6 +830,20 @@ export function buildRoleBrief(
}
parts.push('## Your dimension', '', brief.brief);
const repositoryContext = repositoryContextOf(report);
if (role === '7') {
if (repositoryContext) {
parts.push('', ...repositoryBuildBoundary(repositoryContext));
}
} else if (
brief.reviewsCode ||
(isRepositoryContextRoleId(role) &&
repositoryContext?.requiredAgents.includes(role))
) {
if (repositoryContext) {
parts.push('', ...repositoryContextBlock(repositoryContext));
}
}
// Cross-repo lightweight mode: there is no tree, only the diff. Two briefs assume
// one, and the degradation used to be a sentence the orchestrator was told to add
@@ -1107,16 +1182,27 @@ export function buildRoleLaunchPrompt(
}
/**
* The findings block folded above a verify / reverse-audit launch prompt, so the
* caller pastes one thing instead of hand-assembling it.
* The findings section folded above a verify / reverse-audit launch prompt, so
* the caller pastes one thing instead of hand-assembling it.
*
* This is folded into the printed prompt AND the record alike — the record is
* the exact printed block, keyed per findings digest, so a launch that drops or
* rewrites this section matches no record. (The first design recorded the
* findings-free block for a shared key; that receipt could be satisfied by
* delivering only the tail.) Its closing line restates that the brief is
* authoritative — the exact sentence the orchestrator truncated when it used to
* build this by hand.
* The list itself rides a file the block points at (`findingsFile`), named by
* the same findings digest that keys the record — the block stays a few hundred
* characters however long the list grows. The list used to be inlined here, and
* the inlining was the point: the record is the exact printed block, keyed per
* findings digest, so a launch that drops or rewrites this section matches no
* record. Inlined in EVERY block of a 12-14-auditor round, though, the list made
* the launch one 65-82 KB assistant message, and the stream generating it never
* completed (issue #8597). The pointer keeps the guarantee — a launch that drops
* it matches no record, and the delivery floor counts the read it instructs
* exactly as it counts the brief's — at a size the orchestrator will actually
* carry. Each branch also restates, above the pointer, that the brief is
* authoritative ("this list does not replace the brief; read it first") — the
* exact sentence the orchestrator truncated when it used to build this by hand.
*
* When the findings write failed (`findingsFile` null, non-empty list), the
* section falls back to inlining the list — pointing at a file that was never
* written would run the whole round against a dead path, while the inline
* list keeps the recorded prompt self-contained exactly as it was pre-#8597.
*
* Each `acceptsFindings` role has its own framing, and the branches are explicit: a
* future role that opts into `--findings` but has no framing here throws, rather than
@@ -1124,30 +1210,63 @@ export function buildRoleLaunchPrompt(
* for any role not hunting gaps. (Same reasoning as the no-role guard message, which
* also derives from `acceptsFindings` so a new role cannot leave it stale.)
*/
export function findingsSection(role: RoleId, content: string): string {
export function findingsSection(
role: RoleId,
content: string,
findingsFile: string | null,
): string {
const body = content.trim();
let listRef: string | null = null;
if (body.length > 0) {
if (findingsFile === null) {
// The findings write failed (writeFindingsFile said so on stderr):
// inline the list — the pre-#8597 shape — rather than point the block
// at a file that does not exist. The recorded prompt carries the list
// itself then, the delivery check compares it verbatim as before, and
// the floor owes no separate findings read (findingsPointerOf finds
// none). The pointer shape below is the happy path; this is the
// degraded one that still reviews with what it was launched.
listRef = body;
} else {
listRef = [
// The line count makes under-reading visible: `read_file` truncates,
// so an agent told only "read ALL of it" can stop after the first
// page and never know it saw a fraction of the list. Count from the
// UNtrimmed content — that is what writeFindingsFile writes — but
// drop one trailing newline's empty segment, so the number matches
// the real lines a newline-terminated file holds.
`The list is a file (${content.replace(/\n$/, '').split('\n').length} lines). Read ALL ` +
'of it, right after your brief — page with a larger `offset` if a ' +
'read comes back `isTruncated`:',
'',
'```',
`read_file(file_path="${findingsFile}")`,
'```',
].join('\n');
}
}
if (role === 'verify') {
return [
'## The findings you are ruling on',
'',
'Rule on each below — one verdict, traced through the real code, as your brief ' +
'Rule on each — one verdict, traced through the real code, as your brief ' +
'defines. This list does not replace the brief; read it first.',
'',
body || '(no findings were provided — there is nothing to verify)',
listRef ?? '(no findings were provided — there is nothing to verify)',
].join('\n');
}
if (role === 'reverse-audit') {
// The list is what NOT to re-report. Empty is meaningful — an early round on a
// clean review has nothing confirmed yet, and must be told so rather than handed
// a bare heading.
return body
return listRef !== null
? [
'## Already confirmed — do not re-report these',
'',
'These are already on the review; a gap that repeats one is not a gap. Your ' +
'job is what they missed. This list does not replace the brief; read it first.',
'',
body,
listRef,
].join('\n')
: [
'## Nothing is confirmed yet',
@@ -1248,7 +1367,12 @@ function findingsDigest(content: string, rules: string | undefined): string {
* context wrap lands ABOVE it instead of replacing it, and the delivery check
* keeps its first anchor line.
*/
function foldFindings(role: RoleId, content: string, prompt: string): string {
function foldFindings(
role: RoleId,
content: string,
prompt: string,
findingsFile: string | null,
): string {
const nl = prompt.indexOf('\n');
const identity = nl === -1 ? prompt : prompt.slice(0, nl);
// The split is anchored on line one BEING the identity line —
@@ -1264,7 +1388,40 @@ function foldFindings(role: RoleId, content: string, prompt: string): string {
);
}
const rest = nl === -1 ? '' : prompt.slice(nl + 1);
return `${identity}\n\n${findingsSection(role, content)}\n${rest}`;
return `${identity}\n\n${findingsSection(role, content, findingsFile)}\n${rest}`;
}
/**
* The round suffix baked into every findings-role record key and findings
* file name. Spelled once here and shared by `runAllChunks`, the single
* build, and `findingsFileFor` — three sites deriving it independently
* means a change to how a round is spelled updates two of three, and the
* findings file and the record stop describing the same launch, silently.
*/
function roundPartOf(round: number | undefined): string {
return round !== undefined ? `--round-${round}` : '';
}
/**
* Write the findings list a findings-role launch is built from and return the
* path the printed block points at — one file per (role, round, digest), so
* every block of an --all-chunks round points at the SAME list. An empty list
* gets no file: the inline "nothing is confirmed yet" note carries it. Null
* on a failed write: findingsSection then inlines the list instead.
*/
function findingsFileFor(
planPath: string,
role: RoleId,
round: number | undefined,
digest: string,
content: string,
): string | null {
if (content.trim().length === 0) return null;
return writeFindingsFile(
planPath,
`${role}${roundPartOf(round)}--${digest}`,
content,
);
}
/**
@@ -1525,7 +1682,16 @@ function runAllChunks(
const skipped = schedule?.skipped ?? [];
const digest = findingsDigest(findingsContent, rules);
const roundPart = round !== undefined ? `--round-${round}` : '';
const roundPart = roundPartOf(round);
// One findings file per round, shared by every block below — the list
// itself never crosses the orchestrator's context.
const findingsFile = findingsFileFor(
planPath,
role,
round,
digest,
findingsContent,
);
const blocks = dueChunks.map((c, i) => {
const key = `${role}--chunk-${c.id}${roundPart}--${digest}`;
const { prompt } = buildLaunch(
@@ -1534,7 +1700,7 @@ function runAllChunks(
{ role, chunk: c.id, key, round },
rules,
);
const printed = foldFindings(role, findingsContent, prompt);
const printed = foldFindings(role, findingsContent, prompt, findingsFile);
recordPrompt(planPath, key, printed);
// The cold-check tag lives in the SEPARATOR label, never in the prompt:
// separators are display, and the delivery check compares prompts.
@@ -1693,7 +1859,7 @@ function runAgentPrompt(args: AgentPromptArgs): void {
`role "${role}" does not take --file.`,
);
}
// `--findings` folds a findings list into the printed prompt, for the two roles
// `--findings` hands a findings list to the printed block, for the two roles
// that take one: the verifier rules on findings, the reverse auditor avoids
// re-reporting them. Declared on the brief (`acceptsFindings`), like `acceptsChunk`.
// A role that TAKES findings must be GIVEN them. Without this the command still
@@ -1706,10 +1872,11 @@ function runAgentPrompt(args: AgentPromptArgs): void {
// yet passes an empty file — the command says so in the prompt.
if (!hasFindings && BRIEFS[role]?.acceptsFindings) {
bad(
`--role ${role} needs --findings <file>: it is launched with a findings ` +
`list folded in, and this command builds that block so there is nothing ` +
`for you to assemble. Write the list to a file and pass it — an early ` +
`reverse-audit round with nothing confirmed yet passes an empty file.`,
`--role ${role} needs --findings <file>: it is launched against a ` +
`findings list, and this command builds that block (a pointer to the ` +
`digest-named list file) so there is nothing for you to assemble. ` +
`Write the list to a file and pass it — an early reverse-audit round ` +
`with nothing confirmed yet passes an empty file.`,
);
}
if (hasFindings && !BRIEFS[role]?.acceptsFindings) {
@@ -1717,8 +1884,9 @@ function runAgentPrompt(args: AgentPromptArgs): void {
(r) => BRIEFS[r].acceptsFindings,
);
bad(
`--findings folds a findings list into the prompt, only for a role that ` +
`takes one (${findingRoles.join(', ')}); role "${role}" does not.`,
`--findings hands a findings list to the printed block, only for a ` +
`role that takes one (${findingRoles.join(', ')}); role "${role}" ` +
`does not.`,
);
}
// `--round` labels a repeat launch of a findings role. Only those roles run
@@ -1765,8 +1933,8 @@ function runAgentPrompt(args: AgentPromptArgs): void {
(r) => BRIEFS[r].acceptsFindings,
);
bad(
`--findings folds a findings list into a ` +
`${findingRoles.map((r) => `--role ${r}`).join(' / ')} prompt; ` +
`--findings hands a findings list to a ` +
`${findingRoles.map((r) => `--role ${r}`).join(' / ')} block; ` +
'it needs one of those roles.',
);
} else if (args.allChunks) {
@@ -1980,6 +2148,7 @@ function runAgentPrompt(args: AgentPromptArgs): void {
let prompt: string;
let key: string;
let findingsFile: string | null = null;
if (args.wholeDiff) {
prompt = buildWholeDiffBlock(report, rules);
key = 'whole-diff';
@@ -2005,8 +2174,16 @@ function runAgentPrompt(args: AgentPromptArgs): void {
// the digest: two rounds are two launches, two briefs, two receipts —
// sharing one record is what pushed the orchestrator to hand-label the
// identity line in the first place.
const roundPart = args.round !== undefined ? `--round-${args.round}` : '';
keyOverride = `${base}${roundPart}--${findingsDigest(findingsContent, rules)}`;
const roundPart = roundPartOf(args.round);
const digest = findingsDigest(findingsContent, rules);
keyOverride = `${base}${roundPart}--${digest}`;
findingsFile = findingsFileFor(
args.plan,
args.role as RoleId,
args.round,
digest,
findingsContent,
);
}
({ key, prompt } = buildLaunch(
report,
@@ -2025,10 +2202,12 @@ function runAgentPrompt(args: AgentPromptArgs): void {
}
// The record IS the printed prompt. Anything less is a receipt a partial
// delivery can satisfy — the findings-free record was exactly that.
// delivery can satisfy — the findings-free record was exactly that. The
// findings list itself rides the file `findingsFile` names; the pointer is
// part of the printed prompt, so a launch that drops it matches no record.
const printed =
findingsContent !== undefined && args.role
? foldFindings(args.role as RoleId, findingsContent, prompt)
? foldFindings(args.role as RoleId, findingsContent, prompt, findingsFile)
: prompt;
recordPrompt(args.plan, key, printed);
writeStdoutLine(printed);
@@ -2119,12 +2298,13 @@ export const agentPromptCommand: CommandModule = {
.option('findings', {
type: 'string',
describe:
'Path to a file of findings to fold into a --role verify (the shard it ' +
'rules on) / --role reverse-audit (the cumulative confirmed list) prompt, ' +
'so you paste ONE block. The findings are part of the recorded prompt ' +
'(keyed per findings digest), so a launch that drops them matches no ' +
'record — paste the whole output verbatim, do not add a round number ' +
'or reword it.',
'Path to a file of findings for a --role verify (the shard it ' +
'rules on) / --role reverse-audit (the cumulative confirmed list) build. ' +
'The list is copied to a digest-named file the printed block points ' +
'at, so you paste ONE short block however long the list is. The ' +
'pointer is part of the recorded prompt (keyed per findings digest), ' +
'so a launch that drops the read matches no record — paste the whole ' +
'output verbatim, do not add a round number or reword it.',
})
.option('round', {
type: 'number',
@@ -33,7 +33,11 @@ import {
verificationGaps,
TranscriptsUnavailableError,
} from './lib/coverage.js';
import { promptRecordDir, briefPath } from './lib/prompt-record.js';
import {
promptRecordDir,
briefPath,
findingsFilePath,
} from './lib/prompt-record.js';
import { requiredAgents, type RosterPlan } from './lib/roster.js';
import { checkCoverageCommand } from './check-coverage.js';
import { writeStderrLine } from '../../utils/stdioHelpers.js';
@@ -151,6 +155,12 @@ function transcript(
* that wants an agent which ignored its brief passes `opens: []`.
*/
opens?: string[];
/**
* Paths the agent's only contact with is NAMING them in a successful
* non-read tool's args — a search, not an open. Models the agent that
* clears a path-shaped floor without reading the file.
*/
mentions?: string[];
} = {},
): void {
const base = { agentId: id, agentName: 'general-purpose', sessionId: 'S1' };
@@ -229,6 +239,40 @@ function transcript(
}),
);
}
for (const path of opts.mentions ?? []) {
lines.push(
JSON.stringify({
...base,
type: 'assistant',
message: {
role: 'model',
parts: [
{
functionCall: {
name: 'search_file_content',
args: { path, pattern: 'Critical' },
},
},
],
},
}),
JSON.stringify({
...base,
type: 'tool_result',
message: {
role: 'user',
parts: [
{
functionResponse: {
name: 'search_file_content',
response: { output: '1 match' },
},
},
],
},
}),
);
}
lines.push(
JSON.stringify({
...base,
@@ -1497,18 +1541,33 @@ describe('verificationGaps — Step 4 and Step 5 ran, and read their briefs', ()
// `opensBrief: false` — launched with the built prompt, never opened the brief;
// `rewritten: true` — an agent ran and opened the brief, but the orchestrator
// wrote the launch itself (the real 3A run this precision exists for). To model a
// step skipped wholesale, do not set the key up at all.
// step skipped wholesale, do not set the key up at all. `findings: true` bakes
// the #8597 pointer into the recorded prompt — the block points at a
// digest-named list file — and `opensFindings: false` models the agent that
// opened its brief but skipped the one instructed findings read.
function step45(
planPath: string,
key: string,
opts: { launch?: boolean; opensBrief?: boolean; rewritten?: boolean } = {},
opts: {
launch?: boolean;
opensBrief?: boolean;
rewritten?: boolean;
findings?: boolean;
opensFindings?: boolean;
mentionsFindings?: boolean;
} = {},
): void {
const d = promptRecordDir(planPath);
mkdirSync(d, { recursive: true });
const brief = briefPath(planPath, key);
writeFileSync(brief, `The ${key} brief.`);
const findings = findingsFilePath(planPath, key);
if (opts.findings) {
writeFileSync(findings, '- **[Critical]** x.ts:1 — y');
}
const prompt =
`You are review agent \`${key}\`.\n` +
(opts.findings ? `read_file(file_path="${findings}")\n` : '') +
`read_file(file_path="${brief}")\n` +
`read_file(file_path="${DIFF}")`;
writeFileSync(join(d, `${encodeURIComponent(key)}.txt`), prompt);
@@ -1526,10 +1585,11 @@ describe('verificationGaps — Step 4 and Step 5 ran, and read their briefs', ()
);
return;
}
transcript(id, prompt, {
calls: 2,
opens: opts.opensBrief === false ? [] : [brief],
});
const opens = opts.opensBrief === false ? [] : [brief];
if (opts.findings && opts.opensFindings !== false) opens.push(findings);
const mentions =
opts.findings && opts.mentionsFindings ? [findings] : undefined;
transcript(id, prompt, { calls: 2, opens, mentions });
}
it('passes when the reverse audit ran on a review with nothing to verify', () => {
@@ -1548,10 +1608,11 @@ describe('verificationGaps — Step 4 and Step 5 ran, and read their briefs', ()
});
it('a verifier launched without its findings prefix no longer clears the gate', () => {
// The record now IS the printed prompt — findings folded, digest-keyed. The
// old findings-free record was a receipt a partial delivery could satisfy:
// launch the agent with only the recorded tail, let it open the brief, and
// verification read as ok while no verifier ever saw a finding.
// The record now IS the printed prompt — findings section included,
// digest-keyed. The old findings-free record was a receipt a partial
// delivery could satisfy: launch the agent with only the recorded tail,
// let it open the brief, and verification read as ok while no verifier
// ever saw a finding.
const p = plan();
step45(p, 'reverse-audit'); // Step 5 compliant; verification is the subject
const d = promptRecordDir(p);
@@ -1754,6 +1815,86 @@ describe('verificationGaps — Step 4 and Step 5 ran, and read their briefs', ()
);
});
it('flags a verifier that opened its brief but skipped the findings file', () => {
// Since #8597 the findings list rides a digest-named file the block points
// at; the brief's read receipt does not cover it. An instruction-skipping
// verifier that opens the brief but never reads the list must not clear
// the floor — it would otherwise rule on findings it was never shown (the
// probe shape: a skip arm indistinguishable from the compliant one).
const p = plan();
step45(p, 'reverse-audit'); // Step 5 compliant; verification is the subject
step45(p, 'verify--abc123def456', {
findings: true,
opensFindings: false,
});
const r = verificationGaps(p, { postsFindings: true }, ENV);
expect(r.ok).toBe(false);
expect(r.unverifiedFindings).toBe(true);
expect(gapText(r)).toMatch(
/verification — it was launched with the built prompt and opened its brief, but never read the findings file/,
);
// The fix names the findings read as part of the receipt.
expect(r.remediation.join(' ')).toContain('read the findings file');
});
it('clears the floor when the verifier reads the findings file its block points at', () => {
const p = plan();
step45(p, 'reverse-audit');
step45(p, 'verify--abc123def456', { findings: true });
expect(verificationGaps(p, { postsFindings: true }, ENV).ok).toBe(true);
});
it('does not credit a non-read tool that merely names the findings path', () => {
// Every tool serializes its args, so a `search_file_content` over the
// findings file carries the same stringified path as a read of it —
// without reading a line. The floor certifies the list was OPENED; a
// mention is not an open, and only read_file counts.
const p = plan();
step45(p, 'reverse-audit'); // Step 5 compliant; verification is the subject
step45(p, 'verify--abc123def456', {
findings: true,
opensFindings: false,
mentionsFindings: true,
});
const r = verificationGaps(p, { postsFindings: true }, ENV);
expect(r.ok).toBe(false);
expect(r.unverifiedFindings).toBe(true);
expect(gapText(r)).toMatch(
/verification — it was launched with the built prompt and opened its brief, but never read the findings file/,
);
});
it('flags a reverse auditor that opened its brief but skipped the findings file', () => {
const p = plan();
step45(p, 'reverse-audit--round-1--abc123def456', {
findings: true,
opensFindings: false,
});
const r = verificationGaps(p, { postsFindings: false }, ENV);
expect(r.ok).toBe(false);
expect(gapText(r)).toMatch(
/reverse audit — it was launched with the built prompt and opened its brief, but never read the findings file/,
);
});
it('merges both steps into one gap when both skipped the findings file', () => {
const p = plan();
step45(p, 'reverse-audit--round-1--abc123def456', {
findings: true,
opensFindings: false,
});
step45(p, 'verify--abc123def456', {
findings: true,
opensFindings: false,
});
const r = verificationGaps(p, { postsFindings: true }, ENV);
expect(r.ok).toBe(false);
expect(r.gaps).toHaveLength(1);
expect(r.gaps[0].subject).toBe('verification and reverse audit');
expect(r.gaps[0].reason).toMatch(/never read the findings file/);
expect(r.unverifiedFindings).toBe(true);
});
it('merges both steps into one gap when they failed the same way', () => {
// #7268: the posted body carried the verify and reverse-audit `rewritten`
// sentences back to back, near-identical but for the tail. One shape, one
@@ -24,6 +24,7 @@ import { countInlineFindings } from './lib/inline-counts.js';
import {
composeReview,
buildLedger,
repositoryContextGate,
scriptLintGate,
testPlanGate,
composeReviewCommand,
@@ -98,6 +99,7 @@ function plan(
effort?: 'low' | 'medium' | 'high';
/** Override the fixture's 5000 — the low-signal floor reads this. */
srcDiffLines?: number;
repositoryContext?: unknown;
} = {},
): string {
const p = join(dir, 'plan.json');
@@ -111,6 +113,9 @@ function plan(
// The effort the capturing command recorded — the roster and the
// reverse-audit floor both read it from here.
...(opts.effort ? { effort: opts.effort } : {}),
...(opts.repositoryContext === undefined
? {}
: { repositoryContext: opts.repositoryContext }),
srcDiffLines: opts.srcDiffLines ?? 5000,
diffLines: 5000,
files: [{ path: 'a.ts', kind: 'source', removedLines: 0, heavy: false }],
@@ -329,6 +334,7 @@ function coveredPlan(
han?: boolean;
effort?: 'low' | 'medium' | 'high';
srcDiffLines?: number;
repositoryContext?: unknown;
} = {},
): string {
transcript('a1', goodPrompt(1), { toolCalls: 3 });
@@ -458,6 +464,197 @@ describe('composeReview — the low-signal Approve disclosure', () => {
});
});
describe('repository context proof boundary', () => {
it('derives unreviewed dimensions from the validated plan, not model input', () => {
const planPath = join(dir, 'repository-plan.json');
writeFileSync(
planPath,
JSON.stringify({
repositoryContext: {
version: 1,
provider: 'fake-provider',
label: 'Example project',
domains: ['runtime'],
relatedPaths: [],
recommendedTests: [],
requiredConfigurations: ['linux-x64'],
requiredAgents: ['test-matrix'],
unverifiedDimensions: ['Alternate runtime was not exercised'],
verificationNotes: [],
},
}),
);
expect(repositoryContextGate(planPath)).toEqual([
'`Alternate runtime was not exercised` — the repository context marks this proof boundary as unverified',
]);
});
it('renders manifest-controlled proof boundaries as inert Markdown', () => {
const planPath = join(dir, 'mention-plan.json');
writeFileSync(
planPath,
JSON.stringify({
repositoryContext: {
version: 1,
provider: 'manifest',
label: 'Example project',
domains: [],
relatedPaths: [],
recommendedTests: [],
requiredConfigurations: [],
requiredAgents: [],
unverifiedDimensions: ['@security-team'],
verificationNotes: [],
},
}),
);
expect(repositoryContextGate(planPath)).toEqual([
'`@security-team` — the repository context marks this proof boundary as unverified',
]);
});
it('caps the unverified-dimension disclosure at five entries', () => {
// The schema admits 128 dimensions x 512 chars; joined into one
// disclosure that outruns the review body's own size budget — the same
// cap discipline testPlanGate applies to its notes.
const planPath = join(dir, 'capped-plan.json');
writeFileSync(
planPath,
JSON.stringify({
repositoryContext: {
version: 1,
provider: 'fake-provider',
label: 'Example project',
domains: [],
relatedPaths: [],
recommendedTests: [],
requiredConfigurations: [],
requiredAgents: [],
unverifiedDimensions: Array.from(
{ length: 8 },
(_, index) => `dimension ${index}`,
),
verificationNotes: [],
},
}),
);
expect(repositoryContextGate(planPath)).toEqual([
...Array.from(
{ length: 5 },
(_, index) =>
`\`dimension ${index}\` — the repository context marks this proof boundary as unverified`,
),
'and 3 more',
]);
});
it('returns no extra disclosure when the plan has no repository context', () => {
const planPath = join(dir, 'generic-plan.json');
writeFileSync(planPath, JSON.stringify({ files: [] }));
expect(repositoryContextGate(planPath)).toEqual([]);
});
it('returns nothing for an unreadable plan but fails closed on a malformed context', () => {
// Unreadable plan: the coverage gate owns plan validity; the disclosure
// has nothing to say. Present-but-INVALID context: every consumer of the
// field fails closed, so the gate throws instead of silently dropping the
// disclosure.
const missing = join(dir, 'missing-plan.json');
expect(repositoryContextGate(missing)).toEqual([]);
const malformed = join(dir, 'malformed-plan.json');
writeFileSync(
malformed,
JSON.stringify({ repositoryContext: { version: 1 } }),
);
expect(() => repositoryContextGate(malformed)).toThrow(
'unknown or missing fields',
);
});
it('keeps the disclosure on a REQUEST_CHANGES body', () => {
// The RC render site is a separate code path from APPROVE; deleting the
// block there must fail the suite, not ship green.
const planPath = coveredPlan(undefined, {
repositoryContext: {
version: 1,
provider: 'fake-provider',
label: 'Example project',
domains: [],
relatedPaths: [],
recommendedTests: [],
requiredConfigurations: [],
requiredAgents: [],
unverifiedDimensions: ['Alternate runtime was not exercised'],
verificationNotes: [],
},
});
const result = composeReview({
planPath,
env: ENV,
modelId: MODEL,
bodyCriticals: ['whole-PR blocker X'],
});
expect(result.event).toBe('REQUEST_CHANGES');
expect(result.body).toContain('Repository proof boundary (not a blocker)');
expect(result.body).toContain('Alternate runtime was not exercised');
});
it('keeps the disclosure when a cap downgrades the verdict to COMMENT', () => {
// An APPROVE capped at COMMENT renders through the COMMENT clause
// composer — the third render site — and the disclosure must survive
// exactly the verdicts where the reader most needs the boundary.
const planPath = coveredPlan(undefined, {
repositoryContext: {
version: 1,
provider: 'fake-provider',
label: 'Example project',
domains: [],
relatedPaths: [],
recommendedTests: [],
requiredConfigurations: [],
requiredAgents: [],
unverifiedDimensions: ['Alternate runtime was not exercised'],
verificationNotes: [],
},
});
const result = composeReview({
planPath,
env: ENV,
modelId: MODEL,
cannotTellCriticals: ['SKILL.md:35 — full text unfetchable'],
});
expect(result.event).toBe('COMMENT');
expect(result.cappedBy).toContain('cannot-tell-existing-critical');
expect(result.body).toContain('Repository proof boundary (not a blocker)');
expect(result.body).toContain('Alternate runtime was not exercised');
});
it('discloses repository proof boundaries without permanently capping approval', () => {
const planPath = coveredPlan(undefined, {
repositoryContext: {
version: 1,
provider: 'fake-provider',
label: 'Example project',
domains: ['runtime'],
relatedPaths: [],
recommendedTests: [],
requiredConfigurations: ['linux-x64'],
requiredAgents: [],
unverifiedDimensions: ['Alternate runtime was not exercised'],
verificationNotes: [],
},
});
const result = composeReview({ planPath, env: ENV, modelId: MODEL });
expect(result.event).toBe('APPROVE');
expect(result.cappedBy).not.toContain('unreviewed-dimension');
expect(result.body).toContain('Repository proof boundary (not a blocker)');
expect(result.body).toContain('Alternate runtime was not exercised');
});
});
describe('composeReview — event caps (round-7 Critical #2: caps must reach every path)', () => {
it('a cannot-tell existing Critical caps APPROVE at COMMENT and is serialized (round-7: body said Unresolved while event said APPROVE)', () => {
const r = composeReview(
@@ -44,6 +44,7 @@ import {
reviewMode,
type RosterPlan,
} from './lib/roster.js';
import { repositoryContextOf } from './lib/repository-context.js';
import { diffHashOf, type ScriptLintReport } from './script-lint.js';
import type { TestPlanReport } from './test-plan.js';
import {
@@ -482,12 +483,18 @@ function composeReviewBody(
// Test Plan rulings. Disclosed on every verdict and counted toward nothing —
// see `testPlanGate` for why this one neither blocks nor caps.
const testPlanNotes: string[] = [];
// Repository proof boundaries are also disclosures, not findings or permanent
// approval caps. The first schema has no validated evidence channel that could
// resolve one after a specialist inspects it, so capping here would make every
// affected review impossible to approve.
const repositoryContextNotes: string[] = [];
if (input.planPath) {
const gate = scriptLintGate(input.planPath);
bodyCriticals.push(...gate.criticals); // render + count toward `c`, deterministic
unreviewed.push(...gate.unreviewed);
gateDisclosed.push(...gate.disclosed);
testPlanNotes.push(...testPlanGate(input.planPath).notes);
repositoryContextNotes.push(...repositoryContextGate(input.planPath));
}
// The Criticals a verifier must have ruled on before this review may post them as
@@ -906,17 +913,21 @@ function composeReviewBody(
// keeps its bare Approve — there, finding nothing is the expected outcome.
let lowSignal: ComposeReviewResult['lowSignal'] = null;
if (event === 'APPROVE' && input.planPath) {
let plan: RosterPlan | undefined;
try {
const plan = JSON.parse(
readFileSync(input.planPath, 'utf8'),
) as RosterPlan;
plan = JSON.parse(readFileSync(input.planPath, 'utf8')) as RosterPlan;
} catch {
// Unreadable plan, no disclosure — the coverage gate owns plan validity.
}
// A malformed repositoryContext inside an otherwise-readable plan is NOT
// swallowed here: requiredAgents throws, fail-closed like every other
// consumer of the field. On a real APPROVE the coverage gate already
// validated it.
if (plan) {
const src = Number(plan.srcDiffLines ?? 0);
if (src > LOW_SIGNAL_SRC_DIFF_LINES) {
lowSignal = { agents: requiredAgents(plan).length, srcDiffLines: src };
}
} catch {
// Unreachable on a real APPROVE — the coverage gate already read this
// plan — and a disclosure must never take the review down.
}
}
@@ -1199,6 +1210,15 @@ function composeReviewBody(
},
];
const repositoryContextBlock: Bi[] = repositoryContextNotes.length
? [
{
en: `Repository proof boundary (not a blocker): ${repositoryContextNotes.join('; ')}.`,
zh: `仓库验证边界(非阻断):${repositoryContextNotes.join('; ')}。`,
},
]
: [];
if (event === 'REQUEST_CHANGES') {
// Empty body, except the disclosures: every clause whose state holds
// appears on every event — a confirmed blocker must not squeeze out the
@@ -1211,6 +1231,7 @@ function composeReviewBody(
...unverifiedTagsBlock,
...deferredBlock,
...testPlanBlock,
...repositoryContextBlock,
...bodyCriticalBlock,
];
return {
@@ -1233,8 +1254,13 @@ function composeReviewBody(
{ en: 'No issues found. LGTM! ✅', zh: '未发现问题。LGTM!✅' },
...deferredBlock,
...testPlanBlock,
...repositoryContextBlock,
],
deferredBlock.length || testPlanBlock.length ? '\n\n' : ' ',
deferredBlock.length ||
testPlanBlock.length ||
repositoryContextBlock.length
? '\n\n'
: ' ',
),
baseEvent,
cappedBy,
@@ -1357,6 +1383,10 @@ function composeReviewBody(
// the reviewed tree does not bear out.
clauses.push(...testPlanBlock);
// 6d. Repository proof boundaries (non-capping) — dimensions the context
// planner recommends disclosing without claiming the code is defective.
clauses.push(...repositoryContextBlock);
// 7. Body Criticals — on a COMMENT that stands where a REQUEST_CHANGES
// would have been: the presubmit carve-out, and the unverified-blockers
// cap. Either way the body copy is the ONLY copy of an unanchorable
@@ -1469,6 +1499,37 @@ const fetchPrBodyViaGh: PrBodyFetcher = (ownerRepo, prNumber) => {
return (JSON.parse(json) as { body?: string }).body ?? '';
};
export function repositoryContextGate(planPath: string): string[] {
let plan: RosterPlan;
try {
plan = JSON.parse(readFileSync(planPath, 'utf8')) as RosterPlan;
} catch {
// An unreadable plan has nothing to disclose; the coverage gate owns plan
// validity and already fails closed on it.
return [];
}
// A PRESENT-but-invalid context is a corrupted plan, and every consumer of
// this field fails closed on one — coverage throws, the roster throws; the
// disclosure cannot be the one place that silently shrugs.
const context = repositoryContextOf(plan);
const dimensions = context?.unverifiedDimensions ?? [];
// The same cap discipline testPlanGate applies: unbounded entries joined
// into one disclosure drown the verdict they ride on — and at the schema
// bounds (128 x 512 chars) the paragraph outruns the review body's own
// budget before any other content gets a word in.
const MAX_DIMENSIONS = 5;
const disclosed = dimensions
.slice(0, MAX_DIMENSIONS)
.map(
(dimension) =>
`${mdField(dimension)} — the repository context marks this proof boundary as unverified`,
);
if (dimensions.length > MAX_DIMENSIONS) {
disclosed.push(`and ${dimensions.length - MAX_DIMENSIONS} more`);
}
return disclosed;
}
/**
* Read the script-lint report the orchestrator wrote and turn it into verdict
* inputs, deterministically. Returns the pre-confirmed `[lint]` Criticals (a
+14 -10
View File
@@ -164,7 +164,10 @@ function readUsage(
/**
* An auditor's own brief line, exactly as `buildRoleLaunchPrompt` prints it.
* A bare `reverse-audit--chunk-N--round-M--<hex>` path mentioned anywhere is
* NOT this: folded findings quote other rounds' brief paths routinely.
* NOT this — quoted mentions must never be credited. The findings section
* cannot inject one: since #8597 it is a pointer to a `.findings.md` file,
* which this regex's `.brief.md` suffix cannot match, and the list itself no
* longer rides the launch prompt.
*/
const AUDIT_BRIEF_RE =
/read_file\(file_path="[^"]*reverse-audit--chunk-(\d+)--round-(\d+)--[0-9a-f][^"]*\.brief\.md"\)/g;
@@ -172,13 +175,14 @@ const AUDIT_BRIEF_RE =
/** A role label out of the launch prompt, else the fallback. */
function labelOf(launch: string, fallback: string): string {
// Every identity-based parse stays on the identity LINE, and nothing runs
// at all without one at the head. The folded findings below it can quote
// budget disclosures' "(round N)", other agents' `Your file:` lines, ledger
// rows, and `You are review agent` lines — a whole-launch match would hand
// the quoted label to the agent carrying the quote. A prompt with no
// identity line is an older harness's, or an agent this review never
// launched (the session's transcript dir also holds nested subagents): its
// free text can name anything, so keep the one label it owns — the file id.
// at all without one at the head. The text below it — the findings section,
// the builder's own prose — can quote budget disclosures' "(round N)",
// other agents' `Your file:` lines, ledger rows, and `You are review agent`
// lines — a whole-launch match would hand the quoted label to the agent
// carrying the quote. A prompt with no identity line is an older harness's,
// or an agent this review never launched (the session's transcript dir also
// holds nested subagents): its free text can name anything, so keep the one
// label it owns — the file id.
const nl = launch.indexOf('\n');
const identity = nl === -1 ? launch : launch.slice(0, nl);
if (!identity.startsWith('You are review agent `')) return fallback;
@@ -186,8 +190,8 @@ function labelOf(launch: string, fallback: string): string {
// finder; only its brief path carries the stage and the round — without
// it, five audit rounds fold into one row and the ledger reports one agent
// where six pipeline stages ran. Match the agent's OWN brief line, never a
// quoted mention: the folds sit ABOVE the agent's own brief line
// (foldFindings folds them there), so the last brief-shaped read_file in
// quoted mention: the findings section sits ABOVE the agent's own brief
// line (foldFindings puts it there), so the last brief-shaped read_file in
// the launch is the agent's own.
let auditChunk: RegExpExecArray | null = null;
for (const m of launch.matchAll(AUDIT_BRIEF_RE)) auditChunk = m;
@@ -55,6 +55,36 @@ export type RoleId =
| 'verify'
| 'reverse-audit';
/**
* The roles a repository context may require. One list is the single source for
* BOTH the type and the runtime guard: an allow-list the type admitted while the
* guard rejected it (or the reverse) would make the `is` predicate a lie, so
* neither half is written by hand any more.
*/
export const REPOSITORY_CONTEXT_ROLES = [
'1a',
'1b',
'1c',
'2',
'3a',
'3b',
'3c',
'4',
'5',
'6a',
'6b',
'6c',
'test-matrix',
] as const satisfies readonly RoleId[];
export type RepositoryContextRoleId = (typeof REPOSITORY_CONTEXT_ROLES)[number];
export function isRepositoryContextRoleId(
value: string,
): value is RepositoryContextRoleId {
return (REPOSITORY_CONTEXT_ROLES as readonly string[]).includes(value);
}
export interface Brief {
/** How the role is named to a human reading a coverage failure. */
label: string;
@@ -115,8 +145,8 @@ export interface Brief {
*/
acceptsChunk?: boolean;
/**
* May this role be launched `--role <r> --findings <file>`, folding a findings
* list into the prompt the command prints?
* May this role be launched `--role <r> --findings <file>`, so the command
* prints a launch block pointed at the findings list?
*
* The verifier rules on findings; the reverse auditor avoids re-reporting them.
* Both used to get their findings the same way: the command printed a launch
@@ -124,10 +154,13 @@ export interface Brief {
* hand-assembly is where the prompt got paraphrased — the model added a round
* number, inserted its own summary, and truncated the line telling it the brief
* is authoritative — so the delivery check failed even though the agent opened
* its brief. With this flag the command folds the findings in and prints one
* block to paste, and there is no assembly step left to drift. The findings are
* part of the recorded prompt (see runAgentPrompt), keyed per findings digest,
* so a launch that drops or rewrites them matches no record.
* its brief. With this flag the command copies the list to a digest-named file
* and prints one block to paste — a pointer to that file, not the list itself,
* which inlined made a 12-14-agent launch one 65-82 KB message (issue #8597) —
* and there is no assembly step left to drift. The pointer is part of the
* recorded prompt (see runAgentPrompt), keyed per findings digest, so a launch
* that drops it matches no record, and the delivery floor counts the read it
* instructs exactly as it counts the brief's.
*/
acceptsFindings?: boolean;
/** The agent-facing text. */
@@ -545,7 +578,7 @@ Report a **Critical** for each violation, and give **both** locations that toget
publicLabel: 'verification',
publicLabelZh: '验证',
readsDiff: true,
brief: `You are a **verification agent**. You do not look for new problems — you rule on the findings you were handed, listed in the message that launched you, each with a file, a line, an issue, and a **failure scenario**. The failure scenario is the finding's testable claim, and your verdict is the **result of tracing it through the real code**, not a plausibility vote on how the finding reads.
brief: `You are a **verification agent**. You do not look for new problems — you rule on the findings you were handed. They are not in the message that launched you as plain prose — when that message points at a **findings file**, \`read_file\` the \`.findings.md\` path it names, ALL of it, right after this brief (page with a larger \`offset\` if a read comes back \`isTruncated\`); on the rare write-failure fallback the list is inlined in the launch message itself, and you rule on it there instead. Each finding has a file, a line, an issue, and a **failure scenario**. The failure scenario is the finding's testable claim, and your verdict is the **result of tracing it through the real code**, not a plausibility vote on how the finding reads.
For each finding you were given:
@@ -646,7 +679,7 @@ The asymmetry cuts both ways: confirming also requires the trace, and a finding
publicLabel: 'reverse audit',
publicLabelZh: '反向审计',
readsDiff: true,
brief: `You are a **reverse audit agent**. Prior agents have already reviewed this diff and their confirmed findings are listed in the message that launched you. Your job is not to re-report them — it is to find the **gaps**: the important issues no prior agent or round caught.
brief: `You are a **reverse audit agent**. Prior agents have already reviewed this diff; their confirmed findings are not in the message that launched you as plain prose — when that message points at a **findings file**, \`read_file\` the \`.findings.md\` path it names, ALL of it, right after this brief (page with a larger \`offset\` if a read comes back \`isTruncated\`); on the rare write-failure fallback the list is inlined in the launch message itself, and you read it there instead. An early round on a clean review names no file and tells you nothing is confirmed yet — then there is no list to avoid. Your job is not to re-report them — it is to find the **gaps**: the important issues no prior agent or round caught.
- **Read your scope in full** with the diff reads the message gives you — page a truncated read rather than reasoning from its first screenful. A reverse audit that saw a fraction of its scope and returned "No issues found" is worse than none: it ends the loop on a lie.
- **Focus exclusively on what is not already in the finding list.** Assume the obvious defects are found; look where a first pass does not: the interaction between two changes, the assumption that holds in the common case and breaks in the rare one, the removed guard whose replacement is three files away.
@@ -63,6 +63,7 @@ import {
readRecordedPrompts,
wasDeliveredVerbatim,
briefPath,
findingsPointerOf,
} from './prompt-record.js';
import {
requiredAgents,
@@ -886,9 +887,10 @@ export function coverageFromTranscripts(
}
/**
* How a Step 4/5 step's agents got their prompt — four shapes, four different fixes.
* How a Step 4/5 step's agents got their prompt — five shapes, five different fixes.
*
* `ok` — an agent was launched with the prompt the CLI built and opened its brief.
* `ok` — an agent was launched with the prompt the CLI built, opened its brief,
* and — when the built prompt points at one — read the findings file.
* `not-built` — `agent-prompt --role <r>` never ran. Decided before the transcripts
* are consulted (there is no brief whose open could be looked for), so it proves
* the builder was skipped — NOT that no agent ran: a hand-written launch with no
@@ -898,13 +900,20 @@ export function coverageFromTranscripts(
* `rewritten` — an agent ran and opened the brief, but no agent got the built prompt
* intact: the orchestrator wrote the launch itself.
* `brief-unread` — an agent got the built prompt and never opened the brief it names.
* `findings-unread` — an agent got the built prompt and opened its brief, but never
* read the findings file the prompt points at. Since #8597 the verify/reverse-audit
* list rides that file (the block carries only the pointer), and the brief's read
* receipt does not cover it — an instruction-skipping agent could open the brief,
* skip the one instructed findings read, and rule on a list it never saw. The read
* is a tool call like the brief's, so it is checked the same way.
*/
type Delivery =
| 'ok'
| 'not-built'
| 'not-launched'
| 'rewritten'
| 'brief-unread';
| 'brief-unread'
| 'findings-unread';
/**
* Two sentences per failed shape, for two different readers.
@@ -914,7 +923,7 @@ type Delivery =
* command (`agent-prompt --findings …` is not something an author can run, and on
* #7012 fourteen lines of exactly that register WERE the public review). `fix` is
* the per-shape remediation, printed to stderr where the orchestrator reads — the
* four shapes exist because the four fixes differ, and that precision belongs to
* shapes exist because the fixes differ, and that precision belongs to
* the reader who relaunches agents, not the one who reads the verdict.
*/
interface GapEntry {
@@ -1015,6 +1024,19 @@ const REVERSE_AUDIT_GAP: GapText = {
'relaunch with the same printed prompt — the agent must OPEN the brief ' +
'file the prompt names; that read is the receipt',
},
'findings-unread': {
gap:
'it was launched with the built prompt and opened its brief, but never ' +
'read the findings file the prompt points at, so it audited without ' +
'the confirmed list it was launched against',
gapZh:
'它用构建的 prompt 启动并打开了自己的 brief,却从未读取 prompt 所指向的 ' +
'findings 文件,审计时缺失了它本应对照的已确认发现列表',
fix:
'relaunch with the same printed prompt — the agent must OPEN the brief ' +
'file AND read the findings file the prompt names; those reads are ' +
'the receipt',
},
};
const VERIFY_GAP: GapText = {
@@ -1059,6 +1081,19 @@ const VERIFY_GAP: GapText = {
'relaunch with the same printed prompt — the agent must OPEN the brief ' +
'file the prompt names; that read is the receipt',
},
'findings-unread': {
gap:
'it was launched with the built prompt and opened its brief, but never ' +
'read the findings file the prompt points at, so it ruled on findings ' +
'it was never shown',
gapZh:
'它用构建的 prompt 启动并打开了自己的 brief,却从未读取 prompt 所指向的 ' +
'findings 文件,等于在未见到这些发现的情况下作出裁定',
fix:
'relaunch with the same printed prompt — the agent must OPEN the brief ' +
'file AND read the findings file the prompt names; those reads are ' +
'the receipt',
},
};
/**
@@ -1117,6 +1152,17 @@ const COMBINED_STEP45_GAP: Record<
'两者都用构建的 prompt 启动,却都从未打开自己的 brief——发现的裁定缺失了' +
'裁定标准,审计也缺失了它本应遵循的只报缺口的方法',
},
'findings-unread': {
en:
'each was launched with its built prompt and opened its brief, but never ' +
'read the findings file its prompt points at, so the findings were ' +
'ruled on by agents never shown them, and the audit ran without the ' +
'confirmed list it was launched against',
zh:
'两者都用构建的 prompt 启动并打开了各自的 brief,却都未读取 prompt 所指向' +
'的 findings 文件——发现是在裁定者未见到它们的情况下被裁定的,审计也缺失' +
'了它本应对照的已确认发现列表',
},
};
export interface VerificationReport {
@@ -1196,7 +1242,7 @@ export function verificationGaps(
// (Step 4) still runs at medium, so its floor below is untouched.
const balancedMedium = (plan as { effort?: unknown }).effort === 'medium';
// How a step's agents actually got their prompt. The floor needs the four shapes
// How a step's agents actually got their prompt. The floor needs the shapes
// apart, not one boolean, because the fix for each is different — and a refusal
// that names the wrong one is a refusal that gets argued with.
//
@@ -1213,14 +1259,41 @@ export function verificationGaps(
// lesson `parseTranscript` learned for the diff path: a bare substring credits
// `…/x.brief.md.bak` for `…/x.brief.md`. `successfulCallArgs` are already
// `JSON.stringify(args)`, so the quoted path is what a real read of the brief
// leaves in them.
// leaves in them. The findings file — the list a findings-role block points
// at since #8597 — is matched the same way: the pointer comes from the
// recorded prompt itself (a per-chunk key and its round's findings file
// are keyed differently, so the key cannot derive the path), and a prompt
// with no pointer (an empty early round, a pre-#8597 inlined list, or a
// round whose findings-file write failed and fell back to inlining) owes
// no findings read. Deliberate weakening versus the inlined shape this
// replaced: the floor proves the findings file was OPENED (one successful
// read_file of the path — no other tool's args count), not that it was
// paged to completion — `read_file` truncates, so a first-page-only read
// still leaves a matching `fNeedle`.
// The old `wasDeliveredVerbatim` required the whole list in the delivered
// prompt; the pointer proves delivery of the pointer line, not receipt of
// the whole list. Accepted: the brief now orders the full read, and a
// verifier that under-reads surfaces in the verdicts it gets wrong.
const needle = JSON.stringify(briefPath(planPath, key));
const opened = (r: AgentRecord) =>
r.successfulCallArgs.some((a) => a.includes(needle));
const findingsPointer = findingsPointerOf(b);
const readTheFindings = (r: AgentRecord) => {
if (findingsPointer === null) return true;
const fNeedle = JSON.stringify(findingsPointer);
// Successful read_file calls ONLY: every tool serializes its args, and
// a `search_file_content` or a `list_directory` over the record dir
// names the path without reading a line of it. The floor certifies
// that the list was OPENED, and a mention is not an open.
return r.successfulReadFileArgs.some((a) => a.includes(fNeedle));
};
const gotTheBuiltPrompt = records.filter((r) =>
wasDeliveredVerbatim(r.launchPrompt, b),
);
if (gotTheBuiltPrompt.some(opened)) return 'ok';
if (gotTheBuiltPrompt.some((r) => opened(r) && readTheFindings(r))) {
return 'ok';
}
if (gotTheBuiltPrompt.some(opened)) return 'findings-unread';
if (gotTheBuiltPrompt.length > 0) return 'brief-unread';
// Nothing was launched with the built prompt. Did anything open this key's brief
// anyway? Then an agent DID run — on a launch the orchestrator wrote itself. A
@@ -1234,10 +1307,11 @@ export function verificationGaps(
if (keys.length === 0) return 'not-built';
const rank: Record<Delivery, number> = {
ok: 0,
'brief-unread': 1,
rewritten: 2,
'not-launched': 3,
'not-built': 4,
'findings-unread': 1,
'brief-unread': 2,
rewritten: 3,
'not-launched': 4,
'not-built': 5,
};
return keys
.map(deliveryOf)
@@ -1295,9 +1369,10 @@ export function verificationGaps(
let unverifiedFindings = false;
let verify: Delivery | null = null;
if (opts.postsFindings) {
// The whole key family: `verify--<digest>` per shard (the record now folds
// the findings in, so a launch that dropped them matches nothing), plus the
// bare legacy key. Floor of one, as documented.
// The whole key family: `verify--<digest>` per shard (the record carries
// the findings-file pointer, and `deliveryOf` now also requires the agent
// to have read that file, so a launch that dropped the read matches
// nothing), plus the bare legacy key. Floor of one, as documented.
const verifyKeys = [...built.keys()].filter(
(k) => k === 'verify' || k.startsWith('verify--'),
);
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,617 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { lstatSync, readdirSync, realpathSync, statSync } from 'node:fs';
import { isAbsolute, relative, resolve, sep } from 'node:path';
import type { RepositoryContextRoleId } from './agent-briefs.js';
import { isRepositoryContextRoleId } from './agent-briefs.js';
import type {
RepositoryContext,
RepositoryContextProvider,
} from './repository-context.js';
import {
compareText,
isControlFree,
isSafeRepositoryRelativePath,
MAX_ARRAY_ITEMS,
MAX_IDENTITY_BYTES,
MAX_LABEL_LENGTH,
MAX_NOTE_LENGTH,
MAX_PATH_LENGTH,
MAX_TOKEN_LENGTH,
validateBoundedString,
validateBoundedStringArray,
validateRepositoryContext,
} from './repository-context.js';
const MANIFEST_PATH = '.qwen/review-context.json';
/**
* Visited-entry ceiling for one `relatedPaths` expansion, counted across all
* scan roots. Dependency and build-output trees (SKIPPED_DIRECTORIES) are
* never descended into, so only source-bearing entries count. Calibrated on
* this repository: the whole `packages/` tree of an installed checkout stays
* under it, so a honestly scoped manifest never fails a review, while a
* pathological scan still ends. Exceeded, the provider throws (fail closed),
* like every other manifest error.
*/
export const MAX_GLOB_CANDIDATES = 16384;
/**
* Matching-work ceiling for one stage. The visited-entry cap bounds a
* COUNT; the per-candidate matching work is a separate dimension — one
* memoised `**` match is quadratic in segment LENGTH, and in an untrusted
* repository an attacker controls both lengths within their schema maxima
* (255-byte filenames, 512-character patterns), so billing segment COUNTS
* never trips for a schema-legal stall shape. Every attempted pattern match
* therefore charges `pattern.length × path.length` against this budget, in
* the rule filter as well as the expansion, so a matching burst fails
* closed instead of stalling the step. Calibrated so the documented
* legitimate scan — every entry of an installed-checkout `packages/` tree
* against a handful of realistic globs — stays far below it, while a
* schema-max adversarial evaluation exhausts it within the first few dozen
* candidates.
*/
export const MAX_MATCH_WORK = 1024 * 1024 * 1024;
const MAX_RULES = 128;
const MANIFEST_PREFIX = 'repository context manifest ';
// Dependency and build-output trees hold orders of magnitude more entries
// than any source subtree and can never be a review target; descending into
// them would exhaust the visited-entry ceiling on every installed checkout.
// Names tracked source must not live under in this repository's conventions
// (a `build/` directory holds real scripts here) stay out of this set.
// Membership is compared case-insensitively at every enforcement site, or a
// case-varied pattern walks into a skipped tree on every platform.
const SKIPPED_DIRECTORIES = new Set([
'.git',
'.next',
'.turbo',
'coverage',
'dist',
'node_modules',
'out',
'target',
]);
const MANIFEST_KEYS = ['label', 'rules', 'version'].sort();
const RULE_KEYS = [
'domains',
'paths',
'recommendedTests',
'relatedPaths',
'requiredAgents',
'requiredConfigurations',
'unverifiedDimensions',
'verificationNotes',
].sort();
interface ManifestRule {
paths: string[];
relatedPaths: string[];
domains: string[];
recommendedTests: string[];
requiredConfigurations: string[];
requiredAgents: RepositoryContextRoleId[];
unverifiedDimensions: string[];
verificationNotes: string[];
}
interface Manifest {
label: string;
rules: ManifestRule[];
}
function hasExactKeys(
value: Record<string, unknown>,
expected: string[],
): boolean {
const keys = Object.keys(value).sort();
return (
keys.length === expected.length &&
keys.every((key, index) => key === expected[index])
);
}
function validateManifestString(
value: unknown,
field: string,
maxLength: number,
): asserts value is string {
validateBoundedString(value, field, maxLength, MANIFEST_PREFIX);
}
/**
* Manifest arrays are human-authored, so they need only be UNIQUE — hand-
* sorting a config file is a sharp edge that would fail whole reviews over
* cosmetics. The provider merges and sorts before the wire format's strict
* sorted-and-unique validator ever sees the result (see sortedUnique below).
*/
function validateManifestStringArray(
value: unknown,
field: string,
maxLength: number,
): asserts value is string[] {
validateBoundedStringArray(value, field, maxLength, MANIFEST_PREFIX);
if (new Set(value).size !== value.length) {
throw new Error(`${MANIFEST_PREFIX}${field} must not contain duplicates`);
}
}
function validateGlob(pattern: string, field: string): void {
const segments = pattern.split('/');
if (
pattern.length > MAX_PATH_LENGTH ||
!isControlFree(pattern) ||
pattern.startsWith('/') ||
/^[A-Za-z]:/.test(pattern) ||
pattern.startsWith('!') ||
pattern.includes('\\') ||
/[{}[\]()]/.test(pattern) ||
segments.some(
(segment) =>
segment === '' ||
segment === '.' ||
segment === '..' ||
(segment.includes('**') && segment !== '**'),
)
) {
throw new Error(
`repository context manifest ${field} contains unsafe glob`,
);
}
// The never-descend invariant below is enforced for entries discovered
// during recursion; a pattern rooted inside a skipped tree would bypass
// it through the scan roots, so such patterns are rejected here too.
if (
segments.some((segment) => SKIPPED_DIRECTORIES.has(segment.toLowerCase()))
) {
throw new Error(
`repository context manifest ${field} enters a skipped directory`,
);
}
}
function validateGlobArray(
value: unknown,
field: string,
requireDirectoryPrefix: boolean,
): asserts value is string[] {
validateManifestStringArray(value, field, MAX_PATH_LENGTH);
for (const pattern of value) {
validateGlob(pattern, field);
// A wildcard glob must start below a non-wildcard directory segment so
// expansion cannot begin with a repository-wide wildcard. A completely
// static entry can never start with one, so it may sit at the top level
// and resolves to itself when it exists as a regular file.
if (requireDirectoryPrefix && /[*?]/.test(pattern.split('/')[0])) {
throw new Error(
`repository context manifest ${field} requires a directory prefix`,
);
}
}
}
function optionalStringArray(
rule: Record<string, unknown>,
field: keyof Omit<ManifestRule, 'paths' | 'requiredAgents'>,
maxLength: number,
): string[] {
const value = rule[field];
if (value === undefined) return [];
validateManifestStringArray(value, field, maxLength);
return value;
}
function parseManifest(content: string): Manifest {
if (content.length > MAX_IDENTITY_BYTES) {
throw new Error('repository context manifest exceeds the size limit');
}
let value: unknown;
try {
value = JSON.parse(content);
} catch {
throw new Error('repository context manifest is not valid JSON');
}
if (
typeof value !== 'object' ||
value === null ||
Array.isArray(value) ||
!hasExactKeys(value as Record<string, unknown>, MANIFEST_KEYS)
) {
throw new Error(
'repository context manifest has unknown or missing fields',
);
}
const manifest = value as Record<string, unknown>;
if (manifest['version'] !== 1) {
throw new Error('unsupported repository context manifest version');
}
validateManifestString(manifest['label'], 'label', MAX_LABEL_LENGTH);
if (
!Array.isArray(manifest['rules']) ||
manifest['rules'].length > MAX_RULES
) {
throw new Error('repository context manifest rules is invalid');
}
const rules = manifest['rules'].map((value, index): ManifestRule => {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error(`repository context manifest rules[${index}] is invalid`);
}
const rule = value as Record<string, unknown>;
const keys = Object.keys(rule).sort();
if (
!keys.includes('paths') ||
keys.some((key) => !RULE_KEYS.includes(key))
) {
throw new Error(
`repository context manifest rules[${index}] has unknown or missing fields`,
);
}
validateGlobArray(rule['paths'], `rules[${index}].paths`, false);
const relatedPaths = rule['relatedPaths'];
if (relatedPaths !== undefined) {
validateGlobArray(relatedPaths, `rules[${index}].relatedPaths`, true);
}
const requiredAgents = rule['requiredAgents'];
if (requiredAgents !== undefined) {
validateManifestStringArray(
requiredAgents,
`rules[${index}].requiredAgents`,
MAX_TOKEN_LENGTH,
);
if (requiredAgents.some((role) => !isRepositoryContextRoleId(role))) {
throw new Error(
`repository context manifest rules[${index}].requiredAgents contains an unsupported role`,
);
}
}
return {
paths: rule['paths'],
relatedPaths: relatedPaths ?? [],
domains: optionalStringArray(rule, 'domains', MAX_TOKEN_LENGTH),
recommendedTests: optionalStringArray(
rule,
'recommendedTests',
MAX_TOKEN_LENGTH,
),
requiredConfigurations: optionalStringArray(
rule,
'requiredConfigurations',
MAX_TOKEN_LENGTH,
),
requiredAgents: (requiredAgents ?? []) as RepositoryContextRoleId[],
unverifiedDimensions: optionalStringArray(
rule,
'unverifiedDimensions',
MAX_NOTE_LENGTH,
),
verificationNotes: optionalStringArray(
rule,
'verificationNotes',
MAX_NOTE_LENGTH,
),
};
});
// Bound the rule-matching work fail-closed: the filter tests every changed
// path against every `paths` pattern, so the total — not just each rule's
// array — is what a bulk-change PR multiplies against.
const totalPathPatterns = rules.reduce(
(sum, rule) => sum + rule.paths.length,
0,
);
if (totalPathPatterns > MAX_ARRAY_ITEMS) {
throw new Error(`${MANIFEST_PREFIX}paths exceeds limit`);
}
return { label: manifest['label'], rules };
}
// Polynomial wildcard match for one segment. The backtracking regex this
// replaced went exponential on a segment with several `*` — a legal manifest
// pattern plus a legal long filename hangs one `test()` call effectively
// forever, and in an untrusted repository both halves are attacker-committed.
function segmentMatches(pattern: string, value: string): boolean {
let patternIndex = 0;
let valueIndex = 0;
let starIndex = -1;
let markIndex = 0;
while (valueIndex < value.length) {
const character = pattern[patternIndex];
if (character === '?' || character === value[valueIndex]) {
patternIndex++;
valueIndex++;
} else if (character === '*') {
starIndex = patternIndex;
markIndex = valueIndex;
patternIndex++;
} else if (starIndex !== -1) {
patternIndex = starIndex + 1;
markIndex++;
valueIndex = markIndex;
} else {
return false;
}
}
while (pattern[patternIndex] === '*') patternIndex++;
return patternIndex === pattern.length;
}
function globMatches(pattern: string, path: string): boolean {
const patternSegments = pattern.split('/');
const pathSegments = path.split('/');
const memo = new Map<string, boolean>();
const matches = (patternIndex: number, pathIndex: number): boolean => {
const key = `${patternIndex}:${pathIndex}`;
const cached = memo.get(key);
if (cached !== undefined) return cached;
let result: boolean;
if (patternIndex === patternSegments.length) {
result = pathIndex === pathSegments.length;
} else if (patternSegments[patternIndex] === '**') {
result =
matches(patternIndex + 1, pathIndex) ||
(pathIndex < pathSegments.length &&
matches(patternIndex, pathIndex + 1));
} else {
result =
pathIndex < pathSegments.length &&
segmentMatches(
patternSegments[patternIndex],
pathSegments[pathIndex],
) &&
matches(patternIndex + 1, pathIndex + 1);
}
memo.set(key, result);
return result;
};
return matches(0, 0);
}
function isContainedFile(worktree: string, path: string): boolean {
try {
const resolved = realpathSync(resolve(worktree, path));
const contained = relative(worktree, resolved);
return (
contained !== '' &&
!isAbsolute(contained) &&
contained !== '..' &&
!contained.startsWith(`..${sep}`) &&
statSync(resolved).isFile()
);
} catch {
return false;
}
}
function staticDirectoryPrefix(pattern: string): string {
const prefix: string[] = [];
for (const segment of pattern.split('/')) {
if (segment.includes('*') || segment.includes('?')) break;
prefix.push(segment);
}
return prefix.join('/');
}
function minimalScanRoots(patterns: readonly string[]): string[] {
const roots = sortedUnique(patterns.map(staticDirectoryPrefix));
return roots.filter(
(root, index) =>
!roots.some(
(candidate, candidateIndex) =>
candidateIndex !== index && root.startsWith(`${candidate}/`),
),
);
}
function expandRelatedPaths(
worktree: string,
patterns: readonly string[],
changedPaths: ReadonlySet<string>,
): string[] {
const matches = new Set<string>();
let candidates = 0;
const patternLengths = patterns.map((pattern) => pattern.length);
let matchWork = 0;
const anyPatternMatches = (path: string): boolean => {
for (let index = 0; index < patterns.length; index++) {
matchWork += patternLengths[index] * path.length;
if (matchWork > MAX_MATCH_WORK) {
throw new Error(
'repository context manifest relatedPaths matching work exceeds limit',
);
}
if (globMatches(patterns[index], path)) return true;
}
return false;
};
const visit = (directory: string): void => {
let entries;
try {
const stat = lstatSync(resolve(worktree, directory));
if (stat.isSymbolicLink()) return;
if (!stat.isDirectory()) {
candidates++;
if (candidates > MAX_GLOB_CANDIDATES) {
throw new Error(
'repository context manifest relatedPaths scan exceeds limit',
);
}
const path = directory;
if (
!changedPaths.has(path) &&
isContainedFile(worktree, path) &&
anyPatternMatches(path)
) {
matches.add(path);
if (matches.size > MAX_ARRAY_ITEMS) {
throw new Error(
'repository context manifest relatedPaths exceeds limit',
);
}
}
return;
}
entries = readdirSync(resolve(worktree, directory), {
withFileTypes: true,
});
// Bound the listing before sorting it, or an oversized directory pays
// the full read plus an O(n log n) sort before the cap can trip.
if (candidates + entries.length > MAX_GLOB_CANDIDATES) {
throw new Error(
'repository context manifest relatedPaths scan exceeds limit',
);
}
entries.sort((left, right) => compareText(left.name, right.name));
} catch (error) {
if (
error instanceof Error &&
error.message.startsWith('repository context manifest')
) {
throw error;
}
// A subtree that EXISTS but cannot be read fails the review closed,
// the way the identity reader does — silently skipping it would
// degrade the scan into a complete-looking result with a hole in it.
// Only a racing deletion still reads as "absent".
const code = (error as NodeJS.ErrnoException | null)?.code;
if (code !== 'ENOENT' && code !== 'ENOTDIR') throw error;
return;
}
for (const entry of entries) {
candidates++;
if (candidates > MAX_GLOB_CANDIDATES) {
throw new Error(
'repository context manifest relatedPaths scan exceeds limit',
);
}
if (entry.isSymbolicLink()) continue;
const path = `${directory}/${entry.name}`;
if (entry.isDirectory()) {
if (!SKIPPED_DIRECTORIES.has(entry.name.toLowerCase())) visit(path);
continue;
}
// Disk names can carry POSIX-legal bytes the wire format rejects (a
// backslash, control characters); skip them like changedPaths does
// instead of failing the whole review over one odd filename. The
// shape gates run before matching: an over-long path can never reach
// the wire format, so it can never match, and the cheap checks keep
// the memoised matcher away from disk garbage.
if (
!entry.isFile() ||
changedPaths.has(path) ||
!isSafeRepositoryRelativePath(path) ||
!isContainedFile(worktree, path) ||
!anyPatternMatches(path)
) {
continue;
}
matches.add(path);
if (matches.size > MAX_ARRAY_ITEMS) {
throw new Error(
'repository context manifest relatedPaths exceeds limit',
);
}
}
};
for (const root of minimalScanRoots(patterns)) visit(root);
return [...matches].sort(compareText);
}
function sortedUnique(values: Iterable<string>): string[] {
return [...new Set(values)].sort(compareText);
}
/**
* The merge of all matching rules can outgrow the wire bound even when every
* single rule honors it; cap it here so the error names the manifest instead
* of surfacing later as a shape error from the wire validator. `subject`
* distinguishes the merged PATTERN list of `relatedPaths` from the
* resolved-files cap in the expansion, which reports the same field name —
* an operator diagnosing a fail-closed step must be able to tell whether to
* trim the manifest's glob list or narrow the globs' reach.
*/
function cappedSortedUnique(values: string[], subject: string): string[] {
const merged = sortedUnique(values);
if (merged.length > MAX_ARRAY_ITEMS) {
throw new Error(`${MANIFEST_PREFIX}${subject} exceeds limit`);
}
return merged;
}
export const manifestRepositoryContextProvider: RepositoryContextProvider = {
provide(input) {
const content = input.readIdentityFile(MANIFEST_PATH);
if (content === null) return null;
const manifest = parseManifest(content);
// Nothing caps the changed-path count a bulk diff brings, so the filter
// charges the same budget the expansion does, with the same length-based
// billing — a matching burst must fail closed in this stage too instead
// of stalling the step.
let filterWork = 0;
const matched = manifest.rules.filter((rule) =>
input.changedPaths.some((path) =>
rule.paths.some((pattern) => {
filterWork += pattern.length * path.length;
if (filterWork > MAX_MATCH_WORK) {
throw new Error(
`${MANIFEST_PREFIX}paths matching work exceeds limit`,
);
}
return globMatches(pattern, path);
}),
),
);
if (matched.length === 0) return null;
const changedPaths = new Set(input.changedPaths);
const context: RepositoryContext = {
version: 1,
provider: 'manifest',
label: manifest.label,
domains: cappedSortedUnique(
matched.flatMap((rule) => rule.domains),
'domains',
),
relatedPaths: expandRelatedPaths(
input.worktree,
cappedSortedUnique(
matched.flatMap((rule) => rule.relatedPaths),
'relatedPaths glob list',
),
changedPaths,
),
recommendedTests: cappedSortedUnique(
matched.flatMap((rule) => rule.recommendedTests),
'recommendedTests',
),
requiredConfigurations: cappedSortedUnique(
matched.flatMap((rule) => rule.requiredConfigurations),
'requiredConfigurations',
),
requiredAgents: cappedSortedUnique(
matched.flatMap((rule) => rule.requiredAgents),
'requiredAgents',
) as RepositoryContextRoleId[],
unverifiedDimensions: cappedSortedUnique(
matched.flatMap((rule) => rule.unverifiedDimensions),
'unverifiedDimensions',
),
verificationNotes: cappedSortedUnique(
matched.flatMap((rule) => rule.verificationNotes),
'verificationNotes',
),
};
return validateRepositoryContext(context);
},
};
@@ -19,8 +19,23 @@
// enforces is the rule the skill states: **you may add; you may not remove, alter,
// or reorder.**
import { describe, it, expect } from 'vitest';
import { wasDeliveredVerbatim } from './prompt-record.js';
import { describe, it, expect, vi, type Mock } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
wasDeliveredVerbatim,
findingsPointerOf,
writeFindingsFile,
} from './prompt-record.js';
import { writeStderrLineSafe } from '../../../utils/stdioHelpers.js';
vi.mock('../../../utils/stdioHelpers.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../utils/stdioHelpers.js')>()),
writeStdoutLine: vi.fn(),
writeStderrLine: vi.fn(),
writeStderrLineSafe: vi.fn(),
}));
const BUILT = [
'You are review agent `chunk 1 of 5` — the territory agent for lines 1-389.',
@@ -120,3 +135,83 @@ describe('wasDeliveredVerbatim — you may add; you may not remove, alter or reo
expect(wasDeliveredVerbatim('anything at all', ' \n \n ')).toBe(false);
});
});
describe('findingsPointerOf — the list file a recorded launch points at', () => {
it('extracts the pointer from a findings-role block', () => {
const prompt = [
'You are review agent `verify`.',
'',
'```',
'read_file(file_path="/t/verify--round-1--abc123.findings.md")',
'```',
'',
'**Your brief is a file. Read it first.**',
'read_file(file_path="/t/verify--abc123.brief.md")',
].join('\n');
expect(findingsPointerOf(prompt)).toBe(
'/t/verify--round-1--abc123.findings.md',
);
});
it('returns null for a prompt with no findings pointer', () => {
// A chunk agent's block: brief and diff only — and a `.brief.md` path can
// never match the `.findings.md` suffix the pointer carries.
expect(findingsPointerOf(BUILT)).toBeNull();
});
it('ignores a pointer-shaped line quoted inside an inlined findings list', () => {
// The write-failure fallback inlines the list where the pointer would sit.
// A finding entry there can quote a read_file pointer of ITS own (a finding
// about this pipeline); the anchor must not mistake that quotation for the
// pointer, or retirement would confine-and-read an earlier round's file and
// flip a just-filed finding to an echo. A quoted pointer is indented or
// embedded in prose, so a standalone-line anchor skips it.
const prompt = [
'You are review agent `reverse-audit` (round 2).',
'',
'## Already confirmed — do not re-report these',
'',
'- **File:** src/review/prompt-record.ts:85 — a finding quoting the',
' pointer shape: `read_file(file_path="/t/old.findings.md")`',
'- **Severity:** Suggestion',
'',
'**Your brief is a file. Read it first.**',
'read_file(file_path="/t/reverse-audit--abc123.brief.md")',
].join('\n');
expect(findingsPointerOf(prompt)).toBeNull();
});
});
describe('writeFindingsFile — a failed write must not be silent', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('reports the failure on stderr and returns null so the caller inlines', () => {
// The build must not die on an unwritable record dir — and it must not
// point the round at a file that does not exist either: null is what
// makes findingsSection fall back to inlining the list (the pre-#8597
// shape), instead of 12-14 agents burning a round against a dead path.
// A FILE where the record directory must sit makes mkdir fail. The
// diagnostic goes through writeStderrLineSafe: this catch exists to keep
// the build alive, so it must not throw out of it on EPIPE.
const dir = mkdtempSync(join(tmpdir(), 'pr-ff-'));
try {
const blocker = join(dir, 'blocker');
writeFileSync(blocker, 'a file where a directory would go');
const planPath = join(blocker, 'plan.json');
const p = writeFindingsFile(planPath, 'verify--abc', 'the list');
expect(p).toBeNull();
const calls = (writeStderrLineSafe as unknown as Mock).mock.calls.map(
(c) => c[0] as string,
);
expect(
calls.some((m) => m.includes('failed to write findings file')),
).toBe(true);
expect(calls.some((m) => m.includes('inlining the list instead'))).toBe(
true,
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
@@ -35,6 +35,7 @@ import {
writeFileSync,
} from 'node:fs';
import { dirname, join, basename, resolve } from 'node:path';
import { writeStderrLineSafe } from '../../../utils/stdioHelpers.js';
/**
* Where the prompts this plan's agents were built from are recorded.
@@ -63,6 +64,87 @@ export function briefPath(planPath: string, key: string): string {
return join(promptRecordDir(planPath), `${encodeURIComponent(key)}.brief.md`);
}
/** Where a round's findings list lives — the file the launch block points at. */
export function findingsFilePath(planPath: string, key: string): string {
return join(
promptRecordDir(planPath),
`${encodeURIComponent(key)}.findings.md`,
);
}
/**
* The findings file a recorded launch prompt points at, if any — the pointer
* `findingsSection` bakes into the block. Anchored to the exact shape that
* builder emits: the pointer sits alone on its own line inside its fence.
* The anchor matters because of the write-failure fallback: when the findings
* file could not be written, `findingsSection` inlines the LIST in that same
* position, and a finding entry there can itself quote a
* `read_file(file_path="….findings.md")` line. A loose first-match would then
* extract the QUOTATION as the pointer — and the readers diverge: coverage
* demands a read of a path no agent was told to read (spurious
* `findings-unread` on an already-degraded run), and retirement, worse,
* confines-and-reads an earlier round's file, flipping `yielded` to dry and
* retiring a chunk that just reported — the one direction this module's
* header says it never fails. A quoted pointer inside a findings entry is
* indented or embedded in prose, so requiring it standalone removes it. A
* brief path (`.brief.md`) can never match the `.findings.md` suffix.
* Shared by every reader of the pointer: the delivery floor (coverage) and
* the retirement echo-guard both extract it from the recorded prompt rather
* than deriving a path from the record key — a per-chunk record key and its
* round's findings file are keyed differently, so the prompt is the only
* source that is always right.
*/
export function findingsPointerOf(prompt: string): string | null {
const m = /^read_file\(file_path="([^"]*\.findings\.md)"\)$/m.exec(prompt);
return m && m[1] !== undefined ? m[1] : null;
}
/**
* Write the findings list a verify/reverse-audit launch block points at.
*
* The list used to be folded verbatim into every printed block — the point was
* a record a partial delivery cannot satisfy, and inlining made the findings
* part of the recorded prompt. On a 12-14-chunk round that made the orchestrator
* re-emit 65-82 KB in ONE assistant message, and the stream generating that
* message never completed (issue #8597). So the list goes where the brief already
* goes: on disk, named by the same digest that keys the record, read by the
* agent. The block carries the pointer; dropping it mismatches the recorded
* prompt exactly as dropping the list did, and the delivery floor counts the
* read it instructs exactly as it counts the brief's — an agent that opens
* its brief but skips this file does not clear it.
*
* Returns null when the write fails (a read-only tmp dir): the caller then
* inlines the list into the block — the pre-#8597 shape — instead of
* pointing a whole round at a file that does not exist.
*/
export function writeFindingsFile(
planPath: string,
key: string,
content: string,
): string | null {
const p = findingsFilePath(planPath, key);
try {
mkdirSync(promptRecordDir(planPath), { recursive: true });
writeFileSync(p, content);
} catch (err) {
// A read-only tmp dir must not stop a review being BUILT — and it must
// not send a whole round pointing at a file that does not exist either:
// null makes findingsSection inline the list (the pre-#8597 shape), so
// the agents read what they were launched with, instead of 12-14 of
// them burning a full round against a dead path before the delivery
// floor fails it. Say so now: a silent miss used to leave the round
// pointing at nothing with no notice anywhere. Safe writer: this catch
// exists to keep the build alive, so the diagnostic must not throw out
// of it on EPIPE (`qwen … | head`).
writeStderrLineSafe(
`agent-prompt: failed to write findings file ${p}: ` +
`${(err as Error).message} — inlining the list instead`,
);
return null;
}
return p;
}
const RULES_MARKER = '## Project rules';
/**
@@ -156,7 +238,12 @@ export function readRecordedPrompts(
const dir = promptRecordDir(planPath);
let names: string[];
try {
names = readdirSync(dir);
// Sorted: this module reasons per-record and pairs records against
// transcripts, and a filesystem-order walk (`readdir` is filename-hash
// order on ext4, not insertion order) makes the analysis order-sensitive —
// e.g. which record's state poisons a shared cache first. A deterministic
// walk removes that whole class.
names = readdirSync(dir).sort();
} catch {
return out; // Never run, or nothing to record. The caller decides what that means.
}
@@ -14,6 +14,7 @@ import { writeStderrLine } from '../../../utils/stdioHelpers.js';
import { classifyHeavy } from './heavy.js';
import type { DiffChunk, DiffPlan, PathKind } from './diff-plan.js';
import { reviewBudget, type ReviewBudget } from './budget.js';
import type { RepositoryContext } from './repository-context.js';
export interface FileMetric {
path: string;
@@ -96,6 +97,7 @@ export interface PlanReport {
* roster's job, and the roster reads `effort`.
*/
budget: ReviewBudget;
repositoryContext?: RepositoryContext;
}
/**
@@ -0,0 +1,340 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { REPOSITORY_CONTEXT_ROLES } from './agent-briefs.js';
import {
repositoryContextOf,
validateRepositoryContext,
} from './repository-context.js';
const valid = {
version: 1,
provider: 'example-provider',
label: 'Example project',
domains: ['compiler', 'runtime'],
relatedPaths: ['src/compiler.ts', 'src/runtime.ts'],
recommendedTests: ['test:compiler', 'test:runtime'],
requiredConfigurations: ['debug', 'linux-x64'],
requiredAgents: ['1a', 'test-matrix'],
unverifiedDimensions: ['Alternate runtime was not exercised'],
verificationNotes: ['Use the repository native test runner'],
};
describe('repository context validation', () => {
it('accepts the strict versioned generic schema', () => {
expect(validateRepositoryContext(valid)).toEqual(valid);
expect(repositoryContextOf({ repositoryContext: valid })).toEqual(valid);
expect(repositoryContextOf({})).toBeNull();
});
it('fails closed on a present-but-null repositoryContext', () => {
// repo-context writes literal `null` artifact files, so a corrupted plan
// can carry the shape; a falsy-check regression would degrade open in
// every consumer at once instead of failing closed.
expect(() => repositoryContextOf({ repositoryContext: null })).toThrow(
'repositoryContext must be an object',
);
});
it('rejects unknown or missing fields and versions', () => {
expect(() => validateRepositoryContext({ ...valid, version: 2 })).toThrow(
'unsupported repositoryContext version',
);
expect(() => validateRepositoryContext({ ...valid, extra: true })).toThrow(
'unknown or missing fields',
);
const { label: _label, ...withoutLabel } = valid;
expect(() => validateRepositoryContext(withoutLabel)).toThrow(
'unknown or missing fields',
);
});
it('accepts bounded Unicode text and repository paths with spaces', () => {
const context = {
...valid,
label: '示例仓库',
domains: ['编译器', '运行时'],
relatedPaths: ['docs/设计说明.md', 'src/generated files/output.ts'],
recommendedTests: ['运行核心测试'],
requiredConfigurations: ['调试模式'],
unverifiedDimensions: ['未验证备用运行时'],
verificationNotes: ['使用仓库原生测试命令'],
};
expect(validateRepositoryContext(context)).toEqual(context);
});
it('requires bounded sorted unique safe tokens and text', () => {
expect(() =>
validateRepositoryContext({ ...valid, domains: ['runtime', 'compiler'] }),
).toThrow('sorted and unique');
expect(() =>
validateRepositoryContext({ ...valid, domains: ['runtime', 'runtime'] }),
).toThrow('sorted and unique');
expect(() =>
validateRepositoryContext({ ...valid, provider: '../provider' }),
).toThrow('provider is invalid');
// isControlFree rejects all of 0x00-0x1F, 0x7F-0x9F, U+2028/2029, the
// bidi directional formatting block, and zero-width hiding characters;
// probing range ends plus interior points pins the range, not a
// four-separator regex (under which `label: 'X\r## heading'`
// validates and CR-overwrites the rendered heading).
for (const separator of [
'\u0000',
'\u0005',
'\r',
'\u001f',
'\n',
'\u007f',
'\u0085',
'\u009f',
'\u2028',
'\u2029',
'\u061c',
'\u200b',
'\u200e',
'\u200f',
'\u202a',
'\u202e',
'\u2066',
'\u2069',
'\ufeff',
]) {
expect(() =>
validateRepositoryContext({
...valid,
label: `bad${separator}heading`,
}),
).toThrow('label is invalid');
}
expect(() =>
validateRepositoryContext({
...valid,
verificationNotes: ['x'.repeat(513)],
}),
).toThrow('verificationNotes is invalid');
expect(() =>
validateRepositoryContext({
...valid,
domains: Array.from({ length: 129 }, (_, index) => `d${index}`),
}),
).toThrow('domains is invalid');
});
it('enforces sorted-and-unique on every array field', () => {
// The manifest provider pre-sorts today; a future provider or a
// hand-edited plan would not, so the wire check is pinned per field.
const probes: Record<string, [unsorted: string[], duplicated: string[]]> = {
recommendedTests: [
['test:runtime', 'test:compiler'],
['test:compiler', 'test:compiler'],
],
requiredConfigurations: [
['linux-x64', 'debug'],
['debug', 'debug'],
],
relatedPaths: [
['src/runtime.ts', 'src/compiler.ts'],
['src/compiler.ts', 'src/compiler.ts'],
],
requiredAgents: [
['test-matrix', '1a'],
['test-matrix', 'test-matrix'],
],
unverifiedDimensions: [
['later boundary', 'earlier boundary'],
['same boundary', 'same boundary'],
],
verificationNotes: [
['second note', 'first note'],
['same note', 'same note'],
],
};
for (const [field, [unsorted, duplicated]] of Object.entries(probes)) {
expect(() =>
validateRepositoryContext({ ...valid, [field]: unsorted }),
).toThrow(`${field} must be sorted and unique`);
expect(() =>
validateRepositoryContext({ ...valid, [field]: duplicated }),
).toThrow(`${field} must be sorted and unique`);
}
});
it('accepts every length bound exactly and rejects one past it', () => {
// provider 64, label 120, token 160, path 512, note 512: the accept side
// pins `>` (a `>=` regression would reject manifests at the documented
// bounds) and the reject side pins the four remaining constants.
const atBound = {
...valid,
provider: 'p'.repeat(64),
label: 'l'.repeat(120),
domains: ['t'.repeat(160)],
relatedPaths: ['a'.repeat(512)],
recommendedTests: ['t'.repeat(160)],
requiredConfigurations: ['t'.repeat(160)],
unverifiedDimensions: ['n'.repeat(512)],
verificationNotes: ['n'.repeat(512)],
};
expect(validateRepositoryContext(atBound)).toEqual(atBound);
expect(() =>
validateRepositoryContext({ ...valid, provider: 'p'.repeat(65) }),
).toThrow('provider is invalid');
expect(() =>
validateRepositoryContext({ ...valid, label: 'l'.repeat(121) }),
).toThrow('label is invalid');
expect(() =>
validateRepositoryContext({ ...valid, domains: ['t'.repeat(161)] }),
).toThrow('domains is invalid');
expect(() =>
validateRepositoryContext({
...valid,
relatedPaths: ['a'.repeat(513)],
}),
).toThrow('relatedPaths is invalid');
expect(() =>
validateRepositoryContext({
...valid,
recommendedTests: ['t'.repeat(161)],
}),
).toThrow('recommendedTests is invalid');
expect(() =>
validateRepositoryContext({
...valid,
requiredConfigurations: ['t'.repeat(161)],
}),
).toThrow('requiredConfigurations is invalid');
});
it('accepts the item-count bound exactly', () => {
// The reject side pins 129 items; this accept pin sits exactly at
// MAX_ARRAY_ITEMS, where a `>` → `>=` regression would reject the
// maximum valid manifest at the documented bound.
const atBound = {
...valid,
domains: Array.from(
{ length: 128 },
(_, index) => `d-${String(index).padStart(3, '0')}`,
),
};
expect(validateRepositoryContext(atBound)).toEqual(atBound);
});
it('accepts every role the allow-list admits', () => {
// Hardcoded, not spread from the constant: the accept side must pin
// all 13 roles, or dropping one from REPOSITORY_CONTEXT_ROLES ships
// green (`satisfies readonly RoleId[]` still compiles, the type
// narrows silently) and every consumer fails closed on a valid
// manifest's required agent.
const allRoles = [
'1a',
'1b',
'1c',
'2',
'3a',
'3b',
'3c',
'4',
'5',
'6a',
'6b',
'6c',
'test-matrix',
];
expect([...REPOSITORY_CONTEXT_ROLES]).toEqual(allRoles);
const context = { ...valid, requiredAgents: allRoles };
expect(validateRepositoryContext(context)).toEqual(context);
});
it('rejects control characters inside array items', () => {
for (const field of [
'domains',
'relatedPaths',
'unverifiedDimensions',
'verificationNotes',
] as const) {
for (const separator of [
'\u0000',
'\u0005',
'\r',
'\u001f',
'\n',
'\u007f',
'\u0085',
'\u009f',
'\u2028',
'\u2029',
'\u061c',
'\u200b',
'\u200e',
'\u200f',
'\u202a',
'\u202e',
'\u2066',
'\u2069',
'\ufeff',
]) {
expect(() =>
validateRepositoryContext({
...valid,
[field]: [`bad${separator}item`],
}),
).toThrow(`${field} is invalid`);
}
}
});
it('fails closed on non-string and empty values', () => {
// The string-type and non-empty gates fail closed today; pin them, or
// a future simplification ships green and `[object Object]` / `123` /
// empty entries flow into every reviewer prompt and the posted body.
expect(() => validateRepositoryContext({ ...valid, label: 123 })).toThrow(
'label is invalid',
);
expect(() => validateRepositoryContext({ ...valid, label: '' })).toThrow(
'label is invalid',
);
expect(() =>
validateRepositoryContext({ ...valid, domains: [123] }),
).toThrow('domains is invalid');
expect(() =>
validateRepositoryContext({ ...valid, domains: [''] }),
).toThrow('domains is invalid');
expect(() =>
validateRepositoryContext({ ...valid, verificationNotes: [null] }),
).toThrow('verificationNotes is invalid');
});
it('rejects unsafe paths and roles that cannot join the initial roster', () => {
for (const path of [
'../secret',
'/absolute',
'C:',
'C:relative',
'C:/absolute',
'd:relative',
'a//b',
'a/./b',
'a\\b',
'a/../b',
]) {
expect(() =>
validateRepositoryContext({ ...valid, relatedPaths: [path] }),
).toThrow();
}
for (const role of [
'not-a-role',
'7',
'invariant-a',
'verify',
'reverse-audit',
]) {
expect(() =>
validateRepositoryContext({ ...valid, requiredAgents: [role] }),
).toThrow('unsupported role');
}
});
});
@@ -0,0 +1,275 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { RepositoryContextRoleId } from './agent-briefs.js';
import { isRepositoryContextRoleId } from './agent-briefs.js';
export const REPOSITORY_CONTEXT_VERSION = 1 as const;
// Shared bounds for the context contract and every provider that produces one:
// a provider validator must emit exactly what validateRepositoryContext accepts,
// so both read the same constants instead of keeping lockstep copies that can
// drift.
export const MAX_ARRAY_ITEMS = 128;
const MAX_PROVIDER_LENGTH = 64;
export const MAX_LABEL_LENGTH = 120;
export const MAX_TOKEN_LENGTH = 160;
export const MAX_PATH_LENGTH = 512;
export const MAX_NOTE_LENGTH = 512;
/**
* Fail-closed ceiling on a single identity read. An identity file is a small
* marker or manifest; a multi-megabyte one is an attacker payload whose cost
* lands in `JSON.parse` BEFORE any schema validation can reject it (the
* parse runs first). Both the worktree reader (stat size) and the parser
* (content length) enforce this, symmetric in both modes. One megabyte is
* far beyond any honest manifest and far below the heap damage a
* near-push-limit file demonstrably causes.
*/
export const MAX_IDENTITY_BYTES = 1024 * 1024;
export interface RepositoryContext {
version: typeof REPOSITORY_CONTEXT_VERSION;
provider: string;
label: string;
domains: string[];
relatedPaths: string[];
recommendedTests: string[];
requiredConfigurations: string[];
requiredAgents: RepositoryContextRoleId[];
unverifiedDimensions: string[];
verificationNotes: string[];
}
export interface RepositoryContextPlan {
repositoryContext?: unknown;
}
export interface RepositoryContextProviderInput {
worktree: string;
changedPaths: string[];
/**
* Read an identity file the provider keys on. The content is identical in
* every mode — CRLF normalised to LF, surrounding whitespace trimmed — so a
* provider that exact-compares a marker file gets the same value in a pull
* request review (read from the trusted merge base) and a local one (read
* from the worktree). `null` means the file is absent; a read failure
* THROWS, fail-closed, so a broken read cannot pose as "not this
* repository".
*/
readIdentityFile(relativePath: string): string | null;
}
export interface RepositoryContextProvider {
provide(input: RepositoryContextProviderInput): RepositoryContext | null;
}
const CONTEXT_KEYS = [
'version',
'provider',
'label',
'domains',
'relatedPaths',
'recommendedTests',
'requiredConfigurations',
'requiredAgents',
'unverifiedDimensions',
'verificationNotes',
].sort();
const SAFE_PROVIDER = /^[a-z0-9][a-z0-9._-]*$/;
// Unicode bidi directional formatting code points render attacker-controlled
// text in a different order than its bytes, and the zero-width characters
// here hide content the eye never sees (trojan-source class display
// spoofing). A manifest entry carrying one survives the provider and is
// rendered intact into the posted review body and every reviewer prompt, so
// the validator rejects them. Zero-width JOINERS (0x200C/0x200D) stay
// allowed: they carry emoji ZWJ sequences and several living scripts, and
// neither reorder nor hide on their own.
const SPOOFING_FORMAT_CODE_UNITS = (code: number): boolean =>
code === 0x61c ||
code === 0x200b ||
code === 0x200e ||
code === 0x200f ||
(code >= 0x202a && code <= 0x202e) ||
(code >= 0x2066 && code <= 0x2069) ||
code === 0xfeff;
export function isControlFree(value: string): boolean {
for (let index = 0; index < value.length; index++) {
const code = value.charCodeAt(index);
if (
code < 0x20 ||
(code >= 0x7f && code <= 0x9f) ||
code === 0x2028 ||
code === 0x2029 ||
SPOOFING_FORMAT_CODE_UNITS(code)
) {
return false;
}
}
return true;
}
export function compareText(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
function isSortedUnique(values: readonly string[]): boolean {
return values.every(
(value, index) => index === 0 || compareText(values[index - 1], value) < 0,
);
}
export function isSafeRepositoryRelativePath(path: string): boolean {
const segments = path.split('/');
return (
path.length > 0 &&
path.length <= MAX_PATH_LENGTH &&
isControlFree(path) &&
!path.startsWith('/') &&
!/^[A-Za-z]:/.test(path) &&
!path.includes('\\') &&
segments.every(
(segment) => segment !== '' && segment !== '.' && segment !== '..',
)
);
}
/**
* Bounded, non-empty, control-character-free string. `prefix` names the owner
* of the field in the error (`repositoryContext.` for the wire format, the
* manifest's own wording for manifest parsing), so one validator serves both
* without their bounds drifting apart.
*/
export function validateBoundedString(
value: unknown,
field: string,
maxLength: number,
prefix: string,
pattern?: RegExp,
): asserts value is string {
if (
typeof value !== 'string' ||
value.length === 0 ||
value.length > maxLength ||
!isControlFree(value) ||
(pattern !== undefined && !pattern.test(value))
) {
throw new Error(`${prefix}${field} is invalid`);
}
}
/** Item-shape half of {@link validateBoundedString}; ordering is the caller's. */
export function validateBoundedStringArray(
value: unknown,
field: string,
maxLength: number,
prefix: string,
pattern?: RegExp,
): asserts value is string[] {
if (
!Array.isArray(value) ||
value.length > MAX_ARRAY_ITEMS ||
value.some(
(item) =>
typeof item !== 'string' ||
item.length === 0 ||
item.length > maxLength ||
!isControlFree(item) ||
(pattern !== undefined && !pattern.test(item)),
)
) {
throw new Error(`${prefix}${field} is invalid`);
}
}
/** Validate repository context before any downstream consumer trusts it. */
export function validateRepositoryContext(value: unknown): RepositoryContext {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error('repositoryContext must be an object');
}
const context = value as Record<string, unknown>;
const keys = Object.keys(context).sort();
if (
keys.length !== CONTEXT_KEYS.length ||
keys.some((key, index) => key !== CONTEXT_KEYS[index])
) {
throw new Error('repositoryContext has unknown or missing fields');
}
if (context['version'] !== REPOSITORY_CONTEXT_VERSION) {
throw new Error('unsupported repositoryContext version');
}
const prefix = 'repositoryContext.';
// The wire format every downstream consumer trusts is deterministic by
// construction: sorted and unique. Providers earn that shape on the way in
// (the manifest provider sorts and dedupes); the validator enforces it.
const requireSortedUnique = (values: readonly string[], field: string) => {
if (!isSortedUnique(values)) {
throw new Error(`${prefix}${field} must be sorted and unique`);
}
};
validateBoundedString(
context['provider'],
'provider',
MAX_PROVIDER_LENGTH,
prefix,
SAFE_PROVIDER,
);
validateBoundedString(context['label'], 'label', MAX_LABEL_LENGTH, prefix);
for (const field of [
'domains',
'recommendedTests',
'requiredConfigurations',
] as const) {
validateBoundedStringArray(context[field], field, MAX_TOKEN_LENGTH, prefix);
requireSortedUnique(context[field], field);
}
validateBoundedStringArray(
context['relatedPaths'],
'relatedPaths',
MAX_PATH_LENGTH,
prefix,
);
requireSortedUnique(context['relatedPaths'], 'relatedPaths');
if (
context['relatedPaths'].some((path) => !isSafeRepositoryRelativePath(path))
) {
throw new Error('repositoryContext.relatedPaths contains an unsafe path');
}
// requiredAgents carries no token pattern: the role allow-list membership
// check below is strictly tighter than any shape check.
validateBoundedStringArray(
context['requiredAgents'],
'requiredAgents',
MAX_TOKEN_LENGTH,
prefix,
);
requireSortedUnique(context['requiredAgents'], 'requiredAgents');
if (
context['requiredAgents'].some((role) => !isRepositoryContextRoleId(role))
) {
throw new Error(
'repositoryContext.requiredAgents contains an unsupported role',
);
}
for (const field of ['unverifiedDimensions', 'verificationNotes'] as const) {
validateBoundedStringArray(context[field], field, MAX_NOTE_LENGTH, prefix);
requireSortedUnique(context[field], field);
}
return context as unknown as RepositoryContext;
}
export function repositoryContextOf(
plan: RepositoryContextPlan,
): RepositoryContext | null {
if (plan.repositoryContext === undefined) return null;
return validateRepositoryContext(plan.repositoryContext);
}
@@ -16,7 +16,11 @@ import {
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { scheduleReverseAuditRound } from './retirement.js';
import { promptRecordDir, recordPrompt } from './prompt-record.js';
import {
promptRecordDir,
recordPrompt,
writeFindingsFile,
} from './prompt-record.js';
import { REVERSE_AUDIT_EXAMPLE_RECEIPT } from './agent-briefs.js';
// Direct unit coverage for the scheduler's own rules — the classifier's
@@ -717,6 +721,127 @@ describe('scheduleReverseAuditRound — the scheduler on its own', () => {
expect(r3.skipped.map((s) => s.chunkId)).toEqual([13]);
});
it('quoting a WHOLE entry from the findings FILE is not a yield (post-#8597 shape)', () => {
// Since #8597 the cumulative list rides a digest-named `.findings.md`
// file the launch prompt points at, not the prompt itself. The echo
// guard must read the list back from that file: quoting the whole entry
// the auditor was told not to re-report is still not a yield.
const quoted =
'The list already carries this entry, so it is not re-reported:\n' +
'- **File:** src/pay.ts:42\n' +
'- **Severity:** Suggestion\n\n' +
DRY;
for (const r of [1, 2]) {
const findingsFile = writeFindingsFile(
plan,
`reverse-audit--round-${r}--abc123`,
'- **File:** src/pay.ts:42 — the double charge\n' +
'- **Severity:** Suggestion\n',
);
const built = record(
r,
13,
`chunk 13 round ${r} territory\n` +
`read_file(file_path="${findingsFile}")`,
);
transcript(built, quoted);
}
const r3 = schedule(3, [13]);
expect(r3.due).toEqual([]);
expect(r3.skipped.map((s) => s.chunkId)).toEqual([13]);
});
it('a MISSING findings file fails toward auditing — the quotation reads as a yield', () => {
// The pointer is there but the list is gone (a cleaned-up record dir):
// the guard falls back to the prompt, no entry matches, and the quoted
// block keeps the chunk hot — the module's failure direction.
const quoted =
'The list already carries this entry, so it is not re-reported:\n' +
'- **File:** src/pay.ts:42\n' +
'- **Severity:** Suggestion\n\n' +
DRY;
for (const r of [1, 2]) {
const built = record(
r,
13,
`chunk 13 round ${r} territory\n` +
`read_file(file_path="${join(dir, 'gone.findings.md')}")`,
);
transcript(built, quoted);
}
const r3 = schedule(3, [13]);
expect(r3.due).toEqual([13]);
expect(r3.converged).toBe(false);
});
it('a pointer outside the record dir is not followed — degrades to the prompt', () => {
// The echo guard reads the pointer the record carries, confined to this
// plan's record dir. A prompt whose `.findings.md` path escapes it
// (here: a list sitting outside, with the quoted entry in it) must NOT
// be read — the guard falls back to the prompt, no entry matches, and
// the quotation keeps the chunk hot rather than reading an arbitrary path.
const outside = join(dir, 'outside.findings.md');
writeFileSync(
outside,
'- **File:** src/pay.ts:42 — the double charge\n' +
'- **Severity:** Suggestion\n',
);
const quoted =
'The list already carries this entry, so it is not re-reported:\n' +
'- **File:** src/pay.ts:42\n' +
'- **Severity:** Suggestion\n\n' +
DRY;
for (const r of [1, 2]) {
const built = record(
r,
13,
`chunk 13 round ${r} territory\n` + `read_file(file_path="${outside}")`,
);
transcript(built, quoted);
}
const r3 = schedule(3, [13]);
expect(r3.due).toEqual([13]);
expect(r3.converged).toBe(false);
});
it('a missing findings file is not cross-contaminated between chunks of a round', () => {
// Every chunk of a round points at the SAME (chunk-free) findings file.
// When it is missing, each record must fall back to its OWN prompt as the
// echo-guard corpus — not to a sibling chunk's prompt cached under the
// shared pointer. Here chunk 13's prompt carries a `**File:**` line that
// chunk 14 quotes; if chunk 14 were handed chunk 13's prompt, the quote
// would match and chunk 14 would wrongly skip-to-dry.
const missing = join(promptRecordDir(plan), 'gone.findings.md');
const quoted =
'The list already carries this entry, so it is not re-reported:\n' +
'- **File:** src/pay.ts:42\n' +
'- **Severity:** Suggestion\n\n' +
DRY;
for (const r of [1, 2]) {
const b13 = record(
r,
13,
`chunk 13 round ${r} territory\n**File:** src/pay.ts:42\n` +
`read_file(file_path="${missing}")`,
);
const b14 = record(
r,
14,
`chunk 14 round ${r} territory\n` + `read_file(file_path="${missing}")`,
);
transcript(b13, DRY);
transcript(b14, quoted);
}
const r3 = schedule(3, [13, 14]);
// Chunk 13 (clean DRY) may retire; chunk 14 must stay hot — its quotation
// matches nothing in its OWN prompt, so it reads as a yield, not an echo.
expect(r3.due).toContain(14);
});
it('a cold check nobody certified puts the chunk back on the every-round schedule', () => {
dryTwice([13]);
// Round 4 is the cold check — built, but the launch left no certified
@@ -36,13 +36,16 @@
// The failure mode of a bug in this file is the old behaviour (audit every
// territory every round), never a skipped one.
import { statSync } from 'node:fs';
import { readFileSync, statSync } from 'node:fs';
import { resolve, sep } from 'node:path';
import { readTranscripts, type AgentRecord } from './transcripts.js';
import { REVERSE_AUDIT_EXAMPLE_RECEIPT } from './agent-briefs.js';
import {
deliveredVerbatimLines,
findingsPointerOf,
flattenPrompt,
promptLines,
promptRecordDir,
readRecordedPrompts,
} from './prompt-record.js';
@@ -103,13 +106,13 @@ const REVERSE_AUDIT_MARKER = 'reverse-audit';
* prompt bakes no read, where the bar falls back to "opened the diff at
* all" — a shape this module's own records never have.
*
* The scan is bound to the diff's own path because the record is the FOLDED
* launch prompt — the cumulative findings list rides inside it, verbatim,
* above the builder's own text — and findings prose quoting ANY
* `offset=N, limit=M` pair (a read_file call under discussion, this very
* file in a diff) would otherwise inject its range into the territory.
* The scan is bound to the diff's own path because the prompt carries other
* `read_file` lines — the brief, the findings list file — and prose quoting
* ANY `offset=N, limit=M` pair (a read_file call under discussion, this very
* file in a diff) would otherwise inject its range into the territory. When
* the findings list was folded into the prompt verbatim it did exactly that:
* `openedTheTerritory` passes on ANY overlap with ANY range, so an injected
* range can only WIDEN the bar: an auditor whose only diff read was lines
* range can only WIDEN the bar — an auditor whose only diff read was lines
* 1-50 would retire a chunk whose territory is 1001-1200 the moment a
* finding quoted `offset=0, limit=50` — the same range-blind hole the
* territory check exists to close, reopened by honest findings. Only a read
@@ -140,15 +143,15 @@ const FILE_LINE_RE = /\*\*File:\*\*\s*([^\n]*)/g;
/**
* The other half of a filed finding. A `**File:**` line alone is not proof
* the auditor FILED anything: the cumulative list is folded into its launch
* prompt, and an auditor explaining "already covered, not re-reporting" can
* echo an entry's file line into its return. Every finding actually filed
* carries the full block the format mandates — severity included — so the
* pair is what distinguishes a report from a bare file-line echo; a
* quotation of a WHOLE entry is caught in `classifyReturn`, where the
* launch prompt carrying the cumulative list is on hand. Misreading an echo
* as `yielded` is cost, not corruption (the chunk just stays hot), but it is
* exactly the cost this module exists to stop paying.
* the auditor FILED anything: the auditor was launched against a cumulative
* findings list (a `.findings.md` file its prompt points at), and an auditor
* explaining "already covered, not re-reporting" can echo an entry's file
* line into its return. Every finding actually filed carries the full block
* the format mandates — severity included — so the pair is what
* distinguishes a report from a bare file-line echo; a quotation of a WHOLE
* entry is caught in `classifyReturn`, where the list is on hand. Misreading
* an echo as `yielded` is cost, not corruption (the chunk just stays hot),
* but it is exactly the cost this module exists to stop paying.
*/
const SEVERITY_LINE_RE = /\*\*Severity:\*\*/;
@@ -236,6 +239,45 @@ function substantiveClause(clause: string): boolean {
return c.length >= 20;
}
/**
* The cumulative findings list an auditor was launched against. Since #8597
* the list rides a digest-named `.findings.md` file the prompt points at —
* read it back; a prompt with no pointer predates the file shape (or its
* file is gone), and the prompt itself is the fallback, which is where the
* list lived before. The pointer is the CLI's own record's (never the
* orchestrator's pasted copy, which `wasDeliveredVerbatim` allows additions
* around), confined to this plan's record dir before reading; an unreadable
* or out-of-bounds file degrades to the prompt: no entry matches there, a
* quotation counts as a yield, and the chunk stays hot — every failure in
* this module lands on the audit side. `memo` keys on the pointer so the
* pairing walk reads each round's list once, not once per record.
*/
function findingsListFor(
prompt: string,
recordDir: string,
memo: Map<string, string>,
): string {
const pointer = findingsPointerOf(prompt);
if (pointer === null) return prompt;
const root = resolve(recordDir);
const target = resolve(pointer);
if (target !== root && !target.startsWith(root + sep)) return prompt;
const cached = memo.get(pointer);
if (cached !== undefined) return cached;
try {
const content = readFileSync(target, 'utf8');
// Memoize ONLY a successful read: the pointer is shared by every chunk of
// the round (the file key is chunk-free), so caching a failure's fallback
// — THIS record's prompt — would serve one chunk's launch text as every
// other chunk's findings list. On a miss each record falls back to its
// OWN prompt (no entry matches there → stays hot), uncached.
memo.set(pointer, content);
return content;
} catch {
return prompt; // Fall back to this record's own prompt.
}
}
/**
* Classify one auditor's return.
*
@@ -257,21 +299,23 @@ function substantiveClause(clause: string): boolean {
function classifyReturn(
rec: AgentRecord,
territory: Array<[number, number]>,
findingsList: string,
): AuditOutcome {
const text = rec.finalText.trim();
if (SEVERITY_LINE_RE.test(text)) {
// The cumulative list is on hand for this agent: since #8597 it rides
// a digest-named findings file the launch prompt points at (before, it
// was folded into the prompt verbatim), and every entry in it is a full
// block — File AND Severity. An auditor explaining "already covered,
// not re-reporting" can quote one whole, and the quotation must not
// read as a filing: an entry whose exact file line is already on the
// list cannot be a new finding against it. Skipping costs an audit at
// most; counting a quotation re-opens the never-retire direction on
// the loop's most common honest return.
for (const m of text.matchAll(FILE_LINE_RE)) {
const file = (m[1] ?? '').trim();
if (file === '' || /^N\/A\b/i.test(file)) continue;
// The cumulative list rides in this agent's own launch prompt,
// folded verbatim, and every entry in it is a full block — File
// AND Severity. An auditor explaining "already covered, not
// re-reporting" can quote one whole, and the quotation must not
// read as a filing: an entry whose exact file line is already on
// the list cannot be a new finding against it. Skipping costs an
// audit at most; counting a quotation re-opens the never-retire
// direction on the loop's most common honest return.
if (rec.launchPrompt.includes(`**File:** ${file}`)) continue;
if (findingsList.includes(`**File:** ${file}`)) continue;
return 'yielded';
}
}
@@ -372,11 +416,14 @@ export function scheduleReverseAuditRound(
// The prior-round records: one per (chunk, round) prompt this CLI built.
// Only PRIOR rounds are history — a record of the round being built is a
// rebuild of it (a repaired delivery), not evidence about the territory.
const recordDir = promptRecordDir(planPath);
const findingsMemo = new Map<string, string>();
const records: Array<{
chunkId: number;
round: number;
lines: string[];
territory: Array<[number, number]>;
findings: string;
}> = [];
for (const [key, prompt] of built) {
const m = RECORD_KEY_RE.exec(key);
@@ -391,6 +438,7 @@ export function scheduleReverseAuditRound(
// pair.
lines: promptLines(prompt),
territory: bakedRanges(prompt, diffPath),
findings: findingsListFor(prompt, recordDir, findingsMemo),
});
}
@@ -437,7 +485,7 @@ export function scheduleReverseAuditRound(
const outcomesByRecord = matchesByRecord.map((matches, i) =>
matches
.filter((t) => recordsPerTranscript.get(t) === 1)
.map((t) => classifyReturn(t, records[i].territory)),
.map((t) => classifyReturn(t, records[i].territory, records[i].findings)),
);
// chunk id → prior round → every outcome that round's records produced. A
@@ -165,6 +165,117 @@ describe('requiredAgents — Step 3A', () => {
// But there IS a tree, so the tracer and the build still run.
expect(keys(local)).toEqual(expect.arrayContaining(['1c', '7']));
});
it('requires existing roles recorded by validated repository context without duplicates', () => {
const context = {
version: 1,
provider: 'fake-provider',
label: 'Example project',
domains: ['runtime'],
relatedPaths: ['src/runtime.ts'],
recommendedTests: ['test:runtime'],
requiredConfigurations: ['linux-x64'],
requiredAgents: ['1a', '1b'],
unverifiedDimensions: ['Alternate runtime was not exercised'],
verificationNotes: ['Use the repository native test runner'],
};
// 1b is policy-permitted but data-gated away here (the diff deletes
// nothing); a context may require it back. 1a is already required and
// must not duplicate.
const roster = keys({ ...PR, repositoryContext: context });
expect(roster).toContain('1b');
expect(roster.filter((role) => role === '1a')).toHaveLength(1);
});
it('does not let repository context override the effort, topology, or mode gates', () => {
// A manifest may require agents the policy already runs; it may not
// inflate or wedge the run by re-adding roles the policy excludes.
const context = (requiredAgents: string[]) => ({
version: 1,
provider: 'fake-provider',
label: 'Example project',
domains: [],
relatedPaths: [],
recommendedTests: [],
requiredConfigurations: [],
requiredAgents,
unverifiedDimensions: [],
verificationNotes: [],
});
// The adversarial personas are a high-effort dimension: a medium review
// stays medium even when the repository names them.
const medium = keys({
...PR,
effort: 'medium',
repositoryContext: context(['6a', '6b', '6c']),
});
expect(medium).not.toContain('6a');
expect(medium).not.toContain('6b');
expect(medium).not.toContain('6c');
// At high effort the same requirement is honoured (and deduplicated).
expect(
keys({ ...PR, repositoryContext: context(['6a']) }).filter(
(role) => role === '6a',
),
).toHaveLength(1);
// A lightweight review has no tree to grep: 1c cannot be required back.
const light = {
...PR,
worktreePath: undefined,
prNumber: undefined,
repositoryContext: context(['1c']),
};
expect(keys(light)).not.toContain('1c');
// test-matrix is a fan-out role: a manifest cannot require it into a
// whole-diff (Step 3A) review, whose flow is not built around it — the
// denial half of the gate, which a `return fanOut` → `return true`
// regression would silently drop.
expect(
keys({ ...PR, repositoryContext: context(['test-matrix']) }),
).not.toContain('test-matrix');
// A Step 3B fan-out keeps its topology: whole-diff dimension walkers and
// the high-effort personas stay out, while 3B's own roles are honoured.
const big = { ...PR, srcDiffLines: 5000, diffLines: 6000 };
const fanOut = keys({
...big,
repositoryContext: context(['1b', '2', '6a', 'test-matrix']),
});
expect(fanOut).not.toContain('2');
expect(fanOut).not.toContain('6a');
expect(fanOut.filter((role) => role === 'test-matrix')).toHaveLength(1);
expect(fanOut).toContain('1b');
});
it('fails closed on a present-but-invalid repository context', () => {
// Full wire shape but version 2: the exact-keys check passes, the
// version gate throws. A try/catch-return-null wrapper around
// repositoryContextOf would silently drop every context-required role
// from the roster AND the coverage certification — certifying a run
// where the agents the repository required never launched.
const future = {
version: 2,
provider: 'fake-provider',
label: 'Example project',
domains: [],
relatedPaths: [],
recommendedTests: [],
requiredConfigurations: [],
requiredAgents: [],
unverifiedDimensions: [],
verificationNotes: [],
};
expect(() => keys({ ...PR, repositoryContext: future })).toThrow(
'unsupported repositoryContext version',
);
});
it('keeps the generic roster when repository context is absent', () => {
expect(keys(PR)).not.toContain('test-matrix');
});
});
describe('hasExecutableScript — the script-lint gate predicate', () => {
+45 -1
View File
@@ -24,7 +24,8 @@
// Nothing here is supplied by the caller. A roster the caller could shrink is a
// roster that gets shrunk.
import type { RoleId } from './agent-briefs.js';
import type { RepositoryContextRoleId, RoleId } from './agent-briefs.js';
import { repositoryContextOf } from './repository-context.js';
import { pathTool } from '../script-lint.js';
/**
@@ -72,6 +73,7 @@ export interface RosterPlan {
* recomputation then all read the same value and cannot disagree.
*/
effort?: unknown;
repositoryContext?: unknown;
}
/** One agent this review must launch. */
@@ -273,5 +275,47 @@ export function requiredAgents(plan: RosterPlan): RequiredAgent[] {
}
}
const repositoryContext = repositoryContextOf(plan);
for (const role of repositoryContext?.requiredAgents ?? []) {
if (!contextRoleRunsInThisReview(role, plan, mode)) continue;
if (!out.some((agent) => agent.role === role && agent.file === undefined)) {
add(role);
}
}
return out;
}
/**
* A repository context may REQUIRE an agent this review's policy already runs;
* it may not override the policy. The effort gate, the topology split, and the
* mode are cost and capability decisions the roster owns — a manifest naming a
* role they exclude would otherwise silently inflate a medium review with the
* adversarial personas, re-add whole-diff walkers to a chunked 3B fan-out, or
* demand a tree-grepping tracer from a review that has no tree.
*/
function contextRoleRunsInThisReview(
role: RepositoryContextRoleId,
plan: RosterPlan,
mode: ReviewMode,
): boolean {
const fanOut = isTerritoryFanOut(plan);
switch (role) {
case '6a':
case '6b':
case '6c':
return !fanOut && plan.effort !== 'medium';
case 'test-matrix':
return fanOut;
case '1c':
return mode !== 'diff-only';
case '1b':
// Both topologies run the removed-behavior audit; whether it has work is
// the diff's business (hasDeletions), not the policy's.
return true;
default:
// Whole-diff dimension walkers exist only in Step 3A; a 3B chunk agent
// already owns every dimension for its own lines.
return !fanOut;
}
}
@@ -174,6 +174,54 @@ describe('readTranscripts — defensive parsing', () => {
const [rec] = readTranscripts(undefined, ENV);
expect(rec.successfulToolCalls).toBe(0);
});
it('separates read_file calls from other tools that merely name a path', () => {
// successfulReadFileArgs backs the checks where NAMING a path is not
// OPENING it: a search over the findings file carries the same
// stringified path in its args without reading a line of it.
const b = { agentId: 'a1', agentName: 'general-purpose', sessionId: 'S1' };
const pairs: object[] = [];
for (const [name, args] of [
['read_file', { file_path: '/r/f.findings.md' }],
['search_file_content', { path: '/r/f.findings.md', pattern: 'Crit' }],
]) {
pairs.push(
{
...b,
type: 'assistant',
message: {
role: 'model',
parts: [{ functionCall: { name, args } }],
},
},
{
...b,
type: 'tool_result',
message: {
role: 'user',
parts: [{ functionResponse: { name, response: { output: 'ok' } } }],
},
},
);
}
file(
'agent-a1.jsonl',
[
JSON.stringify({
...b,
type: 'user',
message: { role: 'user', parts: [{ text: 'chunk 1 of 1' }] },
}),
...pairs.map((r) => JSON.stringify(r)),
].join('\n') + '\n',
);
const [rec] = readTranscripts(undefined, ENV);
expect(rec.successfulToolCalls).toBe(2);
expect(rec.successfulCallArgs).toHaveLength(2);
expect(rec.successfulReadFileArgs).toHaveLength(1);
expect(rec.successfulReadFileArgs[0]).toContain('/r/f.findings.md');
expect(rec.successfulReadFileArgs[0]).not.toContain('search_file_content');
});
});
describe('wasGivenTheDiff', () => {
@@ -185,6 +233,7 @@ describe('wasGivenTheDiff', () => {
diffToolCalls: 0,
diffReads: [],
successfulCallArgs: [],
successfulReadFileArgs: [],
finalText: '',
mtimeMs: 0,
});
@@ -38,6 +38,7 @@
// come from the environment the CLI itself exported.
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { ToolNames } from '@qwen-code/qwen-code-core';
import { join } from 'node:path';
/** One subagent, as the harness recorded it. */
@@ -77,6 +78,15 @@ export interface AgentRecord {
* the harness wrote down, not a hope.
*/
successfulCallArgs: string[];
/**
* The arguments of the successful `read_file` calls, serialized — a subset of
* `successfulCallArgs` for the checks where NAMING a path is not OPENING it.
* A `search_file_content` or a `list_directory` over the record dir carries
* the same stringified path in its args without reading a line; the
* findings-file floor asks whether the list was read, and only a read is a
* read.
*/
successfulReadFileArgs: string[];
/** The agent's own final text, as the harness saw it. */
finalText: string;
/** When the transcript was last written. */
@@ -213,11 +223,13 @@ function parseTranscript(file: string, diffPath?: string): AgentRecord | null {
// attributed by a stack.
interface Pending {
namedTheDiff: boolean;
readFile: boolean;
range: [number, number] | null;
args: string;
}
const diffReads: Array<[number, number]> = [];
const successfulCallArgs: string[] = [];
const successfulReadFileArgs: string[] = [];
const byId = new Map<string, Pending>();
const anonymous: Pending[] = [];
@@ -261,6 +273,7 @@ function parseTranscript(file: string, diffPath?: string): AgentRecord | null {
: false;
const pending: Pending = {
namedTheDiff,
readFile: fc.name === ToolNames.READ_FILE,
range: namedTheDiff ? rangeOf(args) : null,
args: JSON.stringify(args),
};
@@ -286,6 +299,7 @@ function parseTranscript(file: string, diffPath?: string): AgentRecord | null {
if (!isErrorPart(part as FunctionResponsePart)) {
successfulToolCalls++;
successfulCallArgs.push(pending.args);
if (pending.readFile) successfulReadFileArgs.push(pending.args);
if (pending.namedTheDiff) {
diffToolCalls++;
if (pending.range) diffReads.push(pending.range);
@@ -316,6 +330,7 @@ function parseTranscript(file: string, diffPath?: string): AgentRecord | null {
diffToolCalls,
diffReads,
successfulCallArgs,
successfulReadFileArgs,
finalText,
mtimeMs,
};
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,434 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import { atomicWriteFileSync } from '@qwen-code/qwen-code-core';
import {
existsSync,
mkdirSync,
readFileSync,
realpathSync,
statSync,
} from 'node:fs';
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
import { writeStdoutLine } from '../../utils/stdioHelpers.js';
import { git, gitOpt, gitRaw } from './lib/git.js';
import { manifestRepositoryContextProvider } from './lib/manifest-repository-context.js';
import {
isSafeRepositoryRelativePath,
MAX_IDENTITY_BYTES,
type RepositoryContext,
type RepositoryContextProvider,
validateRepositoryContext,
} from './lib/repository-context.js';
import { stringifyPlanReport } from './lib/report.js';
interface RepoContextArgs {
plan: string;
worktree: string;
out: string;
}
interface PlanFile {
path: unknown;
}
interface MutablePlan {
files?: unknown;
worktreePath?: unknown;
mergeBaseSha?: unknown;
baseFetchFailed?: unknown;
repositoryContext?: unknown;
[key: string]: unknown;
}
export const REPOSITORY_CONTEXT_PROVIDERS: readonly RepositoryContextProvider[] =
[manifestRepositoryContextProvider];
function sameFile(left: string, right: string): boolean {
if (left === right) return true;
if (!existsSync(left) || !existsSync(right)) return false;
const leftStat = statSync(left);
const rightStat = statSync(right);
return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino;
}
function recordedWorktreeMatches(
recordedPath: string,
worktree: string,
): boolean {
const candidates = [resolve(recordedPath)];
if (!isAbsolute(recordedPath)) {
const commonDir = gitOpt('-C', worktree, 'rev-parse', '--git-common-dir');
if (commonDir !== null) {
candidates.push(
resolve(dirname(resolve(worktree, commonDir)), recordedPath),
);
}
}
return candidates.some(
(candidate) =>
existsSync(candidate) && realpathSync(candidate) === worktree,
);
}
/**
* Which identity source this plan may read from. `local` — no merge base was
* ever recorded (a capture-local / plan-diff plan): the worktree. `base` — a
* trusted, resolved merge base: that commit only. `none` — NO source, for
* two PR states the pipeline itself degrades: a base that never resolved
* (`fetch-pr` records `mergeBaseSha: null` rather than failing) and a FAILED
* base fetch (`merge-base` documents the state as not fatal, `fetch-pr`
* warns and continues on a possibly stale sha, `base-tree` refuses one — a
* possibly stale sha is not a trusted identity source either). The shape
* matters: the natural-looking fallback would read the manifest from the PR
* head, the exact read the trust boundary exists to forbid, so the command
* writes a `null` artifact instead — the same degradation `fetch-pr` chose,
* taken one step.
*/
type MergeBaseResolution =
| { kind: 'local' }
| { kind: 'base'; sha: string }
| { kind: 'none' };
function trustedMergeBase(
plan: MutablePlan,
worktree: string,
): MergeBaseResolution {
if (plan.mergeBaseSha === undefined) return { kind: 'local' };
if (plan.baseFetchFailed === true || plan.mergeBaseSha === null) {
return { kind: 'none' };
}
if (
typeof plan.mergeBaseSha !== 'string' ||
!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(plan.mergeBaseSha)
) {
throw new Error('repo-context: plan.mergeBaseSha is invalid');
}
if (
gitOpt(
'-C',
worktree,
'cat-file',
'-e',
`${plan.mergeBaseSha}^{commit}`,
) === null
) {
throw new Error('repo-context: plan.mergeBaseSha cannot be resolved');
}
return { kind: 'base', sha: plan.mergeBaseSha };
}
// `git` normalises stdout (CRLF to LF, trimmed); the worktree read must return
// the same shape, or a provider that exact-compares an identity file gets one
// value in a PR review and another in a local review of the same repository.
function normalizeIdentityContent(content: string): string {
return content.replace(/\r\n/g, '\n').trim();
}
function isAbsentError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException | null)?.code;
return code === 'ENOENT' || code === 'ENOTDIR';
}
/**
* One `git ls-tree <rev> -- <path>` entry. Exit 0 with empty output is git's
* DEFINITE "absent at this revision" — unlike `cat-file -e`, whose non-zero
* exit cannot be told from a failed git call, so a throwing git call below
* stays a throw (fail closed) and never masquerades as "not this repository".
*/
function baseTreeEntry(
worktree: string,
mergeBase: string,
path: string,
): { mode: string; type: string } | null {
const output = git('-C', worktree, 'ls-tree', mergeBase, '--', path);
if (output === '') return null;
const [mode, type] = output.split(/\s+/);
return { mode, type };
}
function readBaseBlob(worktree: string, mergeBase: string, path: string) {
try {
// `gitRaw`'s raised maxBuffer: `git()` inherits execFileSync's 1 MB
// default, so a schema-legal manifest past 1 MB would die with ENOBUFS
// in PR mode while the worktree branch reads it without a cap.
return gitRaw('-C', worktree, 'show', `${mergeBase}:${path}`).toString(
'utf8',
);
} catch (error) {
throw new Error(
`repo-context: identity read failed for ${path}: ` +
`${(error as Error).message}`,
);
}
}
/**
* Resolve a committed symlink's target the way the filesystem resolves one:
* relative to the link's own directory. `null` means the target escapes the
* tree (absolute, or climbs past the root); `''` means it climbs to the tree
* root itself — a directory, never an identity file.
*/
function resolveTreeSymlinkTarget(
fromPath: string,
target: string,
): string | null {
if (target.startsWith('/') || /^[A-Za-z]:/.test(target)) return null;
const segments = `${dirname(fromPath)}/${target}`.split('/');
const resolved: string[] = [];
for (const segment of segments) {
if (segment === '' || segment === '.') continue;
if (segment === '..') {
if (resolved.length === 0) return null;
resolved.pop();
continue;
}
resolved.push(segment);
}
return resolved.join('/');
}
const MAX_IDENTITY_SYMLINK_HOPS = 16;
/**
* The base-mode identity read, mirroring the worktree branch where git can:
* `ls-tree` mode stands in for `lstat`/`statSync` (`cat-file -e` would
* happily "exist" for a tree or symlink entry and hand a provider content
* the worktree branch can never produce), committed symlinks are followed
* under the same containment rule `realpathSync` enforces on disk, and a
* directory yields `null` exactly like `isFile() === false`. One known
* divergence: `ls-tree` never descends through a symlinked intermediate
* path COMPONENT, so an identity below one reads `null` here while the
* worktree branch follows it. The direction is fail-safe — base mode reads
* strictly less, never more — so the gap degrades to "no context", not a
* trust hole.
*/
function readBaseIdentity(
worktree: string,
mergeBase: string,
relativePath: string,
): string | null {
let path = relativePath;
// A symlink target ending in `/`, `.`, or `..` requires the finally
// resolved entry to be a directory, exactly the way realpathSync fails
// ENOTDIR on disk — without this the two modes diverge on a broken
// trailing-component link (a target like `a/.` walks THROUGH `a`).
let requireDirectory = false;
for (let hop = 0; hop < MAX_IDENTITY_SYMLINK_HOPS; hop++) {
const entry = baseTreeEntry(worktree, mergeBase, path);
if (entry === null) return null;
if (entry.mode === '120000') {
const target = readBaseBlob(worktree, mergeBase, path);
const lastSegment = target.split('/').pop();
if (target.endsWith('/') || lastSegment === '.' || lastSegment === '..') {
requireDirectory = true;
}
const resolved = resolveTreeSymlinkTarget(path, target);
if (resolved === null) {
throw new Error(
`repo-context: identity path escapes the worktree: ` +
`${JSON.stringify(relativePath)}`,
);
}
if (resolved === '') return null;
path = resolved;
continue;
}
if (entry.type !== 'blob') return null;
if (requireDirectory) return null;
return normalizeIdentityContent(readBaseBlob(worktree, mergeBase, path));
}
throw new Error(
`repo-context: identity symlink chain is too deep: ` +
`${JSON.stringify(relativePath)}`,
);
}
function identityReader(
worktree: string,
mergeBase: string | null,
): (relativePath: string) => string | null {
return (relativePath) => {
if (!isSafeRepositoryRelativePath(relativePath)) {
throw new Error(
`repo-context: identity path is unsafe: ${JSON.stringify(relativePath)}`,
);
}
if (mergeBase !== null) {
return readBaseIdentity(worktree, mergeBase, relativePath);
}
const candidate = resolve(worktree, relativePath);
let resolved: string;
try {
resolved = realpathSync(candidate);
} catch (error) {
if (isAbsentError(error)) return null;
throw error;
}
const contained = relative(worktree, resolved);
// A path resolving to the worktree root itself is a directory, never an
// identity file; it falls out at the isFile check, not here.
if (
isAbsolute(contained) ||
contained === '..' ||
contained.startsWith(`..${sep}`)
) {
throw new Error(
`repo-context: identity path escapes the worktree: ${JSON.stringify(relativePath)}`,
);
}
try {
const stat = statSync(resolved);
if (!stat.isFile()) return null;
// Fail closed before reading (and therefore parsing) an oversized
// identity — the manifest provider's threat model is an
// attacker-committed file, and JSON.parse runs before any schema
// validation can reject it.
if (stat.size > MAX_IDENTITY_BYTES) {
throw new Error(
`repo-context: identity read exceeds the size limit: ` +
`${JSON.stringify(relativePath)}`,
);
}
return normalizeIdentityContent(readFileSync(resolved, 'utf8'));
} catch (error) {
if (isAbsentError(error)) return null;
throw error;
}
};
}
function readPlan(path: string): MutablePlan {
let value: unknown;
try {
value = JSON.parse(readFileSync(path, 'utf8'));
} catch (error) {
throw new Error(`Cannot read plan ${path}: ${(error as Error).message}`);
}
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error('repo-context: plan must be a JSON object');
}
return value as MutablePlan;
}
function changedPaths(plan: MutablePlan): string[] {
if (!Array.isArray(plan.files)) {
throw new Error('repo-context: plan.files must be an array');
}
const paths: string[] = [];
for (const [index, file] of plan.files.entries()) {
const path =
typeof file === 'object' && file !== null
? (file as PlanFile).path
: undefined;
if (typeof path !== 'string') {
throw new Error(`repo-context: plan.files[${index}].path is invalid`);
}
// Changed paths are only ever MATCHED against manifest globs, never opened,
// so an unsafe-but-real path (a backslash is a legal POSIX filename byte)
// is skipped rather than aborting a step that runs on every review.
if (isSafeRepositoryRelativePath(path)) paths.push(path);
}
return [...new Set(paths)].sort();
}
function contextFromProviders(
providers: readonly RepositoryContextProvider[],
worktree: string,
paths: string[],
readIdentityFile: (relativePath: string) => string | null,
): RepositoryContext | null {
for (const provider of providers) {
const context = provider.provide({
worktree,
changedPaths: paths,
readIdentityFile,
});
if (context !== null) return validateRepositoryContext(context);
}
return null;
}
export function runRepoContext(
args: RepoContextArgs,
providers: readonly RepositoryContextProvider[] = REPOSITORY_CONTEXT_PROVIDERS,
): void {
const planPath = resolve(args.plan);
const outPath = resolve(args.out);
if (sameFile(planPath, outPath)) {
throw new Error('repo-context: --out must differ from --plan');
}
const worktree = realpathSync(resolve(args.worktree));
if (!statSync(worktree).isDirectory()) {
throw new Error(`repo-context: worktree is not a directory: ${worktree}`);
}
const plan = readPlan(planPath);
if (plan.worktreePath !== undefined) {
if (
typeof plan.worktreePath !== 'string' ||
plan.worktreePath.length === 0
) {
throw new Error('repo-context: plan.worktreePath is invalid');
}
if (!recordedWorktreeMatches(plan.worktreePath, worktree)) {
throw new Error(
`repo-context: --worktree does not match plan.worktreePath (${worktree} != ${plan.worktreePath})`,
);
}
}
const mergeBase = trustedMergeBase(plan, worktree);
const context =
mergeBase.kind === 'none'
? null
: contextFromProviders(
providers,
worktree,
changedPaths(plan),
identityReader(
worktree,
mergeBase.kind === 'base' ? mergeBase.sha : null,
),
);
if (context === null) delete plan.repositoryContext;
else plan.repositoryContext = context;
mkdirSync(dirname(outPath), { recursive: true });
atomicWriteFileSync(outPath, `${JSON.stringify(context, null, 2)}\n`);
atomicWriteFileSync(planPath, stringifyPlanReport(plan));
writeStdoutLine(
context === null
? `Wrote null repository context to ${outPath}`
: `Wrote repository context (${context.provider}) to ${outPath}`,
);
}
export const repoContextCommand: CommandModule = {
command: 'repo-context',
describe: 'Attach bounded repository-specific context to a review plan',
builder: (yargs) =>
yargs
.option('plan', {
type: 'string',
demandOption: true,
describe: 'Existing review plan JSON to update',
})
.option('worktree', {
type: 'string',
demandOption: true,
describe: 'Repository worktree used to resolve context',
})
.option('out', {
type: 'string',
demandOption: true,
describe: 'Independent repository-context artifact path',
}),
handler: (argv) => {
runRepoContext(argv as unknown as RepoContextArgs);
},
};
@@ -11,14 +11,17 @@ import {
mkdirSync,
readFileSync,
readdirSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import yargs from 'yargs';
import type { Argv } from 'yargs';
import { buildReport, type Finding } from './findings.js';
import { saveReviewArtifact } from './save-artifact.js';
import { saveArtifactCommand, saveReviewArtifact } from './save-artifact.js';
// On a case-sensitive filesystem the alias below never exists, so that test
// can only run where the filesystem folds case. Probe once, at load time, so
@@ -34,7 +37,6 @@ const caseInsensitiveFs = (() => {
})();
let root: string;
let previousProjectDir: string | undefined;
const finding: Finding = {
id: 'R1-1',
@@ -80,22 +82,21 @@ function fixture() {
writeJson(composed, verdict);
mkdirSync(join(root, '.qwen/reviews'), { recursive: true });
writeFileSync(report, '# Review\n');
return { findings, composed, report, out };
// The workspace root is explicit here because the test process's cwd is the
// package directory, not the temp root — the same explicit-root path the
// skill's own Step 8 invocation takes.
return { findings, composed, report, out, workspaceRoot: root };
}
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'review-artifact-'));
previousProjectDir = process.env['QWEN_CODE_PROJECT_DIR'];
process.env['QWEN_CODE_PROJECT_DIR'] = root;
// realpath, because the cwd-default test below chdirs into the root and
// compares against process.cwd(), which returns the physical path — on
// macOS the temp dir is reached through a /var → /private/var symlink.
root = realpathSync(mkdtempSync(join(tmpdir(), 'review-artifact-')));
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
if (previousProjectDir === undefined) {
delete process.env['QWEN_CODE_PROJECT_DIR'];
} else {
process.env['QWEN_CODE_PROJECT_DIR'] = previousProjectDir;
}
});
describe('saveReviewArtifact', () => {
@@ -384,9 +385,9 @@ describe('saveReviewArtifact', () => {
expect(existsSync(paths.out)).toBe(false);
});
it('resolves relative paths against the workspace root, not cwd', () => {
// The form SKILL.md documents. beforeEach points QWEN_CODE_PROJECT_DIR at
// the temp root while cwd stays the package directory, so the two roots
it('resolves relative paths against the explicit workspace root, not cwd', () => {
// The form the skill's Step 8 block uses. --workspace-root points at the
// temp root while cwd stays the package directory, so the two roots
// differ and the resolution direction is observable.
fixture();
@@ -397,12 +398,12 @@ describe('saveReviewArtifact', () => {
out: '.qwen/reviews/review.json',
target: 'pr-123',
effort: 'high',
workspaceRoot: root,
});
expect(saved.path).toBe(join(root, '.qwen/reviews/review.json'));
// The registration value `record_artifact` wants, printed so the skill
// copies it verbatim — including from a PR worktree run where cwd (the
// disposable review worktree) differs from the workspace root.
// copies it verbatim.
expect(saved.workspacePath).toBe('.qwen/reviews/review.json');
expect(JSON.parse(readFileSync(saved.path, 'utf8'))).toMatchObject({
schemaVersion: 1,
@@ -410,4 +411,82 @@ describe('saveReviewArtifact', () => {
markdownReportPath: '.qwen/reviews/review.md',
});
});
it('resolves against cwd and never QWEN_CODE_PROJECT_DIR', () => {
// The default path, with the trap armed. No `workspaceRoot` is passed —
// an embedder that omits it must land on cwd — while the env var points
// at a decoy the removed preference would have taken: the variable names
// the session-storage directory under the runtime base, never the main
// checkout, and six of six measured CI reviews fumbled on it (DESIGN.md —
// The artifact root that pointed at qwen-home). Re-introducing
// `explicit ?? env ?? cwd` fails this test: resolution lands on the
// decoy, not on cwd.
const decoy = mkdtempSync(join(tmpdir(), 'review-artifact-decoy-'));
const savedCwd = process.cwd();
const previous = process.env['QWEN_CODE_PROJECT_DIR'];
process.env['QWEN_CODE_PROJECT_DIR'] = decoy;
try {
fixture();
process.chdir(root);
const saved = saveReviewArtifact({
findings: '.qwen/tmp/findings.json',
composed: '.qwen/tmp/composed.json',
report: '.qwen/reviews/review.md',
out: '.qwen/reviews/review.json',
target: 'pr-123',
effort: 'high',
});
expect(saved.path).toBe(join(root, '.qwen/reviews/review.json'));
expect(saved.workspacePath).toBe('.qwen/reviews/review.json');
expect(existsSync(join(decoy, '.qwen/reviews/review.json'))).toBe(false);
} finally {
process.chdir(savedCwd);
if (previous === undefined) {
delete process.env['QWEN_CODE_PROJECT_DIR'];
} else {
process.env['QWEN_CODE_PROJECT_DIR'] = previous;
}
rmSync(decoy, { recursive: true, force: true });
}
});
});
describe('the CLI option contract', () => {
// Every test above builds its args by hand — the same shape that let a
// flag-name bug into `test-plan`: yargs camel-cases the flag, a field named
// for the flag read `undefined` on every real invocation, and the suite
// stayed green because nothing went through yargs. `--workspace-root` is
// this command's only multi-word flag AND its trust anchor (it roots the
// containment checks), so this test does not assert the parsed shape and
// stop — it feeds the yargs-parsed object straight into saveReviewArtifact
// and asserts on a write only reachable when the root actually arrived
// from the flag: cwd stays the package directory, where none of the
// fixture inputs exist.
it('parses --workspace-root into the field saveReviewArtifact actually reads', () => {
fixture();
const parsed = (saveArtifactCommand.builder as (y: Argv) => Argv)(
yargs([]),
).parseSync([
'--findings',
'.qwen/tmp/findings.json',
'--composed',
'.qwen/tmp/composed.json',
'--report',
'.qwen/reviews/review.md',
'--target',
'pr-123',
'--effort',
'high',
'--out',
'.qwen/reviews/review.json',
'--workspace-root',
root,
]) as unknown as Parameters<typeof saveReviewArtifact>[0];
const saved = saveReviewArtifact(parsed);
expect(saved.path).toBe(join(root, '.qwen/reviews/review.json'));
expect(saved.workspacePath).toBe('.qwen/reviews/review.json');
});
});
@@ -66,15 +66,30 @@ interface SaveArtifactArgs {
target: string;
effort: ReviewEffort;
out: string;
workspaceRoot?: string;
}
// Every path resolves against the daemon workspace root, not cwd: in PR
// worktree mode cwd is the disposable review worktree, while the durable
// output and `markdownReportPath` must stay relative to the main project for
// Web Shell's `readWorkspaceFile` to find them. The skill threads that root
// through its subprocesses as QWEN_CODE_PROJECT_DIR.
function workspaceRoot(): string {
return resolve(process.env['QWEN_CODE_PROJECT_DIR'] ?? process.cwd());
// Every path resolves against the main checkout, because the durable output
// and `markdownReportPath` must stay relative to the main project for Web
// Shell's `readWorkspaceFile` to find them. That root arrives as
// `--workspace-root`: the skill passes the main project directory explicitly
// on every run (SKILL.md Step 8), because the root anchors the containment
// checks — `isWithin` and the symlink walk below — and an ambient cwd is only
// as trustworthy as wherever the command happened to run. Cwd is the fallback
// when the flag is absent, right whenever the caller runs from the main
// checkout — in PR worktree mode the worktree-resident inputs arrive as
// absolute paths that still sit under the main project's `.qwen/tmp/`.
//
// This used to prefer `QWEN_CODE_PROJECT_DIR`, believing it named that
// checkout. It never does: the harness exports it as the session-storage
// directory under the runtime base (`Storage.getProjectDir()` — where the
// harness's transcripts live), in every environment. Every measured CI review
// resolved its containment root there, refused its own inputs, and burned
// minutes working around it (DESIGN.md — The artifact root that pointed at
// qwen-home). An ambient variable that is wrong 100% of the time it is
// consulted is not a fallback; it is a trap, so it is not consulted at all.
function workspaceRoot(explicit?: string): string {
return resolve(explicit ?? process.cwd());
}
function isWithin(parent: string, child: string): boolean {
@@ -259,7 +274,7 @@ function validateFindingsReport(value: unknown): FindingsReport {
export function saveReviewArtifact(
args: SaveArtifactArgs,
): SavedReviewArtifact {
const root = workspaceRoot();
const root = workspaceRoot(args.workspaceRoot);
const findingsPath = workspacePath(root, args.findings, 'Findings input');
const composedPath = workspacePath(root, args.composed, 'Composed input');
const reportPath = workspacePath(root, args.report, 'Markdown report');
@@ -378,6 +393,12 @@ export const saveArtifactCommand: CommandModule = {
type: 'string',
demandOption: true,
describe: 'Output path under .qwen/reviews/',
})
.option('workspace-root', {
type: 'string',
describe:
'Root that containment and relative paths resolve against ' +
'(default: the working directory — run from the main checkout)',
}),
handler: (argv) => {
const saved = saveReviewArtifact(argv as unknown as SaveArtifactArgs);
+34
View File
@@ -166,6 +166,40 @@ vi.mock('command-exists', () => ({
},
}));
// #7734 added a sandbox runtime probe that spawns a real `docker version`
// subprocess during sandbox selection. These tests enable the sandbox to assert
// image precedence, not which runtime is chosen, so left unmocked the probe runs
// for real and the suite fails on any non-macOS host without a running daemon
// (macOS is masked because the sandbox-exec branch returns before probing).
// Report the runtime probe healthy; leave every other spawnSync call real.
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>();
const spawnSync = vi.fn(
(command: string, args?: readonly string[], options?: unknown) => {
if (
(command === 'docker' || command === 'podman') &&
args?.[0] === 'version'
) {
return {
status: 0,
stdout: '',
stderr: '',
signal: null,
pid: 0,
output: [],
error: undefined,
};
}
return (actual.spawnSync as unknown as (...a: unknown[]) => unknown)(
command,
args,
options,
);
},
);
return { ...actual, default: { ...actual, spawnSync }, spawnSync };
});
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actualServer = await importOriginal<typeof ServerConfig>();
const SkillManagerMock = vi.fn();
@@ -0,0 +1,348 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SpawnSyncReturns } from 'node:child_process';
const { commandExistsSync, spawnSync, platform } = vi.hoisted(() => ({
commandExistsSync: vi.fn<(cmd: string) => boolean>(),
spawnSync: vi.fn(),
platform: vi.fn<() => string>(),
}));
vi.mock('command-exists', () => ({
default: { sync: commandExistsSync },
}));
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>();
// `default` has to carry the stub too: Node builtins are CJS, so Vite's
// interop can resolve a named import through the default export.
return { ...actual, default: { ...actual, spawnSync }, spawnSync };
});
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>();
return { ...actual, platform };
});
vi.mock('../utils/package.js', () => ({
getPackageJson: vi.fn(async () => ({
config: { sandboxImageUri: 'test-image' },
})),
}));
const { loadSandboxConfig, resetSandboxProbeCacheForTest } = await import(
'./sandboxConfig.js'
);
/** A `spawnSync` result standing in for a runtime that answers `version`. */
function healthy(): Partial<SpawnSyncReturns<string>> {
return { status: 0, stdout: 'Version: 99.0.0\n', stderr: '' };
}
/**
* A runtime whose CLI is installed but cannot reach its daemon — Docker
* Desktop stopped, or the user not in the `docker` group.
*/
function daemonDown(): Partial<SpawnSyncReturns<string>> {
return {
status: 1,
stdout: '',
stderr:
'Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?\n',
};
}
/**
* A runtime that exits non-zero but prints nothing — a wrapper that swallows
* output, or a silent permission failure. The empty output is the point: it is
* what makes the `?? synthesized message` fallback in `probeSandboxCommand`
* load-bearing. Without that fallback the probe would return `undefined` here
* and the broken runtime would be declared usable.
*/
function brokenSilently(): Partial<SpawnSyncReturns<string>> {
return { status: 1, stdout: '', stderr: '' };
}
/**
* A wedged daemon killed at the timeout: `spawnSync` reports an `error` and a
* null status. This is the path `SANDBOX_PROBE_TIMEOUT_MS` exists for.
*/
function timedOut(): Partial<SpawnSyncReturns<string>> {
return {
error: new Error('spawnSync docker ETIMEDOUT'),
status: null,
stdout: '',
stderr: '',
};
}
/**
* Route probe results per command so a test can mix healthy and broken.
*
* The stub validates argv and the timeout, not just the command name. Both are
* load-bearing and neither is observable from the selection result: `version`
* contacts the daemon while `--version` only prints the client build, so a
* probe that drifted to `--version` would call every broken runtime healthy and
* silently restore the original bug with the selection assertions still green.
* The timeout is the only bound on a wedged daemon that accepts connections and
* never answers.
*/
function probes(byCommand: Record<string, Partial<SpawnSyncReturns<string>>>) {
spawnSync.mockImplementation(
(cmd: string, args: string[], options: { timeout?: number }) => {
const result = byCommand[cmd];
if (!result) throw new Error(`unexpected probe of '${cmd}'`);
expect(args).toEqual(['version']);
expect(options?.timeout).toBeGreaterThan(0);
return result;
},
);
}
function installed(...commands: string[]) {
commandExistsSync.mockImplementation((cmd: string) => commands.includes(cmd));
}
describe('loadSandboxConfig sandbox command selection', () => {
beforeEach(() => {
vi.clearAllMocks();
resetSandboxProbeCacheForTest();
platform.mockReturnValue('linux');
delete process.env['SANDBOX'];
delete process.env['QWEN_SANDBOX'];
});
afterEach(() => {
delete process.env['SANDBOX'];
delete process.env['QWEN_SANDBOX'];
});
it('falls back to podman when docker is installed but its daemon is unreachable', async () => {
installed('docker', 'podman');
probes({ docker: daemonDown(), podman: healthy() });
const config = await loadSandboxConfig({}, { sandbox: true });
expect(config?.command).toBe('podman');
// Pin the probe argument at the call site as well: `docker version` is what
// reaches the daemon, and it is the reason the fallback fires at all.
expect(spawnSync).toHaveBeenCalledWith(
'docker',
['version'],
expect.objectContaining({ timeout: expect.any(Number) }),
);
});
it('treats a non-zero exit with no output as broken and falls through', async () => {
// Pins the `?? synthesized message` fallback: with empty output the probe
// must still report a failure so selection moves on. Drop the fallback and
// probeSandboxCommand returns undefined here, docker is called usable, and
// the original bug returns with every selection assertion still green.
installed('docker', 'podman');
probes({ docker: brokenSilently(), podman: healthy() });
const config = await loadSandboxConfig({}, { sandbox: true });
expect(config?.command).toBe('podman');
});
it('probes each runtime once and reuses the result across calls', async () => {
// Selection runs more than once per startup (the sandbox hop, then again
// inside loadCliConfig), so without the cache a wedged runtime pays the
// timeout on every pass. Two selections must probe docker only once.
installed('docker', 'podman');
probes({ docker: healthy() });
await loadSandboxConfig({}, { sandbox: true });
await loadSandboxConfig({}, { sandbox: true });
const dockerProbes = spawnSync.mock.calls.filter(
([cmd]) => cmd === 'docker',
).length;
expect(dockerProbes).toBe(1);
});
it('still prefers docker when it is usable', async () => {
installed('docker', 'podman');
probes({ docker: healthy(), podman: healthy() });
const config = await loadSandboxConfig({}, { sandbox: true });
expect(config?.command).toBe('docker');
});
it('names the runtime that broke when no installed runtime can run', async () => {
installed('docker');
probes({ docker: daemonDown() });
await expect(loadSandboxConfig({}, { sandbox: true })).rejects.toThrow(
/docker.*cannot run.*Cannot connect to the Docker daemon/s,
);
});
it('names the first broken runtime when every installed runtime fails', async () => {
// Pins that the error reports docker (tried first), not podman (tried
// last). Flip `firstFailure ??=` to `=` and it would name podman with the
// suite otherwise green, sending the user to debug the wrong daemon.
installed('docker', 'podman');
probes({ docker: daemonDown(), podman: daemonDown() });
const error = await loadSandboxConfig({}, { sandbox: true }).catch(
(e: Error) => e,
);
const message = (error as Error).message;
expect(message).toContain("'docker'");
expect(message).not.toContain("'podman'");
});
it('treats a failure of only control characters as broken, not usable', async () => {
// The runtime exits non-zero but its output is nothing but escape/control
// bytes, which strip to ''. That empty string must not read as "no
// failure" — otherwise the broken runtime is selected, reintroducing the
// presence-vs-liveness bug through the sanitizer.
installed('docker', 'podman');
probes({
docker: { status: 1, stdout: '', stderr: '\x1b[0m\x07\n' },
podman: healthy(),
});
const config = await loadSandboxConfig({}, { sandbox: true });
expect(config?.command).toBe('podman');
});
it('strips ANSI and control characters from the runtime failure', async () => {
// The runtime's stderr is interpolated into a FatalSandboxError that
// reaches the terminal, so its escape and control bytes must not survive.
process.env['QWEN_SANDBOX'] = 'docker';
installed('docker');
probes({
docker: {
status: 1,
stdout: '',
stderr: '\x1b[31mCannot connect to the daemon\x1b[0m\x07\n',
},
});
const error = await loadSandboxConfig({}, {}).catch((e: Error) => e);
const message = (error as Error).message;
expect(message).toContain('Cannot connect to the daemon');
expect(message).not.toContain('\x1b');
expect(message).not.toContain('\x07');
});
it('surfaces the timeout error when a probe is killed at the cap', async () => {
// Pins the `result.error` branch — the wedged-daemon path the timeout
// exists for. Delete that branch and the throw degrades from the ETIMEDOUT
// text to "exited with null" with the suite still green.
process.env['QWEN_SANDBOX'] = 'docker';
installed('docker');
probes({ docker: timedOut() });
await expect(loadSandboxConfig({}, {})).rejects.toThrow(/ETIMEDOUT/);
});
it('does not blame QWEN_SANDBOX when --sandbox enabled the auto-detect path', async () => {
installed('docker');
probes({ docker: daemonDown() });
const failure = await loadSandboxConfig({}, { sandbox: true }).catch(
(error: Error) => error,
);
expect((failure as Error).message).toContain("'docker' is installed");
expect((failure as Error).message).toContain('--sandbox');
expect((failure as Error).message).not.toContain('QWEN_SANDBOX is true');
});
it('says QWEN_SANDBOX is true when the env var enabled the sandbox', async () => {
process.env['QWEN_SANDBOX'] = 'true';
installed('docker');
probes({ docker: daemonDown() });
await expect(loadSandboxConfig({}, {})).rejects.toThrow(
/QWEN_SANDBOX is true and 'docker' is installed but cannot run/,
);
});
it('keeps the generic message when nothing is installed at all', async () => {
installed();
probes({});
await expect(loadSandboxConfig({}, { sandbox: true })).rejects.toThrow(
/failed to determine command for sandbox/,
);
});
it('does not silently override an explicit QWEN_SANDBOX choice', async () => {
process.env['QWEN_SANDBOX'] = 'docker';
installed('docker', 'podman');
probes({ docker: daemonDown(), podman: healthy() });
await expect(loadSandboxConfig({}, {})).rejects.toThrow(
/'docker' \(from QWEN_SANDBOX\) is installed but cannot run/,
);
});
it('does not blame QWEN_SANDBOX for a command that came from --sandbox', async () => {
installed('docker', 'podman');
probes({ docker: daemonDown(), podman: healthy() });
// `--sandbox docker` / `tools.sandbox` reach the same code path, so the
// error must not point at an env var the user never set.
const failure = await loadSandboxConfig({}, { sandbox: 'docker' }).catch(
(error: Error) => error,
);
expect((failure as Error).message).toContain("'docker' is installed");
expect((failure as Error).message).not.toContain('QWEN_SANDBOX');
});
it('names QWEN_SANDBOX when a missing command really did come from it', async () => {
process.env['QWEN_SANDBOX'] = 'podman';
installed('docker');
probes({});
await expect(loadSandboxConfig({}, {})).rejects.toThrow(
/Missing sandbox command 'podman' \(from QWEN_SANDBOX\)/,
);
});
it('accepts an explicit choice that is usable', async () => {
process.env['QWEN_SANDBOX'] = 'podman';
installed('podman');
probes({ podman: healthy() });
const config = await loadSandboxConfig({}, {});
expect(config?.command).toBe('podman');
});
it('selects seatbelt on darwin without probing a daemon', async () => {
platform.mockReturnValue('darwin');
installed('sandbox-exec', 'docker');
probes({});
const config = await loadSandboxConfig({}, { sandbox: true });
expect(config?.command).toBe('sandbox-exec');
expect(spawnSync).not.toHaveBeenCalled();
});
it('returns undefined when the sandbox is disabled', async () => {
installed('docker');
probes({});
const config = await loadSandboxConfig({}, { sandbox: false });
expect(config).toBeUndefined();
expect(spawnSync).not.toHaveBeenCalled();
});
});
+137 -9
View File
@@ -5,8 +5,12 @@
*/
import type { SandboxConfig } from '@qwen-code/qwen-code-core';
import { FatalSandboxError } from '@qwen-code/qwen-code-core';
import {
FatalSandboxError,
stripAnsiAndControl,
} from '@qwen-code/qwen-code-core';
import commandExists from 'command-exists';
import { spawnSync } from 'node:child_process';
import * as os from 'node:os';
import { getPackageJson } from '../utils/package.js';
import type { Settings } from './settings.js';
@@ -28,6 +32,83 @@ function isSandboxCommand(value: string): value is SandboxConfig['command'] {
return (VALID_SANDBOX_COMMANDS as readonly string[]).includes(value);
}
// A healthy `docker version` answers in roughly 200-500ms, so this is already
// an order of magnitude of headroom. Keeping it tight matters because a wedged
// daemon blocks startup for the full cap.
const SANDBOX_PROBE_TIMEOUT_MS = 5_000;
// `loadSandboxConfig` runs twice on a sandboxed startup — once for the sandbox
// hop and once inside loadCliConfig — so selection is entered more than once
// per process. Cache each command's probe outcome so a runtime is contacted at
// most once; otherwise the wedged-daemon-then-fallback case pays the timeout
// twice. Daemon state changing mid-startup is not worth serving. Mirrors the
// ripgrep health cache (`ripgrepUtils.ts`).
const probeCache = new Map<SandboxConfig['command'], string | undefined>();
/** Clears the per-process probe cache so tests stay hermetic. */
export function resetSandboxProbeCacheForTest(): void {
probeCache.clear();
}
/**
* Confirms that a sandbox command can actually run, not merely that it is on
* PATH. A present container CLI is not a usable one: Docker Desktop may be
* stopped, the daemon may be unreachable, or the user may not be in the
* `docker` group. `version` is the cheapest command that still contacts the
* daemon, so it fails exactly when the runtime would fail later.
*
* `sandbox-exec` is a kernel facility rather than a daemon-backed client, so
* its presence on PATH is already sufficient.
*
* @returns The failure output when the command cannot run, or undefined when it
* is usable. The result is cached per command for the process.
*/
function probeSandboxCommand(
command: SandboxConfig['command'],
): string | undefined {
if (command === 'sandbox-exec') {
return undefined;
}
if (probeCache.has(command)) {
return probeCache.get(command);
}
const failure = runSandboxProbe(command);
probeCache.set(command, failure);
return failure;
}
function runSandboxProbe(
command: SandboxConfig['command'],
): string | undefined {
try {
const result = spawnSync(command, ['version'], {
encoding: 'utf8',
stdio: 'pipe',
timeout: SANDBOX_PROBE_TIMEOUT_MS,
});
if (result.error) {
return result.error.message;
}
if (result.status === 0) {
return undefined;
}
const output = `${result.stderr ?? ''}\n${result.stdout ?? ''}`;
const firstLine = output
.split('\n')
.map((line) => line.trim())
.find((line) => line.length > 0);
// The runtime's own output reaches a FatalSandboxError message, so strip
// ANSI/control characters from that untrusted string before it hits the
// terminal. Check for emptiness AFTER stripping: a line that is only
// control characters strips to '', which is falsy — return that and the
// caller reads the broken runtime as usable, the very bug this guards.
const stripped = firstLine ? stripAnsiAndControl(firstLine).trim() : '';
return stripped || `'${command} version' exited with ${result.status}`;
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
}
function getSandboxCommand(
sandbox?: boolean | string,
): SandboxConfig['command'] | '' {
@@ -50,6 +131,12 @@ function getSandboxCommand(
return '';
}
// An explicitly named command can come from QWEN_SANDBOX, --sandbox, or
// tools.sandbox in settings. Naming the wrong one sends the user looking in
// a place they never configured, so only claim the env var when it won.
const sandboxSource =
environmentConfiguredSandbox.length > 0 ? ' (from QWEN_SANDBOX)' : '';
if (typeof sandbox === 'string' && sandbox) {
if (!isSandboxCommand(sandbox)) {
throw new FatalSandboxError(
@@ -60,28 +147,69 @@ function getSandboxCommand(
}
// confirm that specified command exists
if (commandExists.sync(sandbox)) {
// An explicit choice is never silently overridden — but it is still
// probed, so the failure names the runtime instead of surfacing later as
// an opaque container error.
const failure = probeSandboxCommand(sandbox);
if (failure) {
throw new FatalSandboxError(
`Sandbox command '${sandbox}'${sandboxSource} is installed but cannot run: ${failure}`,
);
}
return sandbox;
}
throw new FatalSandboxError(
`Missing sandbox command '${sandbox}' (from QWEN_SANDBOX)`,
`Missing sandbox command '${sandbox}'${sandboxSource}`,
);
}
// look for seatbelt, docker, or podman, in that order
// for container-based sandboxing, require sandbox to be enabled explicitly
const candidates: Array<SandboxConfig['command']> = [];
if (os.platform() === 'darwin' && commandExists.sync('sandbox-exec')) {
return 'sandbox-exec';
} else if (commandExists.sync('docker') && sandbox === true) {
return 'docker';
} else if (commandExists.sync('podman') && sandbox === true) {
return 'podman';
candidates.push('sandbox-exec');
}
if (sandbox === true) {
candidates.push('docker', 'podman');
}
// Selecting on presence alone would stop at an installed-but-unusable
// runtime and leave a working one below it unreachable, so each candidate is
// probed and the first one that actually runs wins.
let firstFailure: { command: string; detail: string } | undefined;
for (const candidate of candidates) {
if (!commandExists.sync(candidate)) {
continue;
}
const failure = probeSandboxCommand(candidate);
if (!failure) {
return candidate;
}
firstFailure ??= { command: candidate, detail: failure };
}
// throw an error if user requested sandbox but no command was found
if (sandbox === true) {
// Sandboxing can be switched on by the env var, by --sandbox, or by
// settings, so these messages name the env var only when it was the one
// that enabled it — same reasoning as sandboxSource above.
const enabledLabel = sandboxSource
? 'QWEN_SANDBOX is true'
: 'Sandbox is enabled';
const specifyHint = sandboxSource
? 'specify command in QWEN_SANDBOX'
: 'specify command via --sandbox or QWEN_SANDBOX';
// Report the runtime that actually broke rather than a generic
// "nothing installed", which would send the user down the wrong path.
if (firstFailure) {
throw new FatalSandboxError(
`${enabledLabel} and '${firstFailure.command}' is installed but cannot run: ` +
`${firstFailure.detail}; start it, try another installed runtime, or ${specifyHint}`,
);
}
throw new FatalSandboxError(
'QWEN_SANDBOX is true but failed to determine command for sandbox; ' +
'install docker or podman or specify command in QWEN_SANDBOX',
`${enabledLabel} but failed to determine command for sandbox; ` +
`install docker or podman or ${specifyHint}`,
);
}
+140
View File
@@ -3332,6 +3332,123 @@ describe('Settings Loading and Merging', () => {
});
});
describe('allowedInsecureVoiceBaseUrls scope handling', () => {
it('should honor the allowlist from user scope', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify({
security: {
allowedInsecureVoiceBaseUrls: [
'http://voice.region-a.internal.example/v1',
],
},
});
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.security?.allowedInsecureVoiceBaseUrls).toEqual([
'http://voice.region-a.internal.example/v1',
]);
});
it('should strip and warn about the allowlist from workspace scope', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify({
security: {
allowedInsecureVoiceBaseUrls: [
'http://voice.region-a.internal.example/v1',
],
allowedHttpHookUrls: ['https://hooks.example.com/*'],
},
});
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(
settings.merged.security?.allowedInsecureVoiceBaseUrls,
).toBeUndefined();
expect(settings.merged.security?.allowedHttpHookUrls).toEqual([
'https://hooks.example.com/*',
]);
expect(
getSettingsWarnings(settings).some((warning) =>
warning.includes('security.allowedInsecureVoiceBaseUrls'),
),
).toBe(true);
});
it('should preserve a user allowlist when workspace defines another', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify({
security: {
allowedInsecureVoiceBaseUrls: [
'http://voice.region-a.internal.example/v1',
],
},
});
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify({
security: {
allowedInsecureVoiceBaseUrls: [
'http://voice.region-b.internal.example/v1',
],
},
});
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.security?.allowedInsecureVoiceBaseUrls).toEqual([
'http://voice.region-a.internal.example/v1',
]);
});
it('should let a system-scope empty allowlist revoke a user entry', () => {
const systemSettingsPath = '/mock/system/settings.json';
process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH'] = systemSettingsPath;
try {
(mockFsExistsSync as Mock).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify({
security: {
allowedInsecureVoiceBaseUrls: [
'http://voice.region-a.internal.example/v1',
],
},
});
if (p === systemSettingsPath)
return JSON.stringify({
security: { allowedInsecureVoiceBaseUrls: [] },
});
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.security?.allowedInsecureVoiceBaseUrls).toEqual(
[],
);
} finally {
delete process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH'];
}
});
});
describe('reloadScopeFromDisk', () => {
it('reloads a scope from disk and resolves home env vars', () => {
const homeQwenEnvPath = path.join(
@@ -3559,6 +3676,8 @@ describe('Settings Loading and Merging', () => {
SettingScope.User,
'model.name',
'manually-added-model',
undefined,
{ throwOnWriteFailure: true },
);
const writeCall = (fs.writeFileSync as Mock).mock.calls.at(-1);
@@ -3571,6 +3690,27 @@ describe('Settings Loading and Merging', () => {
);
});
it('throws without mutating when a surgical update cannot be written', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(() => '{}');
const settings = loadSettings(MOCK_WORKSPACE_DIR);
const mockFn = jsoncEditor.updateSettingsFilePreservingFormat as Mock;
mockFn.mockReturnValueOnce(false);
expect(() =>
settings.setValue(
SettingScope.User,
'general.language',
'zh',
undefined,
{ throwOnWriteFailure: true },
),
).toThrow(
/saveSettings: updateSettingsFilePreservingFormat returned false/,
);
expect(settings.user.settings.general?.language).toBeUndefined();
});
it('strips a runtime snapshot prefix before persisting model.name', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(() => '{}');
+38 -14
View File
@@ -370,6 +370,15 @@ export function getSettingsWarnings(loadedSettings: LoadedSettings): string[] {
`Warning: security.allowPrivateNetworkHooks in workspace settings (${workspaceFile.path}) is ignored. This setting is only honored from User, System, or SystemDefaults scope settings.`,
);
}
if (
workspaceFile.rawJson !== undefined &&
workspaceFile.originalSettings.security?.allowedInsecureVoiceBaseUrls !==
undefined
) {
warningSet.add(
`Warning: security.allowedInsecureVoiceBaseUrls in workspace settings (${workspaceFile.path}) is ignored. This setting is only honored from User, System, or SystemDefaults scope settings.`,
);
}
return [...warningSet];
}
@@ -399,18 +408,23 @@ function tagMcpServerScope(
}
/**
* `security.allowPrivateNetworkHooks` relaxes SSRF protection for HTTP hooks,
* so it must never be honored from Workspace scope — otherwise a malicious
* repository could self-grant the bypass and point hooks at link-local or
* private infrastructure. Strip it from workspace settings before merging.
* Network security bypasses must never be honored from Workspace scope —
* otherwise a malicious repository could self-grant access to private
* infrastructure. Strip them from workspace settings before merging.
* Returns a shallow copy — never mutates input.
*/
function stripWorkspacePrivateNetworkHooks(settings: Settings): Settings {
if (settings.security?.allowPrivateNetworkHooks === undefined) {
function stripWorkspaceSecurityBypasses(settings: Settings): Settings {
if (
settings.security?.allowPrivateNetworkHooks === undefined &&
settings.security?.allowedInsecureVoiceBaseUrls === undefined
) {
return settings;
}
const { allowPrivateNetworkHooks: _stripped, ...restSecurity } =
settings.security;
const {
allowPrivateNetworkHooks: _privateHooks,
allowedInsecureVoiceBaseUrls: _insecureVoice,
...restSecurity
} = settings.security;
return { ...settings, security: restSecurity };
}
@@ -422,10 +436,7 @@ function mergeSettings(
isTrusted: boolean,
): Settings {
const safeWorkspace = isTrusted
? tagMcpServerScope(
stripWorkspacePrivateNetworkHooks(workspace),
'workspace',
)
? tagMcpServerScope(stripWorkspaceSecurityBypasses(workspace), 'workspace')
: ({} as Settings);
// Settings are merged with the following precedence (last one wins for
@@ -518,6 +529,7 @@ export class LoadedSettings {
key: string,
value: unknown,
assertCanCommit?: () => void,
opts: { throwOnWriteFailure?: boolean } = {},
): void {
// Never persist a runtime snapshot ID to model.name (it re-wraps on restart).
if (key === 'model.name' && typeof value === 'string') {
@@ -525,11 +537,23 @@ export class LoadedSettings {
}
assertCanCommit?.();
const settingsFile = this.forScope(scope);
const replacePath = key === 'mcpServers' ? key.split('.') : [];
if (opts.throwOnWriteFailure) {
saveSettings(
settingsFile,
createSettingsUpdate(key, value),
replacePath,
{
throwOnWriteFailure: true,
},
);
}
setNestedPropertySafe(settingsFile.settings, key, value);
setNestedPropertySafe(settingsFile.originalSettings, key, value);
this._merged = this.computeMergedSettings();
const replacePath = key === 'mcpServers' ? key.split('.') : [];
saveSettings(settingsFile, createSettingsUpdate(key, value), replacePath);
if (!opts.throwOnWriteFailure) {
saveSettings(settingsFile, createSettingsUpdate(key, value), replacePath);
}
}
setValues(
+15
View File
@@ -3008,6 +3008,21 @@ const SETTINGS_SCHEMA = {
'When true, HTTP hooks may target private/link-local IP ranges (the SSRF IP-range checks are skipped). Cloud metadata hostnames (e.g. 169.254.169.254, metadata.google.internal) remain blocked. Only honored from User, System, and SystemDefaults settings scopes; values set in Workspace settings are ignored so a cloned repository cannot self-grant this bypass. Enable only in trusted, managed environments, and pair with security.allowedHttpHookUrls.',
showInDialog: false,
},
allowedInsecureVoiceBaseUrls: {
type: 'array',
label: 'Allowed Insecure Voice Base URLs',
category: 'Security',
requiresRestart: false,
default: [] as string[],
description:
'Complete voice base URLs that may use HTTP or private-network addresses. Entries must include an explicit http:// or https:// scheme and the full provider path; only URL serialization and trailing slashes are normalized. Wildcards are not supported; metadata, link-local, local-use NAT64, 6to4, and Teredo addresses remain blocked even when listed, as do hostnames that resolve to loopback; IPv4-mapped, IPv4-compatible, and well-known NAT64 (64:ff9b::/96) literals are classified by their embedded IPv4 address. Only honored from User, System, and SystemDefaults settings scopes; values set in Workspace settings are ignored. Enable only for trusted endpoints in managed private networks. Cleartext HTTP also exposes the provider API key transmitted in the Authorization header. An allowlisted hostname is only as trustworthy as its DNS; prefer IP-literal entries when the gateway address is stable.',
showInDialog: false,
items: {
type: 'string',
description:
'Complete voice provider base URL with explicit scheme and full path (no wildcards)',
},
},
},
},
+1 -1
View File
@@ -353,7 +353,7 @@ describe('allowOriginCors (T2.4 #4514)', () => {
);
expect(res.headers.get('access-control-max-age')).toBe('86400');
expect(res.headers.get('access-control-expose-headers')).toBe(
'Retry-After, X-Qwen-Event-Epoch',
'Retry-After, X-Qwen-Event-Epoch, X-Qwen-SSE-Stream-Id',
);
});
+2 -1
View File
@@ -142,7 +142,8 @@ export function allowOriginCors(
const allowedHeaders =
'Authorization, Content-Type, X-Qwen-Client-Id, Last-Event-ID, X-Qwen-Event-Epoch';
const maxAgeSeconds = '86400';
const exposedHeaders = 'Retry-After, X-Qwen-Event-Epoch';
const exposedHeaders =
'Retry-After, X-Qwen-Event-Epoch, X-Qwen-SSE-Stream-Id';
return (req: Request, res: Response, next: NextFunction) => {
const origin = req.headers.origin;
if (!origin) {
+549 -56
View File
@@ -4,6 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import {
addDaemonRequestAttribute,
captureDaemonTelemetryContext,
emitDaemonLog,
runWithDaemonTelemetryContext,
} from '@qwen-code/qwen-code-core';
import { mapDomainErrorToErrorKind } from '@qwen-code/acp-bridge';
import type { Application } from 'express';
import { writeStderrLine } from '../../utils/stdioHelpers.js';
@@ -12,12 +20,15 @@ import type { DaemonLogger } from '../daemon-logger.js';
import {
SubscriberLimitExceededError,
type BridgeEvent,
type EventBusSubscriberDiagnostic,
} from '@qwen-code/acp-bridge/eventBus';
import {
errorMessage,
type SendBridgeError,
} from '../server/error-response.js';
import {
CLIENT_ID_RE,
MAX_CLIENT_ID_LENGTH,
parseLastEventId,
parseMaxQueuedQuery,
} from '../server/request-helpers.js';
@@ -31,6 +42,73 @@ import {
let activeSseCount = 0;
const SSE_STREAM_ID_HEADER = 'X-Qwen-SSE-Stream-Id';
// Keep in sync with the REST transport's response-header validator.
const SSE_STREAM_ID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const SSE_CONNECT_REASONS = [
'initial',
'resume',
'prompt_restart',
'stream_end',
'transport_error',
'state_resync',
'unknown',
] as const;
const SSE_CONNECT_REASON_SET: ReadonlySet<string> = new Set(
SSE_CONNECT_REASONS,
);
type SseConnectReason = (typeof SSE_CONNECT_REASONS)[number];
type SseCloseReason =
| 'writer_idle_timeout'
| 'socket_error'
| 'iterator_error'
| 'event_bus_evicted'
| 'session_terminal'
| 'source_complete'
| 'client_disconnect';
function parseSseConnectReason(raw: unknown): SseConnectReason {
return typeof raw === 'string' && SSE_CONNECT_REASON_SET.has(raw)
? (raw as SseConnectReason)
: 'unknown';
}
function parsePreviousSseStreamId(raw: unknown): string | undefined {
return typeof raw === 'string' && SSE_STREAM_ID_RE.test(raw)
? raw.toLowerCase()
: undefined;
}
function parseSseClientId(raw: unknown): string | undefined {
// Observability hints are best-effort and must not reject an SSE handshake.
return typeof raw === 'string' &&
raw.length > 0 &&
raw.length <= MAX_CLIENT_ID_LENGTH &&
CLIENT_ID_RE.test(raw)
? raw
: undefined;
}
function boundedDiagnosticString(value: string): string {
return Array.from(value.slice(0, 256))
.slice(0, 128)
.map((character) => {
const codePoint = character.codePointAt(0) ?? 0;
return codePoint <= 0x1f ||
(codePoint >= 0x7f && codePoint <= 0x9f) ||
codePoint === 0x2028 ||
codePoint === 0x2029 ||
(codePoint >= 0x202a && codePoint <= 0x202e) ||
(codePoint >= 0x2066 && codePoint <= 0x2069)
? ' '
: character;
})
.join('');
}
export function getActiveSseCount(): number {
return activeSseCount;
}
@@ -90,6 +168,152 @@ export function registerSseEventsRoutes(
app.get('/session/:id/events', async (req, res) => {
const sessionId = req.params['id'];
const diagnosticSessionId = boundedDiagnosticString(sessionId);
const streamId = randomUUID();
const clientId = parseSseClientId(req.headers['x-qwen-client-id']);
const connectReason = parseSseConnectReason(req.query['connectReason']);
const previousStreamId = parsePreviousSseStreamId(
req.query['previousStreamId'],
);
const telemetryContext = captureDaemonTelemetryContext();
const telemetryBaseAttributes: Record<string, string | number | boolean> = {
'session.id': sessionId,
'qwen-code.daemon.sse.stream_id': streamId,
'qwen-code.daemon.sse.client_reported_connect_reason': connectReason,
...(clientId ? { 'qwen-code.client_id': clientId } : {}),
...(previousStreamId
? {
'qwen-code.daemon.sse.client_reported_previous_stream_id':
previousStreamId,
}
: {}),
};
const emitLifecycleLog = (
eventName: string,
body: string,
attributes: Record<string, string | number | boolean> = {},
) => {
void runWithDaemonTelemetryContext(telemetryContext, async () => {
emitDaemonLog(
body,
{ ...telemetryBaseAttributes, ...attributes },
{ eventName },
);
}).catch(() => {});
};
let slowWarningCount = 0;
let eventBusEvictionReason: string | undefined;
const onSubscriberDiagnostic = (
diagnostic: EventBusSubscriberDiagnostic,
): boolean => {
const triggerEventType = boundedDiagnosticString(
diagnostic.data.triggerEventType,
);
const common = {
queueSize: diagnostic.data.queueSize,
maxQueued: diagnostic.data.maxQueued,
queuedBytes: diagnostic.data.queuedBytes,
maxQueuedBytes: diagnostic.data.maxQueuedBytes,
triggerEventType,
triggerEventBytes: diagnostic.data.triggerEventBytes,
};
const telemetryCommon = {
'qwen-code.daemon.sse.queue_size': common.queueSize,
'qwen-code.daemon.sse.max_queued': common.maxQueued,
'qwen-code.daemon.sse.queued_bytes': common.queuedBytes,
'qwen-code.daemon.sse.max_queued_bytes': common.maxQueuedBytes,
'qwen-code.daemon.sse.trigger_event_type': common.triggerEventType,
'qwen-code.daemon.sse.trigger_event_bytes': common.triggerEventBytes,
};
let handled = false;
if (diagnostic.type === 'slow_client_warning') {
slowWarningCount += 1;
const context = {
sessionId: diagnosticSessionId,
clientId,
streamId,
connectReason,
previousStreamId,
...common,
threshold: diagnostic.data.threshold,
lastEventId: diagnostic.data.lastEventId,
};
try {
if (daemonLog) {
daemonLog.warn('SSE slow client warning', context);
} else {
writeStderrLine(
`qwen serve: SSE slow client warning ${JSON.stringify(context)}`,
);
}
handled = true;
} catch {
handled = false;
}
emitLifecycleLog(
'qwen-code.daemon.sse.slow_client_warning',
'Daemon SSE slow client warning.',
{
...telemetryCommon,
'qwen-code.daemon.sse.threshold': diagnostic.data.threshold,
'qwen-code.daemon.sse.last_event_id': diagnostic.data.lastEventId,
},
);
} else {
eventBusEvictionReason = boundedDiagnosticString(
diagnostic.data.reason,
);
const threshold =
diagnostic.data.reason === 'queue_bytes_overflow'
? 'bytes'
: 'frames';
const context = {
sessionId: diagnosticSessionId,
clientId,
streamId,
connectReason,
previousStreamId,
...common,
threshold,
reason: eventBusEvictionReason,
droppedAfter: diagnostic.data.droppedAfter,
...(diagnostic.data.eventBytes !== undefined
? { eventBytes: diagnostic.data.eventBytes }
: {}),
};
try {
if (daemonLog) {
daemonLog.warn('SSE client evicted', context);
} else {
writeStderrLine(
`qwen serve: SSE client evicted ${JSON.stringify(context)}`,
);
}
handled = true;
} catch {
handled = false;
}
emitLifecycleLog(
'qwen-code.daemon.sse.client_evicted',
'Daemon SSE client evicted.',
{
...telemetryCommon,
'qwen-code.daemon.sse.threshold': threshold,
'qwen-code.daemon.sse.event_bus_eviction_reason':
eventBusEvictionReason,
'qwen-code.daemon.sse.dropped_after_event_id':
diagnostic.data.droppedAfter,
...(diagnostic.data.eventBytes !== undefined
? {
'qwen-code.daemon.sse.rejected_event_bytes':
diagnostic.data.eventBytes,
}
: {}),
},
);
}
return handled;
};
const lastEventId = parseLastEventId(req.headers['last-event-id']);
// Epoch token accompanying the resume cursor (DAEMON-001). Invalid
// values degrade to "not provided" so the bus falls back to the
@@ -121,6 +345,7 @@ export function registerSseEventsRoutes(
signal: abort.signal,
lastEventId,
...(maxQueued !== undefined ? { maxQueued } : {}),
onSubscriberDiagnostic,
})
: runtime.bridge.subscribeEvents(sessionId, {
signal: abort.signal,
@@ -128,6 +353,7 @@ export function registerSseEventsRoutes(
...(eventEpoch !== undefined ? { epoch: eventEpoch } : {}),
...(maxQueued !== undefined ? { maxQueued } : {}),
...(snapshot ? { snapshot: true } : {}),
onSubscriberDiagnostic,
});
if (!iterable) {
res.status(404).json({
@@ -173,7 +399,7 @@ export function registerSseEventsRoutes(
// a raw-fetch client gets the same structured error.
if (err instanceof SubscriberLimitExceededError) {
writeStderrLine(
`qwen serve: subscriber limit reached for session ${sessionId} (limit=${err.limit}); rejecting new SSE client with 429`,
`qwen serve: subscriber limit reached for session ${diagnosticSessionId} (limit=${err.limit}); rejecting new SSE client with 429`,
);
res.setHeader('Retry-After', '5');
res.status(429).json({
@@ -190,22 +416,94 @@ export function registerSseEventsRoutes(
return;
}
if (daemonLog) {
const sseOpenedAt = Date.now();
const sseClientId = req.headers['x-qwen-client-id'] as string | undefined;
daemonLog.info('SSE stream opened', { sessionId, clientId: sseClientId });
res.on('close', () => {
try {
daemonLog.info('SSE stream closed', {
sessionId,
clientId: sseClientId,
durationMs: Date.now() - sseOpenedAt,
});
} catch {
/* logger failure must not prevent counter decrement */
const openedAt = performance.now();
let closeReason: SseCloseReason | undefined;
let terminalCandidate: SseCloseReason | undefined;
let terminalEventType: string | undefined;
let eventFramesWriteSettled = 0;
let lastEventIdWritten: number | undefined;
let backpressureCount = 0;
let maxDrainWaitMs = 0;
let activeDrainStartedAt: number | undefined;
let maxLivePublishToWriteSettledMs = 0;
let liveTimingEnabled = lastEventId === undefined;
let finalized = false;
let sseCounted = false;
const finalize = () => {
if (finalized) return;
finalized = true;
if (activeDrainStartedAt !== undefined) {
maxDrainWaitMs = Math.max(
maxDrainWaitMs,
performance.now() - activeDrainStartedAt,
);
}
const durationMs = Math.max(0, performance.now() - openedAt);
const resolvedCloseReason =
closeReason ?? terminalCandidate ?? 'client_disconnect';
const closeAttributes: Record<string, string | number | boolean> = {
'qwen-code.daemon.sse.duration_ms': durationMs,
'qwen-code.daemon.sse.event_frames_write_settled':
eventFramesWriteSettled,
'qwen-code.daemon.sse.backpressure_count': backpressureCount,
'qwen-code.daemon.sse.max_drain_wait_ms': maxDrainWaitMs,
'qwen-code.daemon.sse.max_live_publish_to_write_settled_ms':
maxLivePublishToWriteSettledMs,
'qwen-code.daemon.sse.slow_warning_count': slowWarningCount,
'qwen-code.daemon.sse.close_reason': resolvedCloseReason,
...(lastEventIdWritten !== undefined
? {
'qwen-code.daemon.sse.last_event_id_written': lastEventIdWritten,
}
: {}),
...(eventBusEvictionReason
? {
'qwen-code.daemon.sse.event_bus_eviction_reason':
eventBusEvictionReason,
}
: {}),
...(terminalEventType
? {
'qwen-code.daemon.sse.terminal_event_type': terminalEventType,
}
: {}),
};
void runWithDaemonTelemetryContext(telemetryContext, async () => {
for (const [key, value] of Object.entries(closeAttributes)) {
addDaemonRequestAttribute(key, value);
}
});
}
emitDaemonLog(
'Daemon SSE stream closed.',
{ ...telemetryBaseAttributes, ...closeAttributes },
{ eventName: 'qwen-code.daemon.sse.closed' },
);
}).catch(() => {});
try {
daemonLog?.info('SSE stream closed', {
sessionId: diagnosticSessionId,
clientId,
streamId,
connectReason,
previousStreamId,
closeReason: resolvedCloseReason,
durationMs,
eventFramesWriteSettled,
lastEventIdWritten,
backpressureCount,
maxDrainWaitMs,
maxLivePublishToWriteSettledMs,
slowWarningCount,
eventBusEvictionReason,
terminalEventType,
});
} catch {
// Diagnostics must not interfere with stream cleanup.
}
if (sseCounted) {
sseCounted = false;
activeSseCount--;
}
};
res.status(200);
res.setHeader('Content-Type', 'text/event-stream');
@@ -220,17 +518,49 @@ export function registerSseEventsRoutes(
if (busEpoch !== undefined) {
res.setHeader('X-Qwen-Event-Epoch', busEpoch);
}
// Always present on the supported Node versions (engines.node >=22).
res.flushHeaders();
res.setHeader(SSE_STREAM_ID_HEADER, streamId);
res.prependOnceListener('finish', finalize);
res.prependOnceListener('close', finalize);
activeSseCount++;
let sseCounted = true;
res.on('close', () => {
if (sseCounted) {
sseCounted = false;
activeSseCount--;
sseCounted = true;
for (const [key, value] of Object.entries(telemetryBaseAttributes)) {
addDaemonRequestAttribute(key, value);
}
try {
daemonLog?.info('SSE stream opened', {
sessionId: diagnosticSessionId,
clientId,
streamId,
connectReason,
previousStreamId,
lastEventId,
});
} catch {
// Diagnostics must not interfere with the stream handshake.
}
emitLifecycleLog(
'qwen-code.daemon.sse.opened',
'Daemon SSE stream opened.',
{
...(lastEventId !== undefined
? { 'qwen-code.daemon.sse.resume_from_event_id': lastEventId }
: {}),
},
);
// Always present on the supported Node versions (engines.node >=22).
try {
res.flushHeaders();
} catch {
closeReason = 'socket_error';
abort.abort();
finalize();
try {
if (!res.writableEnded) res.end();
} catch {
// The handshake socket is already unusable.
}
});
return;
}
// Backpressure helper: `res.write` returns false when the kernel send
// buffer is full. Without awaiting `drain` Node accumulates the
@@ -246,6 +576,7 @@ export function registerSseEventsRoutes(
// backpressure guard — and could even interleave bytes between two
// SSE frames on the wire. The chain is single-flight: each call
// waits for the previous write to settle before scheduling its own.
type WriteOutcome = 'settled' | 'closed';
let writeChain: Promise<void> = Promise.resolve();
// T2.9: epoch (ms) of the last write that fully resolved — either
// synchronous `res.write` returned `true`, or the async `drain`
@@ -262,10 +593,18 @@ export function registerSseEventsRoutes(
const trackWriterIdle =
writerIdleTimeoutMs !== undefined && writerIdleTimeoutMs > 0;
let lastWriteAt = trackWriterIdle ? Date.now() : 0;
const doWrite = (chunk: string): Promise<void> =>
const settleDrainMeasurement = () => {
if (activeDrainStartedAt === undefined) return;
maxDrainWaitMs = Math.max(
maxDrainWaitMs,
performance.now() - activeDrainStartedAt,
);
activeDrainStartedAt = undefined;
};
const doWrite = (chunk: string): Promise<WriteOutcome> =>
new Promise((resolve, reject) => {
if (res.writableEnded) {
resolve();
resolve('closed');
return;
}
// `res.write` can throw synchronously when the socket is
@@ -280,41 +619,50 @@ export function registerSseEventsRoutes(
try {
ok = res.write(chunk);
} catch (err) {
if (!closeReason) closeReason = 'socket_error';
reject(err);
return;
}
if (ok) {
if (trackWriterIdle) lastWriteAt = Date.now();
resolve();
resolve('settled');
return;
}
backpressureCount += 1;
activeDrainStartedAt = performance.now();
const onDrain = () => {
res.off('close', onClose);
res.off('error', onError);
settleDrainMeasurement();
if (trackWriterIdle) lastWriteAt = Date.now();
resolve();
resolve('settled');
};
const onClose = () => {
res.off('drain', onDrain);
res.off('error', onError);
resolve();
settleDrainMeasurement();
resolve('closed');
};
const onError = (err: Error) => {
res.off('drain', onDrain);
res.off('close', onClose);
settleDrainMeasurement();
reject(err);
};
res.once('drain', onDrain);
res.once('close', onClose);
res.once('error', onError);
});
const writeWithBackpressure = (chunk: string): Promise<void> => {
const writeWithBackpressure = (chunk: string): Promise<WriteOutcome> => {
const next = writeChain.then(() => doWrite(chunk));
// Tail-swallow rejections on the chain itself so a single failed
// write doesn't poison every subsequent call. The CALLER's
// returned promise still rejects — chain-internal failures are
// someone else's problem, not blockers for queueing.
writeChain = next.catch(() => undefined);
writeChain = next.then(
() => undefined,
() => undefined,
);
return next;
};
@@ -386,6 +734,8 @@ export function registerSseEventsRoutes(
if (res.writableEnded) return;
const idleForMs = Date.now() - lastWriteAt;
if (idleForMs < writerIdleTimeoutMsValue) return;
closeReason = 'writer_idle_timeout';
terminalEventType = 'client_evicted';
// Reuse the existing `client_evicted` taxonomy from the bridge event
// bus so SDK reducers branch on the same frame type they already
// handle for queue-overflow eviction; the new `reason` slot is the
@@ -398,7 +748,7 @@ export function registerSseEventsRoutes(
// socket is closed in the next two statements, so any drop
// is bounded.
try {
res.write(
const settled = res.write(
formatSseFrame({
v: 1,
type: 'client_evicted',
@@ -410,6 +760,7 @@ export function registerSseEventsRoutes(
},
}),
);
if (settled) eventFramesWriteSettled += 1;
} catch {
/* socket already destroyed; nothing to send. */
}
@@ -420,14 +771,38 @@ export function registerSseEventsRoutes(
// heartbeat + idle timers would never clear, and every
// subsequent tick would re-throw — turning one transient
// failure into a permanent uncaughtException loop.
const idleContext = {
sessionId: diagnosticSessionId,
clientId,
streamId,
connectReason,
previousStreamId,
idleForMs,
timeoutMs: writerIdleTimeoutMsValue,
};
try {
writeStderrLine(
`qwen serve: evicting SSE client (session ${sessionId}) — ` +
`writer idle for ${idleForMs}ms > ${writerIdleTimeoutMsValue}ms timeout`,
);
if (daemonLog) {
daemonLog.warn('SSE writer idle timeout', idleContext);
} else {
writeStderrLine(
`qwen serve: evicting SSE client (session ${diagnosticSessionId}) — ` +
`writer idle for ${idleForMs}ms > ${writerIdleTimeoutMsValue}ms timeout ` +
`(streamId=${streamId}${clientId ? `, clientId=${clientId}` : ''})`,
);
}
} catch {
/* stderr pipe closed; eviction is still happening. */
}
emitLifecycleLog(
'qwen-code.daemon.sse.client_evicted',
'Daemon SSE client evicted by writer idle timeout.',
{
'qwen-code.daemon.sse.writer_idle_for_ms': idleForMs,
'qwen-code.daemon.sse.writer_idle_timeout_ms':
writerIdleTimeoutMsValue,
'qwen-code.daemon.sse.eviction_reason': 'writer_idle_timeout',
},
);
cleanup();
try {
if (!res.writableEnded) res.end();
@@ -446,13 +821,29 @@ export function registerSseEventsRoutes(
// suppresses the noise + ensures cleanup runs even if for some reason
// the close event doesn't fire first.
res.on('error', (err) => {
if (!closeReason) closeReason = 'socket_error';
// Without this log the daemon side is blind to SSE disconnects
// (RST, mid-flight kill -9, network blip). Cleanup still runs —
// the listener exists primarily so Node doesn't crash on EPIPE
// — but operators get a breadcrumb when chasing flaky clients.
writeStderrLine(
`qwen serve: SSE socket error (session ${sessionId}): ${err.message}`,
);
try {
if (daemonLog) {
daemonLog.error('SSE socket error', err, {
sessionId: diagnosticSessionId,
clientId,
streamId,
connectReason,
previousStreamId,
});
} else {
writeStderrLine(
`qwen serve: SSE socket error (session ${diagnosticSessionId}): ${err.message} ` +
`(streamId=${streamId}${clientId ? `, clientId=${clientId}` : ''})`,
);
}
} catch {
// Socket cleanup must proceed even when diagnostics fail.
}
cleanup();
});
@@ -460,8 +851,28 @@ export function registerSseEventsRoutes(
try {
while (true) {
const next = await iter!.next();
if (next.done) break;
if (next.done) {
if (!closeReason) {
closeReason = terminalCandidate ?? 'source_complete';
}
break;
}
if (res.writableEnded) break;
if (next.value.type === 'client_evicted') {
terminalCandidate = 'event_bus_evicted';
terminalEventType = next.value.type;
const reason = (next.value.data as { reason?: unknown } | null)
?.reason;
if (typeof reason === 'string') {
eventBusEvictionReason = boundedDiagnosticString(reason);
}
} else if (
next.value.type === 'session_died' ||
next.value.type === 'session_closed'
) {
terminalCandidate = 'session_terminal';
terminalEventType = next.value.type;
}
// Log ring eviction events for operator observability.
if (next.value.type === 'state_resync_required') {
const data = next.value.data as {
@@ -475,22 +886,103 @@ export function registerSseEventsRoutes(
typeof data.lastDeliveredId === 'number'
? data.earliestAvailableId - data.lastDeliveredId - 1
: undefined;
writeStderrLine(
`qwen serve: SSE ring eviction detected (session ${sessionId}): ` +
`lastEventId=${data.lastDeliveredId ?? '?'}, ` +
`earliestInRing=${data.earliestAvailableId ?? '?'}, ` +
`gap=${gap ?? '?'} events, ` +
`reason=${data.reason ?? '?'}` +
(typeof data.detail === 'string'
? `, detail=${data.detail}`
: '') +
`. Consumer must call loadSession to recover.`,
const reason =
typeof data.reason === 'string'
? boundedDiagnosticString(data.reason)
: 'unknown';
const detail =
typeof data.detail === 'string'
? boundedDiagnosticString(data.detail)
: undefined;
const context = {
sessionId: diagnosticSessionId,
clientId,
streamId,
connectReason,
previousStreamId,
lastEventId: data.lastDeliveredId,
earliestInRing: data.earliestAvailableId,
gap,
reason,
detail,
};
try {
if (daemonLog) {
daemonLog.warn(
'SSE ring eviction detected; consumer must call loadSession to recover',
context,
);
} else {
writeStderrLine(
`qwen serve: SSE ring eviction detected (session ${diagnosticSessionId}): ` +
`lastEventId=${data.lastDeliveredId ?? '?'}, ` +
`earliestInRing=${data.earliestAvailableId ?? '?'}, ` +
`gap=${gap ?? '?'} events, ` +
`reason=${typeof data.reason === 'string' ? reason : '?'}` +
(detail ? `, detail=${detail}` : '') +
`, streamId=${streamId}` +
(clientId ? `, clientId=${clientId}` : '') +
`. Consumer must call loadSession to recover.`,
);
}
} catch {
// The recovery frame must still reach the client.
}
emitLifecycleLog(
'qwen-code.daemon.sse.state_resync_required',
'Daemon SSE state resync required.',
{
'qwen-code.daemon.sse.resync_reason': reason,
...(detail
? { 'qwen-code.daemon.sse.resync_detail': detail }
: {}),
...(typeof data.lastDeliveredId === 'number'
? {
'qwen-code.daemon.sse.resync_last_delivered_id':
data.lastDeliveredId,
}
: {}),
...(typeof data.earliestAvailableId === 'number'
? {
'qwen-code.daemon.sse.resync_earliest_available_id':
data.earliestAvailableId,
}
: {}),
...(gap !== undefined
? { 'qwen-code.daemon.sse.resync_gap_events': gap }
: {}),
},
);
}
await writeWithBackpressure(formatSseFrame(next.value));
const liveEvent = liveTimingEnabled;
const serverTimestamp = next.value._meta?.['serverTimestamp'];
const outcome = await writeWithBackpressure(
formatSseFrame(next.value),
);
if (outcome === 'closed') break;
eventFramesWriteSettled += 1;
if (typeof next.value.id === 'number') {
lastEventIdWritten = next.value.id;
}
if (
liveEvent &&
typeof serverTimestamp === 'number' &&
Number.isFinite(serverTimestamp)
) {
maxLivePublishToWriteSettledMs = Math.max(
maxLivePublishToWriteSettledMs,
Math.max(0, Date.now() - serverTimestamp),
);
}
if (next.value.type === 'replay_complete') {
liveTimingEnabled = true;
}
}
} catch (err) {
if (!res.writableEnded) {
if (!closeReason) {
closeReason = 'iterator_error';
}
if (!res.writableEnded && closeReason !== 'socket_error') {
// Don't burn an `id:` slot — `stream_error` is a terminal frame
// emitted on the daemon side when the bridge iterator throws, so
// it has no place in the per-session monotonic sequence and a
@@ -508,11 +1000,11 @@ export function registerSseEventsRoutes(
// Log bridge iterator errors to daemon stderr for
// operator observability.
writeStderrLine(
`qwen serve: bridge iterator error (session ${sessionId}): ` +
`qwen serve: bridge iterator error (session ${diagnosticSessionId}): ` +
`${errorMessage(err)}` +
(errorKind ? ` [${errorKind}]` : ''),
);
await writeWithBackpressure(
const outcome = await writeWithBackpressure(
formatSseFrame({
v: 1,
type: 'stream_error',
@@ -521,7 +1013,8 @@ export function registerSseEventsRoutes(
...(errorKind ? { errorKind } : {}),
},
}),
).catch(() => {});
).catch(() => 'closed' as const);
if (outcome === 'settled') eventFramesWriteSettled += 1;
}
} finally {
cleanup();
@@ -758,6 +758,75 @@ describe('workspace voice routes', () => {
expect(h.persistSetting).not.toHaveBeenCalled();
});
it('POST accepts an exactly allowlisted private voice provider', async () => {
const baseUrl = 'http://voice.region-a.internal.example/v1';
await writeJson(path.join(h.home, 'settings.json'), {
modelProviders: {
openai: [
{
id: 'qwen3-asr-flash',
label: 'Private Qwen ASR',
baseUrl,
envKey: 'PRIVATE_ASR_KEY',
},
],
},
env: { PRIVATE_ASR_KEY: 'sk-secret' },
security: { allowedInsecureVoiceBaseUrls: [baseUrl] },
voiceModel: 'qwen3-asr-flash',
});
const res = await request(h.app)
.post('/workspace/voice')
.set('Host', hostHeader)
.set('Authorization', 'Bearer secret')
.send({ enabled: true });
expect(res.status).toBe(200);
expect(h.persistSetting).toHaveBeenCalledWith(
h.workspace,
SettingScope.User,
'general.voice.enabled',
true,
expect.any(Function),
);
});
it('POST rejects a private voice provider allowlisted only in workspace scope', async () => {
await teardown(h);
h = await makeHarness({ trusted: true });
const baseUrl = 'http://voice.region-a.internal.example/v1';
// A cloned repo must not be able to self-grant HTTP/private-network voice
// egress: security.allowedInsecureVoiceBaseUrls is stripped from workspace
// scope, so the resolver still rejects the cleartext endpoint.
await writeJson(path.join(h.workspace, '.qwen', 'settings.json'), {
modelProviders: {
openai: [
{
id: 'qwen3-asr-flash',
label: 'Private Qwen ASR',
baseUrl,
envKey: 'PRIVATE_ASR_KEY',
},
],
},
env: { PRIVATE_ASR_KEY: 'sk-secret' },
security: { allowedInsecureVoiceBaseUrls: [baseUrl] },
voiceModel: 'qwen3-asr-flash',
});
const res = await request(h.app)
.post('/workspace/voice')
.set('Host', hostHeader)
.set('Authorization', 'Bearer secret')
.send({ enabled: true });
expect(res.status).toBe(400);
expect(res.body.code).toBe('invalid_voice_model');
expect(res.body.error).toContain('must use an https baseUrl');
expect(h.persistSetting).not.toHaveBeenCalled();
});
it('POST allows disabling voice without a selected model', async () => {
const res = await request(h.app)
.post('/workspace/voice')
@@ -38,6 +38,8 @@ import type {
} from '@qwen-code/acp-bridge/bridgeTypes';
import * as qwenCore from '@qwen-code/qwen-code-core';
import * as serverModule from './server.js';
import * as webShellResolver from './web-shell-resolver.js';
import * as webShellStatic from './web-shell-static.js';
import * as settingsRuntime from '../config/settings.js';
import * as environmentRuntime from '../config/environment.js';
import * as trustedFoldersRuntime from '../config/trustedFolders.js';
@@ -462,6 +464,51 @@ function makeRuntimeBridge(): HttpAcpBridge {
} as unknown as HttpAcpBridge;
}
function writeWebShellFixture(workspaceDir: string): string {
const shellDir = path.join(workspaceDir, 'web-shell');
fs.mkdirSync(path.join(shellDir, 'assets'), { recursive: true });
fs.writeFileSync(
path.join(shellDir, 'index.html'),
'<!doctype html><body><div id="root"></div></body>',
);
vi.spyOn(webShellResolver, 'resolveWebShellDir').mockReturnValue(shellDir);
return shellDir;
}
async function startDeferredDaemon(
workspace: string,
overrides: {
serveOptions?: Partial<Parameters<typeof runQwenServe>[0]>;
createBridge?: () => HttpAcpBridge;
} = {},
) {
const createBridge = vi
.spyOn(acpBridge, 'createAcpSessionBridge')
.mockImplementation(() => {
const bridge = overrides.createBridge
? overrides.createBridge()
: makeRuntimeBridge();
return bridge as ReturnType<typeof acpBridge.createAcpSessionBridge>;
});
const handle = await runQwenServe(
{
port: 0,
hostname: '127.0.0.1',
mode: 'http-bridge',
workspace,
maxSessions: 1,
token: 'secret-token',
...overrides.serveOptions,
},
{
resolveOnListen: true,
deferRuntimeUntilFirstHealth: true,
runtimeStartupTimeoutMs: 0,
},
);
return { handle, createBridge };
}
const mockCreateSpawnChannelFactoryOptions = vi.hoisted(
() => [] as Array<Record<string, unknown>>,
);
@@ -5019,6 +5066,306 @@ describe('runQwenServe runtime startup failures', () => {
}
});
it('serves Web Shell document navigations during the deferred runtime window', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-deferred-webshell-')),
);
writeWebShellFixture(tmpDir);
const { handle, createBridge } = await startDeferredDaemon(tmpDir);
try {
// HEAD goes first so the cold deferred gate's pre-auth exemption is
// exercised for both methods the warm app serves pre-auth.
const headRes = await fetch(`${handle.url}/session/abc`, {
method: 'HEAD',
headers: { accept: 'text/html' },
});
expect(headRes.status).toBe(200);
expect(headRes.headers.get('content-type')).toContain('text/html');
// A browser refresh of a session deep link carries no bearer header and
// must load the shell (and start the runtime) instead of 401ing in the
// deferred gate.
const navRes = await fetch(`${handle.url}/session/abc`, {
headers: { accept: 'text/html' },
});
expect(navRes.status).toBe(200);
expect(navRes.headers.get('content-type')).toContain('text/html');
expect(await navRes.text()).toContain('<div id="root">');
expect(createBridge).toHaveBeenCalledTimes(1);
await expect(handle.runtimeReady).resolves.toBeUndefined();
} finally {
await handle.close();
}
});
it('serves the Web Shell root during the deferred runtime window', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-deferred-root-')),
);
writeWebShellFixture(tmpDir);
const { handle, createBridge } = await startDeferredDaemon(tmpDir);
try {
// First request to a cold daemon, so the `/` exemption is exercised at
// the deferred gate itself — there is no warm-app path it could take.
const rootRes = await fetch(`${handle.url}/`);
expect(rootRes.status).toBe(200);
expect(rootRes.headers.get('content-type')).toContain('text/html');
expect(await rootRes.text()).toContain('<div id="root">');
expect(createBridge).toHaveBeenCalledTimes(1);
await expect(handle.runtimeReady).resolves.toBeUndefined();
} finally {
await handle.close();
}
});
it('serves the // root alias during the deferred window like the warm app', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-deferred-root-alias-')),
);
writeWebShellFixture(tmpDir);
const { handle, createBridge } = await startDeferredDaemon(tmpDir);
try {
// Express non-strict routing matches a raw `//` against `app.get('/')`,
// so the warm app serves it pre-auth; the cold gate must exempt it too
// instead of 401ing.
const aliasRes = await fetch(`${handle.url}//`);
expect(aliasRes.status).toBe(200);
expect(aliasRes.headers.get('content-type')).toContain('text/html');
expect(await aliasRes.text()).toContain('<div id="root">');
expect(createBridge).toHaveBeenCalledTimes(1);
await expect(handle.runtimeReady).resolves.toBeUndefined();
} finally {
await handle.close();
}
});
it('degrades to the bearer gate when the pre-auth predicate rejects', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-deferred-predicate-fail-')),
);
writeWebShellFixture(tmpDir);
const { handle, createBridge } = await startDeferredDaemon(tmpDir);
const predicateSpy = vi
.spyOn(webShellStatic, 'isPreAuthWebShellRequest')
.mockImplementation(() => {
throw new Error('predicate module glitch');
});
try {
// Without the fail-closed guard, a rejecting predicate 500s the whole
// deferred branch. A tokenless navigation must hit the bearer gate...
const anonRes = await fetch(`${handle.url}/`, {
headers: { accept: 'text/html' },
});
expect(anonRes.status).toBe(401);
// ...and a correctly-tokened request must still get through.
const authedRes = await fetch(`${handle.url}/session/abc`, {
headers: {
accept: 'text/html',
authorization: 'Bearer secret-token',
},
});
expect(authedRes.status).toBe(200);
expect(authedRes.headers.get('content-type')).toContain('text/html');
expect(createBridge).toHaveBeenCalledTimes(1);
await expect(handle.runtimeReady).resolves.toBeUndefined();
} finally {
predicateSpy.mockRestore();
await handle.close();
}
});
it('serves Web Shell assets during the deferred runtime window', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-deferred-assets-')),
);
const shellDir = writeWebShellFixture(tmpDir);
fs.writeFileSync(
path.join(shellDir, 'assets', 'fixture.js'),
'console.log("fixture");',
);
const { handle, createBridge } = await startDeferredDaemon(tmpDir);
try {
// First request to a cold daemon, so only the predicate's `/assets/`
// branch can exempt this from the bearer gate.
const assetRes = await fetch(`${handle.url}/assets/fixture.js`);
expect(assetRes.status).toBe(200);
expect(await assetRes.text()).toContain('console.log("fixture");');
expect(createBridge).toHaveBeenCalledTimes(1);
await expect(handle.runtimeReady).resolves.toBeUndefined();
} finally {
await handle.close();
}
});
it('answers bare /assets during the deferred window like the warm app', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-deferred-assets-bare-')),
);
writeWebShellFixture(tmpDir);
const { handle, createBridge } = await startDeferredDaemon(tmpDir);
try {
// First request to a cold daemon. Express 5's `app.use('/assets', ...)`
// also matches the bare mount path pre-auth — the warm app answers 301
// to `/assets/` (then 404) — so the deferred gate must exempt it too
// instead of 401ing.
const bareRes = await fetch(`${handle.url}/assets`, {
redirect: 'manual',
});
expect(bareRes.status).toBe(301);
expect(bareRes.headers.get('location')).toBe('/assets/');
expect(createBridge).toHaveBeenCalledTimes(1);
await expect(handle.runtimeReady).resolves.toBeUndefined();
} finally {
await handle.close();
}
});
it('serves trailing-slash and case-variant session deep links during the deferred window', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-deferred-shapes-')),
);
writeWebShellFixture(tmpDir);
const { handle, createBridge } = await startDeferredDaemon(tmpDir);
try {
// The warm app serves this shape pre-auth (Express matches routes
// case-insensitively and non-strictly by default), so the cold gate
// must exempt it too instead of 401ing the refresh.
const navRes = await fetch(`${handle.url}/Session/abc/`, {
headers: { accept: 'text/html' },
});
expect(navRes.status).toBe(200);
expect(navRes.headers.get('content-type')).toContain('text/html');
expect(await navRes.text()).toContain('<div id="root">');
expect(createBridge).toHaveBeenCalledTimes(1);
await expect(handle.runtimeReady).resolves.toBeUndefined();
} finally {
await handle.close();
}
});
it('serves query-carrying session deep links during the deferred window', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-deferred-query-')),
);
writeWebShellFixture(tmpDir);
const { handle, createBridge } = await startDeferredDaemon(tmpDir);
try {
// First request to a cold daemon, with a query string (a real
// deep-link refresh shape). The predicate matches on `req.path`, which
// strips the query — pinning the gate against a mutation to `req.url`
// that would 401 the refresh.
const queryRes = await fetch(`${handle.url}/session/abc/?ref=1`, {
headers: { accept: 'text/html' },
});
expect(queryRes.status).toBe(200);
expect(queryRes.headers.get('content-type')).toContain('text/html');
expect(await queryRes.text()).toContain('<div id="root">');
expect(createBridge).toHaveBeenCalledTimes(1);
await expect(handle.runtimeReady).resolves.toBeUndefined();
} finally {
await handle.close();
}
});
it('keeps JSON and API-subpath requests gated during the deferred runtime window', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-deferred-webshell-gate-')),
);
writeWebShellFixture(tmpDir);
const { handle, createBridge } = await startDeferredDaemon(tmpDir);
try {
const jsonRes = await fetch(`${handle.url}/session/abc`, {
headers: { accept: 'application/json' },
});
expect(jsonRes.status).toBe(401);
const apiRes = await fetch(`${handle.url}/session/abc/status`, {
headers: { accept: 'text/html' },
});
expect(apiRes.status).toBe(401);
expect(createBridge).not.toHaveBeenCalled();
} finally {
await handle.close();
}
});
it('keeps session document navigations gated during the deferred window with --no-web', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-deferred-noweb-')),
);
const { handle, createBridge } = await startDeferredDaemon(tmpDir, {
serveOptions: { serveWebShell: false },
});
try {
const navRes = await fetch(`${handle.url}/session/abc`, {
headers: { accept: 'text/html' },
});
expect(navRes.status).toBe(401);
expect(createBridge).not.toHaveBeenCalled();
} finally {
await handle.close();
}
});
it('answers pre-auth Web Shell navigations with the failure envelope when the deferred runtime fails', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-deferred-webshell-fail-')),
);
writeWebShellFixture(tmpDir);
vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockResolvedValue({
enabled: false,
sensitiveSpanAttributeMaxLength: 1024 * 1024,
});
const { handle, createBridge } = await startDeferredDaemon(tmpDir, {
createBridge: () => {
throw new Error('runtime boom');
},
});
try {
// These paths are declared pre-auth, so on a startup failure they must
// report the real fault instead of the bootstrap bearer gate's 401.
const navRes = await fetch(`${handle.url}/session/abc`, {
headers: { accept: 'text/html' },
});
expect(navRes.status).toBe(503);
expect(await navRes.json()).toEqual({
error: 'Daemon runtime failed to start',
code: 'daemon_runtime_failed',
});
const rootRes = await fetch(`${handle.url}/`);
expect(rootRes.status).toBe(503);
expect(await rootRes.json()).toEqual({
error: 'Daemon runtime failed to start',
code: 'daemon_runtime_failed',
});
// Non-exempted requests stay behind the bearer gate.
const jsonRes = await fetch(`${handle.url}/session/abc`, {
headers: { accept: 'application/json' },
});
expect(jsonRes.status).toBe(401);
expect(createBridge).toHaveBeenCalledTimes(1);
await expect(handle.runtimeReady).rejects.toThrow('runtime boom');
} finally {
await handle.close();
}
});
it('starts deferred runtime for webhook routes without bearer auth', async () => {
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-runtime-webhook-start-')),
+44 -16
View File
@@ -1430,6 +1430,21 @@ const BOOTSTRAP_SERVE_PATHS = new Set([
BOOTSTRAP_DAEMON_STATUS_PATH,
]);
const RUNTIME_STARTUP_FAILED_ENVELOPE = {
error: 'Daemon runtime failed to start',
code: 'daemon_runtime_failed',
} as const;
const RUNTIME_STARTUP_STARTING_ENVELOPE = {
error: 'Daemon runtime is still starting',
code: 'daemon_runtime_starting',
} as const;
function runtimeStartupEnvelope(runtimeError: string | undefined) {
return runtimeError
? RUNTIME_STARTUP_FAILED_ENVELOPE
: RUNTIME_STARTUP_STARTING_ENVELOPE;
}
function createBootstrapServeApp(input: {
opts: ServeOptions;
getPort: () => number;
@@ -1512,14 +1527,7 @@ function createBootstrapServeApp(input: {
if (runtimeError === undefined) {
res.setHeader('Retry-After', '1');
}
res.status(503).json({
error: runtimeError
? 'Daemon runtime failed to start'
: 'Daemon runtime is still starting',
code: runtimeError
? 'daemon_runtime_failed'
: 'daemon_runtime_starting',
});
res.status(503).json(runtimeStartupEnvelope(runtimeError));
return;
}
res.status(200).json(
@@ -1698,13 +1706,7 @@ function createBootstrapServeApp(input: {
});
app.use((_req: Request, res: Response): void => {
const runtimeError = getRuntimeError();
res.status(503).json({
error: runtimeError
? 'Daemon runtime failed to start'
: 'Daemon runtime is still starting',
code: runtimeError ? 'daemon_runtime_failed' : 'daemon_runtime_starting',
});
res.status(503).json(runtimeStartupEnvelope(getRuntimeError()));
});
return app;
@@ -1719,6 +1721,7 @@ function createDelegatingServeApp(
runtimeReady?: Promise<void>;
authenticateDeferredRuntimeRequest?: RequestHandler;
authenticateDeferredChannelWebhookRequest?: RequestHandler;
isPreAuthRequest?: (req: Request) => boolean | Promise<boolean>;
} = {},
): Application {
const app = express();
@@ -1743,7 +1746,14 @@ function createDelegatingServeApp(
? (options.authenticateDeferredChannelWebhookRequest ??
options.authenticateDeferredRuntimeRequest)
: options.authenticateDeferredRuntimeRequest;
if (authGate) {
// A rejecting predicate must not 500 every deferred request —
// fail closed to the bearer gate instead.
const preAuthExempted =
authGate !== undefined &&
(await Promise.resolve(options.isPreAuthRequest?.(req)).catch(
() => false,
)) === true;
if (authGate && !preAuthExempted) {
if (!runSynchronousRequestGate(authGate, req, res, next)) {
return;
}
@@ -1755,6 +1765,13 @@ function createDelegatingServeApp(
try {
await options.runtimeReady;
} catch {
if (preAuthExempted) {
// The bootstrap app serves the failure envelope only behind its
// bearer gate, which a pre-auth navigation cannot pass — answer
// here so the browser sees the startup failure, not a 401.
res.status(503).json(RUNTIME_STARTUP_FAILED_ENVELOPE);
return;
}
// Fall through to the bootstrap app so it can report the startup error.
} finally {
timing.waitMs =
@@ -5809,6 +5826,17 @@ async function runQwenServeImpl(
runtimeReady,
authenticateDeferredRuntimeRequest: bearerAuth(opts.token),
authenticateDeferredChannelWebhookRequest: deferredChannelWebhookAuth,
// The runtime app serves these before bearerAuth; a browser navigation
// cannot attach the bearer header, so the cold gate must let them
// through (and start the runtime) exactly like the warm app would.
// Dynamic import keeps web-shell-static out of the serve fast-path
// static closure (see the import-boundary guards in fast-path.test.ts).
isPreAuthRequest: webShellMounted
? (req) =>
import('./web-shell-static.js').then((webShellStatic) =>
webShellStatic.isPreAuthWebShellRequest(req),
)
: undefined,
});
// Node's `app.listen()` wants the unbracketed IPv6 literal (`::1`) but
+675 -33
View File
@@ -178,6 +178,7 @@ import {
LIVE_HOST_PROTOCOL_VERSION,
} from './live/types.js';
import { WorkspaceVoiceCoordinator } from './voice/workspace-voice-coordinator.js';
import { getActiveSseCount } from './routes/sse-events.js';
// ── Worktree mock infrastructure ────────────────────────────────────
// GitWorktreeService's constructor calls simpleGit() which validates
@@ -3114,6 +3115,17 @@ describe('createServeApp', () => {
expect(res.headers['cache-control']).toContain('no-cache');
});
it('serves the shell for a // root request pre-auth (non-strict routing)', async () => {
// Express non-strict routing matches a raw `//` against `app.get('/')`
// too; the deferred gate's isPreAuthWebShellRequest mirrors this shape.
const app = createServeApp({ ...baseOpts, token: 'secret' }, undefined, {
webShellDir,
});
const res = await request(app).get('//').set('Host', host);
expect(res.status).toBe(200);
expect(res.text).toContain('<div id="root">');
});
it('allows configured extension origins to frame the shell without self-framing', async () => {
const app = createServeApp(
{
@@ -3151,7 +3163,7 @@ describe('createServeApp', () => {
expect(res.text).not.toContain('<div id="root">');
});
it('falls back to the shell for SPA deep-link navigations', async () => {
it('serves the shell for /session/:id document navigations (pre-auth route)', async () => {
const app = createServeApp(baseOpts, undefined, { webShellDir });
const res = await request(app)
.get('/session/abc123')
@@ -3161,6 +3173,86 @@ describe('createServeApp', () => {
expect(res.text).toContain('<div id="root">');
});
it('serves the shell for GET and HEAD /session/:id document navigations', async () => {
const app = createServeApp({ ...baseOpts, token: 'secret' }, undefined, {
webShellDir,
});
const shell = await request(app)
.get('/session/abc123')
.set('Host', host)
.set('Accept', 'text/html');
expect(shell.status).toBe(200);
expect(shell.text).toContain('<div id="root">');
const head = await request(app)
.head('/session/abc123')
.set('Host', host)
.set('Accept', 'text/html');
expect(head.status).toBe(200);
expect(head.headers['content-type']).toContain('text/html');
// Express non-strict routing: a refresh URL with a trailing slash is a
// real client shape and must load the shell too.
const trailingSlash = await request(app)
.get('/session/abc123/')
.set('Host', host)
.set('Accept', 'text/html');
expect(trailingSlash.status).toBe(200);
expect(trailingSlash.text).toContain('<div id="root">');
});
it('serves the shell for /session/:id on a sec-fetch-only navigation signal', async () => {
// A refresh navigates with `Sec-Fetch-Mode: navigate` and no HTML
// Accept prefix; the pre-auth route must honor the full
// isDocumentNavigation signal or this exact flow falls through to
// bearerAuth and 401s.
const app = createServeApp({ ...baseOpts, token: 'secret' }, undefined, {
webShellDir,
});
const res = await request(app)
.get('/session/abc123')
.set('Host', host)
.set('Accept', '*/*')
.set('Sec-Fetch-Mode', 'navigate');
expect(res.status).toBe(200);
expect(res.text).toContain('<div id="root">');
});
it('401s /session/:id for JSON requests', async () => {
const app = createServeApp({ ...baseOpts, token: 'secret' }, undefined, {
webShellDir,
});
const res = await request(app)
.get('/session/abc123')
.set('Host', host)
.set('Accept', 'application/json');
expect(res.status).toBe(401);
});
it('401s session API subpaths', async () => {
const app = createServeApp({ ...baseOpts, token: 'secret' }, undefined, {
webShellDir,
});
const res = await request(app)
.get('/session/abc123/status')
.set('Host', host)
.set('Accept', 'text/html');
expect(res.status).toBe(401);
});
it('401s /session/:id document navigations when the shell is disabled', async () => {
const app = createServeApp(
{ ...baseOpts, token: 'secret', serveWebShell: false },
undefined,
{ webShellDir },
);
const res = await request(app)
.get('/session/abc123')
.set('Host', host)
.set('Accept', 'text/html');
expect(res.status).toBe(401);
});
it('leaves non-navigation API misses as JSON 404s', async () => {
const app = createServeApp(baseOpts, undefined, { webShellDir });
const res = await request(app)
@@ -3207,7 +3299,7 @@ describe('createServeApp', () => {
it('falls back to the shell on a sec-fetch navigation signal', async () => {
const app = createServeApp(baseOpts, undefined, { webShellDir });
const res = await request(app)
.get('/session/deep')
.get('/deep/link')
.set('Host', host)
.set('Accept', '*/*')
.set('Sec-Fetch-Mode', 'navigate');
@@ -3215,6 +3307,19 @@ describe('createServeApp', () => {
expect(res.text).toContain('<div id="root">');
});
it('falls back to the shell for non-session SPA deep-link navigations', async () => {
// The pre-auth `/session/:id` route claims the session deep-link shapes
// above, so these non-session navigations are the direct coverage of
// mountWebShellSpaFallback's shell branch.
const app = createServeApp(baseOpts, undefined, { webShellDir });
const res = await request(app)
.get('/deep/link')
.set('Host', host)
.set('Accept', 'text/html');
expect(res.status).toBe(200);
expect(res.text).toContain('<div id="root">');
});
it('does not shadow /health on a browser navigation (Critical #1)', async () => {
// Non-loopback + requireAuth registers /health POST-auth. A browser
// navigation (Accept text/html) must fall THROUGH the SPA fallback to
@@ -21752,7 +21857,7 @@ describe('runQwenServe', () => {
'https://anywhere.example.com',
);
expect(res.headers.get('access-control-expose-headers')).toBe(
'Retry-After, X-Qwen-Event-Epoch',
'Retry-After, X-Qwen-Event-Epoch, X-Qwen-SSE-Stream-Id',
);
});
@@ -22759,6 +22864,530 @@ describe('GET /session/:id/events (SSE)', () => {
expect(JSON.parse(frames[1]!.data!)).not.toHaveProperty('promptId');
});
it('correlates the SSE response, daemon lifecycle log, and request span', async () => {
const predecessor = '019535d9-3df7-7a61-8f6d-6f37c39c5f19';
const setAttribute = vi.fn();
const getSpanSpy = vi.spyOn(trace, 'getSpan').mockReturnValue({
setAttribute,
spanContext: () => ({
traceId: '1'.repeat(32),
spanId: '2'.repeat(16),
traceFlags: 1,
}),
} as unknown as Span);
const daemonLog = fakeDaemonLog();
const bridge = fakeBridge({
async *subscribeImpl() {
yield { id: 1, v: 1, type: 'session_update', data: 'done' };
},
});
const beforeActive = getActiveSseCount();
const app = createServeApp(baseOpts, undefined, { bridge, daemonLog });
try {
const res = await request(app)
.get(
`/session/sess-A/events?connectReason=prompt_restart&previousStreamId=${predecessor}`,
)
.set('Host', `127.0.0.1:${baseOpts.port}`)
.set('X-Qwen-Client-Id', 'client-1');
expect(res.status).toBe(200);
const streamId = res.headers['x-qwen-sse-stream-id'];
expect(streamId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
expect(daemonLog.info).toHaveBeenCalledWith(
'SSE stream opened',
expect.objectContaining({
sessionId: 'sess-A',
clientId: 'client-1',
streamId,
connectReason: 'prompt_restart',
previousStreamId: predecessor,
}),
);
expect(daemonLog.info).toHaveBeenCalledWith(
'SSE stream closed',
expect.objectContaining({
streamId,
closeReason: 'source_complete',
eventFramesWriteSettled: 1,
lastEventIdWritten: 1,
}),
);
expect(
vi
.mocked(daemonLog.info)
.mock.calls.filter(([message]) => message === 'SSE stream closed'),
).toHaveLength(1);
expect(setAttribute).toHaveBeenCalledWith(
'qwen-code.daemon.sse.stream_id',
streamId,
);
expect(setAttribute).toHaveBeenCalledWith(
'qwen-code.daemon.sse.close_reason',
'source_complete',
);
expect(setAttribute).toHaveBeenCalledWith(
'qwen-code.daemon.sse.event_frames_write_settled',
1,
);
expect(setAttribute).toHaveBeenCalledWith(
'qwen-code.daemon.sse.duration_ms',
expect.any(Number),
);
expect(
setAttribute.mock.calls.some(([key]) => key === 'duration_ms'),
).toBe(false);
const attributeKeys = setAttribute.mock.calls.map(([key]) => key);
for (const bareKey of [
'event_frames_write_settled',
'last_event_id_written',
'backpressure_count',
'max_drain_wait_ms',
'max_live_publish_to_write_settled_ms',
'slow_warning_count',
'event_bus_eviction_reason',
'terminal_event_type',
'close_reason',
]) {
expect(attributeKeys).not.toContain(bareKey);
}
expect(getActiveSseCount()).toBe(beforeActive);
} finally {
getSpanSpy.mockRestore();
}
});
it('does not attach stream identity when the subscription is rejected', async () => {
const setAttribute = vi.fn();
const getSpanSpy = vi.spyOn(trace, 'getSpan').mockReturnValue({
setAttribute,
spanContext: () => ({
traceId: '1'.repeat(32),
spanId: '2'.repeat(16),
traceFlags: 1,
}),
} as unknown as Span);
const bridge = fakeBridge({
subscribeImpl: (sessionId) => {
throw new SessionNotFoundError(sessionId);
},
});
const app = createServeApp(baseOpts, undefined, { bridge });
try {
const res = await request(app)
.get('/session/missing/events?connectReason=initial')
.set('Host', `127.0.0.1:${baseOpts.port}`);
expect(res.status).toBe(404);
expect(
setAttribute.mock.calls.some(
([key]) => key === 'qwen-code.daemon.sse.stream_id',
),
).toBe(false);
} finally {
getSpanSpy.mockRestore();
}
});
it('normalizes invalid client-reported diagnostics without rejecting or logging them', async () => {
const daemonLog = fakeDaemonLog();
const bridge = fakeBridge({
async *subscribeImpl() {
yield { id: 1, v: 1, type: 'session_update', data: 'done' };
},
});
const app = createServeApp(baseOpts, undefined, { bridge, daemonLog });
const res = await request(app)
.get(
'/session/sess-A/events?connectReason=%0Aforged&previousStreamId=not-a-uuid',
)
.set('Host', `127.0.0.1:${baseOpts.port}`)
.set('X-Qwen-Client-Id', 'invalid client id');
expect(res.status).toBe(200);
expect(daemonLog.info).toHaveBeenCalledWith(
'SSE stream opened',
expect.objectContaining({
connectReason: 'unknown',
clientId: undefined,
previousStreamId: undefined,
}),
);
expect(JSON.stringify(vi.mocked(daemonLog.info).mock.calls)).not.toContain(
'forged',
);
expect(JSON.stringify(vi.mocked(daemonLog.info).mock.calls)).not.toContain(
'invalid client id',
);
});
it('adds stream identity to EventBus warning and eviction diagnostics', async () => {
const release = deferred<void>();
let subscribeOptions: SubscribeOptions | undefined;
const daemonLog = fakeDaemonLog();
const bridge = fakeBridge({
async *subscribeImpl(_sessionId, opts) {
subscribeOptions = opts;
yield { id: 1, v: 1, type: 'session_update', data: 'first' };
await release.promise;
},
});
const beforeActive = getActiveSseCount();
const app = createServeApp(baseOpts, undefined, { bridge, daemonLog });
const responsePromise = request(app)
.get('/session/sess-%E2%80%A8A/events?connectReason=resume')
.set('Host', `127.0.0.1:${baseOpts.port}`)
.set('X-Qwen-Client-Id', 'client-1')
.then((response) => response);
await vi.waitFor(() => {
expect(subscribeOptions?.onSubscriberDiagnostic).toBeTypeOf('function');
expect(getActiveSseCount()).toBe(beforeActive + 1);
});
const opened = vi
.mocked(daemonLog.info)
.mock.calls.find(([message]) => message === 'SSE stream opened');
const streamId = (opened?.[1] as { streamId?: string } | undefined)
?.streamId;
expect(streamId).toBeDefined();
const warningHandled = subscribeOptions!.onSubscriberDiagnostic?.({
type: 'slow_client_warning',
data: {
queueSize: 12,
maxQueued: 16,
lastEventId: 9,
queuedBytes: 2048,
maxQueuedBytes: 4096,
threshold: 'frames',
triggerEventType: `tool\n\u2028result${'x'.repeat(10_000)}`,
triggerEventBytes: 512,
},
});
const evictionHandled = subscribeOptions!.onSubscriberDiagnostic?.({
type: 'client_evicted',
data: {
reason: 'queue_bytes_overflow',
droppedAfter: 10,
queueSize: 13,
maxQueued: 16,
queuedBytes: 4608,
maxQueuedBytes: 4096,
eventBytes: 2560,
triggerEventType: 'tool_result',
triggerEventBytes: 2560,
},
});
expect(warningHandled).toBe(true);
expect(evictionHandled).toBe(true);
expect(daemonLog.warn).toHaveBeenCalledWith(
'SSE slow client warning',
expect.objectContaining({
sessionId: 'sess- A',
clientId: 'client-1',
streamId,
threshold: 'frames',
triggerEventType: `tool result${'x'.repeat(116)}`,
triggerEventBytes: 512,
}),
);
expect(daemonLog.warn).toHaveBeenCalledWith(
'SSE client evicted',
expect.objectContaining({
streamId,
threshold: 'bytes',
reason: 'queue_bytes_overflow',
triggerEventBytes: 2560,
}),
);
release.resolve();
const res = await responsePromise;
expect(res.headers['x-qwen-sse-stream-id']).toBe(streamId);
expect(getActiveSseCount()).toBe(beforeActive);
});
it('starts live lag measurement only after replay_complete settles', async () => {
const daemonLog = fakeDaemonLog();
const bridge = fakeBridge({
async *subscribeImpl() {
yield {
id: 2,
v: 1,
type: 'session_update',
data: 'historical',
_meta: { serverTimestamp: 1 },
};
yield {
v: 1,
type: 'replay_complete',
data: {},
_meta: { serverTimestamp: 1 },
};
yield {
id: 3,
v: 1,
type: 'session_update',
data: 'live',
_meta: { serverTimestamp: Date.now() },
};
},
});
const app = createServeApp(baseOpts, undefined, { bridge, daemonLog });
const res = await request(app)
.get('/session/sess-A/events')
.set('Host', `127.0.0.1:${baseOpts.port}`)
.set('Last-Event-ID', '1');
expect(res.status).toBe(200);
expect(daemonLog.info).toHaveBeenCalledWith(
'SSE stream closed',
expect.objectContaining({
closeReason: 'source_complete',
eventFramesWriteSettled: 3,
lastEventIdWritten: 3,
maxLivePublishToWriteSettledMs: expect.any(Number),
}),
);
const closed = vi
.mocked(daemonLog.info)
.mock.calls.find(([message]) => message === 'SSE stream closed');
expect(
(closed?.[1] as { maxLivePublishToWriteSettledMs: number })
.maxLivePublishToWriteSettledMs,
).toBeLessThan(10_000);
});
it.each([
{
event: {
v: 1,
type: 'client_evicted',
data: { reason: 'queue_bytes_overflow' },
} satisfies BridgeEvent,
closeReason: 'event_bus_evicted',
terminalEventType: 'client_evicted',
},
{
event: {
v: 1,
type: 'session_closed',
data: { reason: 'idle' },
} satisfies BridgeEvent,
closeReason: 'session_terminal',
terminalEventType: 'session_closed',
},
])(
'uses $closeReason when $terminalEventType ends normally',
async ({ event, closeReason, terminalEventType }) => {
const daemonLog = fakeDaemonLog();
const bridge = fakeBridge({
async *subscribeImpl() {
yield event;
},
});
const app = createServeApp(baseOpts, undefined, { bridge, daemonLog });
const res = await request(app)
.get('/session/sess-A/events')
.set('Host', `127.0.0.1:${baseOpts.port}`);
expect(res.status).toBe(200);
expect(daemonLog.info).toHaveBeenCalledWith(
'SSE stream closed',
expect.objectContaining({ closeReason, terminalEventType }),
);
},
);
it('records a socket error without letting iterator cleanup overwrite it', async () => {
const http = await import('node:http');
type WriteCallback = (error?: Error | null) => void;
const originalWrite = http.ServerResponse.prototype.write as unknown as (
this: ServerResponse,
chunk: string | Uint8Array,
encodingOrCb?: BufferEncoding | WriteCallback,
cb?: WriteCallback,
) => boolean;
const writeSpy = vi.spyOn(http.ServerResponse.prototype, 'write');
let injected = false;
writeSpy.mockImplementation(function (
this: ServerResponse,
chunk: string | Uint8Array,
encodingOrCb?: BufferEncoding | WriteCallback,
cb?: WriteCallback,
): boolean {
const text = typeof chunk === 'string' ? chunk : chunk.toString();
if (!injected && text.includes('event: session_update')) {
injected = true;
throw new Error('socket reset');
}
const wrote =
typeof encodingOrCb === 'function'
? originalWrite.call(this, chunk, encodingOrCb)
: originalWrite.call(this, chunk, encodingOrCb, cb);
return wrote;
});
try {
const daemonLog = fakeDaemonLog();
const bridge = fakeBridge({
async *subscribeImpl(_sessionId, opts) {
yield { id: 1, v: 1, type: 'session_update', data: 'first' };
if (!opts?.signal?.aborted) {
await new Promise<void>((resolve) => {
opts?.signal?.addEventListener('abort', () => resolve(), {
once: true,
});
});
}
},
});
const app = createServeApp(baseOpts, undefined, { bridge, daemonLog });
const res = await request(app)
.get('/session/sess-A/events')
.set('Host', `127.0.0.1:${baseOpts.port}`);
expect(res.status).toBe(200);
expect(injected).toBe(true);
expect(daemonLog.info).toHaveBeenCalledWith(
'SSE stream closed',
expect.objectContaining({ closeReason: 'socket_error' }),
);
} finally {
writeSpy.mockRestore();
}
});
it('records iterator errors without changing the terminal SSE frame contract', async () => {
const daemonLog = fakeDaemonLog();
const bridge = fakeBridge({
async *subscribeImpl() {
yield { id: 1, v: 1, type: 'session_update', data: 'first' };
throw new Error('iterator failed');
},
});
const app = createServeApp(baseOpts, undefined, { bridge, daemonLog });
const res = await request(app)
.get('/session/sess-A/events')
.set('Host', `127.0.0.1:${baseOpts.port}`);
expect(res.status).toBe(200);
expect(res.text).toContain('event: stream_error');
expect(daemonLog.info).toHaveBeenCalledWith(
'SSE stream closed',
expect.objectContaining({
closeReason: 'iterator_error',
eventFramesWriteSettled: 2,
}),
);
});
it('does not let a terminal candidate hide a later iterator error', async () => {
const daemonLog = fakeDaemonLog();
const bridge = fakeBridge({
async *subscribeImpl() {
yield {
v: 1,
type: 'session_closed',
data: { reason: 'idle' },
};
throw new Error('iterator failed after terminal event');
},
});
const app = createServeApp(baseOpts, undefined, { bridge, daemonLog });
const res = await request(app)
.get('/session/sess-A/events')
.set('Host', `127.0.0.1:${baseOpts.port}`);
expect(res.status).toBe(200);
expect(daemonLog.info).toHaveBeenCalledWith(
'SSE stream closed',
expect.objectContaining({
closeReason: 'iterator_error',
terminalEventType: 'session_closed',
}),
);
});
it('records an unfinished drain when writer idle eviction closes the stream', async () => {
const http = await import('node:http');
type WriteCallback = (error?: Error | null) => void;
const originalWrite = http.ServerResponse.prototype.write as unknown as (
this: ServerResponse,
chunk: string | Uint8Array,
encodingOrCb?: BufferEncoding | WriteCallback,
cb?: WriteCallback,
) => boolean;
const writeSpy = vi.spyOn(http.ServerResponse.prototype, 'write');
let forcedBackpressure = false;
writeSpy.mockImplementation(function (
this: ServerResponse,
chunk: string | Uint8Array,
encodingOrCb?: BufferEncoding | WriteCallback,
cb?: WriteCallback,
): boolean {
const wrote =
typeof encodingOrCb === 'function'
? originalWrite.call(this, chunk, encodingOrCb)
: originalWrite.call(this, chunk, encodingOrCb, cb);
const text = typeof chunk === 'string' ? chunk : chunk.toString();
if (!forcedBackpressure && text.includes('event: session_update')) {
forcedBackpressure = true;
return false;
}
return wrote;
});
try {
const daemonLog = fakeDaemonLog();
const bridge = fakeBridge({
async *subscribeImpl() {
yield { id: 1, v: 1, type: 'session_update', data: 'first' };
await new Promise(() => {});
},
});
const app = createServeApp(
{ ...baseOpts, writerIdleTimeoutMs: 200 },
undefined,
{ bridge, daemonLog },
);
const res = await request(app)
.get('/session/sess-A/events')
.set('Host', `127.0.0.1:${baseOpts.port}`);
expect(res.status).toBe(200);
expect(forcedBackpressure).toBe(true);
expect(daemonLog.info).toHaveBeenCalledWith(
'SSE stream closed',
expect.objectContaining({
closeReason: 'writer_idle_timeout',
backpressureCount: 1,
eventFramesWriteSettled: 1,
maxDrainWaitMs: expect.any(Number),
}),
);
const closed = vi
.mocked(daemonLog.info)
.mock.calls.find(([message]) => message === 'SSE stream closed');
expect(
(closed?.[1] as { maxDrainWaitMs: number }).maxDrainWaitMs,
).toBeGreaterThan(0);
} finally {
writeSpy.mockRestore();
}
});
it('streams virtual subagent events without subscribing to the parent session', async () => {
const bridge = fakeBridge({
subscribeImpl: () => {
@@ -22803,6 +23432,7 @@ describe('GET /session/:id/events (SSE)', () => {
expect(subscribeSpy.mock.calls[0]?.[2]).toMatchObject({
lastEventId: 7,
maxQueued: 32,
onSubscriberDiagnostic: expect.any(Function),
});
} finally {
subscribeSpy.mockRestore();
@@ -23181,6 +23811,7 @@ describe('GET /session/:id/events (SSE)', () => {
it('aborts the bridge subscription when the client disconnects', async () => {
const aborted = { value: false };
const daemonLog = fakeDaemonLog();
const bridge = fakeBridge({
async *subscribeImpl(_sessionId, opts) {
opts?.signal?.addEventListener(
@@ -23198,22 +23829,35 @@ describe('GET /session/:id/events (SSE)', () => {
});
},
});
handle = await runQwenServe(
{ hostname: '127.0.0.1', port: 0, mode: 'http-bridge' },
{ bridge },
);
const port = (handle.server.address() as { port: number }).port;
let port = baseOpts.port;
const app = createServeApp(baseOpts, () => port, { bridge, daemonLog });
const server = app.listen(0, '127.0.0.1');
await new Promise<void>((resolve) => server.once('listening', resolve));
port = (server.address() as { port: number }).port;
const res = await fetch(`http://127.0.0.1:${port}/session/sess-A/events`);
const frames = await readSseFrames(res.body!, 1);
expect(frames).toHaveLength(1);
// readSseFrames calls reader.cancel() once the requested frame count is
// reached, which severs the underlying connection — the daemon's
// `req.on('close')` handler then aborts the bridge subscription.
// Wait briefly for the close handler to propagate to the bridge.
await new Promise((r) => setTimeout(r, 100));
expect(aborted.value).toBe(true);
try {
const res = await fetch(
`http://127.0.0.1:${port}/session/sess-A/events`,
{ headers: { Host: `127.0.0.1:${port}` } },
);
expect(res.status).toBe(200);
const frames = await readSseFrames(res.body!, 1);
expect(frames).toHaveLength(1);
// readSseFrames calls reader.cancel() once the requested frame count is
// reached, which severs the underlying connection — the daemon's
// `req.on('close')` handler then aborts the bridge subscription.
await vi.waitFor(() => {
expect(aborted.value).toBe(true);
expect(daemonLog.info).toHaveBeenCalledWith(
'SSE stream closed',
expect.objectContaining({ closeReason: 'client_disconnect' }),
);
});
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
it('emits a stream_error frame when the bridge iterator throws mid-stream', async () => {
@@ -23311,11 +23955,11 @@ describe('GET /session/:id/events (SSE)', () => {
.filter((s) => s.includes('SSE ring eviction detected'));
expect(stderrLines.length).toBeGreaterThanOrEqual(1);
const line = stderrLines[0]!;
expect(line).toContain('session sess-A');
expect(line).toContain('sessionId=sess-A');
expect(line).toContain('lastEventId=5');
expect(line).toContain('earliestInRing=12');
// gap = 12 - 5 - 1 = 6 events
expect(line).toContain('gap=6 events');
expect(line).toContain('gap=6');
expect(line).toContain('reason=ring_evicted');
expect(line).toContain('loadSession');
} finally {
@@ -23323,12 +23967,9 @@ describe('GET /session/:id/events (SSE)', () => {
}
});
it('falls back to "?" placeholders when state_resync_required data is partial', async () => {
// Defensive: the `?? '?'` fallback for missing fields lets the log
// line still print intelligibly when the daemon emits a partial
// payload (e.g. a future schema change drops one field). Pins the
// placeholder behavior so a regression that crashes the log call
// is caught.
it('logs a bounded unknown reason when state_resync_required data is partial', async () => {
// Defensive: malformed future/partial data must not crash the diagnostic
// path or inject undefined numeric values into the structured daemon log.
const stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
try {
const bridge = fakeBridge({
@@ -23361,11 +24002,12 @@ describe('GET /session/:id/events (SSE)', () => {
.filter((s) => s.includes('SSE ring eviction detected'));
expect(stderrLines.length).toBeGreaterThanOrEqual(1);
const line = stderrLines[0]!;
// All four `?? '?'` branches print `?` for the missing values.
expect(line).toContain('lastEventId=?');
expect(line).toContain('earliestInRing=?');
expect(line).toContain('gap=? events');
expect(line).toContain('reason=?');
expect(line).toContain('sessionId=sess-A');
expect(line).toContain('reason=unknown');
expect(line).toContain('streamId=');
expect(line).not.toContain('lastEventId=');
expect(line).not.toContain('earliestInRing=');
expect(line).not.toContain('gap=');
} finally {
stderrSpy.mockRestore();
}
@@ -23675,7 +24317,7 @@ describe('--allow-origin CORS allowlist (T2.4 #4514)', () => {
);
expect(res.headers['access-control-max-age']).toBe('86400');
expect(res.headers['access-control-expose-headers']).toBe(
'Retry-After, X-Qwen-Event-Epoch',
'Retry-After, X-Qwen-Event-Epoch, X-Qwen-SSE-Stream-Id',
);
});
@@ -23695,7 +24337,7 @@ describe('--allow-origin CORS allowlist (T2.4 #4514)', () => {
);
expect(res.headers['access-control-allow-methods']).toMatch(/POST/);
expect(res.headers['access-control-expose-headers']).toBe(
'Retry-After, X-Qwen-Event-Epoch',
'Retry-After, X-Qwen-Event-Epoch, X-Qwen-SSE-Stream-Id',
);
expect(res.text).toBe('');
});
+3 -1
View File
@@ -1568,7 +1568,9 @@ export function createServeApp(
// API calls still carry the bearer (getDaemonAuthHeaders) and every API
// route below stays token-gated. The SPA deep-link fallback is registered
// LATER (after all API routes, see mountWebShellSpaFallback) so authed
// routes win over the shell. The assets dir is resolved by the caller
// routes win over the shell. Exact `/session/:id` document navigations are
// mounted here too because a browser refresh cannot attach the bearer header
// before the shell loads. The assets dir is resolved by the caller
// (runQwenServe) and injected via deps.webShellDir; `--no-web` sets
// opts.serveWebShell=false to opt out.
const webShellDir =
@@ -17,7 +17,11 @@ import {
type SessionTranscriptCursorState,
type SessionTranscriptRecordPage,
} from '@qwen-code/qwen-code-core';
import { EventBus, type BridgeEvent } from '@qwen-code/acp-bridge/eventBus';
import {
EventBus,
type BridgeEvent,
type EventBusSubscriberDiagnostic,
} from '@qwen-code/acp-bridge/eventBus';
import { createTranscriptMessageUpdate } from '@qwen-code/acp-bridge/transcriptReplay';
import { replayTranscriptRecordPage } from '../acp-integration/session/history-replay-page.js';
import type { WorkspaceRuntime } from './workspace-registry.js';
@@ -31,6 +35,15 @@ interface VirtualSubagentSessionKey {
agentId: string;
}
interface VirtualSubagentSubscribeOptions {
signal: AbortSignal;
lastEventId?: number;
maxQueued?: number;
onSubscriberDiagnostic?: (
diagnostic: EventBusSubscriberDiagnostic,
) => boolean;
}
interface ResolvedAgentTask {
id: string;
title: string;
@@ -649,11 +662,9 @@ class VirtualSubagentTarget {
};
}
private async *iterate(opts: {
signal: AbortSignal;
lastEventId?: number;
maxQueued?: number;
}): AsyncIterableIterator<BridgeEvent> {
private async *iterate(
opts: VirtualSubagentSubscribeOptions,
): AsyncIterableIterator<BridgeEvent> {
if (this.retentionTimer) {
clearTimeout(this.retentionTimer);
this.retentionTimer = undefined;
@@ -682,11 +693,7 @@ class VirtualSubagentTarget {
}
}
subscribe(opts: {
signal: AbortSignal;
lastEventId?: number;
maxQueued?: number;
}): AsyncIterable<BridgeEvent> {
subscribe(opts: VirtualSubagentSubscribeOptions): AsyncIterable<BridgeEvent> {
return {
[Symbol.asyncIterator]: () => this.iterate(opts),
};
@@ -970,7 +977,7 @@ export class VirtualSubagentSessions {
async subscribe(
runtime: WorkspaceRuntime,
sessionId: string,
opts: { signal: AbortSignal; lastEventId?: number; maxQueued?: number },
opts: VirtualSubagentSubscribeOptions,
): Promise<AsyncIterable<BridgeEvent> | undefined> {
return (await this.getTarget(runtime, sessionId))?.subscribe(opts);
}
@@ -94,6 +94,44 @@ describe('loadDaemonVoiceContext', () => {
});
});
it('resolves providerProtocol-mapped custom provider groups for voice', async () => {
// Managed deployments can place a gateway under a custom provider-group
// id; the daemon's ModelsConfig must thread providerProtocol through so
// the voice resolver sees the same configured models the CLI would.
mocks.loadSettings.mockReturnValue({
merged: {
voiceModel: 'qwen3-asr-flash',
modelProviders: {
'internal-asr': [
{
id: 'qwen3-asr-flash',
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
envKey: 'DASHSCOPE_API_KEY',
},
],
},
providerProtocol: { 'internal-asr': 'openai' },
},
});
mocks.getAuthTypeFromEnv.mockReturnValue(AuthType.USE_OPENAI);
mocks.resolveCliGenerationConfig.mockReturnValue({
generationConfig: {},
sources: {},
});
mocks.isStreamingVoiceModel.mockReturnValue(false);
const { loadDaemonVoiceContext } = await import(
'./resolve-voice-config.js'
);
loadDaemonVoiceContext('/work/voice', { env: {}, workspaceTrusted: true });
const modelsArg = mocks.resolveVoiceTranscriptionConfig.mock.calls[0][0]
.config as { getAllConfiguredModels(): Array<{ id: string }> };
expect(
modelsArg.getAllConfiguredModels().map((model) => model.id),
).toContain('qwen3-asr-flash');
});
it('skips workspace settings when the runtime is untrusted', async () => {
mocks.loadSettings.mockReturnValue({
merged: {
@@ -62,6 +62,7 @@ function buildModelsConfig(
return new ModelsConfig({
initialAuthType: selectedAuthType,
modelProvidersConfig: merged.modelProviders,
providerProtocolConfig: merged.providerProtocol,
generationConfig: resolvedCliConfig.generationConfig,
generationConfigSources: resolvedCliConfig.sources,
});
@@ -7,12 +7,59 @@
// @vitest-environment node
import { afterEach, describe, it, expect, vi } from 'vitest';
import { AuthType } from '@qwen-code/qwen-code-core';
import { createVoiceWsConnectionHandler } from './voice-ws.js';
import { WorkspaceVoiceCoordinator } from './workspace-voice-coordinator.js';
import type { DaemonVoiceContext } from './resolve-voice-config.js';
import type { VoiceStreamSession } from '../../ui/voice/voice-stream-session.js';
import type { WorkspaceRuntime } from '../workspace-registry.js';
// The daemon defaults (defaultOpenStream/defaultTranscribe) pass the resolved
// config into assertVoiceBaseUrlNetworkAllowed; the default-wiring tests below
// drive them without injected openStream/transcribe, so the upstream `ws`
// module is swapped for a dial-recording fake to keep the tests offline.
const { FakeUpstreamSocket } = vi.hoisted(() => {
class FakeUpstreamSocket {
static instances: FakeUpstreamSocket[] = [];
readonly OPEN = 1;
readyState = this.OPEN;
readonly url: string;
readonly sent: Array<string | Uint8Array> = [];
private readonly handlers = new Map<
string,
Array<(...args: unknown[]) => void>
>();
constructor(url: string, _options?: unknown) {
this.url = url;
FakeUpstreamSocket.instances.push(this);
}
send(data: string | Uint8Array): void {
this.sent.push(data);
}
close(): void {
this.readyState = 3;
}
on(event: string, cb: (...args: unknown[]) => void): void {
const list = this.handlers.get(event) ?? [];
list.push(cb);
this.handlers.set(event, list);
}
emit(event: string, ...args: unknown[]): void {
for (const handler of this.handlers.get(event) ?? []) {
handler(...args);
}
}
}
return { FakeUpstreamSocket };
});
vi.mock('ws', () => ({ default: FakeUpstreamSocket }));
/** Minimal stand-in for a `ws` WebSocket the handler attaches to. */
class FakeWs {
readonly OPEN = 1;
@@ -601,3 +648,136 @@ describe('createVoiceWsConnectionHandler', () => {
expect(ws.closeReason).toBe('Server shutting down');
});
});
// These tests drive the production defaults (no injected openStream/transcribe)
// so a revert that drops allowInsecureBaseUrl from the default guard wiring
// fails instead of staying green (CLI analogue of the desktop
// voice-ws-handler.isolated.ts default-wiring tests).
describe('daemon default guard wiring', () => {
const PRIVATE_BASE_URL = 'http://10.0.0.8/v1';
function allowlistedCtx(
voiceModel: string,
streaming: boolean,
): DaemonVoiceContext {
return {
settings: {
merged: {
security: { allowedInsecureVoiceBaseUrls: [PRIVATE_BASE_URL] },
},
} as unknown as DaemonVoiceContext['settings'],
models: {
getAllConfiguredModels: () => [
{
id: voiceModel,
label: 'Private ASR',
authType: AuthType.USE_OPENAI,
baseUrl: PRIVATE_BASE_URL,
},
],
},
voiceModel,
streaming,
};
}
it('reaches the default batch transport when the private-network opt-in is set', async () => {
const fetchedUrls: string[] = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(async (input: unknown) => {
fetchedUrls.push(String(input));
return new Response(
JSON.stringify({
choices: [{ message: { content: 'hello gateway' } }],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}) as typeof fetch;
try {
const ws = new FakeWs();
const handler = createVoiceWsConnectionHandler('/ws', {
loadContext: () => allowlistedCtx('qwen3-asr-flash', false),
});
handler(ws as never, {} as never);
ws.text({ type: 'start' });
await tick();
ws.binary([1, 2, 3, 4]);
await tick();
ws.text({ type: 'stop' });
await tick();
expect(fetchedUrls).toContain('http://10.0.0.8/v1/chat/completions');
expect(ws.frames()).toContainEqual({
type: 'final',
text: 'hello gateway',
});
expect(ws.frames().some((f) => f['type'] === 'error')).toBe(false);
} finally {
globalThis.fetch = originalFetch;
}
});
it('blocks the default batch transport for a private gateway without the opt-in', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn() as typeof fetch;
try {
const ws = new FakeWs();
const handler = createVoiceWsConnectionHandler('/ws', {
loadContext: () =>
({
settings: {
merged: {},
} as unknown as DaemonVoiceContext['settings'],
models: {
getAllConfiguredModels: () => [
{
id: 'qwen3-asr-flash',
label: 'Private ASR',
authType: AuthType.USE_OPENAI,
baseUrl: 'https://10.0.0.8/v1',
},
],
},
voiceModel: 'qwen3-asr-flash',
streaming: false,
}) satisfies DaemonVoiceContext,
});
handler(ws as never, {} as never);
ws.text({ type: 'start' });
await tick();
ws.binary([1, 2, 3, 4]);
await tick();
ws.text({ type: 'stop' });
await tick();
expect(globalThis.fetch).not.toHaveBeenCalled();
expect(ws.frames().some((f) => f['type'] === 'error')).toBe(true);
} finally {
globalThis.fetch = originalFetch;
}
});
it('reaches the default streaming transport when the private-network opt-in is set', async () => {
FakeUpstreamSocket.instances.length = 0;
const ws = new FakeWs();
const handler = createVoiceWsConnectionHandler('/ws', {
loadContext: () => allowlistedCtx('qwen3-asr-flash-realtime', true),
});
handler(ws as never, {} as never);
ws.text({ type: 'start' });
await tick();
// The guard let the allowlisted gateway through: the production default
// dialed the upstream realtime socket instead of rejecting.
const upstream = FakeUpstreamSocket.instances.at(-1);
expect(upstream?.url).toBe(
'ws://10.0.0.8/api-ws/v1/realtime?model=qwen3-asr-flash-realtime',
);
expect(ws.frames().some((f) => f['type'] === 'error')).toBe(false);
});
});
+53 -2
View File
@@ -77,6 +77,43 @@ export function isDocumentNavigation(req: Request): boolean {
);
}
/**
* Exact session deep-link document navigations: `/session/<id>` with an
* optional trailing slash and no further segments. Expressed as a regex (not
* an Express route) so callers outside the runtime app — the deferred-runtime
* gate in `run-qwen-serve.ts` — can apply the same discriminator.
*/
const SESSION_DEEP_LINK_PATH = /^\/session\/[^/]+\/?$/u;
/**
* True when the request matches a route `mountWebShellAssets` registers
* BEFORE `bearerAuth`. The deferred-runtime gate in `createDelegatingServeApp`
* exempts exactly these so a cold daemon answers the shell's entry points the
* same way the warm runtime app does, instead of 401ing browser navigations
* that cannot attach the bearer header. Percent-encoded single-segment deep
* links (e.g. `/session/<id>%2fstatus`) also match — Express does not decode
* `%2F` during route matching — but they cannot reach an API route or session
* data: pre-auth answers serve only the public shell HTML, identical to
* `GET /` (or the startup-failure envelope). Keep in sync with the routes
* registered in `mountWebShellAssets`.
*/
export function isPreAuthWebShellRequest(req: Request): boolean {
if (req.method !== 'GET' && req.method !== 'HEAD') return false;
// Express route matching is case-insensitive by default, so the warm app
// serves /Session/<id> and /Assets/* pre-auth too; mirror that exactly.
const reqPath = req.path.toLowerCase();
if (
reqPath === '/' ||
// Express non-strict routing compiles `/` to `/^(?:\/)(?:\/$)?$/i`, so
// a raw `//` also matches `app.get('/')` pre-auth (but `///` does not).
reqPath === '//' ||
reqPath === '/assets' ||
reqPath.startsWith('/assets/')
)
return true;
return SESSION_DEEP_LINK_PATH.test(reqPath) && isDocumentNavigation(req);
}
/**
* Build the `index.html` responder for a Web Shell dir. Sets the security
* headers + a no-cache policy (a redeploy changes the hashed asset names
@@ -149,6 +186,11 @@ function createSendIndex(
*
* - `GET /assets/*` — hashed, immutable build chunks (long-cache).
* - `GET /` — the HTML shell, always (so `curl /` shows the UI too).
* - `GET /session/:id` document navigations — the HTML shell, so a browser
* refresh can load before the front-end adds its bearer header.
*
* `isPreAuthWebShellRequest` encodes this same surface for the
* deferred-runtime gate; keep the two in sync.
*
* Caller must have already verified `webShellDir` exists.
*/
@@ -183,10 +225,14 @@ export function mountWebShellAssets(
res.status(404).type('text/plain').send('Not found');
});
app.get('/', (_req: Request, res: Response) => sendIndex(res));
app.get('/session/:id', (req: Request, res: Response, next: NextFunction) => {
if (!isDocumentNavigation(req)) return next();
sendIndex(res);
});
}
/**
* Mount the SPA deep-link fallback (for navigations like `/session/<id>`).
* Mount the SPA deep-link fallback for routes not explicitly mounted above.
* Registered AFTER all API routes — just before the error handler — so real
* routes, INCLUDING their `bearerAuth` 401s, always win and only genuine 404
* misses fall through to the shell.
@@ -195,7 +241,12 @@ export function mountWebShellAssets(
* attacker-controlled `Accept: text/html` to an authed route (e.g.
* `/capabilities`, `/health` on a non-loopback bind) hits that route's real
* response / 401, not this shell. Because real routes run first, no per-path
* denylist is needed.
* denylist is needed. The one exception is exact `/session/:id` document
* navigations, which `mountWebShellAssets` claims BEFORE auth so a browser
* refresh can load the shell. That stays safe because the route matches a
* single path segment only, serves only document navigations, and there is no
* `GET /session/:id` API route for it to shadow — API subpaths like
* `/session/:id/status` still hit `bearerAuth`.
*
* Only GET/HEAD document navigations are claimed; API fetches send
* `Accept: application/json`, fail `isDocumentNavigation`, and fall through to
@@ -14,6 +14,7 @@ import type { Settings } from '../config/settingsSchema.js';
import {
buildWorkspaceVoiceSettingsWrites,
hasConfiguredBatchVoiceTranscriptionModel,
listAvailableVoiceModels,
transcribeWorkspaceVoiceAudio,
validateWorkspaceVoiceConfig,
validateWorkspaceVoiceModel,
@@ -244,6 +245,32 @@ describe('voice service', () => {
expect(hasConfiguredBatchVoiceTranscriptionModel(settings)).toBe(true);
});
it('resolves voice models in providerProtocol-mapped custom provider groups', () => {
// Managed deployments can place a gateway under a custom provider-group
// id; with a providerProtocol mapping the voice surface must see the
// model exactly like the rest of the CLI model surface does.
const settings = makeSettings({
user: {
modelProviders: {
'internal-asr': [
{
id: 'qwen3-asr-flash',
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
envKey: 'DASHSCOPE_API_KEY',
},
],
},
providerProtocol: { 'internal-asr': 'openai' },
env: { DASHSCOPE_API_KEY: 'sk-secret' },
},
});
expect(hasConfiguredBatchVoiceTranscriptionModel(settings)).toBe(true);
expect(
listAvailableVoiceModels(settings).map((model) => model.id),
).toContain('qwen3-asr-flash');
});
it('rejects unknown, duplicate, and unsupported voice model selections', () => {
const settings = makeSettings({
user: {
@@ -146,6 +146,7 @@ export function createVoiceModelSource(
modelProvidersConfig: settings.merged.modelProviders as
| ModelProvidersConfig
| undefined,
providerProtocolConfig: settings.merged.providerProtocol,
});
}
+269 -36
View File
@@ -6,7 +6,7 @@
import process from 'node:process';
import { lookup as dnsLookup } from 'node:dns/promises';
import { isIP } from 'node:net';
import { BlockList, isIP } from 'node:net';
import { createDebugLogger } from '@qwen-code/qwen-code-core';
import type { AvailableModel } from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../config/settings.js';
@@ -24,6 +24,17 @@ const MIN_KEYTERM_ECHO_TOKENS = 8;
const MIN_ABSOLUTE_KEYTERM_ECHO_TOKENS = 10;
const MIN_KEYTERM_SET_ECHO_RATIO = 0.3;
const debugLogger = createDebugLogger('VOICE_TRANSCRIBER');
// The address classification in this file is mirrored in
// packages/desktop/packages/server-core/src/voice/net-guard.ts. The bun
// workspace boundary prevents sharing a module; keep the two in sync.
const BLOCKED_TRANSITION_IPV6_ADDRESSES = new BlockList();
for (const [address, prefix] of [
['64:ff9b:1::', 48],
['2001::', 23],
['2002::', 16],
] as const) {
BLOCKED_TRANSITION_IPV6_ADDRESSES.addSubnet(address, prefix, 'ipv6');
}
export { resolveVoiceTransport };
export type { VoiceTransport } from './voice-model.js';
@@ -41,6 +52,7 @@ export interface VoiceTranscriptionConfig {
model: string;
baseUrl: string;
apiKey?: string;
allowInsecureBaseUrl?: boolean;
}
export interface VoiceStreamConfig {
@@ -49,6 +61,7 @@ export interface VoiceStreamConfig {
apiKey?: string;
language?: string;
keytermsContext?: string;
allowInsecureBaseUrl?: boolean;
}
export interface ResolvedVoiceStreamConfig extends VoiceStreamConfig {
@@ -117,37 +130,89 @@ function normalizeBaseUrl(baseUrl: string, modelName: string): string {
} catch {
throw new Error(`Voice model '${modelName}' has an invalid baseUrl.`);
}
url.username = '';
url.password = '';
if (url.username || url.password) {
throw new Error(
`Voice model '${modelName}' baseUrl must not contain embedded credentials.`,
);
}
return trimTrailingSlashes(url.toString());
}
function normalizeAllowedVoiceBaseUrl(baseUrl: string): string | undefined {
try {
const url = new URL(baseUrl.trim());
if (url.username || url.password) {
return undefined;
}
return trimTrailingSlashes(url.toString());
} catch {
return undefined;
}
}
function isInsecureVoiceBaseUrlAllowed(
settings: LoadedSettings,
normalizedBaseUrl: string,
): boolean {
const allowed = settings.merged.security?.allowedInsecureVoiceBaseUrls;
return (
Array.isArray(allowed) &&
allowed.some(
(candidate) =>
typeof candidate === 'string' &&
normalizeAllowedVoiceBaseUrl(candidate) === normalizedBaseUrl,
)
);
}
function normalizeHostname(hostname: string): string {
return hostname.toLowerCase().replace(/^\[|\]$/g, '');
}
function normalizeIpAddress(address: string): string {
const host = normalizeHostname(address);
if (isIP(host) !== 6) {
return host;
}
try {
return normalizeHostname(new URL(`http://[${host}]/`).hostname);
} catch {
return host;
}
}
function isLoopbackHost(hostname: string): boolean {
const host = normalizeHostname(hostname);
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
}
function isAwsIpv6MetadataAddress(hostname: string): boolean {
const host = normalizeHostname(hostname);
if (isIP(host) !== 6) {
return false;
}
try {
return (
normalizeHostname(new URL(`http://[${host}]/`).hostname) ===
'fd00:ec2::254'
);
} catch {
return false;
}
}
function readIpv4CompatibleIpv6(host: string): string | undefined {
if (!host.startsWith('::') || host.startsWith('::ffff:')) {
return undefined;
}
const parts = host.slice(2).split(':');
if (parts.length === 0 || parts.length > 2 || parts.some((p) => !p)) {
if (parts.length === 0 || parts.length > 2 || parts.some((part) => !part)) {
return undefined;
}
if (parts.some((part) => !/^[0-9a-f]{1,4}$/i.test(part))) {
return undefined;
}
const hextets = parts.map((part) => Number.parseInt(part, 16));
if (
hextets.some((part) => !Number.isInteger(part) || part < 0 || part > 0xffff)
) {
return undefined;
}
const value =
hextets.length === 1 ? hextets[0]! : (hextets[0]! << 16) | hextets[1]!;
return [
@@ -158,28 +223,91 @@ function readIpv4CompatibleIpv6(host: string): string | undefined {
].join('.');
}
function readIpv4MappedIpv6(host: string): string | undefined {
// The dotted-quad branch is unreachable once normalizeIpAddress has
// canonicalized IPv6 literals to hex form; kept defensively.
const dotted = host.match(/^::ffff:(\d+(?:\.\d+){3})$/i);
if (dotted && isIP(dotted[1]!) === 4) {
return dotted[1];
}
const hex = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
if (!hex) {
return undefined;
}
return readIpv4HexPair(hex[1]!, hex[2]!);
}
function readIpv4HexPair(highHex: string, lowHex: string): string {
const high = Number.parseInt(highHex, 16);
const low = Number.parseInt(lowHex, 16);
return [high >>> 8, high & 0xff, low >>> 8, low & 0xff].join('.');
}
function readWellKnownNat64Ipv6(host: string): string | undefined {
const prefix = '64:ff9b::';
if (!host.startsWith(prefix)) {
return undefined;
}
const suffix = host.slice(prefix.length);
if (!suffix) {
return '0.0.0.0';
}
const groups = suffix.split(':');
if (
groups.length > 2 ||
groups.some((group) => !/^[0-9a-f]{1,4}$/i.test(group))
) {
return undefined;
}
return groups.length === 1
? readIpv4HexPair('0', groups[0]!)
: readIpv4HexPair(groups[0]!, groups[1]!);
}
function isBlockedTransitionIpv6Address(host: string): boolean {
return (
isIP(host) === 6 && BLOCKED_TRANSITION_IPV6_ADDRESSES.check(host, 'ipv6')
);
}
function unwrapIpv6TransitionStep(
host: string,
): { address: string } | 'blocked' | undefined {
const ipv4Mapped = readIpv4MappedIpv6(host);
if (ipv4Mapped) {
return { address: ipv4Mapped };
}
const ipv4Compatible = readIpv4CompatibleIpv6(host);
if (ipv4Compatible) {
return { address: ipv4Compatible };
}
const nat64 = readWellKnownNat64Ipv6(host);
if (nat64) {
return { address: nat64 };
}
if (host.startsWith('::ffff:')) {
return 'blocked';
}
return undefined;
}
// Blocks IP-literal private networks only. Hostname DNS resolution and
// rebinding protection require an async lookup or socket-level remoteAddress check.
function isPrivateNetworkIp(hostname: string): boolean {
const host = normalizeHostname(hostname);
const host = normalizeIpAddress(hostname);
if (isBlockedTransitionIpv6Address(host)) {
return true;
}
if (isLoopbackHost(host)) {
return false;
}
const ipv4Mapped = host.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (ipv4Mapped) {
return isPrivateNetworkIp(ipv4Mapped[1]!);
}
const ipv4Compatible = host.match(/^::(\d+\.\d+\.\d+\.\d+)$/);
if (ipv4Compatible) {
return isPrivateNetworkIp(ipv4Compatible[1]!);
}
const normalizedIpv4Compatible = readIpv4CompatibleIpv6(host);
if (normalizedIpv4Compatible) {
return isPrivateNetworkIp(normalizedIpv4Compatible);
}
if (host.startsWith('::ffff:')) {
const step = unwrapIpv6TransitionStep(host);
if (step === 'blocked') {
return true;
}
if (step) {
return isPrivateNetworkIp(step.address);
}
if (isIP(host) === 4) {
const [first = 0, second = 0] = host.split('.').map(Number);
return (
@@ -193,10 +321,62 @@ function isPrivateNetworkIp(hostname: string): boolean {
);
}
if (isIP(host) === 6) {
const firstHextet = Number.parseInt(host.split(':', 1)[0] || '', 16);
const isLinkLocal = firstHextet >= 0xfe80 && firstHextet <= 0xfebf;
const isUniqueLocal = (firstHextet & 0xfe00) === 0xfc00;
return host === '::' || isLinkLocal || isUniqueLocal;
const firstHextet = Number.parseInt(host.split(':', 1)[0] || '0', 16);
return (
host === '::' ||
(firstHextet & 0xffc0) === 0xfe80 ||
(firstHextet & 0xfe00) === 0xfc00
);
}
return false;
}
function isAlwaysBlockedVoiceAddress(address: string): boolean {
const host = normalizeIpAddress(address);
if (isBlockedTransitionIpv6Address(host)) {
return true;
}
if (isLoopbackHost(host)) {
return true;
}
const step = unwrapIpv6TransitionStep(host);
if (step === 'blocked') {
return true;
}
if (step) {
return isAlwaysBlockedVoiceAddress(step.address);
}
if (isIP(host) === 4) {
const [first = 0, second = 0] = host.split('.').map(Number);
return (
first === 0 ||
first === 127 ||
(first === 169 && second === 254) ||
host === '100.100.100.200'
);
}
if (isIP(host) === 6) {
const firstHextet = Number.parseInt(host.split(':', 1)[0] || '0', 16);
return (
host === '::' ||
isAwsIpv6MetadataAddress(host) ||
(firstHextet & 0xffc0) === 0xfe80
);
}
return false;
}
function isLoopbackVoiceAddress(address: string): boolean {
const host = normalizeIpAddress(address);
if (isLoopbackHost(host)) {
return true;
}
const step = unwrapIpv6TransitionStep(host);
if (step && step !== 'blocked') {
return isLoopbackVoiceAddress(step.address);
}
if (isIP(host) === 4) {
return host.startsWith('127.');
}
return false;
}
@@ -217,9 +397,14 @@ export async function assertVoiceBaseUrlNetworkAllowed(
return;
}
if (isIP(hostname) !== 0) {
if (isPrivateNetworkIp(hostname)) {
if (
isAlwaysBlockedVoiceAddress(hostname) ||
(!voiceConfig.allowInsecureBaseUrl && isPrivateNetworkIp(hostname))
) {
throw new Error(
`Voice model '${voiceConfig.model}' resolved to a private-network address.`,
isLoopbackVoiceAddress(hostname)
? `Voice model '${voiceConfig.model}' uses a loopback address outside the accepted spellings. To use a local ASR endpoint, set the baseUrl to http://localhost, http://127.0.0.1, or http://[::1].`
: `Voice model '${voiceConfig.model}' resolved to a private-network address.`,
);
}
return;
@@ -254,9 +439,23 @@ export async function assertVoiceBaseUrlNetworkAllowed(
if (onAbort) abortSignal?.removeEventListener('abort', onAbort);
}
const records = Array.isArray(result) ? result : [result];
if (records.some((record) => isPrivateNetworkIp(record.address))) {
if (
records.some(
(record) =>
isAlwaysBlockedVoiceAddress(record.address) ||
(!voiceConfig.allowInsecureBaseUrl &&
isPrivateNetworkIp(record.address)),
)
) {
throw new Error(
`Voice model '${voiceConfig.model}' resolved to a private-network address.`,
records.some((record) => isLoopbackVoiceAddress(record.address))
? `Voice model '${voiceConfig.model}' resolved to a loopback address. Loopback DNS results are always blocked; to use a local ASR endpoint, configure an explicit loopback baseUrl: http://localhost, http://127.0.0.1, or http://[::1].`
: voiceConfig.allowInsecureBaseUrl &&
records.some((record) =>
isAlwaysBlockedVoiceAddress(record.address),
)
? `Voice model '${voiceConfig.model}' resolved to an address that is always blocked (metadata, link-local, or transition range), even when the baseUrl is listed in security.allowedInsecureVoiceBaseUrls.`
: `Voice model '${voiceConfig.model}' resolved to a private-network address.`,
);
}
}
@@ -271,7 +470,12 @@ function readApiKey(
return undefined;
}
const envKey = model.envKey ?? DEFAULT_OPENAI_API_KEY;
const envValue = (env ?? process.env)[envKey];
const envSource = env ?? process.env;
// Object.hasOwn keeps an envKey naming an inherited Object.prototype member
// (e.g. "constructor") from reaching .trim() as a function.
const envValue = Object.hasOwn(envSource, envKey)
? envSource[envKey]
: undefined;
if (envValue && envValue.trim().length > 0) {
return envValue.trim();
}
@@ -320,14 +524,41 @@ export function resolveVoiceTranscriptionConfig({
const normalizedBaseUrl = normalizeBaseUrl(baseUrl, voiceModel);
const parsedBaseUrl = new URL(normalizedBaseUrl);
const isLocalhost = isLoopbackHost(parsedBaseUrl.hostname);
if (parsedBaseUrl.protocol !== 'https:' && !isLocalhost) {
const allowInsecureBaseUrl = isInsecureVoiceBaseUrlAllowed(
settings,
normalizedBaseUrl,
);
if (
parsedBaseUrl.protocol !== 'http:' &&
parsedBaseUrl.protocol !== 'https:'
) {
throw new Error(
`Voice model '${voiceModel}' must use an https baseUrl. Voice audio must not be transmitted in cleartext.`,
`Voice model '${voiceModel}' must use an http or https baseUrl.`,
);
}
if (isPrivateNetworkIp(parsedBaseUrl.hostname)) {
if (!isLocalhost && isAlwaysBlockedVoiceAddress(parsedBaseUrl.hostname)) {
throw new Error(
`Voice model '${voiceModel}' must not use a private-network baseUrl.`,
isLoopbackVoiceAddress(parsedBaseUrl.hostname)
? `Voice model '${voiceModel}' uses a loopback address outside the accepted spellings. To use a local ASR endpoint, set the baseUrl to http://localhost, http://127.0.0.1, or http://[::1].`
: `Voice model '${voiceModel}' must not use a private-network baseUrl.`,
);
}
if (
parsedBaseUrl.protocol !== 'https:' &&
!isLocalhost &&
!allowInsecureBaseUrl
) {
throw new Error(
`Voice model '${voiceModel}' must use an https baseUrl. Voice audio must not be transmitted in cleartext. To trust this managed endpoint, add its exact complete normalized URL (${normalizedBaseUrl}) to security.allowedInsecureVoiceBaseUrls. This setting is only honored from User, System, or SystemDefaults scope settings; Workspace entries are ignored.`,
);
}
if (
!isLocalhost &&
!allowInsecureBaseUrl &&
isPrivateNetworkIp(parsedBaseUrl.hostname)
) {
throw new Error(
`Voice model '${voiceModel}' must not use a private-network baseUrl. To trust this managed endpoint, add its exact complete normalized URL (${normalizedBaseUrl}) to security.allowedInsecureVoiceBaseUrls. This setting is only honored from User, System, or SystemDefaults scope settings; Workspace entries are ignored.`,
);
}
@@ -340,6 +571,7 @@ export function resolveVoiceTranscriptionConfig({
model: voiceModel,
baseUrl: normalizedBaseUrl,
...(apiKey ? { apiKey } : {}),
...(allowInsecureBaseUrl ? { allowInsecureBaseUrl: true } : {}),
};
}
@@ -374,6 +606,7 @@ export function resolveVoiceStreamConfig(
baseUrl: base.baseUrl,
model: base.model,
...(base.apiKey ? { apiKey: base.apiKey } : {}),
...(base.allowInsecureBaseUrl ? { allowInsecureBaseUrl: true } : {}),
...(language ? { language } : {}),
...(keytermsContext ? { keytermsContext } : {}),
};
@@ -230,6 +230,7 @@ describe('AppContainer State Management', () => {
const mockedUseKeypress = useKeypress as Mock;
let originalStdoutIsTTY: boolean | undefined;
let restoreCiEnv = () => {};
let mockClearPendingState: Mock;
const mockedRestorePromptStash = vi.mocked(restorePromptStash);
beforeEach(() => {
@@ -249,6 +250,7 @@ describe('AppContainer State Management', () => {
capturedUIActions = null!;
capturedRenderMode = 'render';
capturedThoughtExpanded = null!;
mockClearPendingState = vi.fn();
// **Provide a default return value for EVERY mocked hook.**
mockedUseHistory.mockReturnValue({
@@ -338,6 +340,7 @@ describe('AppContainer State Management', () => {
retryLastPrompt: vi.fn(),
streamingResponseLengthRef: { current: 0 },
isReceivingContent: false,
clearPendingState: mockClearPendingState,
});
mockedUseVim.mockReturnValue({ handleInput: vi.fn() });
mockedUseFolderTrust.mockReturnValue({
@@ -1259,6 +1262,7 @@ describe('AppContainer State Management', () => {
capturedUIActions.handleClearScreen();
expect(clearSpy).toHaveBeenCalledTimes(1);
expect(mockClearPendingState).toHaveBeenCalledTimes(1);
expect(mockStdout.write).not.toHaveBeenCalledWith(
ansiEscapes.clearTerminal,
);
@@ -5774,6 +5778,10 @@ describe('AppContainer State Management', () => {
rewindUserItem(1, 'first prompt', 'prompt-1'),
{ id: 2, type: 'gemini', text: 'first response' },
]);
expect(mockClearPendingState).toHaveBeenCalledTimes(1);
expect(mockClearPendingState.mock.invocationCallOrder[0]).toBeLessThan(
harness.loadHistory.mock.invocationCallOrder[0]!,
);
expect(harness.setText).toHaveBeenCalledWith('second prompt');
expect(harness.addItem).toHaveBeenCalledWith(
expect.objectContaining({
+13
View File
@@ -1497,6 +1497,12 @@ export const AppContainer = (props: AppContainerProps) => {
});
}, [addHistoryItem, config]);
const clearPendingStateRef = useRef<() => void>(() => {});
const clearPendingStateFromRef = useCallback(
() => clearPendingStateRef.current(),
[],
);
const {
isResumeDialogOpen,
resumeMatchedSessions,
@@ -1508,6 +1514,7 @@ export const AppContainer = (props: AppContainerProps) => {
settings,
historyManager,
startNewSession,
clearPendingState: clearPendingStateFromRef,
setSessionName,
remount: refreshStatic,
});
@@ -1517,6 +1524,7 @@ export const AppContainer = (props: AppContainerProps) => {
settings,
historyManager,
startNewSession,
clearPendingState: clearPendingStateFromRef,
setSessionName,
remount: refreshStatic,
});
@@ -1735,6 +1743,7 @@ export const AppContainer = (props: AppContainerProps) => {
handleBranch,
openDeleteDialog,
openHelpDialog,
clearPendingState: () => clearPendingStateRef.current(),
}),
[
openAuthDialog,
@@ -2044,6 +2053,7 @@ export const AppContainer = (props: AppContainerProps) => {
submitQuery,
initError,
pendingHistoryItems: pendingGeminiHistoryItems,
clearPendingState,
thought,
cancelOngoingRequest,
preemptGoalTurn,
@@ -2082,6 +2092,7 @@ export const AppContainer = (props: AppContainerProps) => {
goalQueueRef,
);
cancelOngoingRequestRef.current = cancelOngoingRequest;
clearPendingStateRef.current = clearPendingState;
// Now that streamingState is available, keep isIdleRef in sync and
// flush any deferred update notifications when the model finishes responding.
@@ -2930,6 +2941,7 @@ export const AppContainer = (props: AppContainerProps) => {
);
const handleClearScreen = useCallback(() => {
clearPendingStateRef.current();
historyManager.clearItems();
clearScreen();
remountStaticHistory();
@@ -3577,6 +3589,7 @@ export const AppContainer = (props: AppContainerProps) => {
const truncatedUi = expandCollapsedHistory(
originalHistory.filter((h) => h.id < userItem.id),
);
clearPendingStateRef.current();
historyManager.loadHistory(truncatedUi);
refreshStatic();
@@ -96,6 +96,7 @@ describe('languageCommand', () => {
settings: {
merged: {},
setValue: vi.fn(),
setValues: vi.fn(),
},
},
});
@@ -406,6 +407,8 @@ describe('languageCommand', () => {
expect.anything(), // SettingScope.User
'general.language',
'en',
undefined,
{ throwOnWriteFailure: true },
);
});
@@ -421,6 +424,8 @@ describe('languageCommand', () => {
'user',
'general.language',
'en',
undefined,
{ throwOnWriteFailure: true },
);
});
@@ -438,6 +443,8 @@ describe('languageCommand', () => {
'workspace',
'general.language',
'zh',
undefined,
{ throwOnWriteFailure: true },
);
});
@@ -920,6 +927,128 @@ describe('languageCommand', () => {
content: expect.stringContaining('do not accept additional arguments'),
});
});
// Regression for #8592: the web-shell settings panel switches language
// via `/language ui <id> --global|--project`, and the router descends
// into these nested subcommands, so scope flags must not be rejected.
it('zh-CN action should accept --global and persist to user scope', async () => {
if (!zhCNSubcommand?.action) {
throw new Error('zh-CN subcommand must have an action.');
}
const result = await zhCNSubcommand.action(mockContext, '--global');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
'user',
'general.language',
'zh',
undefined,
{ throwOnWriteFailure: true },
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('zh-CN action should accept --project and persist to workspace scope when trusted', async () => {
if (!zhCNSubcommand?.action) {
throw new Error('zh-CN subcommand must have an action.');
}
(mockContext.services.settings as { isTrusted?: boolean }).isTrusted =
true;
const result = await zhCNSubcommand.action(mockContext, '--project');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
'workspace',
'general.language',
'zh',
undefined,
{ throwOnWriteFailure: true },
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('reports a workspace persistence failure without changing the language', async () => {
if (!zhCNSubcommand?.action) {
throw new Error('zh-CN subcommand must have an action.');
}
(mockContext.services.settings as { isTrusted?: boolean }).isTrusted =
true;
vi.mocked(mockContext.services.settings.setValue).mockImplementation(
() => {
throw new Error('EACCES');
},
);
const result = await zhCNSubcommand.action(mockContext, '--project');
expect(i18n.setLanguageAsync).not.toHaveBeenCalled();
expect(mockContext.ui.reloadCommands).not.toHaveBeenCalled();
expect(result).toMatchObject({
messageType: 'error',
content: expect.stringContaining('EACCES'),
});
});
it('zh-CN action should reject --project in an untrusted workspace without persisting', async () => {
if (!zhCNSubcommand?.action) {
throw new Error('zh-CN subcommand must have an action.');
}
(mockContext.services.settings as { isTrusted?: boolean }).isTrusted =
false;
const result = await zhCNSubcommand.action(mockContext, '--project');
expect(i18n.setLanguageAsync).not.toHaveBeenCalled();
expect(mockContext.services.settings.setValue).not.toHaveBeenCalled();
expect(result).toMatchObject({
messageType: 'error',
content: expect.stringContaining('untrusted'),
});
});
it('zh-CN action should reject using both --project and --global together', async () => {
if (!zhCNSubcommand?.action) {
throw new Error('zh-CN subcommand must have an action.');
}
const result = await zhCNSubcommand.action(
mockContext,
'--project --global',
);
expect(i18n.setLanguageAsync).not.toHaveBeenCalled();
expect(mockContext.services.settings.setValue).not.toHaveBeenCalled();
expect(result).toMatchObject({
messageType: 'error',
content: expect.stringContaining('Cannot use both'),
});
});
it('zh-CN action should still reject extra arguments after a scope flag', async () => {
if (!zhCNSubcommand?.action) {
throw new Error('zh-CN subcommand must have an action.');
}
const result = await zhCNSubcommand.action(mockContext, '--global extra');
expect(i18n.setLanguageAsync).not.toHaveBeenCalled();
expect(mockContext.services.settings.setValue).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('do not accept additional arguments'),
});
});
});
describe('initializeLlmOutputLanguage', () => {
+65 -33
View File
@@ -95,6 +95,41 @@ function parseUiScopeFlags(input: string): {
return { scope, remaining, hasProject, hasGlobal };
}
/**
* Validates parsed `--project` / `--global` scope flags for a UI-language
* change, returning an error message when the flags are inconsistent or the
* target scope is not writable. Shared by the `/language ui` action and the
* nested per-language subcommands so both accept the same scope flags.
*/
function validateUiScopeFlags(
context: CommandContext,
parsed: ReturnType<typeof parseUiScopeFlags>,
): MessageActionReturn | undefined {
if (parsed.hasProject && parsed.hasGlobal) {
return {
type: 'message',
messageType: 'error',
content: t(
'Cannot use both --project and --global. Choose one scope flag.',
),
};
}
// Workspace settings are ignored on merge when untrusted, so a
// --project save would silently not take effect — reject it up front.
if (
parsed.scope === SettingScope.Workspace &&
context.services.settings &&
!context.services.settings.isTrusted
) {
return {
type: 'message',
messageType: 'error',
content: t('Workspace is untrusted; run /trust first or use --global.'),
};
}
return undefined;
}
/**
* Sets the UI language and persists it to the given scope (user settings by
* default).
@@ -114,16 +149,26 @@ async function setUiLanguage(
};
}
await setLanguageAsync(lang);
if (services.settings?.setValue) {
try {
services.settings.setValue(scope, 'general.language', lang);
services.settings.setValue(scope, 'general.language', lang, undefined, {
throwOnWriteFailure: true,
});
} catch (error) {
debugLogger.warn('Failed to save language setting:', error);
return {
type: 'message',
messageType: 'error',
content: t('Failed to set "{{key}}": {{error}}', {
key: 'general.language',
error: error instanceof Error ? error.message : String(error),
}),
};
}
}
await setLanguageAsync(lang);
// Reload commands so `t()` lookups in their metadata re-resolve under the new language.
context.ui.reloadCommands();
@@ -305,34 +350,12 @@ export const languageCommand: SlashCommand = {
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
const { scope, remaining, hasProject, hasGlobal } = parseUiScopeFlags(
args.trim(),
);
if (hasProject && hasGlobal) {
return {
type: 'message',
messageType: 'error',
content: t(
'Cannot use both --project and --global. Choose one scope flag.',
),
};
const parsed = parseUiScopeFlags(args.trim());
const scopeError = validateUiScopeFlags(context, parsed);
if (scopeError) {
return scopeError;
}
// Workspace settings are ignored on merge when untrusted, so a
// --project save would silently not take effect — reject it up front.
if (
scope === SettingScope.Workspace &&
context.services.settings &&
!context.services.settings.isTrusted
) {
return {
type: 'message',
messageType: 'error',
content: t(
'Workspace is untrusted; run /trust first or use --global.',
),
};
}
const trimmedArgs = remaining;
const trimmedArgs = parsed.remaining;
if (!trimmedArgs) {
return {
@@ -368,7 +391,7 @@ export const languageCommand: SlashCommand = {
};
}
return setUiLanguage(context, targetLang, scope);
return setUiLanguage(context, targetLang, parsed.scope);
},
// Nested subcommands for each supported language (e.g., /language ui zh-CN)
@@ -383,7 +406,16 @@ export const languageCommand: SlashCommand = {
kind: CommandKind.BUILT_IN,
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
action: async (context, args) => {
if (args.trim()) {
// The web-shell settings panel switches language through
// `/language ui <id> --global|--project`, and the command router
// descends into this nested subcommand — so scope flags must be
// accepted here exactly like in the `ui` action above.
const parsed = parseUiScopeFlags(args.trim());
const scopeError = validateUiScopeFlags(context, parsed);
if (scopeError) {
return scopeError;
}
if (parsed.remaining) {
return {
type: 'message',
messageType: 'error',
@@ -392,7 +424,7 @@ export const languageCommand: SlashCommand = {
),
};
}
return setUiLanguage(context, lang.code);
return setUiLanguage(context, lang.code, parsed.scope);
},
}),
),
@@ -54,6 +54,7 @@ describe('restoreCommand', () => {
config: mockConfig,
},
});
mockContext.ui.clearPendingState = vi.fn();
});
afterEach(async () => {
@@ -170,6 +171,7 @@ describe('restoreCommand', () => {
expect(mockContext.ui.loadHistory).toHaveBeenCalledWith(
toolCallData.history,
);
expect(mockContext.ui.clearPendingState).toHaveBeenCalledTimes(1);
expect(mockSetHistory).toHaveBeenCalledWith(toolCallData.clientHistory);
expect(mockRewind).toHaveBeenCalledWith(toolCallData.promptId, true);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
@@ -144,6 +144,7 @@ async function restoreAction(
content: 'loadHistory function is not available.',
};
}
context.ui.clearPendingState?.();
loadHistory(toolCallData.history);
}
+2
View File
@@ -63,6 +63,8 @@ export interface CommandContext {
addItem: UseHistoryManagerReturn['addItem'];
/** Clears all history items and the console screen. */
clear: () => void;
/** Clears transient assistant output before replacing conversation history. */
clearPendingState?: () => void;
/**
* Sets the transient debug message displayed in the application footer in debug mode.
*/
@@ -5,6 +5,7 @@
*/
import { describe, it, expect, vi } from 'vitest';
import { Text } from 'ink';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { type HistoryItem, ToolCallStatus } from '../types.js';
import { MessageType } from '../types.js';
@@ -30,6 +31,12 @@ vi.mock('./messages/ToolGroupMessage.js', () => ({
ToolGroupMessage: vi.fn(() => <div />),
}));
vi.mock('./TerminalImage.js', () => ({
TerminalImage: ({ image }: { image: { mimeType: string } }) => (
<Text>MockTerminalImage:{image.mimeType}</Text>
),
}));
vi.mock('../hooks/useMouseEvents.js', () => ({
useMouseEvents: vi.fn(),
}));
@@ -94,6 +101,29 @@ describe('<HistoryItemDisplay />', () => {
expect(output).toContain('◆\uFE0E Hello');
});
it.each(['gemini', 'gemini_content'] as const)(
'passes images from a %s history item to the assistant renderer',
(type) => {
const item: HistoryItem = {
id: 1,
type,
text: '',
images: [{ data: 'aW1hZ2U=', mimeType: 'image/png' }],
omittedImageCount: 2,
};
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay
item={item}
terminalWidth={100}
isPending={false}
/>,
);
expect(lastFrame()).toContain('MockTerminalImage:image/png');
expect(lastFrame()).toContain('[+2 more images]');
},
);
it('renders tool summaries without a leading spacer row', () => {
const item: HistoryItem = {
id: 1,
@@ -300,6 +300,8 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
)}
<AssistantMessage
text={itemForDisplay.text}
images={itemForDisplay.images}
omittedImageCount={itemForDisplay.omittedImageCount}
isPending={isPending}
availableTerminalHeight={
availableTerminalHeightGemini ?? availableTerminalHeight
@@ -312,6 +314,8 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'gemini_content' && (
<AssistantMessageContent
text={itemForDisplay.text}
images={itemForDisplay.images}
omittedImageCount={itemForDisplay.omittedImageCount}
isPending={isPending}
availableTerminalHeight={
availableTerminalHeightGemini ?? availableTerminalHeight
@@ -6,10 +6,11 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render } from 'ink-testing-library';
import { Static } from 'ink';
import { Static, useIsScreenReaderEnabled } from 'ink';
import type { Config } from '@qwen-code/qwen-code-core';
import { TerminalOutputProvider } from '../contexts/TerminalOutputContext.js';
import {
prepareInlineTerminalImage,
renderTerminalImage,
type TerminalImageRenderResult,
} from '../utils/terminal-image-renderer.js';
@@ -18,6 +19,7 @@ import { TerminalImage } from './TerminalImage.js';
const { writtenKeys } = vi.hoisted(() => ({ writtenKeys: new Set<string>() }));
vi.mock('../utils/terminal-image-renderer.js', () => ({
prepareInlineTerminalImage: vi.fn(),
renderTerminalImage: vi.fn(),
wasKittyImageWritten: vi.fn((key: string) => writtenKeys.has(key)),
markKittyImageWritten: vi.fn((key: string) => {
@@ -25,7 +27,16 @@ vi.mock('../utils/terminal-image-renderer.js', () => ({
}),
}));
vi.mock('ink', async (importOriginal) => {
const actual = await importOriginal<typeof import('ink')>();
return {
...actual,
useIsScreenReaderEnabled: vi.fn(() => false),
};
});
const mockedRenderTerminalImage = vi.mocked(renderTerminalImage);
const mockedPrepareInlineTerminalImage = vi.mocked(prepareInlineTerminalImage);
function configWithWorkspaceResult(isWithinWorkspace: boolean): Config {
return {
@@ -41,6 +52,11 @@ const IMAGE = {
mimeType: 'image/png' as const,
};
const INLINE_IMAGE = {
data: 'iVBORw0KGgo=',
mimeType: 'image/png',
};
const KITTY_RESULT: TerminalImageRenderResult = {
kind: 'kitty',
key: 'kitty-payload',
@@ -77,6 +93,7 @@ describe('TerminalImage', () => {
beforeEach(() => {
writtenKeys.clear();
vi.clearAllMocks();
vi.mocked(useIsScreenReaderEnabled).mockReturnValue(false);
});
it('writes trusted Kitty data and renders its placeholder', async () => {
@@ -183,4 +200,64 @@ describe('TerminalImage', () => {
expect(secondWriteRaw).not.toHaveBeenCalled();
second.unmount();
});
it('renders inline image data through the shared renderer', async () => {
const writeRaw = vi.fn();
mockedPrepareInlineTerminalImage.mockReturnValue({
fallbackText: '[image: 1x1 png]',
result: KITTY_RESULT,
});
const { lastFrame } = render(
<TerminalOutputProvider value={writeRaw}>
<TerminalImage image={INLINE_IMAGE} contentWidth={80} />
</TerminalOutputProvider>,
);
await vi.waitFor(() => {
expect(writeRaw).toHaveBeenCalledWith(KITTY_RESULT.sequence);
});
expect(lastFrame()).toContain('placeholder');
expect(mockedPrepareInlineTerminalImage).toHaveBeenCalledWith(
expect.objectContaining({
data: INLINE_IMAGE.data,
mimeType: INLINE_IMAGE.mimeType,
disabled: false,
}),
);
});
it('explains why an inline image renderer is unavailable', () => {
mockedPrepareInlineTerminalImage.mockReturnValue({
fallbackText: '[image: 1x1 png]',
result: {
kind: 'unavailable',
reason: 'chafa is not installed',
},
});
const { lastFrame } = render(
<TerminalImage image={INLINE_IMAGE} contentWidth={80} />,
);
expect(lastFrame()).toContain('[image: 1x1 png]');
expect(lastFrame()).toContain('chafa is not installed');
});
it('uses the deterministic inline placeholder for screen readers', () => {
vi.mocked(useIsScreenReaderEnabled).mockReturnValue(true);
mockedPrepareInlineTerminalImage.mockReturnValue({
fallbackText: '[image: 1x1 png]',
result: null,
});
const { lastFrame } = render(
<TerminalImage image={INLINE_IMAGE} contentWidth={80} />,
);
expect(lastFrame()).toContain('[image: 1x1 png]');
expect(mockedPrepareInlineTerminalImage).toHaveBeenCalledWith(
expect.objectContaining({ disabled: true }),
);
});
});
+118 -41
View File
@@ -6,12 +6,14 @@
import path from 'node:path';
import React from 'react';
import { Box, Text } from 'ink';
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import type { Config, TerminalImageDisplay } from '@qwen-code/qwen-code-core';
import type { InlineImageData } from '../types.js';
import { MaxSizedBox } from './shared/MaxSizedBox.js';
import { useTerminalOutput } from '../contexts/TerminalOutputContext.js';
import {
markKittyImageWritten,
prepareInlineTerminalImage,
renderTerminalImage,
wasKittyImageWritten,
type TerminalImageRenderResult,
@@ -22,62 +24,42 @@ import {
sanitizeTerminalText,
} from '../utils/textUtils.js';
interface TerminalImageProps {
data: TerminalImageDisplay;
config: Config;
interface SharedTerminalImageProps {
contentWidth: number;
availableTerminalHeight?: number;
}
export const TerminalImage: React.FC<TerminalImageProps> = ({
data,
config,
contentWidth,
availableTerminalHeight,
}) => {
interface FileTerminalImageProps extends SharedTerminalImageProps {
data: TerminalImageDisplay;
config: Config;
}
interface InlineTerminalImageProps extends SharedTerminalImageProps {
image: InlineImageData;
}
type TerminalImageProps = FileTerminalImageProps | InlineTerminalImageProps;
const RenderedTerminalImage: React.FC<
SharedTerminalImageProps & {
result: TerminalImageRenderResult;
unavailableText: string;
}
> = ({ result, unavailableText, contentWidth, availableTerminalHeight }) => {
const writeRaw = useTerminalOutput();
const filePath = path.resolve(data.filePath);
const safePath = config.getWorkspaceContext().isPathWithinWorkspace(filePath);
const result = React.useMemo<TerminalImageRenderResult | null>(
() =>
safePath
? renderTerminalImage({
display: {
type: 'terminal_image',
filePath,
mimeType: data.mimeType,
},
contentWidth,
availableTerminalHeight,
})
: null,
[availableTerminalHeight, contentWidth, data.mimeType, filePath, safePath],
);
// The Kitty payload is written once per terminal session per render key; the
// terminal keeps the image and redraws it from the placeholder cells, so a
// remount (live row -> Static row, or a resize) must not re-transmit it.
React.useEffect(() => {
if (!result || result.kind !== 'kitty') return;
if (result.kind !== 'kitty') return;
if (wasKittyImageWritten(result.key)) return;
markKittyImageWritten(result.key);
const sequence = result.sequence;
process.nextTick(() => writeRaw(sequence));
}, [result, writeRaw]);
if (!safePath) {
return (
<Text color={theme.status.error}>
Refusing to display an image outside the current workspace.
</Text>
);
}
if (!result) return null;
if (result.kind === 'unavailable') {
const fileName = sanitizeMultilineForDisplay(path.basename(filePath));
return (
<Text color={theme.text.secondary} wrap="wrap">
{fileName}: {sanitizeTerminalText(result.reason)}
{unavailableText}
</Text>
);
}
@@ -112,3 +94,98 @@ export const TerminalImage: React.FC<TerminalImageProps> = ({
</MaxSizedBox>
);
};
const FileTerminalImage: React.FC<FileTerminalImageProps> = ({
data,
config,
contentWidth,
availableTerminalHeight,
}) => {
const filePath = path.resolve(data.filePath);
const safePath = config.getWorkspaceContext().isPathWithinWorkspace(filePath);
const result = React.useMemo<TerminalImageRenderResult | null>(
() =>
safePath
? renderTerminalImage({
display: {
type: 'terminal_image',
filePath,
mimeType: data.mimeType,
},
contentWidth,
availableTerminalHeight,
})
: null,
[availableTerminalHeight, contentWidth, data.mimeType, filePath, safePath],
);
if (!safePath) {
return (
<Text color={theme.status.error}>
Refusing to display an image outside the current workspace.
</Text>
);
}
if (!result) return null;
const unavailableText =
result.kind === 'unavailable'
? `${sanitizeMultilineForDisplay(path.basename(filePath))}: ${sanitizeTerminalText(result.reason)}`
: '';
return (
<RenderedTerminalImage
result={result}
unavailableText={unavailableText}
contentWidth={contentWidth}
availableTerminalHeight={availableTerminalHeight}
/>
);
};
const InlineTerminalImage: React.FC<InlineTerminalImageProps> = ({
image,
contentWidth,
availableTerminalHeight,
}) => {
const isScreenReaderEnabled = useIsScreenReaderEnabled();
const prepared = React.useMemo(
() =>
prepareInlineTerminalImage({
data: image.data,
mimeType: image.mimeType,
contentWidth,
availableTerminalHeight,
disabled: isScreenReaderEnabled,
}),
[
availableTerminalHeight,
contentWidth,
image.data,
image.mimeType,
isScreenReaderEnabled,
],
);
if (!prepared.result) {
return <Text color={theme.text.secondary}>{prepared.fallbackText}</Text>;
}
return (
<RenderedTerminalImage
result={prepared.result}
unavailableText={
prepared.result.kind === 'unavailable'
? `${prepared.fallbackText}: ${sanitizeTerminalText(prepared.result.reason)}`
: prepared.fallbackText
}
contentWidth={contentWidth}
availableTerminalHeight={availableTerminalHeight}
/>
);
};
export const TerminalImage: React.FC<TerminalImageProps> = (props) =>
'image' in props ? (
<InlineTerminalImage {...props} />
) : (
<FileTerminalImage {...props} />
);
@@ -5,12 +5,91 @@
*/
import { render } from 'ink-testing-library';
import { Text } from 'ink';
import { vi } from 'vitest';
import {
AssistantMessage,
AssistantMessageContent,
ThinkMessage,
ThinkMessageContent,
toggleKeyHint,
} from './ConversationMessages.js';
vi.mock('../TerminalImage.js', () => ({
TerminalImage: ({
image,
availableTerminalHeight,
}: {
image: { mimeType: string };
availableTerminalHeight?: number;
}) => (
<Text>
MockTerminalImage:{image.mimeType}:height=
{availableTerminalHeight ?? 'undef'}
</Text>
),
}));
describe('<AssistantMessage />', () => {
it('routes assistant images through TerminalImage', () => {
const { lastFrame } = render(
<AssistantMessage
text=""
images={[{ data: 'aW1hZ2U=', mimeType: 'image/png' }]}
isPending={false}
contentWidth={80}
/>,
);
expect(lastFrame()).toContain('MockTerminalImage:image/png');
});
it('renders the number of omitted images', () => {
const { lastFrame } = render(
<AssistantMessage
text=""
omittedImageCount={2}
isPending={false}
contentWidth={80}
/>,
);
expect(lastFrame()).toContain('[+2 more images]');
});
it('shares the pending height budget across assistant images', () => {
const { lastFrame } = render(
<AssistantMessage
text="streaming"
images={[
{ data: 'Zmlyc3Q=', mimeType: 'image/png' },
{ data: 'c2Vjb25k', mimeType: 'image/png' },
]}
isPending={true}
availableTerminalHeight={20}
contentWidth={80}
/>,
);
expect(lastFrame()).toContain('MockTerminalImage:image/png:height=6');
});
});
describe('<AssistantMessageContent />', () => {
it('routes continuation images through TerminalImage', () => {
const { lastFrame } = render(
<AssistantMessageContent
text=""
images={[{ data: 'aW1hZ2U=', mimeType: 'image/png' }]}
isPending={false}
contentWidth={80}
/>,
);
expect(lastFrame()).toContain('MockTerminalImage:image/png');
});
});
describe('<ThinkMessage />', () => {
const defaultProps = {
text: 'Analyzing the code structure',
@@ -22,6 +22,9 @@ import { ErrorBoundary } from '../shared/ErrorBoundary.js';
import { ICON } from '../../constants.js';
import { sanitizeTerminalText } from '../../utils/textUtils.js';
import { formatDuration } from '../../utils/displayUtils.js';
import type { InlineImageData } from '../../types.js';
import { TerminalImage } from '../TerminalImage.js';
import { formatInlineImageOverflow } from '../../utils/inline-image-parts.js';
const debugLogger = createDebugLogger('THINK_RENDER');
@@ -40,6 +43,8 @@ interface UserShellMessageProps {
interface AssistantMessageProps {
text: string;
images?: InlineImageData[];
omittedImageCount?: number;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
@@ -48,6 +53,8 @@ interface AssistantMessageProps {
interface AssistantMessageContentProps {
text: string;
images?: InlineImageData[];
omittedImageCount?: number;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
@@ -89,6 +96,8 @@ interface PrefixedTextMessageProps {
interface PrefixedMarkdownMessageProps {
text: string;
images?: InlineImageData[];
omittedImageCount?: number;
prefix: string;
prefixColor: string;
isPending: boolean;
@@ -101,6 +110,8 @@ interface PrefixedMarkdownMessageProps {
interface ContinuationMarkdownMessageProps {
text: string;
images?: InlineImageData[];
omittedImageCount?: number;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
@@ -148,6 +159,8 @@ const PrefixedTextMessage: React.FC<PrefixedTextMessageProps> = ({
const PrefixedMarkdownMessage: React.FC<PrefixedMarkdownMessageProps> = ({
text,
images,
omittedImageCount,
prefix,
prefixColor,
isPending,
@@ -158,6 +171,10 @@ const PrefixedMarkdownMessage: React.FC<PrefixedMarkdownMessageProps> = ({
sourceCopyIndexOffsets,
}) => {
const prefixWidth = getPrefixWidth(prefix);
const imageHeightBudget =
availableTerminalHeight !== undefined && images?.length
? Math.max(1, Math.floor(availableTerminalHeight / (images.length + 1)))
: availableTerminalHeight;
return (
<Box flexDirection="row">
@@ -167,14 +184,27 @@ const PrefixedMarkdownMessage: React.FC<PrefixedMarkdownMessageProps> = ({
</Text>
</Box>
<Box flexGrow={1} flexDirection="column">
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth - prefixWidth}
textColor={textColor}
sourceCopyIndexOffsets={sourceCopyIndexOffsets}
/>
{text.length > 0 && (
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth - prefixWidth}
textColor={textColor}
sourceCopyIndexOffsets={sourceCopyIndexOffsets}
/>
)}
{images?.map((image, index) => (
<TerminalImage
key={index}
image={image}
contentWidth={contentWidth - prefixWidth}
availableTerminalHeight={imageHeightBudget}
/>
))}
{omittedImageCount !== undefined && omittedImageCount > 0 && (
<Text dimColor>{formatInlineImageOverflow(omittedImageCount)}</Text>
)}
</Box>
</Box>
);
@@ -184,6 +214,8 @@ const ContinuationMarkdownMessage: React.FC<
ContinuationMarkdownMessageProps
> = ({
text,
images,
omittedImageCount,
isPending,
availableTerminalHeight,
contentWidth,
@@ -192,17 +224,34 @@ const ContinuationMarkdownMessage: React.FC<
sourceCopyIndexOffsets,
}) => {
const prefixWidth = getPrefixWidth(basePrefix);
const imageHeightBudget =
availableTerminalHeight !== undefined && images?.length
? Math.max(1, Math.floor(availableTerminalHeight / (images.length + 1)))
: availableTerminalHeight;
return (
<Box flexDirection="column" paddingLeft={prefixWidth}>
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth - prefixWidth}
textColor={textColor}
sourceCopyIndexOffsets={sourceCopyIndexOffsets}
/>
{text.length > 0 && (
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth - prefixWidth}
textColor={textColor}
sourceCopyIndexOffsets={sourceCopyIndexOffsets}
/>
)}
{images?.map((image, index) => (
<TerminalImage
key={index}
image={image}
contentWidth={contentWidth - prefixWidth}
availableTerminalHeight={imageHeightBudget}
/>
))}
{omittedImageCount !== undefined && omittedImageCount > 0 && (
<Text dimColor>{formatInlineImageOverflow(omittedImageCount)}</Text>
)}
</Box>
);
};
@@ -236,6 +285,8 @@ export const UserShellMessage: React.FC<UserShellMessageProps> = ({ text }) => {
export const AssistantMessage: React.FC<AssistantMessageProps> = ({
text,
images,
omittedImageCount,
isPending,
availableTerminalHeight,
contentWidth,
@@ -243,6 +294,8 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
}) => (
<PrefixedMarkdownMessage
text={text}
images={images}
omittedImageCount={omittedImageCount}
prefix={ICON.DIAMOND}
prefixColor={theme.text.accent}
ariaLabel={SCREEN_READER_MODEL_PREFIX}
@@ -257,6 +310,8 @@ export const AssistantMessageContent: React.FC<
AssistantMessageContentProps
> = ({
text,
images,
omittedImageCount,
isPending,
availableTerminalHeight,
contentWidth,
@@ -264,6 +319,8 @@ export const AssistantMessageContent: React.FC<
}) => (
<ContinuationMarkdownMessage
text={text}
images={images}
omittedImageCount={omittedImageCount}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth}
@@ -194,6 +194,38 @@ describe('<ToolGroupMessage />', () => {
expect(frame).not.toContain('MockTool');
});
it('renders image-bearing collapsible tools individually', () => {
const toolCalls = [
createToolCall({
callId: 'image-read',
name: 'ReadFile',
description: 'chart.png',
images: [{ data: 'aW1hZ2U=', mimeType: 'image/png' }],
}),
];
const { lastFrame } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toContain('MockTool[image-read]');
});
it('renders an overflow-only collapsible tool individually', () => {
const toolCalls = [
createToolCall({
callId: 'overflow-read',
name: 'ReadFile',
description: 'many charts',
omittedImageCount: 2,
}),
];
const { lastFrame } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toContain('MockTool[overflow-read]');
});
it('renders mixed group with summary + individual tools', () => {
const toolCalls = [
createToolCall({ callId: 'r1', name: 'ReadFile', description: 'a.ts' }),
@@ -45,6 +45,10 @@ function isRunningAgent(
);
}
function hasInlineImageOutput(tool: IndividualToolCallDisplay): boolean {
return Boolean(tool.images?.length || tool.omittedImageCount);
}
/**
* Predicate: tool entry whose `resultDisplay` is an `AgentResultDisplay`
* (i.e. a `task_execution` subagent invocation), regardless of status.
@@ -388,13 +392,17 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
? []
: inlineToolCalls.filter(
(t) =>
isCollapsibleTool(t.name) && t.status !== ToolCallStatus.Canceled,
isCollapsibleTool(t.name) &&
t.status !== ToolCallStatus.Canceled &&
!hasInlineImageOutput(t),
);
const nonCollapsibleTools = forceExpandAll
? inlineToolCalls
: inlineToolCalls.filter(
(t) =>
!isCollapsibleTool(t.name) || t.status === ToolCallStatus.Canceled,
!isCollapsibleTool(t.name) ||
t.status === ToolCallStatus.Canceled ||
hasInlineImageOutput(t),
);
// Memory badge — shared between all-collapsible and mixed paths.
@@ -448,7 +456,10 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
let countToolCallsWithResults = 0;
for (const tool of nonCollapsibleTools) {
if (tool.resultDisplay !== undefined && tool.resultDisplay !== '') {
if (
(tool.resultDisplay !== undefined && tool.resultDisplay !== '') ||
hasInlineImageOutput(tool)
) {
countToolCallsWithResults++;
}
}
@@ -73,11 +73,17 @@ vi.mock('../AnsiOutput.js', () => ({
vi.mock('../TerminalImage.js', () => ({
TerminalImage: ({
data,
image,
availableTerminalHeight,
}: {
data: { filePath: string; mimeType: string };
data?: { filePath: string; mimeType: string };
image?: { mimeType: string };
availableTerminalHeight?: number;
}) => (
<Text>
MockTerminalImage:{data.filePath}:{data.mimeType}
{image
? `MockTerminalImage:${image.mimeType}:height=${availableTerminalHeight ?? 'undef'}`
: `MockTerminalImage:${data?.filePath}:${data?.mimeType}:height=${availableTerminalHeight ?? 'undef'}`}
</Text>
),
}));
@@ -187,6 +193,43 @@ describe('<ToolMessage />', () => {
expect(output).not.toContain('MockMarkdown:Test result'); // collapsed
});
it('renders inline images returned by a tool', () => {
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
images={[{ data: 'aW1hZ2U=', mimeType: 'image/png' }]}
/>,
StreamingState.Idle,
);
expect(lastFrame()).toContain('MockTerminalImage:image/png');
});
it('renders the number of omitted inline images', () => {
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} omittedImageCount={2} />,
StreamingState.Idle,
);
expect(lastFrame()).toContain('[+2 more images]');
});
it('shares the tool height budget across inline images', () => {
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
availableTerminalHeight={20}
images={[
{ data: 'Zmlyc3Q=', mimeType: 'image/png' },
{ data: 'c2Vjb25k', mimeType: 'image/png' },
]}
/>,
StreamingState.Responding,
);
expect(lastFrame()).toContain('MockTerminalImage:image/png:height=4');
});
it('always shows the vision bridge disclosure for a completed read', () => {
const { lastFrame } = renderWithContext(
<ToolMessage
@@ -58,6 +58,7 @@ import {
} from '../shared/ToolStatusIndicator.js';
import { ToolElapsedTime } from '../shared/ToolElapsedTime.js';
import { TerminalImage } from '../TerminalImage.js';
import { formatInlineImageOverflow } from '../../utils/inline-image-parts.js';
// Names that resolve to the agent tool: the canonical name plus whatever
// legacy request aliases core's migration map declares (e.g. 'task').
@@ -694,6 +695,8 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
name,
description,
resultDisplay,
images,
omittedImageCount,
visionBridgeNotice,
detailedDisplay,
status,
@@ -775,6 +778,10 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
MIN_LINES_SHOWN + 1, // enforce minimum lines shown
)
: undefined;
const inlineImageHeight =
availableHeight !== undefined && images?.length
? Math.max(1, Math.floor(availableHeight / (images.length + 1)))
: availableHeight;
// Cap inline shell output. Applies to both the streaming ANSI display and
// the completed string display (shell.ts emits the final result as a plain
// string via `returnDisplayMessage = result.output`). ShellStatsBar surfaces
@@ -973,6 +980,26 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
</Box>
</Box>
)}
{((images?.length ?? 0) > 0 ||
(omittedImageCount !== undefined && omittedImageCount > 0)) && (
<Box
paddingLeft={STATUS_INDICATOR_WIDTH}
width="100%"
flexDirection="column"
>
{images?.map((image, index) => (
<TerminalImage
key={index}
image={image}
contentWidth={innerWidth}
availableTerminalHeight={inlineImageHeight}
/>
))}
{omittedImageCount !== undefined && omittedImageCount > 0 && (
<Text dimColor>{formatInlineImageOverflow(omittedImageCount)}</Text>
)}
</Box>
)}
{isThisShellFocused && config && (
<Box paddingLeft={STATUS_INDICATOR_WIDTH} marginTop={1}>
<ShellInputPrompt
@@ -145,6 +145,7 @@ describe('useSlashCommandProcessor', () => {
const mockOpenMemoryDialog = vi.fn();
const mockOpenModelDialog = vi.fn();
const mockSetQuittingMessages = vi.fn();
const mockClearPendingState = vi.fn();
const mockConfig = makeFakeConfig({});
mockConfig.getChatRecordingService = vi.fn().mockReturnValue({
@@ -181,6 +182,7 @@ describe('useSlashCommandProcessor', () => {
openMcpDialog: vi.fn(),
openHooksDialog: vi.fn(),
openRewindSelector: vi.fn(),
clearPendingState: mockClearPendingState,
});
beforeEach(() => {
@@ -2632,6 +2634,18 @@ describe('useSlashCommandProcessor', () => {
});
describe('ui.clear and /btw dialog', () => {
it('should discard pending Gemini state when ui.clear is called', async () => {
const result = setupProcessorHook();
await waitFor(() => expect(result.current.commandContext).toBeDefined());
act(() => {
result.current.commandContext.ui.clear();
});
expect(mockClearPendingState).toHaveBeenCalledTimes(1);
expect(mockClearItems).toHaveBeenCalledTimes(1);
});
it('should dismiss an active btw dialog when ui.clear is called', async () => {
const result = setupProcessorHook();
await waitFor(() => expect(result.current.commandContext).toBeDefined());
@@ -163,6 +163,7 @@ export interface SlashCommandProcessorActions {
openRewindSelector: () => void;
openDiffDialog: () => void;
openHelpDialog: () => void;
clearPendingState: () => void;
}
/**
@@ -479,11 +480,13 @@ export const useSlashCommandProcessor = (
addItem,
clear: () => {
cancelBtw();
actions.clearPendingState();
clearItems();
clearScreen();
refreshStatic();
setSessionName?.(null);
},
clearPendingState: actions.clearPendingState,
loadHistory,
refreshStatic,
setDebugMessage: actions.setDebugMessage,
@@ -23,6 +23,7 @@ describe('useBranchCommand', () => {
let startNewSessionConfig: ReturnType<typeof vi.fn>;
let getGoalRuntimeReady: ReturnType<typeof vi.fn>;
let startNewSessionUI: ReturnType<typeof vi.fn>;
let clearPendingState: ReturnType<typeof vi.fn>;
let findSessionTitlesByPrefix: ReturnType<typeof vi.fn>;
let clearItems: ReturnType<typeof vi.fn>;
let loadHistory: ReturnType<typeof vi.fn>;
@@ -54,6 +55,7 @@ describe('useBranchCommand', () => {
settings: mockSettings,
historyManager: { clearItems, loadHistory, addItem },
startNewSession: startNewSessionUI,
clearPendingState,
setSessionName,
remount,
});
@@ -92,6 +94,7 @@ describe('useBranchCommand', () => {
startNewSessionConfig = vi.fn();
getGoalRuntimeReady = vi.fn().mockResolvedValue({});
startNewSessionUI = vi.fn();
clearPendingState = vi.fn();
clearItems = vi.fn();
loadHistory = vi.fn();
setSessionName = vi.fn();
@@ -164,6 +167,10 @@ describe('useBranchCommand', () => {
expect(monitorRegistry.reset).toHaveBeenCalledOnce();
expect(backgroundShellRegistry.reset).toHaveBeenCalledOnce();
expect(workflowRunRegistry.reset).toHaveBeenCalledOnce();
expect(clearPendingState).toHaveBeenCalledOnce();
expect(clearPendingState.mock.invocationCallOrder[0]).toBeLessThan(
loadHistory.mock.invocationCallOrder[0]!,
);
expect(startNewSessionUI.mock.invocationCallOrder[0]).toBeLessThan(
backgroundTaskRegistry.reset.mock.invocationCallOrder[0]!,
);
+11 -2
View File
@@ -68,6 +68,7 @@ export interface UseBranchCommandOptions {
'clearItems' | 'loadHistory' | 'addItem'
>;
startNewSession: (sessionId: string) => void;
clearPendingState?: () => void;
setSessionName?: (name: string | null) => void;
remount?: () => void;
}
@@ -93,8 +94,14 @@ export interface UseBranchCommandResult {
export function useBranchCommand(
options: UseBranchCommandOptions,
): UseBranchCommandResult {
const { config, historyManager, startNewSession, setSessionName, remount } =
options;
const {
config,
historyManager,
startNewSession,
clearPendingState,
setSessionName,
remount,
} = options;
const handleBranch = useCallback(
async (name?: string) => {
@@ -208,6 +215,7 @@ export function useBranchCommand(
collapsePreviewCount,
);
startNewSession(newSessionId);
clearPendingState?.();
historyManager.clearItems();
historyManager.loadHistory(uiHistoryItems);
uiSwapped = true;
@@ -294,6 +302,7 @@ export function useBranchCommand(
config,
historyManager,
startNewSession,
clearPendingState,
setSessionName,
remount,
options.settings.merged.ui?.history?.collapseOnResume,
File diff suppressed because it is too large. Load diff
+295 -38
View File
@@ -75,6 +75,7 @@ import type {
HistoryItemWithoutId,
HistoryItemToolGroup,
HistoryItemGemini,
InlineImageData,
SlashCommandProcessorResult,
} from '../types.js';
import { StreamingState, MessageType, ToolCallStatus } from '../types.js';
@@ -124,6 +125,10 @@ import {
} from './useMessageQueue.js';
import { classifyApiError } from '../../utils/classify-api-error.js';
import { cleanupReviewWorktreeLeases } from '../../services/review-worktree-lease.js';
import {
getInlineImageData,
MAX_INLINE_IMAGES_PER_ITEM,
} from '../utils/inline-image-parts.js';
const debugLogger = createDebugLogger('GEMINI_STREAM');
@@ -367,6 +372,7 @@ const LOADING_THOUGHT_DESCRIPTION_MAX_CHARS = 4_096;
type BufferedStreamEvent =
| { kind: 'content'; value: string }
| { kind: 'image'; value: InlineImageData }
| { kind: 'thought'; value: ThoughtSummary };
function showCitations(settings: LoadedSettings): boolean {
@@ -693,6 +699,44 @@ export const useGeminiStream = (
const auxiliaryAbortRefsRef = useRef<Set<AbortController>>(new Set());
const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] =
useStateAndRef<HistoryItemWithoutId | null>(null);
// Mixed assistant output needs multiple live rows to preserve
// text/image/text ordering. Keep completed runs in the dynamic region until
// the response reaches a normal commit boundary so a fresh retry or model
// fallback can still discard the entire failed attempt.
const [
pendingAssistantItems,
pendingAssistantItemsRef,
setPendingAssistantItems,
] = useStateAndRef<HistoryItemWithoutId[]>([]);
const commitPendingAssistantItems = useCallback(
(userMessageTimestamp: number) => {
const items = pendingAssistantItemsRef.current;
if (items.length === 0) {
return;
}
for (const item of items) {
commitItem(item, userMessageTimestamp);
}
setPendingAssistantItems([]);
},
[commitItem, pendingAssistantItemsRef, setPendingAssistantItems],
);
const commitItemInOrder = useCallback(
(item: HistoryItemWithoutId, userMessageTimestamp: number): number => {
commitPendingAssistantItems(userMessageTimestamp);
return commitItem(item, userMessageTimestamp);
},
[commitItem, commitPendingAssistantItems],
);
const stagePendingAssistantItem = useCallback((): boolean => {
const item = pendingHistoryItemRef.current;
if (item?.type !== 'gemini' && item?.type !== 'gemini_content') {
return false;
}
setPendingAssistantItems((items) => [...items, item]);
setPendingHistoryItem(null);
return true;
}, [pendingHistoryItemRef, setPendingAssistantItems, setPendingHistoryItem]);
// Streamed model reasoning for the current turn. Rendered (height-limited)
// above the answer while thinking, then committed to history as a
// collapsible `gemini_thought` block when the answer/tool/turn begins.
@@ -709,6 +753,15 @@ export const useGeminiStream = (
pendingRetryCountdownItemRef,
setPendingRetryCountdownItem,
] = useStateAndRef<HistoryItemWithoutId | null>(null);
const clearPendingState = useCallback(() => {
setPendingAssistantItems([]);
setPendingHistoryItem(null);
setPendingRetryErrorItem(null);
}, [
setPendingAssistantItems,
setPendingHistoryItem,
setPendingRetryErrorItem,
]);
const retryCountdownTimerRef = useRef<ReturnType<typeof setInterval> | null>(
null,
);
@@ -1027,7 +1080,7 @@ export const useGeminiStream = (
logApiCancel(config, cancellationEvent);
if (pendingHistoryItemRef.current) {
commitItem(pendingHistoryItemRef.current, Date.now());
commitItemInOrder(pendingHistoryItemRef.current, Date.now());
}
addItem(
{
@@ -1078,7 +1131,7 @@ export const useGeminiStream = (
}, [
streamingState,
addItem,
commitItem,
commitItemInOrder,
setPendingHistoryItem,
onCancelSubmit,
pendingHistoryItemRef,
@@ -1421,6 +1474,7 @@ export const useGeminiStream = (
eventValue: ContentEvent['value'],
currentGeminiMessageBuffer: string,
userMessageTimestamp: number,
startAsContinuation = false,
): string => {
if (turnCancelledRef.current) {
// Prevents additional output after a user initiated cancel.
@@ -1435,6 +1489,17 @@ export const useGeminiStream = (
// React history by the time AppContainer's guard runs).
turnSawContentEventRef.current = true;
let newGeminiMessageBuffer = currentGeminiMessageBuffer + eventValue;
const pendingItem = pendingHistoryItemRef.current;
if (
(pendingItem?.type === 'gemini' ||
pendingItem?.type === 'gemini_content') &&
(pendingItem.images?.length || pendingItem.omittedImageCount)
) {
if (newGeminiMessageBuffer.trim().length === 0) {
return newGeminiMessageBuffer;
}
stagePendingAssistantItem();
}
if (
pendingHistoryItemRef.current?.type !== 'gemini' &&
pendingHistoryItemRef.current?.type !== 'gemini_content'
@@ -1443,20 +1508,28 @@ export const useGeminiStream = (
return newGeminiMessageBuffer;
}
if (pendingHistoryItemRef.current) {
commitItem(pendingHistoryItemRef.current, userMessageTimestamp);
commitItemInOrder(
pendingHistoryItemRef.current,
userMessageTimestamp,
);
}
setPendingHistoryItem({
type: 'gemini',
text: '',
timestamp: Date.now(),
});
setPendingHistoryItem(
startAsContinuation
? { type: 'gemini_content', text: '' }
: { type: 'gemini', text: '', timestamp: Date.now() },
);
newGeminiMessageBuffer = stripLeadingBlankLines(newGeminiMessageBuffer);
}
// Split large messages for better rendering performance. Ideally,
// we should maximize the amount of output sent to <Static />.
let nextPendingType = pendingHistoryItemRef.current?.type as
| 'gemini'
| 'gemini_content';
let nextPendingType: 'gemini' | 'gemini_content' =
pendingHistoryItemRef.current?.type === 'gemini_content'
? 'gemini_content'
: pendingHistoryItemRef.current?.type === 'gemini'
? 'gemini'
: startAsContinuation
? 'gemini_content'
: 'gemini';
while (newGeminiMessageBuffer.length > STREAM_PENDING_ITEM_MAX_CHARS) {
const splitPoint = findLastSafeSplitPoint(
newGeminiMessageBuffer,
@@ -1481,7 +1554,7 @@ export const useGeminiStream = (
newGeminiMessageBuffer,
safeSplitPoint,
);
commitItem(
commitItemInOrder(
{
type: nextPendingType,
text: beforeText,
@@ -1595,7 +1668,7 @@ export const useGeminiStream = (
newGeminiMessageBuffer,
splitPoint,
);
commitItem(
commitItemInOrder(
{
type: nextPendingType,
text: beforeText,
@@ -1621,9 +1694,10 @@ export const useGeminiStream = (
return newGeminiMessageBuffer;
},
[
commitItem,
commitItemInOrder,
pendingHistoryItemRef,
setPendingHistoryItem,
stagePendingAssistantItem,
terminalWidth,
terminalHeight,
availableTerminalHeightRef,
@@ -1811,7 +1885,10 @@ export const useGeminiStream = (
};
addItem(pendingItem, userMessageTimestamp);
} else {
commitItem(pendingHistoryItemRef.current, userMessageTimestamp);
commitItemInOrder(
pendingHistoryItemRef.current,
userMessageTimestamp,
);
}
setPendingHistoryItem(null);
}
@@ -1826,7 +1903,7 @@ export const useGeminiStream = (
[
addItem,
commitPendingThought,
commitItem,
commitItemInOrder,
pendingHistoryItemRef,
setPendingHistoryItem,
setThought,
@@ -1848,7 +1925,7 @@ export const useGeminiStream = (
// Persist any streamed reasoning (collapsed) above the error.
commitPendingThought(userMessageTimestamp);
if (pendingHistoryItemRef.current) {
commitItem(pendingHistoryItemRef.current, userMessageTimestamp);
commitItemInOrder(pendingHistoryItemRef.current, userMessageTimestamp);
setPendingHistoryItem(null);
}
// Only show Ctrl+Y hint if not already showing an auto-retry countdown
@@ -1892,7 +1969,7 @@ export const useGeminiStream = (
},
[
commitPendingThought,
commitItem,
commitItemInOrder,
pendingHistoryItemRef,
setPendingHistoryItem,
setPendingRetryErrorItem,
@@ -1909,14 +1986,14 @@ export const useGeminiStream = (
}
if (pendingHistoryItemRef.current) {
commitItem(pendingHistoryItemRef.current, userMessageTimestamp);
commitItemInOrder(pendingHistoryItemRef.current, userMessageTimestamp);
setPendingHistoryItem(null);
}
addItem({ type: MessageType.INFO, text }, userMessageTimestamp);
},
[
addItem,
commitItem,
commitItemInOrder,
pendingHistoryItemRef,
setPendingHistoryItem,
settings,
@@ -1987,7 +2064,7 @@ export const useGeminiStream = (
) => {
autonomousLoopTickResolverRef.current?.resetCache();
if (pendingHistoryItemRef.current) {
commitItem(pendingHistoryItemRef.current, userMessageTimestamp);
commitItemInOrder(pendingHistoryItemRef.current, userMessageTimestamp);
setPendingHistoryItem(null);
}
const activeModel = modelOverrideRef.current ?? config.getModel();
@@ -2011,7 +2088,13 @@ export const useGeminiStream = (
Date.now(),
);
},
[addItem, commitItem, config, pendingHistoryItemRef, setPendingHistoryItem],
[
addItem,
commitItemInOrder,
config,
pendingHistoryItemRef,
setPendingHistoryItem,
],
);
const handleMaxSessionTurnsEvent = useCallback(
@@ -2084,7 +2167,7 @@ export const useGeminiStream = (
userMessageTimestamp: number,
) => {
if (pendingHistoryItemRef.current) {
commitItem(pendingHistoryItemRef.current, userMessageTimestamp);
commitItemInOrder(pendingHistoryItemRef.current, userMessageTimestamp);
setPendingHistoryItem(null);
}
addItem(
@@ -2096,7 +2179,7 @@ export const useGeminiStream = (
userMessageTimestamp,
);
},
[addItem, commitItem, pendingHistoryItemRef, setPendingHistoryItem],
[addItem, commitItemInOrder, pendingHistoryItemRef, setPendingHistoryItem],
);
const handleStopHookLoopEvent = useCallback(
@@ -2109,7 +2192,7 @@ export const useGeminiStream = (
userMessageTimestamp: number,
) => {
if (pendingHistoryItemRef.current) {
commitItem(pendingHistoryItemRef.current, userMessageTimestamp);
commitItemInOrder(pendingHistoryItemRef.current, userMessageTimestamp);
setPendingHistoryItem(null);
}
addItem(
@@ -2122,7 +2205,7 @@ export const useGeminiStream = (
userMessageTimestamp,
);
},
[addItem, commitItem, pendingHistoryItemRef, setPendingHistoryItem],
[addItem, commitItemInOrder, pendingHistoryItemRef, setPendingHistoryItem],
);
const processGeminiStreamEvents = useCallback(
@@ -2136,6 +2219,19 @@ export const useGeminiStream = (
let geminiMessageBuffer = '';
let thoughtBuffer = '';
let scheduledToolContinuation = false;
let assistantOutputStarted =
pendingHistoryItemRef.current?.type === 'gemini' ||
pendingHistoryItemRef.current?.type === 'gemini_content';
let assistantInlineImageCount = [
...pendingAssistantItemsRef.current,
pendingHistoryItemRef.current,
].reduce(
(count, item) =>
item?.type === 'gemini' || item?.type === 'gemini_content'
? count + (item.images?.length ?? 0)
: count,
0,
);
const toolCallRequests: ToolCallRequestInfo[] = [];
const bufferedEvents: BufferedStreamEvent[] = [];
let flushTimer: ReturnType<typeof setTimeout> | null = null;
@@ -2176,7 +2272,67 @@ export const useGeminiStream = (
contentParts.join(''),
geminiMessageBuffer,
userMessageTimestamp,
assistantOutputStarted,
);
if (contentParts.some((part) => part.trim().length > 0)) {
assistantOutputStarted = true;
}
continue;
}
if (nextEvent.kind === 'image') {
if (turnCancelledRef.current) {
continue;
}
setIsReceivingContent(true);
turnSawContentEventRef.current = true;
const pendingItem = pendingHistoryItemRef.current;
const isOverflowOnlyItem =
(pendingItem?.type === 'gemini' ||
pendingItem?.type === 'gemini_content') &&
pendingItem.text.length === 0 &&
!pendingItem.images?.length &&
Boolean(pendingItem.omittedImageCount);
const shouldDisplayImage =
assistantInlineImageCount < MAX_INLINE_IMAGES_PER_ITEM;
if (!shouldDisplayImage && isOverflowOnlyItem) {
setPendingHistoryItem({
...pendingItem,
omittedImageCount: (pendingItem.omittedImageCount ?? 0) + 1,
});
geminiMessageBuffer = '';
assistantOutputStarted = true;
continue;
}
if (pendingHistoryItemRef.current) {
if (!stagePendingAssistantItem()) {
commitItemInOrder(
pendingHistoryItemRef.current,
userMessageTimestamp,
);
setPendingHistoryItem(null);
}
}
geminiMessageBuffer = '';
if (shouldDisplayImage) {
setPendingHistoryItem({
type: assistantOutputStarted ? 'gemini_content' : 'gemini',
text: '',
images: [nextEvent.value],
...(!assistantOutputStarted ? { timestamp: Date.now() } : {}),
});
assistantInlineImageCount++;
} else {
setPendingHistoryItem({
type: assistantOutputStarted ? 'gemini_content' : 'gemini',
text: '',
omittedImageCount: 1,
...(!assistantOutputStarted ? { timestamp: Date.now() } : {}),
});
}
assistantOutputStarted = true;
continue;
}
@@ -2238,7 +2394,7 @@ export const useGeminiStream = (
scheduleBufferedStreamFlush();
}
break;
case ServerGeminiEventType.Content:
case ServerGeminiEventType.Content: {
// Thinking is done once the answer starts streaming; reset the
// title status. On the thinking→answer transition, flush any
// buffered reasoning so the full thought is captured, then commit
@@ -2253,9 +2409,24 @@ export const useGeminiStream = (
thoughtBuffer = '';
}
setThought((prev) => (prev ? null : prev));
bufferedEvents.push({ kind: 'content', value: event.value });
const displayParts = event.parts ?? [{ text: event.value }];
for (const part of displayParts) {
if ('text' in part) {
if (part.text.length > 0) {
bufferedEvents.push({ kind: 'content', value: part.text });
}
} else {
const image = getInlineImageData({
inlineData: part.inlineData,
});
if (image) {
bufferedEvents.push({ kind: 'image', value: image });
}
}
}
scheduleBufferedStreamFlush();
break;
}
case ServerGeminiEventType.ToolCallRequest:
// Thinking is done once a tool call is issued; flush buffered
// reasoning then commit it to history (collapsed) above the tool
@@ -2296,6 +2467,8 @@ export const useGeminiStream = (
case ServerGeminiEventType.ChatCompressed:
flushBufferedStreamEvents();
handleChatCompressionEvent(event.value, userMessageTimestamp);
geminiMessageBuffer = '';
assistantOutputStarted = false;
break;
case ServerGeminiEventType.ToolCallConfirmation:
case ServerGeminiEventType.ToolCallResponse:
@@ -2303,21 +2476,35 @@ export const useGeminiStream = (
break;
case ServerGeminiEventType.MaxSessionTurns:
flushBufferedStreamEvents();
if (pendingHistoryItemRef.current) {
commitItemInOrder(
pendingHistoryItemRef.current,
userMessageTimestamp,
);
setPendingHistoryItem(null);
}
handleMaxSessionTurnsEvent();
geminiMessageBuffer = '';
assistantOutputStarted = false;
break;
case ServerGeminiEventType.SessionTokenLimitExceeded:
flushBufferedStreamEvents();
if (pendingHistoryItemRef.current) {
commitItemInOrder(
pendingHistoryItemRef.current,
userMessageTimestamp,
);
setPendingHistoryItem(null);
}
handleSessionTokenLimitExceededEvent(event.value);
geminiMessageBuffer = '';
assistantOutputStarted = false;
break;
case ServerGeminiEventType.Finished:
flushBufferedStreamEvents();
// A thinking-only turn (no content/tool) still commits its
// reasoning so it persists collapsed in history.
commitPendingThought(userMessageTimestamp);
handleFinishedEvent(
event as ServerGeminiFinishedEvent,
userMessageTimestamp,
);
// Seal off this turn's UI state before the parent re-enters
// sendMessageStream for a continuation (Stop-hook block at
// client.ts:1378 or next-speaker auto-continue at 1444). Both
@@ -2327,16 +2514,29 @@ export const useGeminiStream = (
// as "t" → "te" → "tes" cumulative rendering even though each
// turn is persisted as a clean, separate assistant message.
if (pendingHistoryItemRef.current) {
commitItem(pendingHistoryItemRef.current, userMessageTimestamp);
commitItemInOrder(
pendingHistoryItemRef.current,
userMessageTimestamp,
);
setPendingHistoryItem(null);
}
geminiMessageBuffer = '';
thoughtBuffer = '';
assistantOutputStarted = false;
assistantInlineImageCount = 0;
setThought(null);
handleFinishedEvent(
event as ServerGeminiFinishedEvent,
userMessageTimestamp,
);
break;
case ServerGeminiEventType.Citation:
flushBufferedStreamEvents();
handleCitationEvent(event.value, userMessageTimestamp);
if (showCitations(settings)) {
geminiMessageBuffer = '';
assistantOutputStarted = false;
}
break;
case ServerGeminiEventType.LoopDetected:
flushBufferedStreamEvents();
@@ -2354,6 +2554,7 @@ export const useGeminiStream = (
// losing the partial text we meant to preserve.
if (!event.isContinuation) {
discardBufferedStreamEvents();
setPendingAssistantItems([]);
if (pendingHistoryItemRef.current) {
setPendingHistoryItem(null);
}
@@ -2361,6 +2562,8 @@ export const useGeminiStream = (
thoughtBuffer = '';
setThought(null);
geminiMessageBuffer = '';
assistantOutputStarted = false;
assistantInlineImageCount = 0;
} else {
flushBufferedStreamEvents();
}
@@ -2384,6 +2587,7 @@ export const useGeminiStream = (
// switching to the next fallback model. Discard partial content
// from the failed attempt and show a notification.
discardBufferedStreamEvents();
setPendingAssistantItems([]);
if (pendingHistoryItemRef.current) {
setPendingHistoryItem(null);
}
@@ -2391,6 +2595,8 @@ export const useGeminiStream = (
thoughtBuffer = '';
setThought(null);
geminiMessageBuffer = '';
assistantOutputStarted = false;
assistantInlineImageCount = 0;
toolCallRequests.length = 0;
clearRetryCountdown();
const fromModel =
@@ -2410,7 +2616,10 @@ export const useGeminiStream = (
// Display system message from Stop hooks with "Stop says:" prefix
// First commit any pending AI response to ensure correct ordering
if (pendingHistoryItemRef.current) {
commitItem(pendingHistoryItemRef.current, userMessageTimestamp);
commitItemInOrder(
pendingHistoryItemRef.current,
userMessageTimestamp,
);
setPendingHistoryItem(null);
}
addItem(
@@ -2420,6 +2629,8 @@ export const useGeminiStream = (
} as HistoryItemWithoutId,
userMessageTimestamp,
);
geminiMessageBuffer = '';
assistantOutputStarted = false;
break;
case ServerGeminiEventType.UserPromptSubmitBlocked:
flushBufferedStreamEvents();
@@ -2427,10 +2638,14 @@ export const useGeminiStream = (
event.value,
userMessageTimestamp,
);
geminiMessageBuffer = '';
assistantOutputStarted = false;
break;
case ServerGeminiEventType.StopHookLoop:
flushBufferedStreamEvents();
handleStopHookLoopEvent(event.value, userMessageTimestamp);
geminiMessageBuffer = '';
assistantOutputStarted = false;
break;
case ServerGeminiEventType.ActiveGoal:
break;
@@ -2438,7 +2653,7 @@ export const useGeminiStream = (
if (event.cause && shouldDisplayGoalStateCause(event.cause)) {
flushBufferedStreamEvents();
if (pendingHistoryItemRef.current) {
commitItem(
commitItemInOrder(
pendingHistoryItemRef.current,
userMessageTimestamp,
);
@@ -2452,6 +2667,8 @@ export const useGeminiStream = (
},
userMessageTimestamp,
);
geminiMessageBuffer = '';
assistantOutputStarted = false;
}
break;
default: {
@@ -2585,18 +2802,22 @@ export const useGeminiStream = (
handleMaxSessionTurnsEvent,
handleSessionTokenLimitExceededEvent,
handleCitationEvent,
settings,
startRetryCountdown,
clearRetryCountdown,
setThought,
commitPendingThought,
pendingHistoryItemRef,
pendingAssistantItemsRef,
pendingThoughtItemRef,
setPendingHistoryItem,
handleUserPromptSubmitBlockedEvent,
handleStopHookLoopEvent,
bindGoalTurn,
addItem,
commitItem,
commitItemInOrder,
stagePendingAssistantItem,
setPendingAssistantItems,
dualOutput,
],
);
@@ -2954,6 +3175,34 @@ export const useGeminiStream = (
const userMessageTimestamp = Date.now();
// A thrown stream can leave partial assistant runs in the dynamic
// region. An explicit Ctrl+Y retry is a fresh attempt, matching a core
// non-continuation Retry event, so discard every run from the failed
// attempt before the replacement stream starts. A different top-level
// turn preserves what the user already saw, but must commit it before
// prepareQueryForGemini appends the next user item.
if (submitType === SendMessageType.Retry) {
setPendingAssistantItems([]);
const pendingItem = pendingHistoryItemRef.current;
if (
pendingItem?.type === 'gemini' ||
pendingItem?.type === 'gemini_content'
) {
setPendingHistoryItem(null);
}
} else if (!isTurnContinuation && !allowConcurrentBtwDuringResponse) {
const pendingItem = pendingHistoryItemRef.current;
if (
pendingItem?.type === 'gemini' ||
pendingItem?.type === 'gemini_content'
) {
commitItemInOrder(pendingItem, userMessageTimestamp);
setPendingHistoryItem(null);
} else {
commitPendingAssistantItems(userMessageTimestamp);
}
}
// Reset quota error flag when starting a new query (not a continuation).
// Notifications (background agent/shell/monitor completions) are system
// events, not new user turns: they must not clear the user's model
@@ -3303,7 +3552,10 @@ export const useGeminiStream = (
}
if (pendingHistoryItemRef.current) {
commitItem(pendingHistoryItemRef.current, userMessageTimestamp);
commitItemInOrder(
pendingHistoryItemRef.current,
userMessageTimestamp,
);
setPendingHistoryItem(null);
}
@@ -3509,7 +3761,9 @@ export const useGeminiStream = (
processGeminiStreamEvents,
pendingHistoryItemRef,
addItem,
commitItem,
commitPendingAssistantItems,
commitItemInOrder,
setPendingAssistantItems,
setPendingHistoryItem,
setInitError,
geminiClient,
@@ -4358,6 +4612,7 @@ export const useGeminiStream = (
[
// Reasoning renders above the streaming answer.
pendingThoughtItem,
...pendingAssistantItems,
pendingHistoryItem,
pendingRetryErrorItem,
pendingRetryCountdownItem,
@@ -4365,6 +4620,7 @@ export const useGeminiStream = (
].filter((i) => i !== undefined && i !== null),
[
pendingThoughtItem,
pendingAssistantItems,
pendingHistoryItem,
pendingRetryErrorItem,
pendingRetryCountdownItem,
@@ -4928,6 +5184,7 @@ export const useGeminiStream = (
submitQuery,
initError,
pendingHistoryItems,
clearPendingState,
thought,
cancelOngoingRequest,
preemptGoalTurn,
@@ -6,7 +6,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useHistory, UI_COMPACT_CLEARED_MESSAGE } from './useHistoryManager.js';
import {
useHistory,
UI_COMPACT_CLEARED_MESSAGE,
UI_COMPACT_CLEARED_IMAGE_MESSAGE,
} from './useHistoryManager.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import type { HistoryItemWithoutId, HistoryItemToolGroup } from '../types.js';
import { ToolCallStatus } from '../types.js';
@@ -370,6 +374,136 @@ describe('useHistoryManager', () => {
expect(recentTool.detailedDisplay).toBe('full secret file content here');
});
it('clears image payloads from old tool results', () => {
const { result } = renderHook(() => useHistory());
const ts = Date.now();
for (let i = 0; i < 25; i++) {
act(() => {
result.current.addItem(
{
type: 'tool_group',
tools: [
{
callId: String(i),
name: 'screenshot',
description: '',
resultDisplay: undefined,
images: [{ data: 'aW1hZ2U=', mimeType: 'image/png' }],
omittedImageCount: 2,
status: ToolCallStatus.Success,
confirmationDetails: undefined,
},
],
} as unknown as HistoryItemWithoutId,
ts + i,
);
});
}
act(() => {
result.current.compactOldItems();
});
const oldestTool = (
result.current.history[0] as unknown as HistoryItemToolGroup
).tools[0];
expect(oldestTool.resultDisplay).toBe(UI_COMPACT_CLEARED_MESSAGE);
expect(oldestTool.images).toBeUndefined();
expect(oldestTool.omittedImageCount).toBeUndefined();
const recentTool = (
result.current.history[24] as unknown as HistoryItemToolGroup
).tools[0];
expect(recentTool.images).toHaveLength(1);
expect(recentTool.omittedImageCount).toBe(2);
});
it('clears old assistant image payloads while keeping recent images', () => {
const { result } = renderHook(() => useHistory());
const ts = Date.now();
for (let i = 0; i < 25; i++) {
act(() => {
result.current.addItem(
{
type: i === 0 ? 'gemini' : 'gemini_content',
text: i === 0 ? 'Generated chart' : '',
images: [{ data: `aW1hZ2Ut${i}`, mimeType: 'image/png' }],
omittedImageCount: 2,
},
ts + i,
);
});
}
act(() => {
result.current.compactOldItems();
});
const oldestItem = result.current.history[0];
expect(oldestItem).toMatchObject({
type: 'gemini',
text: `Generated chart\n\n${UI_COMPACT_CLEARED_IMAGE_MESSAGE}`,
});
expect(
oldestItem.type === 'gemini' ? oldestItem.images : undefined,
).toBeUndefined();
expect(
oldestItem.type === 'gemini' ? oldestItem.omittedImageCount : undefined,
).toBeUndefined();
const recentItem = result.current.history[24];
expect(
recentItem.type === 'gemini_content' ? recentItem.images : undefined,
).toHaveLength(1);
expect(
recentItem.type === 'gemini_content'
? recentItem.omittedImageCount
: undefined,
).toBe(2);
});
it('compacts old assistant image overflow markers without payloads', () => {
const { result } = renderHook(() => useHistory());
const ts = Date.now();
for (let i = 0; i < 25; i++) {
act(() => {
result.current.addItem(
{
type: 'gemini_content',
text: '',
omittedImageCount: 2,
},
ts + i,
);
});
}
act(() => {
result.current.compactOldItems();
});
const oldestItem = result.current.history[0];
expect(oldestItem).toMatchObject({
type: 'gemini_content',
text: UI_COMPACT_CLEARED_IMAGE_MESSAGE,
});
expect(
oldestItem.type === 'gemini_content'
? oldestItem.omittedImageCount
: undefined,
).toBeUndefined();
const recentItem = result.current.history[24];
expect(
recentItem.type === 'gemini_content'
? recentItem.omittedImageCount
: undefined,
).toBe(2);
});
it('clears a tool that carries detailedDisplay but no resultDisplay (defensive)', () => {
const { result } = renderHook(() => useHistory());
const ts = Date.now();
+56 -10
View File
@@ -17,6 +17,8 @@ type HistoryItemUpdater = (
) => Partial<HistoryItemWithoutId>;
export const UI_COMPACT_CLEARED_MESSAGE = '[Old tool result content cleared]';
export const UI_COMPACT_CLEARED_IMAGE_MESSAGE =
'[Old assistant image content cleared]';
const UI_COMPACT_KEEP_RECENT = 20;
export interface UseHistoryManagerReturn {
@@ -148,22 +150,30 @@ export function useHistory(): UseHistoryManagerReturn {
let thoughtRemoved = 0;
let toolGroupsCompacted = 0;
let assistantImageItemsCompacted = 0;
let totalThoughts = 0;
let totalToolGroupsWithOutput = 0;
let totalAssistantItemsWithImages = 0;
for (const item of prev) {
if (
item.type === 'gemini_thought' ||
item.type === 'gemini_thought_content'
) {
totalThoughts++;
} else if (
(item.type === 'gemini' || item.type === 'gemini_content') &&
(item.images?.length || item.omittedImageCount)
) {
totalAssistantItemsWithImages++;
} else if (
item.type === 'tool_group' &&
item.tools.some(
(t) =>
(t.resultDisplay != null &&
t.resultDisplay !== UI_COMPACT_CLEARED_MESSAGE) ||
t.detailedDisplay != null,
t.detailedDisplay != null ||
Boolean(t.images?.length || t.omittedImageCount),
)
) {
totalToolGroupsWithOutput++;
@@ -177,8 +187,13 @@ export function useHistory(): UseHistoryManagerReturn {
0,
totalToolGroupsWithOutput - UI_COMPACT_KEEP_RECENT,
);
const assistantImageItemsToCompact = Math.max(
0,
totalAssistantItemsWithImages - UI_COMPACT_KEEP_RECENT,
);
let thoughtsDropped = 0;
let toolGroupsSeen = 0;
let assistantImageItemsSeen = 0;
const next = prev
.filter((item) => {
@@ -195,16 +210,34 @@ export function useHistory(): UseHistoryManagerReturn {
return true;
})
.map((item) => {
if (
(item.type === 'gemini' || item.type === 'gemini_content') &&
(item.images?.length || item.omittedImageCount)
) {
assistantImageItemsSeen++;
if (assistantImageItemsSeen <= assistantImageItemsToCompact) {
assistantImageItemsCompacted++;
return {
...item,
text: item.text
? `${item.text}\n\n${UI_COMPACT_CLEARED_IMAGE_MESSAGE}`
: UI_COMPACT_CLEARED_IMAGE_MESSAGE,
images: undefined,
omittedImageCount: undefined,
};
}
}
if (item.type !== 'tool_group') return item;
// Check for any non-null resultDisplay (covers string, FileDiff,
// AnsiOutputDisplay, AgentResultDisplay, etc.) OR a lingering
// `detailedDisplay` — so a tool that somehow carries only the raw
// transcript detail still triggers compaction and gets cleared.
// AnsiOutputDisplay, AgentResultDisplay, etc.), a lingering
// `detailedDisplay`, or image payloads. Every retained output form
// must participate in the same keep-recent limit.
const hasOldOutput = item.tools.some(
(t) =>
(t.resultDisplay != null &&
t.resultDisplay !== UI_COMPACT_CLEARED_MESSAGE) ||
t.detailedDisplay != null,
t.detailedDisplay != null ||
Boolean(t.images?.length || t.omittedImageCount),
);
if (!hasOldOutput) return item;
toolGroupsSeen++;
@@ -216,19 +249,23 @@ export function useHistory(): UseHistoryManagerReturn {
if (
(t.resultDisplay != null &&
t.resultDisplay !== UI_COMPACT_CLEARED_MESSAGE) ||
t.detailedDisplay != null
t.detailedDisplay != null ||
t.images?.length ||
t.omittedImageCount
) {
// Also drop `detailedDisplay` (the raw functionResponse text
// kept for the Ctrl+O full-detail transcript): clearing only
// `resultDisplay` would let a post-compaction transcript reopen
// re-surface the supposedly cleared read/search/list output,
// defeating the memory/privacy compaction. The `detailedDisplay`
// arm keeps the guard robust even if a tool ever carries the
// raw detail without a `resultDisplay`.
// and `images` arms keep the guard robust when a tool carries
// raw detail or media without a `resultDisplay`.
return {
...t,
resultDisplay: UI_COMPACT_CLEARED_MESSAGE,
detailedDisplay: undefined,
images: undefined,
omittedImageCount: undefined,
};
}
return t;
@@ -236,17 +273,26 @@ export function useHistory(): UseHistoryManagerReturn {
};
});
if (thoughtRemoved > 0 || toolGroupsCompacted > 0) {
if (
thoughtRemoved > 0 ||
toolGroupsCompacted > 0 ||
assistantImageItemsCompacted > 0
) {
if (debugLogger.isEnabled()) {
debugLogger.debug(
`[COMPACT_UI_HISTORY] removed ${thoughtRemoved} thought item(s), ` +
`compacted ${assistantImageItemsCompacted} assistant image item(s), ` +
`compacted ${toolGroupsCompacted} tool group(s), ` +
`historyLength ${prev.length} -> ${next.length}, ` +
`memory=${(process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1)}MB`,
);
}
}
return thoughtRemoved > 0 || toolGroupsCompacted > 0 ? next : prev;
return thoughtRemoved > 0 ||
toolGroupsCompacted > 0 ||
assistantImageItemsCompacted > 0
? next
: prev;
});
}, []);
@@ -5,13 +5,19 @@
*/
import { describe, it, expect } from 'vitest';
import type { Part } from '@google/genai';
import { mapToDisplay, type TrackedToolCall } from './useReactToolScheduler.js';
import { MAX_INLINE_IMAGES_PER_ITEM } from '../utils/inline-image-parts.js';
// Build a minimal successful tracked tool call with the fields mapToDisplay's
// success branch reads. `displayName` drives the collapsible gate.
const makeSuccess = (displayName: string): TrackedToolCall =>
const makeCompleted = (
status: 'success' | 'error' | 'cancelled',
displayName: string,
responseMedia: Part[] = [],
): TrackedToolCall =>
({
status: 'success',
status,
request: { callId: 'call-1', name: 'read_file', args: {} },
tool: { displayName, isOutputMarkdown: false },
invocation: { getDescription: () => 'reading' },
@@ -23,12 +29,18 @@ const makeSuccess = (displayName: string): TrackedToolCall =>
id: 'call-1',
name: 'read_file',
response: { output: 'FULL FILE CONTENT' },
...(responseMedia.length > 0 ? { parts: responseMedia } : {}),
},
},
],
},
}) as unknown as TrackedToolCall;
const makeSuccess = (
displayName: string,
responseMedia: Part[] = [],
): TrackedToolCall => makeCompleted('success', displayName, responseMedia);
describe('mapToDisplay — detailedDisplay (§4.9 live path)', () => {
it('extracts detailedDisplay for a collapsible (read/search/list) tool', () => {
const group = mapToDisplay(makeSuccess('Read File'));
@@ -45,4 +57,50 @@ describe('mapToDisplay — detailedDisplay (§4.9 live path)', () => {
const group = mapToDisplay(makeSuccess('Edit'));
expect(group.tools[0].detailedDisplay).toBeUndefined();
});
it.each(['success', 'error', 'cancelled'] as const)(
'extracts nested inline images from %s tool response parts',
(status) => {
const group = mapToDisplay(
makeCompleted(status, 'Read File', [
{
inlineData: {
data: 'dG9vbC1pbWFnZQ==',
mimeType: 'image/png',
displayName: 'result.png',
},
},
]),
);
expect(group.tools[0].images).toEqual([
{
data: 'dG9vbC1pbWFnZQ==',
mimeType: 'image/png',
},
]);
},
);
it('caps tool images and reports the overflow count', () => {
const images = Array.from(
{ length: MAX_INLINE_IMAGES_PER_ITEM + 2 },
(_, index) => ({
inlineData: {
data: Buffer.from(`tool-image-${index}`).toString('base64'),
mimeType: 'image/png',
},
}),
);
const tool = mapToDisplay(makeCompleted('success', 'Read File', images))
.tools[0];
expect(tool.images).toEqual(
images
.slice(0, MAX_INLINE_IMAGES_PER_ITEM)
.map((part) => part.inlineData),
);
expect(tool.omittedImageCount).toBe(2);
});
});
@@ -38,6 +38,7 @@ import type {
} from '../types.js';
import { ToolCallStatus } from '../types.js';
import { isCollapsibleTool } from '../components/messages/CompactToolGroupDisplay.js';
import { collectInlineImages } from '../utils/inline-image-parts.js';
const debugLogger = createDebugLogger('REACT_TOOL_SCHEDULER');
@@ -390,8 +391,15 @@ export function mapToDisplay(
: undefined,
};
const inlineImageCollection =
trackedCall.status === 'success' ||
trackedCall.status === 'error' ||
trackedCall.status === 'cancelled'
? collectInlineImages(trackedCall.response.responseParts)
: null;
switch (trackedCall.status) {
case 'success':
case 'success': {
return {
...baseDisplayProperties,
status: mapCoreStatusToDisplayStatus(trackedCall.status),
@@ -415,9 +423,19 @@ export function mapToDisplay(
detailedDisplay: isCollapsibleTool(displayName)
? getToolResponseDisplayText(trackedCall.response.responseParts)
: undefined,
...(inlineImageCollection?.images.length
? { images: inlineImageCollection.images }
: {}),
...(inlineImageCollection?.omittedImageCount
? {
omittedImageCount: inlineImageCollection.omittedImageCount,
}
: {}),
confirmationDetails: undefined,
};
}
case 'error':
case 'cancelled': {
return {
...baseDisplayProperties,
status: mapCoreStatusToDisplayStatus(trackedCall.status),
@@ -429,22 +447,17 @@ export function mapToDisplay(
visionBridgeNotice: trackedCall.response.visionBridgeNotice,
}
: {}),
confirmationDetails: undefined,
};
case 'cancelled':
return {
...baseDisplayProperties,
status: mapCoreStatusToDisplayStatus(trackedCall.status),
resultDisplay: compactToolResultDisplayForHistory(
trackedCall.response.resultDisplay,
),
...(trackedCall.response.visionBridgeNotice !== undefined
...(inlineImageCollection?.images.length
? { images: inlineImageCollection.images }
: {}),
...(inlineImageCollection?.omittedImageCount
? {
visionBridgeNotice: trackedCall.response.visionBridgeNotice,
omittedImageCount: inlineImageCollection.omittedImageCount,
}
: {}),
confirmationDetails: undefined,
};
}
case 'awaiting_approval':
return {
...baseDisplayProperties,
@@ -240,6 +240,7 @@ describe('useResumeCommand', () => {
loadHistory: vi.fn(),
};
const startNewSession = vi.fn();
const clearPendingState = vi.fn();
const geminiClient = {
initialize: vi.fn().mockResolvedValue(undefined),
};
@@ -287,6 +288,7 @@ describe('useResumeCommand', () => {
settings: mockSettings,
historyManager,
startNewSession,
clearPendingState,
}),
);
@@ -325,6 +327,10 @@ describe('useResumeCommand', () => {
expect(geminiClient.initialize).toHaveBeenCalledWith();
expect(historyManager.clearItems).toHaveBeenCalledTimes(1);
expect(historyManager.loadHistory).toHaveBeenCalledTimes(1);
expect(clearPendingState).toHaveBeenCalledTimes(1);
expect(clearPendingState.mock.invocationCallOrder[0]).toBeLessThan(
historyManager.loadHistory.mock.invocationCallOrder[0]!,
);
expect(resetMonitorRegistry).toHaveBeenCalledTimes(1);
expect(config.getGoalRuntimeReady).toHaveBeenCalledTimes(1);
});
@@ -32,6 +32,7 @@ export interface UseResumeCommandOptions {
'addItem' | 'clearItems' | 'loadHistory'
>;
startNewSession: (sessionId: string) => void;
clearPendingState?: () => void;
setSessionName?: (name: string | null) => void;
remount?: () => void;
}
@@ -80,6 +81,7 @@ export function useResumeCommand(
settings,
historyManager,
startNewSession,
clearPendingState,
setSessionName,
remount,
} = options;
@@ -177,6 +179,7 @@ export function useResumeCommand(
// into the old JSONL (split-brain).
startNewSession(sessionId);
setSessionName?.(customTitle ?? null);
clearPendingState?.();
clearItems();
loadHistory(uiHistoryItems);
if (recoveredBackgroundAgentsNotice) {
@@ -242,6 +245,7 @@ export function useResumeCommand(
clearItems,
loadHistory,
startNewSession,
clearPendingState,
setSessionName,
remount,
settings.merged.ui?.history?.collapseOnResume,
+13
View File
@@ -54,6 +54,11 @@ export enum ToolCallStatus {
Error = 'Error',
}
export interface InlineImageData {
data: string;
mimeType: string;
}
export interface ToolCallEvent {
type: 'tool_call';
status: ToolCallStatus;
@@ -79,6 +84,10 @@ export interface IndividualToolCallDisplay {
* is only a count. Undefined → fall back to the summary.
*/
detailedDisplay?: string;
/** Inline images carried by this tool's persisted response parts. */
images?: InlineImageData[];
/** Images hidden after the per-row rendering limit. */
omittedImageCount?: number;
status: ToolCallStatus;
confirmationDetails: ToolCallConfirmationDetails | undefined;
renderOutputAsMarkdown?: boolean;
@@ -138,12 +147,16 @@ export type HistoryItemUser = HistoryItemBase & {
export type HistoryItemGemini = HistoryItemBase & {
type: 'gemini';
text: string;
images?: InlineImageData[];
omittedImageCount?: number;
timestamp?: number;
};
export type HistoryItemGeminiContent = HistoryItemBase & {
type: 'gemini_content';
text: string;
images?: InlineImageData[];
omittedImageCount?: number;
};
export type HistoryItemGeminiThought = HistoryItemBase & {
Loaded 100 of 172 files, more files were not shown because too many files have changed in this diff. Show more