Merge branch 'main' into worktree-feat+runtime-context

This commit is contained in:
Shaojin Wen 2026-07-07 23:18:47 +08:00 committed by GitHub
commit 0fd3502c10
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
85 changed files with 4212 additions and 389 deletions

View file

@ -50,7 +50,7 @@ jobs:
runner: 'windows-2022'
arch: 'x64'
steps:
- uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
- uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
- uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '22'

View file

@ -28,7 +28,7 @@ jobs:
steps:
- name: 'Checkout repository'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: '${{ github.ref }}'

View file

@ -136,7 +136,7 @@ jobs:
- name: 'Checkout'
id: 'checkout'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: "${{ github.event.inputs.branch_ref || (github.event_name == 'pull_request' && format('refs/pull/{0}/head', github.event.pull_request.number)) || (github.event_name == 'merge_group' && github.event.merge_group.head_sha) || github.ref }}"
# Shallow: nothing here walks git history (the verify guard below checks
@ -386,7 +386,7 @@ jobs:
- name: 'Checkout'
id: 'checkout'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: "${{ github.event.inputs.branch_ref || (github.event_name == 'pull_request' && format('refs/pull/{0}/head', github.event.pull_request.number)) || (github.event_name == 'merge_group' && github.event.merge_group.head_sha) || github.ref }}"
@ -441,7 +441,7 @@ jobs:
- name: 'Checkout'
id: 'checkout'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: "${{ github.event.inputs.branch_ref || (github.event_name == 'pull_request' && format('refs/pull/{0}/head', github.event.pull_request.number)) || (github.event_name == 'merge_group' && github.event.merge_group.head_sha) || github.ref }}"
@ -510,7 +510,7 @@ jobs:
- '22.x'
steps:
- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
- name: 'Download coverage reports artifact'
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
@ -557,7 +557,7 @@ jobs:
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
steps:
- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: "${{ github.event.inputs.branch_ref || (github.event_name == 'merge_group' && github.event.merge_group.head_sha) || github.ref }}"
# Shallow, mirroring the Ubuntu gate: nothing here walks git history,

View file

@ -30,7 +30,7 @@ jobs:
timeout-minutes: 30
steps:
- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
- name: 'Initialize CodeQL'
uses: 'github/codeql-action/init@df559355d593797519d70b90fc8edd5db049e7a2' # ratchet:github/codeql-action/init@v3

View file

@ -76,7 +76,7 @@ jobs:
steps:
- name: 'Check out source'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
fetch-depth: 0
@ -234,7 +234,7 @@ jobs:
steps:
- name: 'Check out source'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: '${{ needs.release_metadata.outputs.release_ref }}'

View file

@ -24,7 +24,7 @@ jobs:
runs-on: 'ubuntu-latest'
steps:
- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
- name: 'Setup Pages'
uses: 'actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d' # ratchet:actions/configure-pages@v6

View file

@ -44,7 +44,7 @@ jobs:
- '22.x'
steps:
- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
- name: 'Set up Node.js ${{ matrix.node-version }}'
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
@ -109,7 +109,7 @@ jobs:
${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
steps:
- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
- name: 'Set up Node.js'
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0

View file

@ -20,7 +20,7 @@ jobs:
prs_needing_comment: '${{ steps.run_triage.outputs.prs_needing_comment }}'
steps:
- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
- name: 'Run PR Triage Script'
id: 'run_triage'

View file

@ -215,7 +215,7 @@ jobs:
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:
- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
@ -997,7 +997,7 @@ jobs:
WATERMARK: '${{ matrix.target.watermark }}'
steps:
- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
@ -1281,7 +1281,7 @@ jobs:
if git rev-parse --verify "${BRANCH}" > /dev/null 2>&1; then
git diff "origin/main...${BRANCH}" > "${WORKDIR}/pr.diff" || true
fi
for f in feedback.md address-summary.md no-action.md failure.md pr.diff; do
for f in feedback.md address-summary.md no-action.md failure.md handoff.md pr.diff; do
if [[ -f "${WORKDIR}/${f}" ]]; then
echo "=============== ${f} ==============="
cat "${WORKDIR}/${f}"
@ -1382,6 +1382,8 @@ jobs:
OUTCOME: '${{ steps.verify.outputs.outcome }}'
CONFLICT: '${{ steps.prepare.outputs.conflict }}'
DRY_RUN: '${{ needs.route.outputs.dry_run }}'
GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}'
NEWEST: '${{ steps.prepare.outputs.newest }}'
run: |-
SUFFIX=''
[[ "${DRY_RUN}" == "true" ]] && SUFFIX=' (dry-run, nothing pushed)'
@ -1389,7 +1391,7 @@ jobs:
echo "### PR #${PR} (issue #${ISSUE}) — outcome=${OUTCOME:-unknown}${SUFFIX}"
echo "- Base conflict: ${CONFLICT:-unknown}"
echo
for f in address-summary.md no-action.md failure.md; do
for f in address-summary.md no-action.md failure.md handoff.md; do
if [[ -s "${WORKDIR}/${f}" ]]; then
echo "**${f}:**"
cat "${WORKDIR}/${f}"
@ -1397,3 +1399,33 @@ jobs:
fi
done
} >> "${GITHUB_STEP_SUMMARY}"
if [[ "${DRY_RUN}" != "true" && "${OUTCOME:-unknown}" == "failed" && -n "${NEWEST:-}" && -n "${GITHUB_TOKEN:-}" && -s "${WORKDIR}/handoff.md" ]]; then
api_error_file="$(mktemp)"
if ! bot_actor="$(GH_TOKEN="${GITHUB_TOKEN}" gh api user --jq '.login' 2>"${api_error_file}")"; then
api_error="$(tr '\r\n' ' ' < "${api_error_file}")"
rm -f "${api_error_file}"
echo "::error::Failed to verify CI_DEV_BOT_PAT identity with gh api user: ${api_error:-unknown error}."
exit 1
fi
rm -f "${api_error_file}"
echo "CI_DEV_BOT_PAT authenticates as ${bot_actor}"
if [[ "${bot_actor}" != "${AUTOFIX_BOT}" ]]; then
echo "::error::CI_DEV_BOT_PAT authenticates as ${bot_actor}; expected ${AUTOFIX_BOT}."
exit 1
fi
{
echo "🤖 Could not address the latest review feedback automatically."
echo
echo "The feedback was evaluated, but AutoFix failed before producing a verified commit. A human should take over this PR."
echo
echo "Run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
echo
echo "**Failure:**"
head -c 1500 "${WORKDIR}/failure.md" | sed 's/<!--[^>]*-->//g'
echo
echo
echo "<!-- autofix-eval ts=${NEWEST} acted=false round=${ROUND} -->"
} > "${WORKDIR}/report.md"
gh pr comment "${PR}" --repo "${REPO}" --body-file "${WORKDIR}/report.md" || echo "::warning::Failed to post handoff comment on PR #${PR}"
fi

View file

@ -387,7 +387,7 @@ jobs:
# SECURITY: checkout trusted base code; /review fetches PR diff context.
- name: 'Checkout base branch'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: '${{ github.event.repository.default_branch }}'
fetch-depth: 0
@ -895,7 +895,7 @@ jobs:
echo "pr_number=${pr_number}" >> "$GITHUB_OUTPUT"
- name: 'Checkout base branch'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: '${{ github.event.repository.default_branch }}'
fetch-depth: 0

View file

@ -25,7 +25,7 @@ jobs:
decision: '${{ steps.assess.outputs.decision }}'
steps:
- name: 'Checkout trusted precheck script'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: '${{ github.event.repository.default_branch }}'
sparse-checkout: '.github/scripts/pr-safety-precheck.mjs'

View file

@ -244,7 +244,7 @@ jobs:
echo "stale agent state cleaned"
- name: 'Checkout repo'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
token: '${{ secrets.GITHUB_TOKEN }}'
@ -442,7 +442,7 @@ jobs:
- name: 'Checkout PR merge ref'
if: "steps.pr.outputs.decision == 'run'"
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
# Untrusted PR code — keep the token out of .git/config.
persist-credentials: false

View file

@ -100,7 +100,7 @@ jobs:
fi
- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: '${{ github.event.inputs.ref || github.sha }}'
fetch-depth: 0

View file

@ -64,7 +64,7 @@ jobs:
steps:
- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: '${{ github.event.inputs.ref || github.sha }}'
fetch-depth: 0

View file

@ -60,7 +60,7 @@ jobs:
steps:
- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: '${{ github.event.release.tag_name || github.event.inputs.ref || github.sha }}'
fetch-depth: 0
@ -200,7 +200,7 @@ jobs:
steps:
- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: '${{ github.event.release.tag_name || github.event.inputs.ref || github.sha }}'
fetch-depth: 0
@ -285,7 +285,7 @@ jobs:
steps:
- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: '${{ github.event.release.tag_name || github.event.inputs.ref || github.sha }}'

View file

@ -57,7 +57,7 @@ jobs:
steps:
- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: '${{ github.event.inputs.ref || github.sha }}'
fetch-depth: 0
@ -154,7 +154,7 @@ jobs:
steps:
- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: '${{ github.event.inputs.ref || github.sha }}'
fetch-depth: 0
@ -212,7 +212,7 @@ jobs:
steps:
- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: '${{ github.event.inputs.ref || github.sha }}'
fetch-depth: 0
@ -259,7 +259,7 @@ jobs:
steps:
- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: '${{ github.event.inputs.ref || github.sha }}'
fetch-depth: 0
@ -347,7 +347,7 @@ jobs:
steps:
- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
# Persist the bot PAT for release-branch pushes so downstream CI
# workflows are triggered.

View file

@ -72,7 +72,7 @@ jobs:
python-version: ['3.10', '3.11', '3.12']
steps:
- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
- name: 'Set up Python'
uses: 'actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405' # v6.2.0

View file

@ -18,7 +18,7 @@ jobs:
group: '${{ github.workflow }}-stale'
cancel-in-progress: true
steps:
- uses: 'actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f' # v10.2.0
- uses: 'actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899' # v10.3.0
with:
repo-token: '${{ secrets.GITHUB_TOKEN }}'
# Issues are intentionally disabled here; a separate policy will

View file

@ -45,7 +45,7 @@ jobs:
contents: 'read'
steps:
- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
- name: 'Resolve cua-driver version'
id: 'meta'

View file

@ -30,7 +30,7 @@ jobs:
steps:
- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: '${{ env.RELEASE_TAG }}'

View file

@ -26,7 +26,7 @@ jobs:
- 'swe-bench-astropy-1'
steps:
- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
submodules: 'recursive'
- name: 'Install uv and set the python version'

View file

@ -1,7 +1,8 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { spawn } from 'node:child_process';
import {
createWriteStream,
existsSync,
mkdirSync,
readFileSync,
@ -17,7 +18,7 @@ const skillPath = resolve(
'..',
'SKILL.md',
);
const QWEN_TIMEOUT_MS = 50 * 60 * 1000;
const QWEN_TIMEOUT_MS = Number(process.env.QWEN_TIMEOUT_MS) || 50 * 60 * 1000;
const specs = {
'assess-candidates': {
inputs: ['candidates.json'],
@ -66,6 +67,87 @@ function writeFailure(workdir, message) {
);
}
function writeHandoff(workdir, message) {
mkdirSync(workdir, { recursive: true });
writeFileSync(file(workdir, 'handoff.md'), `${message}\n`);
}
function isLoopGuardOutput(output) {
return (
output.includes('turn_tool_call_cap') ||
output.includes('Loop detection halted the run')
);
}
function killQwen(child, signal) {
try {
process.kill(-child.pid, signal);
} catch {
child.kill(signal);
}
}
function runQwen(options, prompt) {
mkdirSync(options.workdir, { recursive: true });
const log = createWriteStream(file(options.workdir, 'agent.log'), {
flags: 'w',
});
log.on('error', () => {});
let outputTail = '';
let loopDetected = false;
let settled = false;
let timedOut = false;
let timer;
let killTimer;
return new Promise((resolve) => {
const child = spawn(options.qwenBin, ['--yolo', '--prompt', prompt], {
stdio: ['inherit', 'pipe', 'pipe'],
detached: true,
});
const finish = (result) => {
if (settled) return;
settled = true;
clearTimeout(timer);
clearTimeout(killTimer);
const payload = {
...result,
timedOut,
loopDetected: loopDetected || isLoopGuardOutput(outputTail),
};
if (log.destroyed) {
resolve(payload);
} else {
log.end(() => resolve(payload));
}
};
const record = (chunk, stream) => {
const text = chunk.toString('utf8');
outputTail = (outputTail + text).slice(-20_000);
if (!loopDetected && isLoopGuardOutput(outputTail)) loopDetected = true;
log.write(chunk);
stream.write(chunk);
};
child.stdout.on('data', (chunk) => record(chunk, process.stdout));
child.stderr.on('data', (chunk) => record(chunk, process.stderr));
child.on('error', (error) => finish({ error, status: null, signal: null }));
child.on('close', (status, signal) =>
finish({ error: null, status, signal }),
);
timer = setTimeout(() => {
timedOut = true;
killQwen(child, 'SIGTERM');
killTimer = setTimeout(() => {
if (!settled) killQwen(child, 'SIGKILL');
}, 10_000);
}, QWEN_TIMEOUT_MS);
});
}
function promptFor(options, spec) {
const skill = readFileSync(skillPath, 'utf8')
.replace(/\r\n/g, '\n')
@ -123,22 +205,36 @@ if (missingInputs.length > 0) {
);
}
const result = spawnSync(options.qwenBin, ['--yolo', '--prompt', prompt], {
stdio: 'inherit',
timeout: QWEN_TIMEOUT_MS,
});
const result = await runQwen(options, prompt);
if (result.error || result.signal || result.status !== 0) {
const detail = result.error
? result.error.message
: result.signal
? `signal ${result.signal}`
: `status ${String(result.status)}`;
: result.timedOut
? `timeout (${QWEN_TIMEOUT_MS}ms)`
: result.signal
? `signal ${result.signal}`
: `status ${String(result.status)}`;
if (!existsSync(file(options.workdir, 'failure.md'))) {
writeFailure(
options.workdir,
`Qwen failed during ${options.mode}: ${detail}.`,
);
if (result.loopDetected) {
writeFailure(
options.workdir,
`Qwen hit the tool-call loop guard during ${options.mode}. A human should take over this feedback batch.`,
);
writeHandoff(
options.workdir,
'Qwen hit the tool-call loop guard; a human should take over this feedback batch.',
);
} else {
writeFailure(
options.workdir,
`Qwen failed during ${options.mode}: ${detail}.`,
);
}
} else {
writeHandoff(
options.workdir,
'The agent wrote failure.md before qwen exited; a human should take over this feedback batch.',
);
console.error(
`Qwen failed during ${options.mode}: ${detail}; preserving agent-written failure.md.`,
);
@ -148,6 +244,10 @@ if (result.error || result.signal || result.status !== 0) {
if (existsSync(file(options.workdir, 'failure.md'))) {
const content = readFileSync(file(options.workdir, 'failure.md'), 'utf8');
writeHandoff(
options.workdir,
'The agent wrote failure.md; a human should take over this feedback batch.',
);
console.error(`Autofix agent wrote failure.md:\n${content}`);
process.exit(0);
}

View file

@ -40,7 +40,11 @@ import {
WorkspaceMismatchError,
} from './bridgeErrors.js';
import { MAX_WORKSPACE_PATH_LENGTH } from './workspacePaths.js';
import { extractErrorMessage, extractErrorCode } from './bridge.js';
import {
classifyTurnErrorKind,
extractErrorMessage,
extractErrorCode,
} from './bridge.js';
import { SERVE_STATUS_EXT_METHODS } from './status.js';
import type { ChannelFactory } from './channel.js';
import type { BridgeTelemetry } from './bridgeOptions.js';
@ -3376,6 +3380,42 @@ describe('createAcpSessionBridge', () => {
await bridge.shutdown();
});
it('adds structured errorKind when a turn ends with terminated', async () => {
const handle = makeChannel({
promptImpl: () => {
throw new RequestError(-32603, 'terminated');
},
});
const bridge = makeBridge({ channelFactory: async () => handle.channel });
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const abort = new AbortController();
const iter = bridge.subscribeEvents(session.sessionId, {
signal: abort.signal,
});
const turnError = (async () => {
for await (const event of iter) {
if (event.type === 'turn_error') return event;
}
throw new Error('turn_error was not published');
})();
await expect(
bridge.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'stream may fail' }],
}),
).rejects.toThrow('terminated');
const event = await turnError;
expect(event.data).toMatchObject({
message: 'terminated',
errorKind: 'model_stream_interrupted',
});
abort.abort();
await bridge.shutdown();
});
it('echoes user_message_chunk to ALL session subscribers (cross-client sync)', async () => {
// Cross-client sync fix: a prompt sent by client A must be visible
// to every SSE subscriber of the same session — not just the
@ -10287,6 +10327,24 @@ describe('extractErrorCode', () => {
});
});
describe('classifyTurnErrorKind', () => {
it('classifies bare terminated errors as model stream interruptions', () => {
expect(classifyTurnErrorKind('terminated')).toBe(
'model_stream_interrupted',
);
expect(classifyTurnErrorKind('Terminated')).toBe(
'model_stream_interrupted',
);
expect(classifyTurnErrorKind(' terminated ')).toBe(
'model_stream_interrupted',
);
});
it('leaves unrelated turn errors unclassified', () => {
expect(classifyTurnErrorKind('API rate limit exceeded')).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// §2.3 side-channel state layer: publish helpers + reconciliation + snapshot
// ---------------------------------------------------------------------------

View file

@ -912,6 +912,14 @@ export function extractErrorCode(err: unknown): string | undefined {
return undefined;
}
export function classifyTurnErrorKind(
message: string,
): 'model_stream_interrupted' | undefined {
return message.trim().toLowerCase() === 'terminated'
? 'model_stream_interrupted'
: undefined;
}
function broadcastTurnError(
entry: SessionEntry,
sessionId: string,
@ -921,6 +929,16 @@ function broadcastTurnError(
): void {
const message = extractErrorMessage(err);
const code = extractErrorCode(err);
const errorKind = classifyTurnErrorKind(message);
if (errorKind) {
writeServeDebugLine(
`turn_error classified session=${JSON.stringify(sessionId)} ` +
`message=${JSON.stringify(message)} ` +
`errorKind=${JSON.stringify(errorKind)}` +
(code ? ` code=${JSON.stringify(code)}` : '') +
(promptId ? ` promptId=${JSON.stringify(promptId)}` : ''),
);
}
entry.retryAllowed = true;
try {
entry.events.publish({
@ -929,6 +947,7 @@ function broadcastTurnError(
sessionId,
message,
...(code ? { code } : {}),
...(errorKind ? { errorKind } : {}),
...(promptId ? { promptId } : {}),
},
...(originatorClientId ? { originatorClientId } : {}),

View file

@ -107,6 +107,39 @@ describe('AcpFileSystemService', () => {
});
});
it('converts core-only read params at the ACP boundary', async () => {
const mockResponse = {
content: 'slice',
_meta: { bom: false, encoding: 'utf-8' },
};
const client = {
readTextFile: vi.fn().mockResolvedValue(mockResponse),
} as unknown as AgentSideConnection;
const signal = new AbortController().signal;
const svc = new AcpFileSystemService(
client,
'session-1',
{ readTextFile: true, writeTextFile: true },
createFallback(),
);
await svc.readTextFile({
path: '/some/file.txt',
line: 0,
limit: 5,
maxOutputBytes: 1024,
signal,
});
expect(client.readTextFile).toHaveBeenCalledWith({
path: '/some/file.txt',
line: 1,
limit: 5,
sessionId: 'session-1',
});
});
it('converts RESOURCE_NOT_FOUND error to ENOENT', async () => {
const resourceNotFoundError = {
code: RESOURCE_NOT_FOUND_CODE,
@ -953,11 +986,20 @@ describe('AcpFileSystemService', () => {
fallback,
);
const result = await svc.readTextFile({ path: '/some/file.txt' });
const signal = new AbortController().signal;
const result = await svc.readTextFile({
path: '/some/file.txt',
line: 0,
maxOutputBytes: 2048,
signal,
});
expect(result).toEqual(fallbackResponse);
expect(fallback.readTextFile).toHaveBeenCalledWith({
path: '/some/file.txt',
line: 0,
maxOutputBytes: 2048,
signal,
});
expect(client.readTextFile).not.toHaveBeenCalled();
});

View file

@ -13,6 +13,7 @@ import type {
} from '@agentclientprotocol/sdk';
import { RequestError } from '@agentclientprotocol/sdk';
import type {
CoreReadTextFileRequest,
FileSystemService,
ReadTextFileResponse,
} from '@qwen-code/qwen-code-core';
@ -105,6 +106,29 @@ async function resolveRealPath(value: string): Promise<string | undefined> {
}
}
function toAcpReadTextFileRequest(
params: CoreReadTextFileRequest,
sessionId: string,
): ReadTextFileRequest {
// `maxOutputBytes`, `signal`, and `stats` are core-local concerns that the
// current ACP schema cannot represent. Keep this boundary explicit if the
// schema grows.
const request: ReadTextFileRequest = {
path: params.path,
sessionId,
};
if (params._meta !== undefined) {
request._meta = params._meta;
}
if (params.limit !== undefined) {
request.limit = params.limit;
}
if (params.line != null) {
request.line = params.line + 1;
}
return request;
}
export class AcpFileSystemService implements FileSystemService {
constructor(
private readonly connection: AgentSideConnection,
@ -115,7 +139,7 @@ export class AcpFileSystemService implements FileSystemService {
) {}
async readTextFile(
params: Omit<ReadTextFileRequest, 'sessionId'>,
params: CoreReadTextFileRequest,
): Promise<ReadTextFileResponse> {
if (!this.capabilities.readTextFile) {
return this.fallback.readTextFile(params);
@ -123,10 +147,9 @@ export class AcpFileSystemService implements FileSystemService {
let response: ReadTextFileResponse;
try {
response = await this.connection.readTextFile({
...params,
sessionId: this.sessionId,
});
response = await this.connection.readTextFile(
toAcpReadTextFileRequest(params, this.sessionId),
);
} catch (error) {
const errorCode = getErrorCode(error);

View file

@ -17,12 +17,12 @@ import type { Intent, ResolvedPath } from './paths.js';
* typical source files, small enough that an SSE replay buffer
* doesn't fill on a single read.
*
* Files **above** this cap are refused with `file_too_large` rather
* than truncated the underlying `readFileWithLineAndLimit`
* reads the whole file into memory before slicing lines, so soft
* truncation past the cap would still OOM the daemon. Files
* **at or below** the cap honor a tighter `opts.maxBytes` via
* post-decode truncation (`enforceReadSize`); that's where the
* Full-snapshot reads above this cap are refused with `file_too_large`
* rather than truncated. `readText` can serve explicit line windows
* from larger files, but the default read/edit contract still needs a
* bounded snapshot for hash stability, SSE buffering, and oldText
* matching. Files at or below the cap honor a tighter `opts.maxBytes`
* via post-decode truncation (`enforceReadSize`); that's where the
* `meta.truncated = true` flag fires.
*
* `enforceReadBytesSize` (the `readBytes` gate) and `edit()` use the

View file

@ -735,6 +735,20 @@ describe('WorkspaceFileSystem - write/edit', () => {
expect(after).toBe('foo=42\nbar=2\n');
});
it('edit() preserves the tail of files larger than the default range cap', async () => {
const target = path.join(h.workspace, 'large-edit.txt');
const tail = 'tail-marker\n';
const content = `foo=1\n${'body\n'.repeat(6_000)}${tail}`;
await fsp.writeFile(target, content);
const r = await h.fs.resolve('large-edit.txt', 'edit');
const out = await h.fs.edit(r, 'foo=1', 'foo=42');
expect(out.writtenBytes).toBeGreaterThan(25_000);
const after = await fsp.readFile(target, 'utf-8');
expect(after).toBe(`foo=42\n${'body\n'.repeat(6_000)}${tail}`);
});
it('throws parse_error when oldText is not present', async () => {
const target = path.join(h.workspace, 'c.txt');
await fsp.writeFile(target, 'abc');

View file

@ -156,6 +156,40 @@ describe('handleAtCommand', () => {
expect(result.toolDisplays![0].status).toBe(ToolCallStatus.Success);
});
it('should attach a truncated text file larger than 10MB', async () => {
const filePath = await createTestFile(
path.join(testRootDir, 'large.log'),
'x'.repeat(11 * 1024 * 1024),
);
const result = await handleAtCommand({
query: `@${filePath}`,
config: mockConfig,
onDebugMessage: mockOnDebugMessage,
messageId: 626,
signal: abortController.signal,
});
const processedText = Array.isArray(result.processedQuery)
? result.processedQuery
.map((part) =>
typeof part === 'string'
? part
: 'text' in part
? part.text
: JSON.stringify(part),
)
.join('')
: '';
expect(processedText).toContain(
'Showing lines 1-1 of at least 1 total lines',
);
expect(processedText).toContain('... [truncated]');
expect(result.shouldProceed).toBe(true);
expect(result.toolDisplays![0].status).toBe(ToolCallStatus.Success);
});
it('should only allow actual temp directory paths outside the workspace', async () => {
const tempParentDir = await fsPromises.mkdtemp(
path.join(os.tmpdir(), 'at-command-temp-'),

View file

@ -364,16 +364,14 @@ describe('ChatRecordingService - auto-title trigger', () => {
expect(svc.getCurrentCustomTitle()).toBe('Auto-generated title');
expect(svc.getCurrentTitleSource()).toBe('auto');
// finalize() was called by the constructor — drain the queued async
// write before inspecting the mock.
await svc.flush();
expect(findCustomTitleRecord()).toBeUndefined();
svc.recordUserMessage([{ text: 'resume work' }]);
await svc.flush();
// The re-appended record must carry titleSource: 'auto', not 'manual'.
const finalizeRecord = vi
.mocked(jsonl.writeLine)
.mock.calls.map((c) => c[1] as ChatRecord)
.find((r) => r.type === 'system' && r.subtype === 'custom_title');
expect(finalizeRecord?.systemPayload).toEqual({
const reanchoredRecord = findCustomTitleRecord();
expect(reanchoredRecord?.systemPayload).toEqual({
customTitle: 'Auto-generated title',
titleSource: 'auto',
});
@ -404,12 +402,13 @@ describe('ChatRecordingService - auto-title trigger', () => {
expect(svc.getCurrentCustomTitle()).toBe('User chose this');
expect(svc.getCurrentTitleSource()).toBe('manual');
await svc.flush();
expect(findCustomTitleRecord()).toBeUndefined();
const finalizeRecord = vi
.mocked(jsonl.writeLine)
.mock.calls.map((c) => c[1] as ChatRecord)
.find((r) => r.type === 'system' && r.subtype === 'custom_title');
expect(finalizeRecord?.systemPayload).toEqual({
svc.recordUserMessage([{ text: 'resume work' }]);
await svc.flush();
const reanchoredRecord = findCustomTitleRecord();
expect(reanchoredRecord?.systemPayload).toEqual({
customTitle: 'User chose this',
titleSource: 'manual',
});
@ -438,13 +437,14 @@ describe('ChatRecordingService - auto-title trigger', () => {
// `titleSource: 'manual'` we can't actually verify.
expect(svc.getCurrentTitleSource()).toBeUndefined();
await svc.flush();
expect(findCustomTitleRecord()).toBeUndefined();
const finalizeRecord = vi
.mocked(jsonl.writeLine)
.mock.calls.map((c) => c[1] as ChatRecord)
.find((r) => r.type === 'system' && r.subtype === 'custom_title');
svc.recordUserMessage([{ text: 'resume work' }]);
await svc.flush();
const reanchoredRecord = findCustomTitleRecord();
// Payload must NOT contain a titleSource field when source is unknown.
expect(finalizeRecord?.systemPayload).toEqual({
expect(reanchoredRecord?.systemPayload).toEqual({
customTitle: 'Legacy title',
});
});

View file

@ -135,8 +135,9 @@ describe('ChatRecordingService - recordCustomTitle', () => {
});
describe('finalize', () => {
it('should re-append cached custom title to EOF', async () => {
it('should re-append cached custom title to EOF after new content', async () => {
chatRecordingService.recordCustomTitle('my-feature');
chatRecordingService.recordUserMessage([{ text: 'new work' }]);
await chatRecordingService.flush();
vi.mocked(jsonl.writeLine).mockClear();
@ -153,6 +154,17 @@ describe('ChatRecordingService - recordCustomTitle', () => {
});
});
it('should not write anything when the title is already the latest record', async () => {
chatRecordingService.recordCustomTitle('my-feature');
await chatRecordingService.flush();
vi.mocked(jsonl.writeLine).mockClear();
chatRecordingService.finalize();
await chatRecordingService.flush();
expect(jsonl.writeLine).not.toHaveBeenCalled();
});
it('should not write anything when no custom title was set', async () => {
chatRecordingService.finalize();
await chatRecordingService.flush();
@ -160,9 +172,33 @@ describe('ChatRecordingService - recordCustomTitle', () => {
expect(jsonl.writeLine).not.toHaveBeenCalled();
});
it('should not re-append a resumed title without new content', async () => {
vi.mocked(mockConfig.getResumedSessionData).mockReturnValue({
lastCompletedUuid: null,
} as unknown as ReturnType<Config['getResumedSessionData']>);
const getSessionTitleInfo = vi.fn().mockReturnValue({
title: 'resumed-title',
source: 'manual',
});
(
mockConfig as unknown as {
getSessionService: () => {
getSessionTitleInfo: typeof getSessionTitleInfo;
};
}
).getSessionService = () => ({ getSessionTitleInfo });
const svc = new ChatRecordingService(mockConfig);
svc.finalize();
await svc.flush();
expect(jsonl.writeLine).not.toHaveBeenCalled();
});
it('should re-append the latest title after multiple renames', async () => {
chatRecordingService.recordCustomTitle('first-name');
chatRecordingService.recordCustomTitle('second-name');
chatRecordingService.recordUserMessage([{ text: 'new work' }]);
await chatRecordingService.flush();
vi.mocked(jsonl.writeLine).mockClear();
@ -288,10 +324,8 @@ describe('ChatRecordingService - recordCustomTitle', () => {
).getSessionService = () => ({ getSessionTitleInfo });
const svc = new ChatRecordingService(mockConfig);
// Constructor's finalize re-appends a custom_title record on resume
// — clear it out so we can isolate the threshold-triggered re-anchor.
await svc.flush();
vi.mocked(jsonl.writeLine).mockClear();
expect(jsonl.writeLine).not.toHaveBeenCalled();
const bulkText = 'x'.repeat(2000);
for (let i = 0; i < 20; i++) {

View file

@ -564,6 +564,7 @@ export class ChatRecordingService {
* returning undefined if the title is beyond both windows).
*/
private bytesSinceTitleAnchor = 0;
private hasNonTitleContentSinceTitleAnchor = false;
constructor(config: Config) {
this.config = config;
@ -577,16 +578,19 @@ export class ChatRecordingService {
// resumed. Legacy records (no `titleSource` field) stay `undefined` —
// treated as manual for safety without rewriting the JSONL.
//
// We then re-append a custom_title record to EOF so the title stays
// within the tail window that readers scan (guarding against a crash
// before the next finalize).
// Do not re-append during construction: loading/resuming a session is a
// read operation from the user's perspective, and touching the JSONL mtime
// would make session lists treat it as fresh activity.
if (config.getResumedSessionData()) {
try {
const sessionService = config.getSessionService();
const info = sessionService.getSessionTitleInfo(config.getSessionId());
this.currentCustomTitle = info.title;
this.currentTitleSource = info.source;
this.finalize();
if (info.title) {
// Prime the threshold so the first real content write re-anchors.
this.bytesSinceTitleAnchor = TITLE_REANCHOR_BYTES;
}
} catch {
// Best-effort — don't block construction
}
@ -793,9 +797,11 @@ export class ChatRecordingService {
private updateTitleAnchorTracking(record: ChatRecord): void {
if (record.type === 'system' && record.subtype === 'custom_title') {
this.bytesSinceTitleAnchor = 0;
this.hasNonTitleContentSinceTitleAnchor = false;
return;
}
if (!this.currentCustomTitle) return;
this.hasNonTitleContentSinceTitleAnchor = true;
// +1 for the trailing newline jsonl.writeLine appends.
this.bytesSinceTitleAnchor +=
Buffer.byteLength(JSON.stringify(record), 'utf8') + 1;
@ -1319,13 +1325,10 @@ export class ChatRecordingService {
}
/**
* Finalizes the current session by re-appending cached metadata to EOF.
*
* Call this whenever leaving the current session whether switching to
* another session, shutting down the process, or any other transition.
* This single entry point replaces scattered re-append calls and ensures
* the custom_title record stays within the last 64KB tail window that
* readSessionTitleFromFile() scans.
* Finalizes the current session by re-appending cached metadata to EOF, but
* only after this recorder has appended non-title content since the last
* title anchor. Pure load/resume must remain read-only so session lists do
* not treat restored sessions as newly active.
*
* Best-effort: errors are logged but never thrown.
*/
@ -1344,6 +1347,9 @@ export class ChatRecordingService {
if (!this.currentCustomTitle) {
return;
}
if (!this.hasNonTitleContentSinceTitleAnchor) {
return;
}
try {
const record: ChatRecord = {
...this.createBaseRecord('system'),

View file

@ -87,7 +87,6 @@ describe('StandardFileSystemService', () => {
expect(readFileWithLineAndLimit).toHaveBeenCalledWith({
path: '/test/file.txt',
limit: Infinity,
line: 0,
});
expect(result.content).toBe('Hello, World!');
expect(result._meta?.bom).toBe(false);
@ -116,6 +115,74 @@ describe('StandardFileSystemService', () => {
expect(result._meta?.originalLineCount).toBe(100);
});
it('should preserve explicit line zero for offset reads', async () => {
vi.mocked(readFileWithLineAndLimit).mockResolvedValue({
content: 'line 1',
bom: false,
encoding: 'utf-8',
originalLineCount: 100,
});
await fileSystem.readTextFile({
path: '/test/file.txt',
line: 0,
});
expect(readFileWithLineAndLimit).toHaveBeenCalledWith({
path: '/test/file.txt',
limit: Infinity,
line: 0,
});
});
it('should pass maxOutputBytes and return byte-truncation metadata', async () => {
vi.mocked(readFileWithLineAndLimit).mockResolvedValue({
content: 'partial',
bom: false,
encoding: 'utf-8',
originalLineCount: 100,
truncatedByBytes: true,
});
const result = await fileSystem.readTextFile({
path: '/test/file.txt',
limit: 10,
line: 5,
maxOutputBytes: 128,
});
expect(readFileWithLineAndLimit).toHaveBeenCalledWith({
path: '/test/file.txt',
limit: 10,
line: 5,
maxOutputBytes: 128,
});
expect(result._meta?.truncatedByBytes).toBe(true);
});
it('should pass cached stats to readFileWithLineAndLimit', async () => {
const stats = { size: 123 } as import('node:fs').Stats;
vi.mocked(readFileWithLineAndLimit).mockResolvedValue({
content: 'line 1',
bom: false,
encoding: 'utf-8',
originalLineCount: 1,
});
await fileSystem.readTextFile({
path: '/test/file.txt',
maxOutputBytes: 128,
stats,
});
expect(readFileWithLineAndLimit).toHaveBeenCalledWith({
path: '/test/file.txt',
limit: Infinity,
maxOutputBytes: 128,
stats,
});
});
it('should return encoding info for GBK file', async () => {
vi.mocked(readFileWithLineAndLimit).mockResolvedValue({
content: '你好世界',

View file

@ -5,6 +5,7 @@
*/
import os from 'node:os';
import type { Stats } from 'node:fs';
import * as path from 'node:path';
import { globSync } from 'glob';
import { atomicWriteFile } from '../utils/atomicFileWrite.js';
@ -29,10 +30,26 @@ export type ReadTextFileResponse = {
bom?: boolean;
encoding?: string;
originalLineCount?: number;
originalLineCountExact?: boolean;
lineEnding?: LineEnding;
truncatedByBytes?: boolean;
};
};
export type CoreReadTextFileRequest = Omit<
ReadTextFileRequest,
'sessionId' | 'line'
> & {
/**
* Core-local callers use 0-based line offsets. ACP protocol boundaries remain
* 1-based and convert explicitly before remote calls.
*/
line?: number | null;
maxOutputBytes?: number;
signal?: AbortSignal;
stats?: Stats;
};
/**
* Supported file encodings for new files.
*/
@ -50,9 +67,7 @@ export type FileEncodingType = (typeof FileEncoding)[keyof typeof FileEncoding];
* Interface for file system operations that may be delegated to different implementations
*/
export interface FileSystemService {
readTextFile(
params: Omit<ReadTextFileRequest, 'sessionId'>,
): Promise<ReadTextFileResponse>;
readTextFile(params: CoreReadTextFileRequest): Promise<ReadTextFileResponse>;
writeTextFile(
params: Omit<WriteTextFileRequest, 'sessionId'>,
@ -261,18 +276,32 @@ export function encodeTextFileContent(
*/
export class StandardFileSystemService implements FileSystemService {
async readTextFile(
params: Omit<ReadTextFileRequest, 'sessionId'>,
params: CoreReadTextFileRequest,
): Promise<ReadTextFileResponse> {
const { path, limit, line } = params;
// Use encoding-aware reader that handles BOM and non-UTF-8 encodings (e.g. GBK)
const { content, bom, encoding, originalLineCount } =
await readFileWithLineAndLimit({
path,
limit: limit ?? Number.POSITIVE_INFINITY,
line: line || 0,
});
const lineEnding = detectLineEnding(content);
return { content, _meta: { bom, encoding, originalLineCount, lineEnding } };
const { path, limit, line, maxOutputBytes, signal, stats } = params;
const readResult = await readFileWithLineAndLimit({
path,
limit: limit ?? Number.POSITIVE_INFINITY,
...(line !== undefined && line !== null ? { line } : {}),
...(maxOutputBytes !== undefined ? { maxOutputBytes } : {}),
...(signal !== undefined ? { signal } : {}),
...(stats !== undefined ? { stats } : {}),
});
const detectedLineEnding =
readResult.lineEnding ?? detectLineEnding(readResult.content);
return {
content: readResult.content,
_meta: {
bom: readResult.bom,
encoding: readResult.encoding,
originalLineCount: readResult.originalLineCount,
originalLineCountExact: readResult.originalLineCountExact,
lineEnding: detectedLineEnding,
...(readResult.truncatedByBytes !== undefined
? { truncatedByBytes: readResult.truncatedByBytes }
: {}),
},
};
}
async writeTextFile(

View file

@ -0,0 +1,75 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, vi } from 'vitest';
import type { Content } from '@google/genai';
import type { Config } from '../config/config.js';
import {
SYSTEM_REMINDER_CLOSE,
SYSTEM_REMINDER_OPEN,
} from '../utils/environmentContext.js';
import { generateSessionRecap } from './sessionRecap.js';
const reminder = (body: string) =>
`${SYSTEM_REMINDER_OPEN}\n${body}\n${SYSTEM_REMINDER_CLOSE}`;
describe('generateSessionRecap', () => {
it('strips startup and mid-session system reminders from recap input', async () => {
const history: Content[] = [
{
role: 'user',
parts: [
{ text: reminder('STARTUP_SKILL_LIST') },
{ text: 'fix session title pollution' },
],
},
{ role: 'model', parts: [{ text: 'I found the title service.' }] },
{ role: 'user', parts: [{ text: reminder('ADDED_MCP_TOOLS') }] },
{
role: 'user',
parts: [
{ text: reminder('PLAN_MODE_REMINDER') },
{
text: `continue with recap coverage\n${reminder('IDE_CONTEXT')}`,
},
],
},
];
let captured: Content[] | null = null;
const generateText = vi.fn(async (opts: { contents: Content[] }) => {
captured = opts.contents;
return {
text: '<recap>Fixing session title pollution. Next: verify tests.</recap>',
usage: undefined,
};
});
const config = {
getFastModel: vi.fn(() => 'qwen-turbo'),
getModel: vi.fn(() => 'qwen-plus'),
getGeminiClient: vi.fn(() => ({
getHistoryShallow: () => history,
})),
getBaseLlmClient: vi.fn(() => ({ generateText })),
getOutputLanguageFilePath: vi.fn(() => undefined),
} as unknown as Config;
const result = await generateSessionRecap(
config,
new AbortController().signal,
);
expect(result).toBe('Fixing session title pollution. Next: verify tests.');
expect(captured).not.toBeNull();
const serialized = JSON.stringify(captured);
expect(serialized).not.toContain('STARTUP_SKILL_LIST');
expect(serialized).not.toContain('ADDED_MCP_TOOLS');
expect(serialized).not.toContain('PLAN_MODE_REMINDER');
expect(serialized).not.toContain('IDE_CONTEXT');
expect(serialized).toContain('fix session title pollution');
expect(serialized).toContain('continue with recap coverage');
});
});

View file

@ -4,9 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Content } from '@google/genai';
import type { Content, Part } from '@google/genai';
import type { Config } from '../config/config.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import {
getStartupContextLength,
stripSystemReminderBlocks,
} from '../utils/environmentContext.js';
import { runSideQuery } from '../utils/sideQuery.js';
const debugLogger = createDebugLogger('SESSION_RECAP');
@ -142,15 +146,22 @@ function extractRecap(raw: string): string {
*/
function filterToDialog(history: Content[]): Content[] {
const out: Content[] = [];
for (const msg of history) {
for (const msg of history.slice(getStartupContextLength(history))) {
if (msg.role !== 'user' && msg.role !== 'model') continue;
const textParts = (msg.parts ?? []).filter(
(part) =>
typeof part?.text === 'string' &&
part.text.trim() !== '' &&
!part.thought &&
!part.thoughtSignature,
);
const textParts: Part[] = [];
for (const part of msg.parts ?? []) {
if (
typeof part?.text !== 'string' ||
part.text.trim() === '' ||
part.thought ||
part.thoughtSignature
) {
continue;
}
const text = stripSystemReminderBlocks(part.text);
if (text.trim() === '') continue;
textParts.push({ ...part, text });
}
if (textParts.length === 0) continue;
out.push({ role: msg.role, parts: textParts });
}

View file

@ -7,6 +7,10 @@
import { describe, expect, it, vi } from 'vitest';
import type { Content } from '@google/genai';
import type { Config } from '../config/config.js';
import {
SYSTEM_REMINDER_CLOSE,
SYSTEM_REMINDER_OPEN,
} from '../utils/environmentContext.js';
import { sanitizeTitle, tryGenerateSessionTitle } from './sessionTitle.js';
interface MockOptions {
@ -50,6 +54,9 @@ const DIALOG_HISTORY: Content[] = [
},
];
const reminder = (body: string) =>
`${SYSTEM_REMINDER_OPEN}\n${body}\n${SYSTEM_REMINDER_CLOSE}`;
describe('tryGenerateSessionTitle', () => {
it('returns {ok:false, reason:"no_fast_model"} when fast model is absent', async () => {
const { config } = makeConfig({
@ -225,6 +232,112 @@ describe('tryGenerateSessionTitle', () => {
expect(serialized).toContain('middleware stores tokens unsafely');
});
it('skips startup context when building the title prompt', async () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: reminder('STARTUP_SKILL_LIST') }] },
{ role: 'user', parts: [{ text: 'fix auto title for short prompts' }] },
{ role: 'model', parts: [{ text: 'I will inspect title generation.' }] },
];
let captured = '';
const { config } = makeConfig({
fastModel: 'qwen-turbo',
history,
generateJsonResult: async (opts: unknown) => {
captured = JSON.stringify((opts as { contents: Content[] }).contents);
return { title: 'Fix auto title prompts' };
},
});
await tryGenerateSessionTitle(config, new AbortController().signal);
expect(captured).not.toContain('STARTUP_SKILL_LIST');
expect(captured).toContain('fix auto title for short prompts');
});
it('strips system-reminder parts while keeping the real user prompt', async () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'start with titles' }] },
{ role: 'model', parts: [{ text: 'Ready.' }] },
{
role: 'user',
parts: [
{ text: reminder('MID_SESSION_TOOL_METADATA') },
{ text: 'please add a regression test' },
],
},
{ role: 'model', parts: [{ text: 'I will add the test.' }] },
];
let captured = '';
const { config } = makeConfig({
fastModel: 'qwen-turbo',
history,
generateJsonResult: async (opts: unknown) => {
captured = JSON.stringify((opts as { contents: Content[] }).contents);
return { title: 'Add title regression test' };
},
});
await tryGenerateSessionTitle(config, new AbortController().signal);
expect(captured).not.toContain('MID_SESSION_TOOL_METADATA');
expect(captured).toContain('please add a regression test');
});
it('drops pure system-reminder messages from the title prompt', async () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'fix session titles' }] },
{ role: 'model', parts: [{ text: 'I found the title service.' }] },
{ role: 'user', parts: [{ text: reminder('ADDED_MCP_TOOLS') }] },
];
let captured = '';
const { config } = makeConfig({
fastModel: 'qwen-turbo',
history,
generateJsonResult: async (opts: unknown) => {
captured = JSON.stringify((opts as { contents: Content[] }).contents);
return { title: 'Fix session titles' };
},
});
await tryGenerateSessionTitle(config, new AbortController().signal);
expect(captured).not.toContain('ADDED_MCP_TOOLS');
expect(captured).toContain('fix session titles');
});
it('strips IDE-merged system reminders from title prompt text', async () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'inspect the selected file' }] },
{ role: 'model', parts: [{ text: 'I will inspect it.' }] },
{
role: 'user',
parts: [
{
text: `what does this function do?\n${reminder('IDE_SKILL_CONTEXT')}`,
},
],
},
];
let captured = '';
const { config } = makeConfig({
fastModel: 'qwen-turbo',
history,
generateJsonResult: async (opts: unknown) => {
captured = JSON.stringify((opts as { contents: Content[] }).contents);
return { title: 'Inspect selected function' };
},
});
await tryGenerateSessionTitle(config, new AbortController().signal);
expect(captured).not.toContain('IDE_SKILL_CONTEXT');
expect(captured).toContain('what does this function do?');
});
it('tail-slices conversations longer than 1000 characters', async () => {
// A session that pivots mid-conversation — the final topic is what the
// title should reflect. Feeding the head risks titling the session by

View file

@ -4,9 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Content } from '@google/genai';
import type { Content, Part } from '@google/genai';
import type { Config } from '../config/config.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import {
getStartupContextLength,
stripSystemReminderBlocks,
} from '../utils/environmentContext.js';
import { runSideQuery } from '../utils/sideQuery.js';
import { stripTerminalControlSequences } from '../utils/terminalSafe.js';
import { SESSION_TITLE_MAX_LENGTH } from './sessionService.js';
@ -205,15 +209,22 @@ export function sanitizeTitle(s: string): string {
*/
function filterToDialog(history: Content[]): Content[] {
const out: Content[] = [];
for (const msg of history) {
for (const msg of history.slice(getStartupContextLength(history))) {
if (msg.role !== 'user' && msg.role !== 'model') continue;
const textParts = (msg.parts ?? []).filter(
(part) =>
typeof part?.text === 'string' &&
part.text.trim() !== '' &&
!part.thought &&
!part.thoughtSignature,
);
const textParts: Part[] = [];
for (const part of msg.parts ?? []) {
if (
typeof part?.text !== 'string' ||
part.text.trim() === '' ||
part.thought ||
part.thoughtSignature
) {
continue;
}
const text = stripSystemReminderBlocks(part.text);
if (text.trim() === '') continue;
textParts.push({ ...part, text });
}
if (textParts.length === 0) continue;
out.push({ role: msg.role, parts: textParts });
}

View file

@ -134,6 +134,31 @@ describe('ArtifactTool', () => {
expect(res.error?.type).toBe(ToolErrorType.FILE_NOT_FOUND);
});
it('forwards cancellation signals to source file reads', async () => {
const file = path.join(workdir, 'page.html');
const controller = new AbortController();
const readTextFile = vi.fn(async () => ({ content: '<p>x</p>' }));
const signalAwareTool = new ArtifactTool(
{
...makeConfig(),
getFileSystemService: () => ({ readTextFile }),
} as unknown as Config,
{
kind: 'oss',
publish: async () => ({ id: 'x', url: 'https://h/x' }),
},
openSpy as unknown as UrlOpener,
);
await signalAwareTool.build({ file_path: file }).execute(controller.signal);
expect(readTextFile).toHaveBeenCalledWith({
path: file,
maxOutputBytes: MAX_ARTIFACT_BYTES,
signal: controller.signal,
});
});
it('returns EXECUTION_FAILED when the publisher throws', async () => {
const file = await writeFragment('page.html', '<p>x</p>');
const failingTool = new ArtifactTool(
@ -154,11 +179,22 @@ describe('ArtifactTool', () => {
expect(openSpy).not.toHaveBeenCalled();
});
it('enforces the size cap', async () => {
it('rejects source fragments that exceed the artifact byte budget', async () => {
const big = '<p>' + 'a'.repeat(MAX_ARTIFACT_BYTES) + '</p>';
const file = await writeFragment('big.html', big);
const res = await tool.build({ file_path: file }).execute(signal);
expect(res.error?.type).toBe(ToolErrorType.FILE_TOO_LARGE);
expect(res.error?.message).toContain('source exceeds');
expect(res.error?.message).toContain(`${MAX_ARTIFACT_BYTES} byte limit`);
expect(openSpy).not.toHaveBeenCalled();
});
it('enforces the published artifact size cap', async () => {
const almostTooBig = 'a'.repeat(MAX_ARTIFACT_BYTES - 1);
const file = await writeFragment('wrapped-big.html', almostTooBig);
const res = await tool.build({ file_path: file }).execute(signal);
expect(res.error?.type).toBe(ToolErrorType.FILE_TOO_LARGE);
expect(res.error?.message).toContain('Artifact is too large');
expect(openSpy).not.toHaveBeenCalled();
});

View file

@ -62,6 +62,14 @@ Set artifact.autoOpen=false in settings.json, or QWEN_ARTIFACT_NO_AUTO_OPEN=1, t
const debugLogger = createDebugLogger('artifact');
function cancelledArtifactResult(): ToolResult {
const message = 'Artifact publishing was cancelled.';
return {
llmContent: message,
returnDisplay: message,
};
}
class ArtifactToolInvocation extends BaseToolInvocation<
ArtifactToolParams,
ToolResult
@ -130,11 +138,26 @@ class ArtifactToolInvocation extends BaseToolInvocation<
// Read the fragment the model wrote.
let fragment: string;
try {
const { content } = await this.config
const { content, _meta } = await this.config
.getFileSystemService()
.readTextFile({ path: file_path });
.readTextFile({
path: file_path,
maxOutputBytes: MAX_ARTIFACT_BYTES,
signal,
});
if (_meta?.truncatedByBytes === true) {
const message = `Artifact is too large (source exceeds the ${MAX_ARTIFACT_BYTES} byte limit). Trim the content or split it across multiple artifacts.`;
return {
llmContent: message,
returnDisplay: message,
error: { message, type: ToolErrorType.FILE_TOO_LARGE },
};
}
fragment = content;
} catch (err) {
if (signal.aborted || isAbortError(err)) {
return cancelledArtifactResult();
}
const notFound = isNodeError(err) && err.code === 'ENOENT';
const message = notFound
? `Artifact source file not found: ${file_path}. Write the page content to this file first.`
@ -193,11 +216,7 @@ class ArtifactToolInvocation extends BaseToolInvocation<
// A user-initiated cancel (Esc / aborted signal) is not a failure —
// surface it as a cancellation rather than a publish error.
if (signal.aborted || isAbortError(err)) {
const message = 'Artifact publishing was cancelled.';
return {
llmContent: message,
returnDisplay: message,
};
return cancelledArtifactResult();
}
const message = `Failed to publish artifact: ${getErrorMessage(err)}`;
return {

View file

@ -1313,6 +1313,39 @@ describe('EditTool', () => {
expect(fs.readFileSync(filePath, 'utf8')).toBe('X\nline b\nline c\n');
});
it('allows editing a large text file after a ranged read', async () => {
const initialContent = [
'target',
'context 1',
'context 2',
'context 3',
'context 4',
'x'.repeat(11 * 1024 * 1024),
].join('\n');
fs.writeFileSync(filePath, initialContent, 'utf8');
const stats = fs.statSync(filePath);
fileReadCache.recordRead(filePath, stats, {
full: false,
cacheable: true,
});
(mockConfig.getApprovalMode as Mock).mockReturnValueOnce(
ApprovalMode.AUTO_EDIT,
);
const result = await tool
.build({
file_path: filePath,
old_string: 'target',
new_string: 'updated',
})
.execute(abortSignal);
expect(result.error).toBeUndefined();
expect(fs.readFileSync(filePath, 'utf8').startsWith('updated\n')).toBe(
true,
);
});
it('rejects an edit when the previous read was non-cacheable (binary / pdf / image)', async () => {
// ReadFile records every successful read into the cache,
// including binary / PDF / image reads that produce a

View file

@ -191,8 +191,15 @@ describe('MonitorTool', () => {
let monitorRegistry: MonitorRegistry;
let mockChild: ReturnType<typeof createMockChild>;
let mockIsPathWithinWorkspace: ReturnType<typeof vi.fn>;
let originalPager: string | undefined;
let originalGitPager: string | undefined;
beforeEach(() => {
originalPager = process.env['PAGER'];
originalGitPager = process.env['GIT_PAGER'];
delete process.env['PAGER'];
delete process.env['GIT_PAGER'];
vi.clearAllMocks();
mockOsPlatform.mockReturnValue('linux');
@ -229,6 +236,18 @@ describe('MonitorTool', () => {
afterEach(() => {
monitorRegistry.abortAll();
if (originalPager === undefined) {
delete process.env['PAGER'];
} else {
process.env['PAGER'] = originalPager;
}
if (originalGitPager === undefined) {
delete process.env['GIT_PAGER'];
} else {
process.env['GIT_PAGER'] = originalGitPager;
}
});
// Helper to access protected validateToolParamValues
@ -682,7 +701,33 @@ describe('MonitorTool', () => {
const spawnOptions = mockSpawn.mock.calls[0][2];
expect(spawnOptions.env['PAGER']).toBe('cat');
expect(spawnOptions.env['GIT_PAGER']).toBe('cat');
expect(spawnOptions.env['GIT_PAGER']).toBeUndefined();
});
it('preserves inherited git pager values for spawned processes', async () => {
process.env['GIT_PAGER'] = 'delta';
const invocation = createInvocation({
command: 'git log --oneline',
});
await invocation.execute(new AbortController().signal);
const spawnOptions = mockSpawn.mock.calls[0][2];
expect(spawnOptions.env['PAGER']).toBe('cat');
expect(spawnOptions.env['GIT_PAGER']).toBe('delta');
});
it('does not inject Unix pager defaults into Windows monitor env when unset', async () => {
mockOsPlatform.mockReturnValue('win32');
const invocation = createInvocation({
command: 'tail -f /var/log/app.log',
});
await invocation.execute(new AbortController().signal);
const spawnOptions = mockSpawn.mock.calls[0][2];
expect(spawnOptions.env['PAGER']).toBe('');
expect(spawnOptions.env['GIT_PAGER']).toBeUndefined();
});
it('propagates explicit pager configuration to spawned processes', async () => {
@ -697,7 +742,7 @@ describe('MonitorTool', () => {
const spawnOptions = mockSpawn.mock.calls[0][2];
expect(spawnOptions.env['PAGER']).toBe('more');
expect(spawnOptions.env['GIT_PAGER']).toBe('more');
expect(spawnOptions.env['GIT_PAGER']).toBeUndefined();
});
it('does not spawn when the turn signal is already aborted', async () => {

View file

@ -369,7 +369,7 @@ class MonitorToolInvocation extends BaseToolInvocation<
QWEN_CODE: '1',
TERM: 'dumb', // no color codes for streaming
...getShellPagerEnv(this.config.getShellExecutionConfig().pager, {
includeGitPager: true,
includeGitPager: false,
platform: os.platform(),
}),
...getShellContextEnvVars(),

View file

@ -423,9 +423,8 @@ describe('ReadFileTool', () => {
});
});
it('should return error for a file that is too large', async () => {
it('should read and truncate a text file larger than 10MB', async () => {
const filePath = path.join(tempRootDir, 'largefile.txt');
// 11MB of content exceeds 10MB limit
const largeContent = 'x'.repeat(11 * 1024 * 1024);
await fsp.writeFile(filePath, largeContent, 'utf-8');
const params: ReadFileToolParams = { file_path: filePath };
@ -435,10 +434,28 @@ describe('ReadFileTool', () => {
>;
const result = await invocation.execute(abortSignal);
expect(result).toHaveProperty('error');
expect(result.error?.type).toBe(ToolErrorType.FILE_TOO_LARGE);
expect(result.error?.message).toContain(
'File size exceeds the 10MB limit',
expect(result.error).toBeUndefined();
expect(result.returnDisplay).toBe(
'Read lines 1-1 of at least 1 from largefile.txt (truncated)',
);
expect(result.llmContent).toContain(
'Showing lines 1-1 of at least 1 total lines',
);
expect(result.llmContent).toContain('... [truncated]');
});
it('should propagate an aborted signal before reading', async () => {
const filePath = path.join(tempRootDir, 'abort.txt');
await fsp.writeFile(filePath, 'content', 'utf-8');
const invocation = tool.build({ file_path: filePath }) as ToolInvocation<
ReadFileToolParams,
ToolResult
>;
const controller = new AbortController();
controller.abort();
await expect(invocation.execute(controller.signal)).rejects.toThrow(
/abort/i,
);
});

View file

@ -132,7 +132,7 @@ class ReadFileToolInvocation extends BaseToolInvocation<
return 'ask';
}
async execute(): Promise<ToolResult> {
async execute(signal: AbortSignal): Promise<ToolResult> {
const absPath = path.resolve(this.params.file_path);
const projectRoot = this.config.getTargetDir();
// Auto-memory files (AGENTS.md and friends under the auto-memory
@ -206,6 +206,7 @@ class ReadFileToolInvocation extends BaseToolInvocation<
offset: this.params.offset,
limit: this.params.limit,
pages: this.params.pages,
signal,
},
);
@ -284,7 +285,9 @@ class ReadFileToolInvocation extends BaseToolInvocation<
) {
const [start, end] = result.linesShown!;
const total = result.originalLineCount!;
llmContent = `Showing lines ${start}-${end} of ${total} total lines.\n\n---\n\n${result.llmContent}`;
const totalLabel =
result.originalLineCountExact === false ? `at least ${total}` : total;
llmContent = `Showing lines ${start}-${end} of ${totalLabel} total lines.\n\n---\n\n${result.llmContent}`;
} else {
llmContent = result.llmContent || '';
}

View file

@ -29,6 +29,7 @@ import {
getInitialChatHistory,
getStartupContextLength,
isSystemReminderContent,
stripSystemReminderBlocks,
stripStartupContext,
formatDateForContext,
SYSTEM_REMINDER_OPEN,
@ -325,30 +326,17 @@ describe('getInitialChatHistory', () => {
});
it('places deferred-tools reminder last so stable prefix stays cacheable on KV-caching servers', async () => {
// Pin: deferred-tools part changes when tool_search reveals a tool.
// Placing it LAST keeps the stable prefix (MCP + skills + startup)
// cacheable; only the tail recomputes on prefix-caching servers.
mockToolRegistry.getDeferredToolSummary.mockReturnValue([
{ name: 'web_fetch', description: 'Fetches web pages' },
]);
const [history] = await getInitialChatHistory(mockConfig as Config);
expect(history).toHaveLength(1);
const parts = history[0]?.parts ?? [];
expect(parts.length).toBeGreaterThanOrEqual(1);
// The LAST text part should be the deferred-tools reminder.
const lastText = parts[parts.length - 1]?.text;
expect(lastText).toBeDefined();
expect(lastText).toContain('reachable via `tool_search`');
expect(lastText).toContain('web_fetch');
// The FIRST text part should NOT be the deferred-tools reminder —
// it must be the stable MCP/skills/startup prefix.
const firstText = parts[0]?.text;
expect(firstText).toBeDefined();
expect(firstText).not.toContain('reachable via `tool_search`');
expect(parts[0]?.text).not.toContain('reachable via `tool_search`');
});
});
@ -399,6 +387,20 @@ describe('stripStartupContext', () => {
).toEqual([{ role: 'user', parts: [{ text: 'Hello' }] }]);
});
it('keeps a first user turn that mixes a reminder part with a prompt part', () => {
const history: Content[] = [
{
role: 'user',
parts: [
{ text: '<system-reminder>\nctx\n</system-reminder>' },
{ text: 'real prompt' },
],
},
];
expect(stripStartupContext(history)).toEqual(history);
});
it('should round-trip with getInitialChatHistory', async () => {
const mockConfig = {
getSkipStartupContext: vi.fn().mockReturnValue(false),
@ -430,6 +432,25 @@ describe('stripStartupContext', () => {
});
});
describe('stripSystemReminderBlocks', () => {
it('strips complete reminder blocks and preserves surrounding text', () => {
expect(
stripSystemReminderBlocks('a<system-reminder>x</system-reminder>b'),
).toBe('ab');
expect(
stripSystemReminderBlocks(
'a<system-reminder>x</system-reminder>b<system-reminder>y</system-reminder>c',
),
).toBe('abc');
});
it('drops a trailing unclosed reminder block', () => {
expect(stripSystemReminderBlocks('keep <system-reminder>secret')).toBe(
'keep ',
);
});
});
describe('formatDateForContext', () => {
it('should format date in en-US locale regardless of system timezone', () => {
expect(formatDateForContext(new Date('2026-06-05T12:00:00Z'))).toBe(

View file

@ -593,15 +593,7 @@ export function getStartupContextLength(
): number {
const firstEntry = history[0];
if (firstEntry?.role !== 'user') return 0;
const firstText = firstEntry.parts?.[0]?.text;
// Open prefix, and close tag AT THE END (not merely present). Excludes a
// prompt quoting the literal tag, and — since IDE mode merges the reminder
// into the prompt's text part — a real first turn trailing after the close.
if (
typeof firstText === 'string' &&
firstText.startsWith(SYSTEM_REMINDER_OPEN) &&
firstText.trimEnd().endsWith(SYSTEM_REMINDER_CLOSE)
) {
if (isSystemReminderContent(firstEntry)) {
if (options.includeCompressed) {
const compressedLength = detectCompressedPrefixLength(history, 1);
if (compressedLength > 0) return 1 + compressedLength;
@ -712,6 +704,27 @@ export function isSystemReminderContent(content: Content): boolean {
);
}
export function stripSystemReminderBlocks(text: string): string {
let out = '';
let offset = 0;
while (offset < text.length) {
const open = text.indexOf(SYSTEM_REMINDER_OPEN, offset);
if (open === -1) return out + text.slice(offset);
const close = text.indexOf(
SYSTEM_REMINDER_CLOSE,
open + SYSTEM_REMINDER_OPEN.length,
);
if (close === -1) return out + text.slice(offset, open);
out += text.slice(offset, open);
offset = close + SYSTEM_REMINDER_CLOSE.length;
}
return out;
}
/**
* Strip the leading startup context reminder from a chat history. Used when
* forwarding a parent session's history to a child agent that will generate

View file

@ -29,11 +29,14 @@ import {
processSingleFileContent,
detectBOM,
decodeBufferWithEncodingInfo,
readFileWithLineAndLimit,
readFileWithEncoding,
readFileWithEncodingInfo,
detectFileEncoding,
fileExists,
} from './fileUtils.js';
import { iconvEncode } from './iconvHelper.js';
import { LargeNonUtf8TextError } from './read-text-range.js';
import type { Config } from '../config/config.js';
import { StandardFileSystemService } from '../services/fileSystemService.js';
import { ToolErrorType } from '../tools/tool-error.js';
@ -1993,6 +1996,149 @@ describe('fileUtils', () => {
expect(result.linesShown).toEqual([1, 2]);
});
it('should preserve default full file-system reads for large text files', async () => {
const content = `head\n${'x'.repeat(11 * 1024 * 1024)}`;
actualNodeFs.writeFileSync(testTextFilePath, content);
const result = await fsService.readTextFile({ path: testTextFilePath });
expect(result.content).toBe(content);
expect(result._meta?.originalLineCount).toBe(2);
expect(result._meta?.originalLineCountExact).toBe(true);
expect(result._meta?.truncatedByBytes).not.toBe(true);
});
it('should stream explicit offset reads for large text files', async () => {
actualNodeFs.writeFileSync(
testTextFilePath,
`skip\n${'line\n'.repeat(3 * 1024 * 1024)}`,
);
const result = await fsService.readTextFile({
path: testTextFilePath,
line: 1,
});
expect(result.content.startsWith('line\n')).toBe(true);
expect(result.content.startsWith('skip\n')).toBe(false);
expect(result._meta?.originalLineCountExact).toBe(false);
expect(result._meta?.truncatedByBytes).toBe(true);
});
it('should preserve unbounded explicit line-zero reads below the large-file threshold', async () => {
const content = `head\n${'body\n'.repeat(6_000)}tail\n`;
actualNodeFs.writeFileSync(testTextFilePath, content);
const result = await fsService.readTextFile({
path: testTextFilePath,
line: 0,
});
expect(result.content).toBe(content);
expect(result._meta?.truncatedByBytes).not.toBe(true);
});
it('should enforce maxOutputBytes for default file-system reads below the large-file threshold', async () => {
actualNodeFs.writeFileSync(testTextFilePath, 'x'.repeat(100));
const result = await fsService.readTextFile({
path: testTextFilePath,
maxOutputBytes: 10,
});
expect(result.content).toBe('x'.repeat(10));
expect(result._meta?.originalLineCount).toBe(1);
expect(result._meta?.originalLineCountExact).toBe(true);
expect(result._meta?.truncatedByBytes).toBe(true);
});
it('should propagate large non-UTF-8 errors through bounded reads', async () => {
const gbkLine = iconvEncode('中文日志行\n', 'gbk');
const gbkChunk = Buffer.concat(
Array.from({ length: 1024 }, () => gbkLine),
);
const repeatCount = Math.ceil((11 * 1024 * 1024) / gbkChunk.length);
actualNodeFs.writeFileSync(
testTextFilePath,
Buffer.concat(Array.from({ length: repeatCount }, () => gbkChunk)),
);
await expect(
readFileWithLineAndLimit({
path: testTextFilePath,
limit: 10,
maxOutputBytes: 10_000,
}),
).rejects.toThrow(LargeNonUtf8TextError);
});
it('should propagate aborts from unbounded full reads', async () => {
actualNodeFs.writeFileSync(testTextFilePath, 'hello\nworld');
const controller = new AbortController();
controller.abort();
await expect(
readFileWithLineAndLimit({
path: testTextFilePath,
limit: Number.POSITIVE_INFINITY,
signal: controller.signal,
}),
).rejects.toThrow(/abort/i);
});
it('should propagate aborts before large unbounded full reads', async () => {
actualNodeFs.writeFileSync(
testTextFilePath,
'x'.repeat(11 * 1024 * 1024),
);
const controller = new AbortController();
controller.abort();
await expect(
readFileWithLineAndLimit({
path: testTextFilePath,
limit: Number.POSITIVE_INFINITY,
signal: controller.signal,
}),
).rejects.toThrow(/abort/i);
});
it('should use provided stats when reading with line and byte limits', async () => {
actualNodeFs.writeFileSync(testTextFilePath, 'hello\nworld');
const stats = actualNodeFs.statSync(testTextFilePath);
const statSpy = vi
.spyOn(fs.promises, 'stat')
.mockRejectedValueOnce(new Error('unexpected stat'));
try {
const result = await readFileWithLineAndLimit({
path: testTextFilePath,
limit: 1,
maxOutputBytes: 100,
stats,
});
expect(result.content).toBe('hello');
expect(statSpy).not.toHaveBeenCalled();
} finally {
statSpy.mockRestore();
}
});
it('should not byte-truncate multibyte text before the character limit', async () => {
const content = '你'.repeat(1000);
actualNodeFs.writeFileSync(testTextFilePath, content, 'utf-8');
const result = await processSingleFileContent(
testTextFilePath,
mockConfig,
);
expect(result.llmContent).toBe(content);
expect(result.returnDisplay).toBe('');
expect(result.isTruncated).toBe(false);
});
it('should truncate long lines in text files', async () => {
const longLine = 'a'.repeat(2500);
actualNodeFs.writeFileSync(
@ -2073,31 +2219,152 @@ describe('fileUtils', () => {
);
});
it('should return an error if the file size exceeds 10MB', async () => {
// Create a small test file
actualNodeFs.writeFileSync(testTextFilePath, 'test content');
it('should read large text files through bounded truncation instead of the 10MB gate', async () => {
const lines = Array.from(
{ length: 65_000 },
(_, index) => `Line ${index + 1} ${'x'.repeat(180)}`,
);
actualNodeFs.writeFileSync(testTextFilePath, lines.join('\n'));
// Spy on fs.promises.stat to return a large file size
const statSpy = vi.spyOn(fs.promises, 'stat').mockResolvedValueOnce({
size: 11 * 1024 * 1024,
isDirectory: () => false,
isFile: () => true,
} as fs.Stats);
const result = await processSingleFileContent(
testTextFilePath,
mockConfig,
);
try {
const result = await processSingleFileContent(
testTextFilePath,
mockConfig,
);
expect(result.error).toBeUndefined();
expect(result.llmContent).toContain('Line 1');
expect(result.returnDisplay).toContain('Read lines 1-');
expect(result.isTruncated).toBe(true);
expect(result.originalLineCount).toBeGreaterThanOrEqual(
result.linesShown?.[1] ?? 1,
);
expect(result.originalLineCount).toBeLessThan(65_000);
expect(result.originalLineCountExact).toBe(false);
expect(result.linesShown?.[0]).toBe(1);
});
expect(result.error).toContain('File size exceeds the 10MB limit');
expect(result.returnDisplay).toContain(
'File size exceeds the 10MB limit',
);
expect(result.llmContent).toContain('File size exceeds the 10MB limit');
} finally {
statSpy.mockRestore();
}
it('should stream large text files when line truncation is disabled', async () => {
actualNodeFs.writeFileSync(
testTextFilePath,
'x'.repeat(11 * 1024 * 1024),
);
const noLineLimitConfig = {
...mockConfig,
getTruncateToolOutputLines: () => Number.POSITIVE_INFINITY,
} as unknown as Config;
const result = await processSingleFileContent(
testTextFilePath,
noLineLimitConfig,
);
expect(result.error).toBeUndefined();
expect(typeof result.llmContent).toBe('string');
expect(result.llmContent).toContain('... [truncated]');
expect(result.returnDisplay).toBe(
'Read lines 1-1 of at least 1 from test.txt (truncated)',
);
expect(result.isTruncated).toBe(true);
expect(result.originalLineCountExact).toBe(false);
});
it('should mark byte truncation metadata without character truncation', async () => {
actualNodeFs.writeFileSync(testTextFilePath, 'visible');
const byteTruncatedConfig = {
...mockConfig,
getTruncateToolOutputThreshold: () => Number.POSITIVE_INFINITY,
getFileSystemService: () => ({
readTextFile: vi.fn().mockResolvedValue({
content: 'visible',
_meta: {
originalLineCount: 1,
originalLineCountExact: false,
truncatedByBytes: true,
},
}),
}),
} as unknown as Config;
const result = await processSingleFileContent(
testTextFilePath,
byteTruncatedConfig,
);
expect(typeof result.llmContent).toBe('string');
const llmContent = result.llmContent as string;
expect(llmContent).toBe('visible\n... [truncated]');
expect(llmContent.match(/\.\.\. \[truncated\]/g)).toHaveLength(1);
expect(result.returnDisplay).toBe(
'Read lines 1-1 of at least 1 from test.txt (truncated)',
);
expect(result.isTruncated).toBe(true);
});
it('should use selected range as a lower bound when large file metadata is missing', async () => {
actualNodeFs.writeFileSync(
testTextFilePath,
'x'.repeat(11 * 1024 * 1024),
);
const missingMetadataConfig = {
...mockConfig,
getFileSystemService: () => ({
readTextFile: vi.fn().mockResolvedValue({
content: 'visible\nnext',
}),
}),
} as unknown as Config;
const result = await processSingleFileContent(
testTextFilePath,
missingMetadataConfig,
{ offset: 9, limit: 2 },
);
expect(result.originalLineCount).toBe(11);
expect(result.originalLineCountExact).toBe(false);
expect(result.returnDisplay).toBe(
'Read lines 10-11 of at least 11 from test.txt',
);
});
it('should preserve disabled output truncation for large text files', async () => {
const byteLength = 11 * 1024 * 1024;
actualNodeFs.writeFileSync(testTextFilePath, 'x'.repeat(byteLength));
const noCharacterLimitConfig = {
...mockConfig,
getTruncateToolOutputThreshold: () => Number.POSITIVE_INFINITY,
} as unknown as Config;
const result = await processSingleFileContent(
testTextFilePath,
noCharacterLimitConfig,
);
expect(typeof result.llmContent).toBe('string');
const llmContent = result.llmContent as string;
expect(llmContent).toHaveLength(byteLength);
expect(llmContent).not.toContain('... [truncated]');
expect(result.returnDisplay).toBe('');
expect(result.isTruncated).toBe(false);
});
it('should still return an error if an inline media file exceeds 10MB', async () => {
mockMimeGetType.mockReturnValue('image/png');
actualNodeFs.writeFileSync(
testImageFilePath,
Buffer.alloc(11 * 1024 * 1024),
);
const result = await processSingleFileContent(
testImageFilePath,
mockConfig,
);
expect(result.error).toContain('File size exceeds the 10MB limit');
expect(result.returnDisplay).toContain(
'File size exceeds the 10MB limit',
);
expect(result.llmContent).toContain('File size exceeds the 10MB limit');
});
it('should allow explicit page ranges above the full-PDF text-extraction size cap', async () => {

View file

@ -18,7 +18,7 @@ import { ToolErrorType } from '../tools/tool-error.js';
import { BINARY_EXTENSIONS } from './ignorePatterns.js';
import type { Config } from '../config/config.js';
import { createDebugLogger } from './debugLogger.js';
import { getErrorMessage, isNodeError } from './errors.js';
import { getErrorMessage, isAbortError, isNodeError } from './errors.js';
import type { InputModalities } from '../core/contentGenerator.js';
import { detectEncodingFromBuffer } from './systemEncoding.js';
import {
@ -35,6 +35,11 @@ import {
shouldRequirePDFPageRange,
} from './pdf.js';
import { readNotebookWithMetadata } from './notebook.js';
import { readTextRange } from './read-text-range.js';
import {
DEFAULT_RANGE_READ_BYTES,
TEXT_RANGE_FAST_PATH_MAX_SIZE,
} from './text-range-constants.js';
const debugLogger = createDebugLogger('FILE_UTILS');
@ -273,9 +278,13 @@ function bomEncodingToName(bomEncoding: UnicodeEncoding): string {
*/
export async function readFileWithEncodingInfo(
filePath: string,
signal?: AbortSignal,
): Promise<FileReadResult> {
// Read the file once; detect BOM and decode from the single buffer.
const full = await fs.promises.readFile(filePath);
const full = await fs.promises.readFile(
filePath,
signal === undefined ? undefined : { signal },
);
return decodeBufferWithEncodingInfo(full);
}
@ -299,14 +308,42 @@ export async function readFileWithLineAndLimit(params: {
path: string;
limit: number;
line?: number;
maxOutputBytes?: number;
signal?: AbortSignal;
stats?: import('node:fs').Stats;
}): Promise<{
content: string;
bom?: boolean;
encoding?: string;
originalLineCount: number;
originalLineCountExact?: boolean;
lineEnding?: 'crlf' | 'lf';
truncatedByBytes?: boolean;
}> {
const { path: filePath, limit, line } = params;
const { content, encoding, bom } = await readFileWithEncodingInfo(filePath);
const { path: filePath, limit, line, maxOutputBytes, signal } = params;
const stats = params.stats ?? (await fs.promises.stat(filePath));
if (
(line !== undefined && line > 0) ||
Number.isFinite(limit) ||
maxOutputBytes !== undefined
) {
return readTextRange({
path: filePath,
offset: line || 0,
limit,
maxOutputBytes: normalizeRangeReadByteLimit(maxOutputBytes),
stats,
...(signal !== undefined ? { signal } : {}),
});
}
signal?.throwIfAborted();
const { content, encoding, bom } = await readFileWithEncodingInfo(
filePath,
signal,
);
signal?.throwIfAborted();
const lines = content.split('\n');
const originalLineCount = lines.length;
const startLine = line || 0;
@ -321,6 +358,7 @@ export async function readFileWithLineAndLimit(params: {
bom,
encoding,
originalLineCount,
originalLineCountExact: true,
};
}
@ -829,6 +867,7 @@ export interface ProcessedFileReadResult {
error?: string; // Optional error message for the LLM if file processing failed
errorType?: ToolErrorType; // Structured error type
originalLineCount?: number; // For text files, the total number of lines in the original file
originalLineCountExact?: boolean; // False when a large range read stopped before EOF
isTruncated?: boolean; // Indicates if displayed content was truncated
linesShown?: [number, number]; // For text files [startLine, endLine] (1-based for display)
/**
@ -867,6 +906,7 @@ export interface ProcessSingleFileContentOptions {
* sets this after deciding the vision bridge should handle the image.
*/
preserveUnsupportedImage?: boolean;
signal?: AbortSignal;
/**
* Large full-PDF text fallback returns a tool error by default. `@`-attached
* PDFs use `reference` so the model gets guidance without a failed read.
@ -945,10 +985,12 @@ export async function processSingleFileContent(
limit,
pages,
preserveUnsupportedImage = false,
signal,
largePdfBehavior = 'error',
} = options;
const rootDirectory = config.getTargetDir();
try {
signal?.throwIfAborted();
let stats: import('node:fs').Stats;
try {
// Async stat doubles as the existence check — ENOENT is handled below
@ -1101,7 +1143,7 @@ export async function processSingleFileContent(
};
}
}
if (fileSizeInMB > 9.9 && !willExtractPdfText) {
if (fileSizeInMB > 9.9 && !willExtractPdfText && fileType !== 'text') {
return {
llmContent: 'File size exceeds the 10MB limit.',
returnDisplay: 'File size exceeds the 10MB limit.',
@ -1182,11 +1224,25 @@ export async function processSingleFileContent(
path: filePath,
limit: limit ?? config.getTruncateToolOutputLines(),
line: offset,
maxOutputBytes: getRangeReadByteLimit(config),
stats,
...(signal !== undefined ? { signal } : {}),
});
const originalLineCount =
_meta?.originalLineCount ?? (await countFileLines(filePath));
const selectedLines = content.split('\n').map((line) => line.trimEnd());
const startLine = offset || 0;
const selectedLineCount =
content.length === 0 ? 0 : selectedLines.length;
const hasOriginalLineCount = _meta?.originalLineCount !== undefined;
const originalLineCount =
_meta?.originalLineCount ??
(stats.size >= TEXT_RANGE_FAST_PATH_MAX_SIZE
? startLine + selectedLineCount
: await countFileLines(filePath));
const originalLineCountExact =
_meta?.originalLineCountExact === false
? false
: hasOriginalLineCount ||
stats.size < TEXT_RANGE_FAST_PATH_MAX_SIZE;
const configCharLimit = config.getTruncateToolOutputThreshold();
// Apply character limit truncation
@ -1227,17 +1283,38 @@ export async function processSingleFileContent(
linesIncluded = selectedLines.length;
}
if (_meta?.truncatedByBytes === true) {
const marker = '... [truncated]';
if (!llmContent.endsWith(marker)) {
const prefix = Number.isFinite(configCharLimit)
? llmContent.slice(
0,
Math.max(configCharLimit - marker.length - 1, 0),
)
: llmContent;
const separator =
prefix.length === 0 || prefix.endsWith('\n') ? '' : '\n';
llmContent = prefix + separator + marker;
}
contentLengthTruncated = true;
}
const actualEndLine = startLine + linesIncluded;
const contentRangeTruncated =
startLine > 0 || actualEndLine < originalLineCount;
startLine > 0 ||
!originalLineCountExact ||
actualEndLine < originalLineCount;
const isTruncated = contentRangeTruncated || contentLengthTruncated;
const lineCountLabel = originalLineCountExact
? `${originalLineCount}`
: `at least ${originalLineCount}`;
// By default, return nothing to streamline the common case of a successful read_file.
let returnDisplay = '';
if (isTruncated) {
returnDisplay = `Read lines ${
startLine + 1
}-${actualEndLine} of ${originalLineCount} from ${relativePathForDisplay}`;
}-${actualEndLine} of ${lineCountLabel} from ${relativePathForDisplay}`;
if (contentLengthTruncated) {
returnDisplay += ' (truncated)';
}
@ -1248,6 +1325,7 @@ export async function processSingleFileContent(
returnDisplay,
isTruncated,
originalLineCount,
originalLineCountExact,
linesShown: [startLine + 1, actualEndLine],
stats,
};
@ -1385,6 +1463,9 @@ export async function processSingleFileContent(
}
}
} catch (error) {
if (signal?.aborted || isAbortError(error)) {
throw error;
}
const errorMessage = getErrorMessage(error);
const displayPath = path
.relative(rootDirectory, filePath)
@ -1398,6 +1479,28 @@ export async function processSingleFileContent(
}
}
function getRangeReadByteLimit(config: Config): number {
const charLimit = config.getTruncateToolOutputThreshold();
if (charLimit === Number.POSITIVE_INFINITY) {
return Number.POSITIVE_INFINITY;
}
if (!Number.isFinite(charLimit)) {
return DEFAULT_RANGE_READ_BYTES;
}
// Leave enough byte headroom for UTF-8 text so the character budget remains
// the primary truncation control for normal source/log content.
return Math.max(DEFAULT_RANGE_READ_BYTES, Math.floor(charLimit) * 4);
}
function normalizeRangeReadByteLimit(maxOutputBytes: number | undefined) {
if (maxOutputBytes === Number.POSITIVE_INFINITY) {
return Number.POSITIVE_INFINITY;
}
return typeof maxOutputBytes === 'number' && Number.isFinite(maxOutputBytes)
? maxOutputBytes
: DEFAULT_RANGE_READ_BYTES;
}
export async function fileExists(filePath: string): Promise<boolean> {
try {
await fsPromises.access(filePath, fs.constants.F_OK);

View file

@ -428,8 +428,7 @@ describe('readPathFromWorkspace', () => {
},
);
it('should return an error string for files exceeding the size limit', async () => {
// Mock a file slightly larger than the 10MB limit defined in fileUtils.ts
it('should truncate text files exceeding 10MB instead of failing', async () => {
const largeContent = 'a'.repeat(11 * 1024 * 1024); // 11MB
mock({
[CWD]: {
@ -442,7 +441,7 @@ describe('readPathFromWorkspace', () => {
const config = createMockConfig(CWD, [], mockFileService);
const result = await readPathFromWorkspace('large.txt', config);
const textResult = result[0] as string;
// The error message comes directly from processSingleFileContent
expect(textResult).toBe('File size exceeds the 10MB limit.');
expect(textResult).toContain('a'.repeat(100));
expect(textResult).toContain('... [truncated]');
});
});

View file

@ -0,0 +1,294 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { iconvEncode } from './iconvHelper.js';
import { LargeNonUtf8TextError, readTextRange } from './read-text-range.js';
describe('readTextRange', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'read-text-range-'));
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
async function writeFile(
name: string,
data: string | Buffer,
): Promise<string> {
const filePath = path.join(tempDir, name);
await fs.writeFile(filePath, data);
return filePath;
}
function largeUtf8Lines(lineCount: number): string {
return Array.from(
{ length: lineCount },
(_, index) => `line-${index + 1} ${'x'.repeat(180)}`,
).join('\n');
}
it('preserves split newline semantics on the fast path', async () => {
const emptyPath = await writeFile('empty.txt', '');
await expect(
readTextRange({
path: emptyPath,
offset: 0,
limit: 10,
maxOutputBytes: 100,
}),
).resolves.toMatchObject({
content: '',
originalLineCount: 1,
truncatedByBytes: false,
});
const trailingPath = await writeFile('trailing.txt', 'a\n');
await expect(
readTextRange({
path: trailingPath,
offset: 0,
limit: 10,
maxOutputBytes: 100,
}),
).resolves.toMatchObject({
content: 'a\n',
originalLineCount: 2,
truncatedByBytes: false,
});
});
it('streams a large UTF-8 file and returns the requested range', async () => {
const filePath = await writeFile('large.log', largeUtf8Lines(65_000));
const result = await readTextRange({
path: filePath,
offset: 42_000,
limit: 3,
maxOutputBytes: 10_000,
});
expect(result.content.split('\n')).toEqual([
expect.stringContaining('line-42001'),
expect.stringContaining('line-42002'),
expect.stringContaining('line-42003'),
]);
expect(result.originalLineCount).toBeGreaterThanOrEqual(42_004);
expect(result.originalLineCount).toBeLessThan(65_000);
expect(result.originalLineCountExact).toBe(false);
expect(result.encoding).toBe('utf-8');
expect(result.bom).toBe(false);
expect(result.truncatedByBytes).toBe(false);
});
it('streams a large UTF-8 file from the beginning when no range is provided', async () => {
const filePath = await writeFile('large.log', largeUtf8Lines(65_000));
const result = await readTextRange({
path: filePath,
maxOutputBytes: 10_000,
});
expect(result.content).toContain('line-1');
expect(result.content).toContain('line-2');
expect(result.content).not.toContain('line-65000');
expect(result.originalLineCount).toBeGreaterThan(1);
expect(result.originalLineCountExact).toBe(false);
expect(result.truncatedByBytes).toBe(true);
});
it('returns empty content when a large UTF-8 range starts beyond EOF', async () => {
const filePath = await writeFile('large.log', largeUtf8Lines(65_000));
const result = await readTextRange({
path: filePath,
offset: 100_000,
limit: 10,
maxOutputBytes: 10_000,
});
expect(result.content).toBe('');
expect(result.originalLineCount).toBe(65_000);
expect(result.originalLineCountExact).toBe(true);
expect(result.truncatedByBytes).toBe(false);
});
it('preserves CRLF content and line-ending metadata for large files', async () => {
const content = Array.from(
{ length: 65_000 },
(_, index) => `line-${index + 1} ${'x'.repeat(180)}`,
).join('\r\n');
const filePath = await writeFile('crlf.log', content);
const result = await readTextRange({
path: filePath,
offset: 1,
limit: 2,
maxOutputBytes: 10_000,
});
expect(result.content).toContain('\r\n');
expect(result.content.split('\n')[0]).toMatch(/\r$/);
expect(result.lineEnding).toBe('crlf');
expect(result.originalLineCount).toBeGreaterThanOrEqual(4);
expect(result.originalLineCount).toBeLessThan(65_000);
expect(result.originalLineCountExact).toBe(false);
});
it('detects CRLF when the pair crosses a stream chunk boundary', async () => {
const highWaterMark = 512 * 1024;
const firstChunk = `${'a'.repeat(highWaterMark - 1)}\r`;
const body = Buffer.concat([
Buffer.from(firstChunk),
Buffer.from('\nsecond\n'),
Buffer.alloc(11 * 1024 * 1024, 'x'),
]);
const filePath = await writeFile('split-crlf.log', body);
const result = await readTextRange({
path: filePath,
offset: 0,
limit: 2,
maxOutputBytes: highWaterMark + 100,
});
expect(result.lineEnding).toBe('crlf');
expect(result.content).toContain('\r\nsecond');
});
it('strips UTF-8 BOM from large file content and reports BOM metadata', async () => {
const body = largeUtf8Lines(65_000);
const filePath = await writeFile(
'bom.log',
Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(body)]),
);
const result = await readTextRange({
path: filePath,
offset: 0,
limit: 1,
maxOutputBytes: 10_000,
});
expect(result.content.charCodeAt(0)).not.toBe(0xfeff);
expect(result.content).toContain('line-1');
expect(result.bom).toBe(true);
expect(result.encoding).toBe('utf-8');
expect(result.originalLineCountExact).toBe(false);
});
it('does not split a UTF-8 character when byte-truncating', async () => {
const filePath = await writeFile('emoji.txt', `a🙂b`);
const result = await readTextRange({
path: filePath,
offset: 0,
limit: 1,
maxOutputBytes: 4,
});
expect(result.content).toBe('a');
expect(result.content).not.toContain('\uFFFD');
expect(result.truncatedByBytes).toBe(true);
expect(result.originalLineCountExact).toBe(true);
});
it('rejects large non-UTF-8 files with a targeted error', async () => {
const gbkLine = iconvEncode('中文日志行\n', 'gbk');
const repeatCount = Math.ceil((11 * 1024 * 1024) / gbkLine.length);
const filePath = await writeFile(
'gbk.log',
Buffer.concat(Array.from({ length: repeatCount }, () => gbkLine)),
);
await expect(
readTextRange({
path: filePath,
offset: 0,
limit: 10,
maxOutputBytes: 10_000,
}),
).rejects.toThrow(LargeNonUtf8TextError);
});
it('rejects large files with invalid UTF-8 beyond the encoding sample', async () => {
const mostlyAsciiThenGbk = Buffer.concat([
Buffer.alloc(9 * 1024, 'a'),
iconvEncode('你好', 'gbk'),
Buffer.alloc(11 * 1024 * 1024, 'b'),
]);
const filePath = await writeFile('late-gbk.log', mostlyAsciiThenGbk);
const promise = readTextRange({
path: filePath,
offset: 0,
limit: 500,
maxOutputBytes: 20_000,
});
await expect(promise).rejects.toThrow(LargeNonUtf8TextError);
await expect(promise).rejects.toThrow(/invalid UTF-8 byte sequence/);
await expect(promise).rejects.toMatchObject({ reason: 'invalid-utf8' });
});
it('bounds selected output for a large single-line file', async () => {
const filePath = await writeFile(
'single-line.log',
'x'.repeat(11 * 1024 * 1024),
);
const result = await readTextRange({
path: filePath,
offset: 0,
limit: 1,
maxOutputBytes: 1024,
});
expect(result.content).toBe('x'.repeat(1024));
expect(result.originalLineCount).toBe(1);
expect(result.originalLineCountExact).toBe(false);
expect(result.truncatedByBytes).toBe(true);
});
it('propagates aborts before reading large files', async () => {
const filePath = await writeFile('large.log', largeUtf8Lines(65_000));
const controller = new AbortController();
controller.abort();
await expect(
readTextRange({
path: filePath,
offset: 0,
limit: 10,
maxOutputBytes: 10_000,
signal: controller.signal,
}),
).rejects.toThrow(/abort/i);
});
it('propagates aborts while streaming large files', async () => {
const filePath = await writeFile('large.log', largeUtf8Lines(80_000));
const controller = new AbortController();
const promise = readTextRange({
path: filePath,
offset: 70_000,
limit: 10,
maxOutputBytes: 10_000,
signal: controller.signal,
});
setTimeout(() => controller.abort(), 0);
await expect(promise).rejects.toThrow(/abort/i);
});
});

View file

@ -0,0 +1,281 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { createReadStream, type Stats } from 'node:fs';
import { stat } from 'node:fs/promises';
import { TextDecoder } from 'node:util';
import { detectFileEncoding, readFileWithEncodingInfo } from './fileUtils.js';
import { isUtf8CompatibleEncoding } from './iconvHelper.js';
import {
DEFAULT_RANGE_READ_BYTES,
TEXT_RANGE_FAST_PATH_MAX_SIZE,
} from './text-range-constants.js';
export interface ReadTextRangeRequest {
path: string;
offset?: number;
limit?: number;
maxOutputBytes: number;
signal?: AbortSignal;
stats?: Stats;
}
export interface ReadTextRangeResult {
content: string;
originalLineCount: number;
encoding?: string;
bom?: boolean;
lineEnding?: 'crlf' | 'lf';
originalLineCountExact: boolean;
truncatedByBytes: boolean;
}
export class LargeNonUtf8TextError extends Error {
constructor(
readonly encoding: string,
readonly reason?: 'invalid-utf8',
) {
super(
reason === 'invalid-utf8'
? 'Large text file contains invalid UTF-8 byte sequence beyond the initial encoding sample. Convert or extract a smaller UTF-8 slice and read that instead.'
: `Large non-UTF-8 text files are not supported for streaming reads (detected ${encoding}). Convert or extract a smaller UTF-8 slice and read that instead.`,
);
this.name = 'LargeNonUtf8TextError';
}
}
export async function readTextRange(
request: ReadTextRangeRequest,
): Promise<ReadTextRangeResult> {
request.signal?.throwIfAborted();
const stats = request.stats ?? (await stat(request.path));
const maxOutputBytes = normalizeMaxBytes(request.maxOutputBytes);
if (stats.size < TEXT_RANGE_FAST_PATH_MAX_SIZE) {
const { content, encoding, bom } = await readFileWithEncodingInfo(
request.path,
request.signal,
);
request.signal?.throwIfAborted();
const range = sliceDecodedContent(
content,
request.offset,
request.limit,
maxOutputBytes,
);
return {
...range,
encoding,
bom,
lineEnding: detectLineEndingFromContent(content),
};
}
return readLargeUtf8Range(request, maxOutputBytes);
}
function normalizeMaxBytes(maxOutputBytes: number): number {
if (maxOutputBytes === Number.POSITIVE_INFINITY) {
return Number.POSITIVE_INFINITY;
}
if (!Number.isFinite(maxOutputBytes)) {
return DEFAULT_RANGE_READ_BYTES;
}
return Math.max(0, Math.floor(maxOutputBytes));
}
function sliceDecodedContent(
content: string,
offset: number | undefined,
limit: number | undefined,
maxOutputBytes: number,
): Pick<
ReadTextRangeResult,
| 'content'
| 'originalLineCount'
| 'originalLineCountExact'
| 'truncatedByBytes'
> {
const lines = content.split('\n');
const originalLineCount = lines.length;
const start = Math.min(Math.max(0, offset ?? 0), originalLineCount);
const end =
limit === undefined
? originalLineCount
: Math.min(start + Math.max(0, limit), originalLineCount);
const selected = lines.slice(start, end).join('\n');
const truncated = truncateUtf8(selected, maxOutputBytes);
return {
content: truncated.content,
originalLineCount,
originalLineCountExact: true,
truncatedByBytes: truncated.truncated,
};
}
async function readLargeUtf8Range(
request: ReadTextRangeRequest,
maxOutputBytes: number,
): Promise<ReadTextRangeResult> {
const encoding = await detectFileEncoding(request.path);
if (!isUtf8CompatibleEncoding(encoding)) {
throw new LargeNonUtf8TextError(encoding);
}
const offset = Math.max(0, request.offset ?? 0);
const endLine =
offset + Math.max(0, request.limit ?? Number.POSITIVE_INFINITY);
let currentLine = 0;
let output = '';
let outputBytes = 0;
let truncatedByBytes = false;
let bom = false;
let firstChunk = true;
let lineEnding: 'crlf' | 'lf' = 'lf';
let previousChunkEndedWithCR = false;
let originalLineCountExact = true;
let stoppedEarly = false;
const decoder = new TextDecoder('utf-8', {
fatal: true,
ignoreBOM: true,
});
const stream = createReadStream(request.path, {
highWaterMark: 512 * 1024,
signal: request.signal,
});
function appendSelected(fragment: string): void {
if (fragment.length === 0 || truncatedByBytes) {
return;
}
const available = maxOutputBytes - outputBytes;
if (available <= 0) {
truncatedByBytes = true;
return;
}
const truncated = truncateUtf8(fragment, available);
output += truncated.content;
outputBytes += Buffer.byteLength(truncated.content, 'utf8');
if (truncated.truncated) {
truncatedByBytes = true;
}
}
function isSelectedLine(): boolean {
return currentLine >= offset && currentLine < endLine;
}
function decodeUtf8Chunk(
chunk?: Buffer,
options?: TextDecodeOptions,
): string {
try {
return decoder.decode(chunk, options);
} catch {
throw new LargeNonUtf8TextError(encoding, 'invalid-utf8');
}
}
try {
for await (const rawChunk of stream) {
request.signal?.throwIfAborted();
let chunk = decodeUtf8Chunk(rawChunk as Buffer, { stream: true });
if (firstChunk) {
firstChunk = false;
if (chunk.charCodeAt(0) === 0xfeff) {
chunk = chunk.slice(1);
bom = true;
}
}
if (
(previousChunkEndedWithCR && chunk.startsWith('\n')) ||
chunk.includes('\r\n')
) {
lineEnding = 'crlf';
}
previousChunkEndedWithCR = chunk.endsWith('\r');
let start = 0;
let newline = chunk.indexOf('\n', start);
while (newline !== -1) {
if (isSelectedLine()) {
appendSelected(chunk.slice(start, newline));
if (currentLine + 1 < endLine) {
appendSelected('\n');
}
}
currentLine++;
start = newline + 1;
if (currentLine >= endLine || truncatedByBytes) {
originalLineCountExact = false;
stoppedEarly = true;
break;
}
newline = chunk.indexOf('\n', start);
}
if (start < chunk.length && isSelectedLine()) {
appendSelected(chunk.slice(start));
}
if (currentLine >= endLine || truncatedByBytes) {
originalLineCountExact = false;
stoppedEarly = true;
break;
}
}
} finally {
stream.destroy();
}
if (!stoppedEarly) {
decodeUtf8Chunk();
}
return {
content: output,
originalLineCount: currentLine + 1,
encoding: 'utf-8',
bom,
lineEnding,
originalLineCountExact,
truncatedByBytes,
};
}
function truncateUtf8(
content: string,
maxBytes: number,
): { content: string; truncated: boolean } {
const bytes = Buffer.byteLength(content, 'utf8');
if (bytes <= maxBytes) {
return { content, truncated: false };
}
if (maxBytes <= 0) {
return { content: '', truncated: true };
}
const buffer = Buffer.from(content, 'utf8');
let end = Math.min(maxBytes, buffer.length);
// `end` is the first excluded byte. If it lands inside a multi-byte UTF-8
// sequence, the byte at `end` is a continuation byte, so back up until the
// prefix ends before the incomplete character.
while (end > 0 && (buffer[end] & 0xc0) === 0x80) {
end--;
}
return {
content: buffer.subarray(0, end).toString('utf8'),
truncated: true,
};
}
function detectLineEndingFromContent(content: string): 'crlf' | 'lf' {
return content.includes('\r\n') ? 'crlf' : 'lf';
}

View file

@ -147,6 +147,22 @@ describe('readManyFiles', () => {
expect(content).toContain('--- End of content ---');
});
it('should include truncated large text files instead of reporting a size error', async () => {
const relativePath = 'large.log';
const absolutePath = path.join(tempRootDir, relativePath);
await fs.writeFile(absolutePath, 'x'.repeat(11 * 1024 * 1024), 'utf-8');
const mockConfig = createMockConfig(tempRootDir);
const result = await readManyFiles(mockConfig, { paths: [relativePath] });
const content = contentToString(result.contentParts);
expect(content).toContain('Showing lines 1-1 of at least 1 total lines');
expect(content).toContain('... [truncated]');
expect(result.files).toHaveLength(1);
expect(result.files[0]!.error).toBeUndefined();
expect(result.files[0]!.filePath).toBe(absolutePath);
});
it('should include truncated notebooks that do not expose text line ranges', async () => {
const relativePath = 'large.ipynb';
const absolutePath = path.join(tempRootDir, relativePath);
@ -276,6 +292,20 @@ describe('readManyFiles', () => {
expect(content).not.toContain('Content of file1.txt');
});
it('should propagate aborts before reading a directory', async () => {
await createTestFile('mydir', 'file1.txt');
const mockConfig = createMockConfig(tempRootDir);
const controller = new AbortController();
controller.abort();
await expect(
readManyFiles(mockConfig, {
paths: ['mydir'],
signal: controller.signal,
}),
).rejects.toThrow(/abort/i);
});
it('should handle directory with trailing slash', async () => {
await createTestFile('mydir', 'file1.txt');
const mockConfig = createMockConfig(tempRootDir);
@ -439,6 +469,20 @@ describe('readManyFiles', () => {
});
describe('per-file error surfacing', () => {
it('should propagate aborts from file reads instead of returning an error message', async () => {
const { relativePath } = await createTestFile('cancel.txt');
const mockConfig = createMockConfig(tempRootDir);
const controller = new AbortController();
controller.abort();
await expect(
readManyFiles(mockConfig, {
paths: [relativePath],
signal: controller.signal,
}),
).rejects.toThrow(/abort/i);
});
it('should surface processSingleFileContent errors instead of silently skipping the file', async () => {
// Trigger the >10MB file-size error path in processSingleFileContent.
const relativePath = 'huge.bin';

View file

@ -8,7 +8,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import type { Part, PartListUnion } from '@google/genai';
import type { Config } from '../config/config.js';
import { getErrorMessage } from './errors.js';
import { getErrorMessage, isAbortError } from './errors.js';
import type { ProcessedFileReadResult } from './fileUtils.js';
import {
isCacheableReadResult,
@ -104,7 +104,11 @@ export async function readManyFiles(
config: Config,
options: ReadManyFilesOptions,
): Promise<ReadManyFilesResult> {
const { paths: inputPatterns, preserveUnsupportedImageForBridge } = options;
const {
paths: inputPatterns,
preserveUnsupportedImageForBridge,
signal,
} = options;
const seenFiles = new Set<string>();
const contentParts: Part[] = [];
@ -114,6 +118,7 @@ export async function readManyFiles(
const projectRoot = config.getProjectRoot();
for (const rawPattern of inputPatterns) {
signal?.throwIfAborted();
const normalizedPattern = rawPattern.replace(/\\/g, '/');
const fullPath = path.resolve(projectRoot, normalizedPattern);
const stats = fs.existsSync(fullPath) ? fs.statSync(fullPath) : null;
@ -122,6 +127,7 @@ export async function readManyFiles(
const { contentParts: dirParts, info } = await readDirectory(
config,
fullPath,
signal,
);
contentParts.push(...dirParts);
files.push(info);
@ -134,6 +140,7 @@ export async function readManyFiles(
config,
fullPath,
preserveUnsupportedImageForBridge,
signal,
);
if (readResult) {
contentParts.push(...readResult.contentParts);
@ -142,6 +149,9 @@ export async function readManyFiles(
}
}
} catch (error) {
if (signal?.aborted || isAbortError(error)) {
throw error;
}
const errorMessage = `Error during file search: ${getErrorMessage(error)}`;
return {
contentParts: [errorMessage],
@ -165,11 +175,14 @@ export async function readManyFiles(
async function readDirectory(
config: Config,
directoryPath: string,
signal?: AbortSignal,
): Promise<{ contentParts: Part[]; info: FileReadInfo }> {
signal?.throwIfAborted();
const structure = await getFolderStructure(directoryPath, {
fileService: config.getFileService(),
fileFilteringOptions: config.getFileFilteringOptions(),
});
signal?.throwIfAborted();
const contentParts: Part[] = [
{ text: `\nContent from ${directoryPath}:\n` },
@ -190,10 +203,12 @@ async function readFileContent(
config: Config,
filePath: string,
preserveUnsupportedImage = false,
signal?: AbortSignal,
): Promise<{ contentParts: Part[]; info: FileReadInfo } | null> {
try {
const fileReadResult = await processSingleFileContent(filePath, config, {
preserveUnsupportedImage,
...(signal !== undefined ? { signal } : {}),
largePdfBehavior: 'reference',
});
@ -233,7 +248,11 @@ async function readFileContent(
) {
const [start, end] = fileReadResult.linesShown!;
const total = fileReadResult.originalLineCount!;
fileContentForLlm = `Showing lines ${start}-${end} of ${total} total lines.\n---\n${fileReadResult.llmContent}`;
const totalLabel =
fileReadResult.originalLineCountExact === false
? `at least ${total}`
: total;
fileContentForLlm = `Showing lines ${start}-${end} of ${totalLabel} total lines.\n---\n${fileReadResult.llmContent}`;
} else {
fileContentForLlm = fileReadResult.llmContent;
}
@ -258,7 +277,10 @@ async function readFileContent(
isDirectory: false,
},
};
} catch {
} catch (error) {
if (signal?.aborted || isAbortError(error)) {
throw error;
}
return null;
}
}

View file

@ -44,6 +44,28 @@ describe('shellPagerEnv', () => {
});
});
it('omits GIT_PAGER when git pager output is not requested', () => {
expect(
getShellPagerEnv('less', {
includeGitPager: false,
platform: 'linux',
}),
).toEqual({
PAGER: 'less',
});
});
it('uses the default pager without GIT_PAGER when git pager output is not requested', () => {
expect(
getShellPagerEnv(undefined, {
includeGitPager: false,
platform: 'linux',
}),
).toEqual({
PAGER: 'cat',
});
});
it('treats an empty pager as an explicit request to disable pager env values', () => {
expect(
getShellPagerEnv('', { includeGitPager: true, platform: 'linux' }),

View file

@ -0,0 +1,8 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export const TEXT_RANGE_FAST_PATH_MAX_SIZE = 10 * 1024 * 1024;
export const DEFAULT_RANGE_READ_BYTES = 25_000;

View file

@ -757,6 +757,7 @@ export interface DaemonTurnErrorData {
sessionId: string;
message: string;
code?: string;
errorKind?: DaemonErrorKind | (string & {});
promptId?: string;
[key: string]: unknown;
}

View file

@ -730,6 +730,8 @@ export const DAEMON_ERROR_KINDS = [
// An SSE writer's last successful flush was older than the daemon's
// writer-idle deadline.
'writer_idle_timeout',
// The model response stream ended before a complete turn could be read.
'model_stream_interrupted',
] as const;
export type DaemonErrorKind = (typeof DAEMON_ERROR_KINDS)[number];

View file

@ -159,6 +159,7 @@ export function normalizeDaemonEvent(
}
case 'turn_error': {
const code = getString(event.data, 'code');
const errorKind = asDaemonErrorKind(getString(event.data, 'errorKind'));
const promptId = getString(event.data, 'promptId');
return [
{
@ -167,6 +168,7 @@ export function normalizeDaemonEvent(
source: 'turn_error',
recoverable: true,
...(code ? { code } : {}),
...(errorKind ? { errorKind } : {}),
...(promptId ? { promptId } : {}),
text:
getString(event.data, 'message') ??

View file

@ -1016,6 +1016,9 @@ function appendStatusBlock(
...(event?.type === 'error' && event.promptId
? { promptId: event.promptId }
: {}),
...(event?.type === 'error' && event.errorKind
? { errorKind: event.errorKind }
: {}),
...(event?.type === 'error' && event.source
? { source: event.source }
: {}),

View file

@ -852,6 +852,7 @@ export interface DaemonStatusTranscriptBlock extends DaemonTranscriptBlockBase {
text: string;
code?: string;
promptId?: string;
errorKind?: DaemonErrorKind;
source?: string;
data?: unknown;
}

View file

@ -1196,6 +1196,103 @@ describe('daemon UI normalizer and transcript reducer', () => {
);
});
it('preserves structured model stream interruption turn errors', () => {
expect(
normalizeDaemonEvent({
id: 44,
v: 1,
type: 'turn_error',
data: {
sessionId: 'session-1',
message: 'terminated',
code: '-32603',
errorKind: 'model_stream_interrupted',
promptId: 'prompt-1',
},
}),
).toMatchObject([
{
type: 'error',
source: 'turn_error',
recoverable: true,
code: '-32603',
errorKind: 'model_stream_interrupted',
promptId: 'prompt-1',
text: 'terminated',
},
]);
});
it('keeps structured model stream interruption on transcript blocks', () => {
const state = reduceDaemonTranscriptEvents(
createDaemonTranscriptState({ now: 100 }),
normalizeDaemonEvent({
id: 46,
v: 1,
type: 'turn_error',
data: {
sessionId: 'session-1',
message: 'terminated',
code: '-32603',
errorKind: 'model_stream_interrupted',
promptId: 'prompt-1',
},
}),
{ now: 101 },
);
expect(state.blocks).toMatchObject([
{
kind: 'error',
source: 'turn_error',
text: 'terminated',
code: '-32603',
errorKind: 'model_stream_interrupted',
promptId: 'prompt-1',
},
]);
});
it('keeps older daemon terminated turn error text unchanged', () => {
expect(
normalizeDaemonEvent({
id: 45,
v: 1,
type: 'turn_error',
data: {
sessionId: 'session-1',
message: 'terminated',
},
}),
).toMatchObject([
{
type: 'error',
source: 'turn_error',
text: 'terminated',
},
]);
});
it('drops unrecognized errorKind from turn error events', () => {
const [event] = normalizeDaemonEvent({
id: 47,
v: 1,
type: 'turn_error',
data: {
sessionId: 'session-1',
message: 'some error',
errorKind: 'some_future_kind',
},
});
expect(event).toMatchObject({
type: 'error',
source: 'turn_error',
text: 'some error',
});
expect(event).not.toHaveProperty('errorKind');
});
it('normalizes daemon lifecycle and control events', () => {
expect(
normalizeDaemonEvent({

View file

@ -808,6 +808,49 @@ describe('App session callbacks', () => {
);
});
it('allows manual retry after a model stream interrupted turn error', async () => {
const { container, rerender } = renderApp();
await flush();
testState.prompt = 'recover this stream';
await clickSubmit(container);
expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith(
'recover this stream',
expect.objectContaining({ retry: undefined }),
);
mockSessionActions.sendPrompt.mockClear();
act(() => {
testState.blocks = [
{
kind: 'error',
source: 'turn_error',
id: 'turn-error-stream-interrupted',
errorKind: 'model_stream_interrupted',
text: 'terminated',
},
];
rerender();
});
expect(container.querySelector('[data-testid="retry"]')).not.toBeNull();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="retry"]')
?.click();
await Promise.resolve();
});
expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith(
'recover this stream',
expect.objectContaining({
optimisticUserMessage: false,
retry: true,
}),
);
});
it('gates queued submissions and only enqueues after approval', async () => {
let approve: (() => void) | undefined;
const onSubmitBefore = vi.fn(

View file

@ -2179,6 +2179,131 @@ describe('transcriptBlocksToDaemonMessages', () => {
]);
});
it('renders model stream interruption errors from structured errorKind labels', () => {
const messages = transcriptBlocksToDaemonMessages(
[
{
id: 'err-1',
kind: 'error' as const,
source: 'turn_error' as const,
errorKind: 'model_stream_interrupted' as const,
text: 'terminated',
data: { diagnosticId: 'abc' },
clientReceivedAt: 1,
createdAt: 1,
updatedAt: 1,
},
],
{
labels: {
modelStreamInterrupted: 'Localized stream interruption.',
},
},
);
expect(messages).toEqual([
{
id: 'err-1',
role: 'system',
content: 'Localized stream interruption.',
variant: 'error',
retryable: true,
source: 'turn_error',
data: {
diagnosticId: 'abc',
errorKind: 'model_stream_interrupted',
},
timestamp: 1,
},
]);
});
it('upgrades older daemon terminated turn errors to localized text', () => {
const messages = transcriptBlocksToDaemonMessages(
[
{
id: 'err-1',
kind: 'error' as const,
source: 'turn_error' as const,
text: 'terminated',
clientReceivedAt: 1,
createdAt: 1,
updatedAt: 1,
},
],
{
labels: {
modelStreamInterrupted: 'Localized stream interruption.',
},
},
);
expect(messages[0]).toMatchObject({
content: 'Localized stream interruption.',
retryable: true,
source: 'turn_error',
});
});
it('does not add data solely for structured errorKind labels', () => {
const messages = transcriptBlocksToDaemonMessages(
[
{
id: 'err-1',
kind: 'error' as const,
source: 'turn_error' as const,
errorKind: 'model_stream_interrupted' as const,
text: 'terminated',
clientReceivedAt: 1,
createdAt: 1,
updatedAt: 1,
},
],
{
labels: {
modelStreamInterrupted: 'Localized stream interruption.',
},
},
);
expect(messages[0]).toMatchObject({
content: 'Localized stream interruption.',
retryable: true,
source: 'turn_error',
});
expect(messages[0]).not.toHaveProperty('data');
});
it('preserves non-object error data when adding structured errorKind', () => {
const messages = transcriptBlocksToDaemonMessages(
[
{
id: 'err-1',
kind: 'error' as const,
source: 'turn_error' as const,
errorKind: 'model_stream_interrupted' as const,
text: 'terminated',
data: 'diagnostic text',
clientReceivedAt: 1,
createdAt: 1,
updatedAt: 1,
},
],
{
labels: {
modelStreamInterrupted: 'Localized stream interruption.',
},
},
);
expect(messages[0]).toMatchObject({
data: {
value: 'diagnostic text',
errorKind: 'model_stream_interrupted',
},
});
});
it('converts debug blocks to system messages with info variant', () => {
const messages = transcriptBlocksToDaemonMessages([
{

View file

@ -33,11 +33,6 @@ type DaemonPermissionTranscriptBlock = Extract<
{ kind: 'permission' }
>;
type ExtendedDaemonStatusTranscriptBlock = DaemonStatusTranscriptBlock & {
source?: string;
data?: unknown;
};
type ExtendedDaemonTextTranscriptBlock = DaemonTextTranscriptBlock & {
meta?: {
source?: unknown;
@ -50,6 +45,7 @@ interface TranscriptMessageLabels {
promptCancelled?: string;
branchSuccess?: (name: string) => string;
midTurnInserted?: (message: string) => string;
modelStreamInterrupted?: string;
}
interface TranscriptMessageOptions {
@ -63,6 +59,35 @@ function isIgnoredWebShellStatus(text: string): boolean {
);
}
function getErrorDisplayText(
block: DaemonStatusTranscriptBlock,
labels?: TranscriptMessageLabels,
): string {
if (
block.errorKind === 'model_stream_interrupted' ||
// Older daemons emit this turn_error before they know about errorKind.
(block.source === 'turn_error' &&
block.text.trim().toLowerCase() === 'terminated')
) {
return labels?.modelStreamInterrupted ?? block.text;
}
return block.text;
}
function getErrorMessageData(
data: unknown,
errorKind: DaemonStatusTranscriptBlock['errorKind'],
): { data?: unknown } {
if (data === undefined) return {};
if (!errorKind) return { data };
return {
data: {
...(getRecord(data) ?? { value: data }),
errorKind,
},
};
}
function getSessionBranchDisplayName(data: unknown): string | null {
if (!data || typeof data !== 'object') return null;
const branchData = data as {
@ -530,7 +555,7 @@ export function transcriptBlocksToDaemonMessages(
case 'status':
case 'debug': {
const statusBlock = block as ExtendedDaemonStatusTranscriptBlock;
const statusBlock = block;
const branchDisplayName =
statusBlock.source === 'session_branched'
? getSessionBranchDisplayName(statusBlock.data)
@ -575,16 +600,17 @@ export function transcriptBlocksToDaemonMessages(
}
case 'error': {
const errorBlock = block as ExtendedDaemonStatusTranscriptBlock;
const errorBlock = block;
const errorKind = errorBlock.errorKind;
messages.push({
id: block.id,
role: 'system',
content: errorBlock.text,
content: getErrorDisplayText(errorBlock, options.labels),
variant: 'error',
retryable: errorBlock.source === 'turn_error',
timestamp: blockTime,
...(errorBlock.source ? { source: errorBlock.source } : {}),
...(errorBlock.data !== undefined ? { data: errorBlock.data } : {}),
...getErrorMessageData(errorBlock.data, errorKind),
});
needsNewContentMessage = true;
break;

View file

@ -10,10 +10,6 @@
overflow: hidden;
}
.paneCurrent {
border-color: color-mix(in srgb, var(--primary) 60%, var(--border));
}
.header {
display: flex;
align-items: center;
@ -52,16 +48,6 @@
color: var(--foreground);
}
.currentBadge {
flex: 0 0 auto;
padding: 1px 7px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
color: var(--primary);
background: color-mix(in srgb, var(--primary) 14%, transparent);
}
.closeButton {
flex: 0 0 auto;
display: inline-flex;

View file

@ -21,6 +21,7 @@ let latestOnSubmit:
| undefined;
let sendPromptResolve: (() => void) | undefined;
let sendPromptReject: ((e: unknown) => void) | undefined;
let sendPromptAdmit: (() => void) | undefined;
const sendPrompt = vi.fn(async () => ({}) as any);
const submitPermission = vi.fn(async () => {});
const cancel = vi.fn(async () => {});
@ -55,7 +56,10 @@ vi.mock('./StreamingStatus', () => ({
StreamingStatus: (props: any) => (
<div
data-testid="pane-streaming"
data-started-at={props.startedAt === undefined ? 'none' : String(props.startedAt)}
data-started-at={
props.startedAt === undefined ? 'none' : String(props.startedAt)
}
data-show-phrase={String(props.showPhrase)}
/>
),
}));
@ -120,10 +124,13 @@ beforeEach(() => {
latestOnSubmit = undefined;
sendPrompt.mockReset();
// Each sendPrompt returns a promise the test controls, so we can assert the
// draft is committed only after the prompt is accepted.
// draft is committed on admission (onAdmitted) rather than on turn completion
// (promise resolution). `sendPromptAdmit` captures the options.onAdmitted hook.
sendPromptAdmit = undefined;
sendPrompt.mockImplementation(
() =>
(_text?: string, options?: { onAdmitted?: () => void }) =>
new Promise<unknown>((resolve, reject) => {
sendPromptAdmit = options?.onAdmitted;
sendPromptResolve = () => resolve({});
sendPromptReject = (e) => reject(e);
}),
@ -163,6 +170,13 @@ describe('ChatPane', () => {
expect(container!.textContent).toContain('Refactor core');
});
it('suppresses the rotating loading phrase in its compact status', () => {
render();
expect(testid('pane-streaming')?.getAttribute('data-show-phrase')).toBe(
'false',
);
});
it('sends a prompt to its own session on submit', () => {
render();
act(() =>
@ -174,16 +188,26 @@ describe('ChatPane', () => {
expect((sendPrompt.mock.calls[0] as unknown[])[0]).toBe('hello there');
});
it('commits the draft only after the prompt is accepted', async () => {
it('commits the draft on admission, without waiting for the turn to finish', async () => {
render();
const commit = vi.fn();
let returned: boolean | undefined;
act(() => {
returned = latestOnSubmit!('hi', undefined, commit);
});
// Returns false (keep the draft) and does NOT commit before acceptance.
// Returns false (keep the draft) and does NOT commit before admission.
expect(returned).toBe(false);
expect(commit).not.toHaveBeenCalled();
// The daemon admits the prompt (onAdmitted). The draft clears now, even
// though the turn promise is still pending — a long response must not strand
// the sent text in the composer until the turn ends.
await act(async () => {
sendPromptAdmit!();
await Promise.resolve();
});
expect(commit).toHaveBeenCalledTimes(1);
// The turn finishing later must NOT commit again — guards against regressing
// to committing on promise resolution (turn end) instead of admission.
await act(async () => {
sendPromptResolve!();
await Promise.resolve();
@ -204,6 +228,29 @@ describe('ChatPane', () => {
expect(commit).not.toHaveBeenCalled();
});
it('keeps the draft cleared and still reports the error when the turn fails after admission', async () => {
const onError = vi.fn();
render({ onError });
const commit = vi.fn();
act(() => {
latestOnSubmit!('hi', undefined, commit);
});
// Admission clears the draft.
await act(async () => {
sendPromptAdmit!();
await Promise.resolve();
});
expect(commit).toHaveBeenCalledTimes(1);
// The turn then fails mid-flight: the draft stays cleared (no second commit)
// and the failure is still surfaced to onError.
await act(async () => {
sendPromptReject!(new Error('turn crashed'));
await Promise.resolve();
});
expect(commit).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalled();
});
it('keeps pane approvals click-only (no global keyboard shortcuts)', () => {
pendingPermission = { id: 'perm-1', toolName: 'write_file', rawInput: {} };
render();
@ -229,7 +276,11 @@ describe('ChatPane', () => {
new MouseEvent('click', { bubbles: true }),
),
);
expect(submitPermission).toHaveBeenCalledWith('perm-1', 'proceed', undefined);
expect(submitPermission).toHaveBeenCalledWith(
'perm-1',
'proceed',
undefined,
);
});
it('routes an AskUserQuestion permission to the AskUserQuestion overlay', () => {

View file

@ -30,8 +30,6 @@ const EMPTY_TOOLBAR: never[] = [];
export interface ChatPaneProps {
/** Header label; falls back to the session's own display name / id. */
title?: string;
/** Marks the pane bound to the window's primary (sidebar-selected) session. */
isCurrent?: boolean;
onClose?: () => void;
onError?: (error: unknown, fallback: string) => void;
}
@ -43,7 +41,7 @@ export interface ChatPaneProps {
* state, approvals, and composer, and the browser scopes keyboard focus to the
* pane the user clicks into so there is no cross-pane approval arbitration.
*/
export function ChatPane({ title, isCurrent, onClose, onError }: ChatPaneProps) {
export function ChatPane({ title, onClose, onError }: ChatPaneProps) {
const { t } = useI18n();
const connection = useConnection();
const actions = useActions();
@ -92,13 +90,19 @@ export function ChatPane({ title, isCurrent, onClose, onError }: ChatPaneProps)
): boolean => {
const trimmed = text.trim();
if (!trimmed) return false;
// Keep the draft (return false) until the prompt is actually accepted:
// sendPrompt can reject (transcript still loading, session disconnected,
// or a turn already active), and committing first would silently drop the
// user's text. Commit only once it resolves.
// Keep the draft (return false) and clear it only once the daemon ADMITS
// the prompt. `onAdmitted` fires at acceptance; the sendPrompt promise
// itself resolves only when the whole (possibly long) turn finishes, so
// committing on resolution would strand the sent text in the composer for
// the entire response. If the prompt is rejected before admission
// (transcript still loading, session disconnected, or a turn already
// active) onAdmitted never fires, so the draft is preserved and the error
// is surfaced.
actions
.sendPrompt(trimmed, images && images.length ? { images } : undefined)
.then(() => commitAccepted?.())
.sendPrompt(trimmed, {
...(images && images.length ? { images } : {}),
onAdmitted: commitAccepted,
})
.catch((error: unknown) => reportError(error, 'Failed to send prompt'));
return false;
},
@ -119,7 +123,9 @@ export function ChatPane({ title, isCurrent, onClose, onError }: ChatPaneProps)
const handleCancel = useCallback(() => {
actions
.cancel()
.catch((error: unknown) => reportError(error, 'Failed to cancel request'));
.catch((error: unknown) =>
reportError(error, 'Failed to cancel request'),
);
}, [actions, reportError]);
const headerLabel =
@ -127,9 +133,7 @@ export function ChatPane({ title, isCurrent, onClose, onError }: ChatPaneProps)
return (
<section
className={[styles.pane, isCurrent ? styles.paneCurrent : '']
.filter(Boolean)
.join(' ')}
className={styles.pane}
data-testid="chat-pane"
aria-label={headerLabel}
>
@ -137,11 +141,6 @@ export function ChatPane({ title, isCurrent, onClose, onError }: ChatPaneProps)
<span className={styles.title} title={headerLabel}>
{headerLabel}
</span>
{isCurrent && (
<span className={styles.currentBadge}>
{t('sessionsOverview.current')}
</span>
)}
{onClose && (
<button
type="button"
@ -206,7 +205,9 @@ export function ChatPane({ title, isCurrent, onClose, onError }: ChatPaneProps)
/>
</div>
)}
<StreamingStatus startedAt={activeTurnStartedAt} />
{/* Panes keep the composer status compact: spinner + elapsed time +
token count + cancel hint, but no rotating "witty" loading phrase. */}
<StreamingStatus startedAt={activeTurnStartedAt} showPhrase={false} />
<ChatEditor
onSubmit={handleSubmit}
onCancel={handleCancel}

View file

@ -46,7 +46,7 @@ vi.mock('./ChatPane', () => ({
// Let a test force a render crash to exercise the per-pane ErrorBoundary.
if (props.title === 'BOOM') throw new Error('pane exploded');
return (
<div data-testid="chat-pane" data-current={props.isCurrent ? 'yes' : 'no'}>
<div data-testid="chat-pane">
<span data-testid="pane-title">{props.title}</span>
{props.onClose && (
<button data-testid="pane-close" onClick={props.onClose}>
@ -102,14 +102,14 @@ function panes(): HTMLElement[] {
return Array.from(container!.querySelectorAll('[data-testid="chat-pane"]'));
}
function titles(): string[] {
return Array.from(container!.querySelectorAll('[data-testid="pane-title"]')).map(
(el) => el.textContent ?? '',
);
return Array.from(
container!.querySelectorAll('[data-testid="pane-title"]'),
).map((el) => el.textContent ?? '');
}
function pickerOptions(): string[] {
return Array.from(
container!.querySelectorAll('[role="option"] button'),
).map((el) => (el.textContent ?? '').trim());
return Array.from(container!.querySelectorAll('[role="option"] button')).map(
(el) => (el.textContent ?? '').trim(),
);
}
function openPicker(): void {
const addButton = container!.querySelector(
@ -140,7 +140,6 @@ describe('SplitView', () => {
it('seeds with the current session when no initial sessions are given', () => {
render({ initialSessionIds: [] });
expect(titles()).toEqual(['Three']);
expect(panes()[0].getAttribute('data-current')).toBe('yes');
});
it('dedupes initial sessions', () => {
@ -235,9 +234,7 @@ describe('SplitView', () => {
render({ initialSessionIds: ['s1'], onExit });
expect(onExit).not.toHaveBeenCalled();
const close = container!.querySelector('[data-testid="pane-close"]');
act(() =>
close!.dispatchEvent(new MouseEvent('click', { bubbles: true })),
);
act(() => close!.dispatchEvent(new MouseEvent('click', { bubbles: true })));
expect(onExit).toHaveBeenCalledTimes(1);
});
@ -344,9 +341,7 @@ describe('SplitView', () => {
expect(onPanesChange).toHaveBeenLastCalledWith(['s1', 's2']);
// …and after every remove.
const close = container!.querySelector('[data-testid="pane-close"]');
act(() =>
close!.dispatchEvent(new MouseEvent('click', { bubbles: true })),
);
act(() => close!.dispatchEvent(new MouseEvent('click', { bubbles: true })));
expect(onPanesChange).toHaveBeenLastCalledWith(['s2']);
});
});

View file

@ -62,9 +62,8 @@ export function SplitView({
const connection = useConnection();
const currentSessionId = connection.sessionId;
const organizationEnabled =
connection.capabilities?.features?.includes(
SESSION_ORGANIZATION_FEATURE,
) ?? false;
connection.capabilities?.features?.includes(SESSION_ORGANIZATION_FEATURE) ??
false;
const { sessions, reload } = useSessions({
autoLoad: true,
pageSize: SESSION_LIST_PAGE_SIZE,
@ -282,7 +281,6 @@ export function SplitView({
>
<ChatPane
title={titleById.get(sessionId)}
isCurrent={sessionId === currentSessionId}
onClose={() => removePane(sessionId)}
onError={onError}
/>

View file

@ -35,7 +35,10 @@ afterEach(() => {
vi.restoreAllMocks();
});
function render(customization: WebShellCustomization = {}): HTMLElement {
function render(
customization: WebShellCustomization = {},
props: { showPhrase?: boolean } = {},
): HTMLElement {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
@ -43,7 +46,7 @@ function render(customization: WebShellCustomization = {}): HTMLElement {
root.render(
<I18nProvider language="en">
<WebShellCustomizationProvider value={customization}>
<StreamingStatus />
<StreamingStatus {...props} />
</WebShellCustomizationProvider>
</I18nProvider>,
);
@ -90,6 +93,23 @@ describe('StreamingStatus loading phrases', () => {
);
});
it('hides the phrase but keeps the dynamic status when showPhrase is false', () => {
pinPhraseSelection();
// A resolver that would otherwise supply a phrase must still be suppressed.
const container = render({ loadingPhrases: () => ['should not appear'] }, {
showPhrase: false,
});
// The witty phrase (the "废话文学") is gone: no label span.
expect(labelText(container)).toBeUndefined();
expect(container.textContent).not.toContain('should not appear');
// But the dynamic status stays: spinner + meta (elapsed time + cancel hint).
const spans = container.firstElementChild?.querySelectorAll('span') ?? [];
expect(spans.length).toBe(2);
expect(spans[0]?.textContent).not.toBe(''); // spinner frame
expect(container.textContent).toContain('esc to cancel'); // meta/cancel hint
expect(container.textContent).toMatch(/\ds/); // elapsed time, e.g. "0s"
});
it('falls back to the built-in defaults when the resolver returns undefined', () => {
pinPhraseSelection();
const container = render({ loadingPhrases: () => undefined });

View file

@ -12,11 +12,21 @@ import styles from './StreamingStatus.module.css';
interface StreamingStatusProps {
startedAt?: number;
/**
* When false, hide the rotating "witty" loading phrase and skip its rotation
* timer entirely the spinner, elapsed time, token count, and cancel hint
* still render. Split-view panes pass false to keep each pane's composer
* status compact. Defaults to true (the main chat shows the phrase).
*/
showPhrase?: boolean;
}
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
export function StreamingStatus({ startedAt }: StreamingStatusProps) {
export function StreamingStatus({
startedAt,
showPhrase = true,
}: StreamingStatusProps) {
const streamingState = useStreamingState();
const { estimatedOutputTokens, isReceivingContent } =
useStreamingLoadingMetrics();
@ -71,6 +81,10 @@ export function StreamingStatus({ startedAt }: StreamingStatusProps) {
}, [isActive, startedAt]);
useEffect(() => {
// Callers that hide the phrase (e.g. split-view panes) don't need the
// rotation timer at all — bail before arming it so panes don't each run a
// needless interval and re-render on every tick.
if (!showPhrase) return;
if (streamingState === 'idle') {
setLoadingPhrase(resolvePhrases(language)[0] ?? '');
return;
@ -90,7 +104,7 @@ export function StreamingStatus({ startedAt }: StreamingStatusProps) {
pickPhrase();
const interval = setInterval(pickPhrase, PHRASE_CHANGE_INTERVAL_MS);
return () => clearInterval(interval);
}, [language, streamingState, resolvePhrases]);
}, [language, streamingState, resolvePhrases, showPhrase]);
useEffect(() => {
if (streamingState === 'idle') return;
@ -113,7 +127,9 @@ export function StreamingStatus({ startedAt }: StreamingStatusProps) {
return (
<div className={styles.status}>
<span className={styles.spinner}>{spinnerChar}</span>
{loadingPhrase && <span className={styles.label}>{loadingPhrase}</span>}
{showPhrase && loadingPhrase && (
<span className={styles.label}>{loadingPhrase}</span>
)}
<span className={styles.meta}>
({timeStr}
{tokenStr} · {t('stream.cancel')})

View file

@ -81,6 +81,8 @@
}
.table {
--action-column-width: 72px;
width: max-content;
min-width: 100%;
border-collapse: separate;
@ -112,6 +114,11 @@
text-align: left;
}
.frozenHeaderCell {
left: var(--action-column-width);
z-index: 5;
}
.headerControls {
display: flex;
align-items: stretch;
@ -162,6 +169,72 @@
cursor: pointer;
}
.activeHeaderCell {
background: color-mix(in srgb, var(--agent-blue-500) 8%, var(--card));
}
.reorderHandle {
flex: 0 0 0;
width: 0;
min-width: 0;
padding: 0;
overflow: hidden;
border: none;
border-left: 0 solid transparent;
background: transparent;
color: var(--muted-foreground);
font: inherit;
font-size: 12px;
cursor: grab;
opacity: 0;
pointer-events: none;
transition:
flex-basis 120ms ease,
width 120ms ease,
opacity 120ms ease,
border-color 120ms ease,
background 120ms ease;
}
.reorderHandleVisible,
.reorderHandle:focus-visible,
.reorderHandle:active {
flex-basis: 24px;
width: 24px;
border-left-width: 1px;
border-left-color: var(--border);
opacity: 1;
pointer-events: auto;
}
.reorderHandle:active {
cursor: grabbing;
}
.reorderHandle:hover,
.reorderHandle:focus-visible {
background: var(--subtle-bg-strong);
}
.resizeHandle {
position: absolute;
top: 0;
right: -3px;
z-index: 7;
width: 6px;
height: 100%;
padding: 0;
border: none;
background: transparent;
cursor: col-resize;
}
.resizeHandle:hover,
.resizeHandle:focus-visible {
background: color-mix(in srgb, var(--agent-blue-500) 45%, transparent);
outline: none;
}
.filterTriggerActive {
background: color-mix(in srgb, var(--agent-blue-500) 18%, transparent);
color: var(--agent-blue-500);
@ -342,18 +415,71 @@ tr:hover .selectedCell {
.actionHeaderCell,
.actionCell {
width: 72px;
min-width: 72px;
max-width: 72px;
width: var(--action-column-width);
min-width: var(--action-column-width);
max-width: var(--action-column-width);
text-align: center;
}
.stickyActionHeaderCell,
.stickyActionCell {
position: sticky;
left: 0;
z-index: 6;
}
.stickyActionHeaderCell {
z-index: 8;
}
.actionCell {
padding: 4px 6px;
cursor: default;
user-select: none;
}
.frozenCell {
position: sticky;
left: var(--action-column-width);
z-index: 4;
}
.stickyActionHeaderCell,
.frozenHeaderCell,
.stickyActionCell,
.frozenCell {
background: var(--card);
}
.evenRow .stickyActionCell,
.evenRow .frozenCell {
background:
linear-gradient(var(--subtle-bg), var(--subtle-bg)),
var(--card);
}
tr:hover .stickyActionCell,
tr:hover .frozenCell {
background:
linear-gradient(var(--subtle-bg-strong), var(--subtle-bg-strong)),
var(--card);
}
.stickyActionCell.selectedCell,
.frozenCell.selectedCell,
.evenRow .stickyActionCell.selectedCell,
.evenRow .frozenCell.selectedCell,
tr:hover .stickyActionCell.selectedCell,
tr:hover .frozenCell.selectedCell {
background:
linear-gradient(
color-mix(in srgb, var(--agent-blue-500) 20%, transparent),
color-mix(in srgb, var(--agent-blue-500) 20%, transparent)
),
var(--card);
box-shadow: inset 0 0 0 1px var(--agent-blue-500);
}
.rowDetailButton {
padding: 3px 7px;
border: 1px solid var(--border);

View file

@ -11,6 +11,7 @@ import { EnhancedMarkdownTable } from './EnhancedMarkdownTable';
const mounted: Array<{ root: Root; container: HTMLElement }> = [];
const originalElementFromPoint = document.elementFromPoint;
const COLUMN_DRAG_MIME = 'application/x-qwen-web-shell-table-column';
afterEach(() => {
for (const { root, container } of mounted.splice(0)) {
@ -255,6 +256,100 @@ function dispatchCopy(target: Element) {
return { event, setData };
}
function dragColumnElements(from: Element, to: Element): void {
const data = new Map<string, string>();
const dataTransfer = {
dropEffect: '',
effectAllowed: '',
get types() {
return [...data.keys()];
},
setData: vi.fn((type: string, value: string) => data.set(type, value)),
getData: vi.fn((type: string) => data.get(type) ?? ''),
};
act(() => {
from.dispatchEvent(
Object.assign(new Event('dragstart', { bubbles: true }), {
dataTransfer,
}),
);
to.dispatchEvent(
Object.assign(
new Event('dragover', { bubbles: true, cancelable: true }),
{
dataTransfer,
},
),
);
to.dispatchEvent(
Object.assign(new Event('drop', { bubbles: true, cancelable: true }), {
dataTransfer,
}),
);
from.dispatchEvent(new Event('dragend', { bubbles: true }));
});
}
function dragColumn(
container: HTMLElement,
fromLabel: string,
toLabel: string,
): void {
dragColumnElements(button(container, fromLabel), button(container, toLabel));
}
function dropExternalColumn(container: HTMLElement, toLabel: string): void {
const dataTransfer = {
dropEffect: '',
effectAllowed: '',
types: ['text/plain'],
setData: vi.fn(),
getData: vi.fn(() => ''),
};
const to = button(container, toLabel);
act(() => {
to.dispatchEvent(
Object.assign(
new Event('dragover', { bubbles: true, cancelable: true }),
{
dataTransfer,
},
),
);
to.dispatchEvent(
Object.assign(new Event('drop', { bubbles: true, cancelable: true }), {
dataTransfer,
}),
);
});
}
function dropForgedColumn(container: HTMLElement, toLabel: string): void {
const dataTransfer = {
dropEffect: '',
effectAllowed: '',
types: [COLUMN_DRAG_MIME],
setData: vi.fn(),
getData: vi.fn(() => '2'),
};
const to = button(container, toLabel);
act(() => {
to.dispatchEvent(
Object.assign(
new Event('dragover', { bubbles: true, cancelable: true }),
{
dataTransfer,
},
),
);
to.dispatchEvent(
Object.assign(new Event('drop', { bubbles: true, cancelable: true }), {
dataTransfer,
}),
);
});
}
function touchEvent(
type: string,
touches: Array<Pick<Touch, 'clientX' | 'clientY'>>,
@ -666,6 +761,330 @@ describe('EnhancedMarkdownTable', () => {
);
});
it('resizes a column from its header handle', () => {
const container = renderTable();
const resize = button(container, 'Resize Team');
act(() => {
resize.dispatchEvent(
new MouseEvent('mousedown', {
bubbles: true,
button: 0,
clientX: 100,
}),
);
});
act(() => {
window.dispatchEvent(
new MouseEvent('mousemove', { bubbles: true, clientX: 160 }),
);
window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
});
expect(button(container, 'Sort by Team').closest('th')?.style.width).toBe(
'220px',
);
expect(dataCell(container, 0, 0).style.width).toBe('220px');
});
it('clamps resized columns to the minimum width', () => {
const container = renderTable();
const resize = button(container, 'Resize Team');
act(() => {
resize.dispatchEvent(
new MouseEvent('mousedown', {
bubbles: true,
button: 0,
clientX: 100,
}),
);
});
act(() => {
window.dispatchEvent(
new MouseEvent('mousemove', { bubbles: true, clientX: 0 }),
);
window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
});
expect(button(container, 'Sort by Team').closest('th')?.style.width).toBe(
'80px',
);
expect(dataCell(container, 0, 0).style.width).toBe('80px');
});
it('clamps resized columns to the maximum width', () => {
const container = renderTable();
const resize = button(container, 'Resize Team');
act(() => {
resize.dispatchEvent(
new MouseEvent('mousedown', {
bubbles: true,
button: 0,
clientX: 100,
}),
);
});
act(() => {
window.dispatchEvent(
new MouseEvent('mousemove', { bubbles: true, clientX: 900 }),
);
window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
});
expect(button(container, 'Sort by Team').closest('th')?.style.width).toBe(
'640px',
);
expect(dataCell(container, 0, 0).style.width).toBe('640px');
});
it('stops resizing a column when the window blurs', () => {
const container = renderTable();
const resize = button(container, 'Resize Team');
act(() => {
resize.dispatchEvent(
new MouseEvent('mousedown', {
bubbles: true,
button: 0,
clientX: 100,
}),
);
});
act(() => {
window.dispatchEvent(new Event('blur'));
});
act(() => {
window.dispatchEvent(
new MouseEvent('mousemove', { bubbles: true, clientX: 220 }),
);
});
expect(button(container, 'Sort by Team').closest('th')?.style.width).toBe(
'160px',
);
});
it('stops resizing a column when page visibility changes', () => {
const container = renderTable();
const resize = button(container, 'Resize Team');
act(() => {
resize.dispatchEvent(
new MouseEvent('mousedown', {
bubbles: true,
button: 0,
clientX: 100,
}),
);
});
act(() => {
document.dispatchEvent(new Event('visibilitychange'));
});
act(() => {
window.dispatchEvent(
new MouseEvent('mousemove', { bubbles: true, clientX: 220 }),
);
});
expect(button(container, 'Sort by Team').closest('th')?.style.width).toBe(
'160px',
);
});
it('resizes a column with keyboard arrows', () => {
const container = renderTable();
const resize = button(container, 'Resize Team');
act(() => {
resize.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
key: 'ArrowRight',
}),
);
});
expect(button(container, 'Sort by Team').closest('th')?.style.width).toBe(
'176px',
);
act(() => {
resize.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
key: 'ArrowLeft',
}),
);
});
expect(button(container, 'Sort by Team').closest('th')?.style.width).toBe(
'160px',
);
});
it('shows column move handles only for the active column', () => {
const container = renderWideTable();
const teamHandle = button(container, 'Move Team');
const scoreHandle = button(container, 'Move Score');
expect(teamHandle.className).not.toContain('reorderHandleVisible');
expect(teamHandle.tabIndex).toBe(-1);
expect(scoreHandle.className).not.toContain('reorderHandleVisible');
expect(scoreHandle.tabIndex).toBe(-1);
click(button(container, 'Sort by Team'));
expect(teamHandle.className).toContain('reorderHandleVisible');
expect(teamHandle.tabIndex).toBe(0);
expect(scoreHandle.className).not.toContain('reorderHandleVisible');
expect(scoreHandle.tabIndex).toBe(-1);
expect(
button(container, 'Sort by Team, ascending').closest('th')?.className,
).toContain('activeHeaderCell');
});
it('reorders columns and quick copies in the visible order', () => {
const writeText = mockClipboard();
const container = renderWideTable();
dragColumn(container, 'Move Score', 'Move Team');
expect(rowTexts(container)).toEqual(['10|Alpha|US', '2|Beta|EMEA']);
click(textButton(container, 'Quick copy'));
expect(writeText).toHaveBeenCalledWith(
['Score\tTeam\tRegion', '10\tAlpha\tUS', '2\tBeta\tEMEA'].join('\n'),
);
});
it('ignores external drops on column move handles', () => {
const container = renderWideTable();
dropExternalColumn(container, 'Move Score');
expect(rowTexts(container)).toEqual(['Alpha|US|10', 'Beta|EMEA|2']);
});
it('ignores forged column drag payloads', () => {
const container = renderWideTable();
dropForgedColumn(container, 'Move Team');
expect(rowTexts(container)).toEqual(['Alpha|US|10', 'Beta|EMEA|2']);
});
it('ignores column drags from another table', () => {
const source = renderWideTable();
const target = renderWideTable();
dragColumnElements(button(source, 'Move Score'), button(target, 'Move Team'));
expect(rowTexts(source)).toEqual(['Alpha|US|10', 'Beta|EMEA|2']);
expect(rowTexts(target)).toEqual(['Alpha|US|10', 'Beta|EMEA|2']);
});
it('drops reordered columns on the target header cell', () => {
const container = renderWideTable();
dragColumnElements(
button(container, 'Move Score'),
button(container, 'Sort by Team'),
);
expect(rowTexts(container)).toEqual(['10|Alpha|US', '2|Beta|EMEA']);
});
it('preserves hidden column slots when reordering visible columns', () => {
const container = renderWideTable();
click(button(container, 'Filter Region'));
click(textButton(container, 'Hide column'));
dragColumn(container, 'Move Score', 'Move Team');
click(textButton(container, 'Show 1 hidden column'));
expect(rowTexts(container)).toEqual(['10|US|Alpha', '2|EMEA|Beta']);
});
it('keeps hidden columns out of reordered selections', () => {
const writeText = mockClipboard();
const container = renderWideTable();
dragColumn(container, 'Move Score', 'Move Team');
click(button(container, 'Filter Region'));
click(textButton(container, 'Hide column'));
dragCells(dataCell(container, 0, 0), dataCell(container, 1, 1));
click(textButton(container, 'Copy TSV'));
expect(writeText).toHaveBeenCalledWith(['10\tAlpha', '2\tBeta'].join('\n'));
});
it('toggles sticky classes for the action column and first visible column', () => {
const container = renderWideTable();
click(textButton(container, 'Freeze first column'));
expect(textButton(container, 'Unfreeze first column')).toBeDefined();
expect(container.querySelector('thead th')?.className).toContain(
'stickyActionHeaderCell',
);
expect(
button(container, 'Sort by Team').closest('th')?.className,
).toContain('frozenHeaderCell');
expect(dataCell(container, 0, 0).className).toContain('frozenCell');
dragColumn(container, 'Move Score', 'Move Team');
expect(
button(container, 'Sort by Score').closest('th')?.className,
).toContain('frozenHeaderCell');
click(textButton(container, 'Unfreeze first column'));
expect(container.textContent).toContain('Freeze first column');
expect(
button(container, 'Sort by Score').closest('th')?.className,
).not.toContain('frozenHeaderCell');
});
it('keeps selected cell classes visible on a frozen column', () => {
const container = renderWideTable();
click(textButton(container, 'Freeze first column'));
dragCells(dataCell(container, 0, 0), dataCell(container, 1, 1));
expect(container.textContent).toContain('4 cells selected');
expect(dataCell(container, 0, 0).className).toContain('frozenCell');
expect(dataCell(container, 0, 0).className).toContain('selectedCell');
expect(dataCell(container, 0, 1).className).toContain('selectedCell');
});
it('copies reordered selections from the keyboard copy event', () => {
const container = renderWideTable();
dragColumn(container, 'Move Score', 'Move Team');
dragCells(dataCell(container, 0, 0), dataCell(container, 1, 1));
const scroller = container.querySelector<HTMLElement>('div[tabindex="0"]');
expect(scroller).not.toBeNull();
const { event, setData } = dispatchCopy(scroller!);
expect(event.defaultPrevented).toBe(true);
expect(setData).toHaveBeenCalledWith(
'text/plain',
['10\tAlpha', '2\tBeta'].join('\n'),
);
});
it('copies reordered selections after freezing the first column', () => {
const writeText = mockClipboard();
const container = renderWideTable();
dragColumn(container, 'Move Score', 'Move Team');
click(textButton(container, 'Freeze first column'));
dragCells(dataCell(container, 0, 0), dataCell(container, 1, 1));
click(textButton(container, 'Copy TSV'));
expect(writeText).toHaveBeenCalledWith(['10\tAlpha', '2\tBeta'].join('\n'));
});
it('shows checkmark feedback after quick copy', async () => {
vi.useFakeTimers();
mockClipboard();
@ -1381,6 +1800,7 @@ describe('EnhancedMarkdownTable', () => {
const container = renderTable('zh-CN');
expect(container.textContent).toContain('快捷复制');
expect(container.textContent).toContain('冻结首列');
expect(container.textContent).toContain('详情');
click(button(container, '筛选 Team'));
expect(container.textContent).toContain('隐藏列');

View file

@ -10,6 +10,8 @@ import {
useState,
type CSSProperties,
type ClipboardEvent,
type DragEvent as ReactDragEvent,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
type ReactElement,
type ReactNode,
@ -81,6 +83,12 @@ interface OpenFilterMenu {
top: number;
}
interface ColumnResizeState {
columnIndex: number;
startX: number;
startWidth: number;
}
interface FilterOption {
value: string;
label: string;
@ -105,6 +113,21 @@ const NUMBER_FILTER_LABEL_KEYS: Record<NumberFilterOperator, string> = {
export const MAX_ENHANCED_TABLE_ROWS = 500;
export const MAX_ENHANCED_TABLE_COLUMNS = 50;
const DEFAULT_COLUMN_WIDTH = 160;
const MIN_COLUMN_WIDTH = 80;
const MAX_COLUMN_WIDTH = 640;
const KEYBOARD_COLUMN_RESIZE_STEP = 16;
const COLUMN_DRAG_MIME = 'application/x-qwen-web-shell-table-column';
const DEFAULT_COLUMN_STYLE: CSSProperties = {
width: DEFAULT_COLUMN_WIDTH,
minWidth: DEFAULT_COLUMN_WIDTH,
maxWidth: DEFAULT_COLUMN_WIDTH,
};
function clampColumnWidth(width: number): number {
return Math.min(MAX_COLUMN_WIDTH, Math.max(MIN_COLUMN_WIDTH, width));
}
const FOCUSABLE_FILTER_MENU_SELECTOR =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
@ -278,15 +301,26 @@ function compareCellText(a: string, b: string): number {
});
}
function getSelectionBounds(range: SelectionRange) {
function getSelectionRowBounds(range: SelectionRange) {
return {
minRow: Math.min(range.anchorRow, range.focusRow),
maxRow: Math.max(range.anchorRow, range.focusRow),
minCol: Math.min(range.anchorCol, range.focusCol),
maxCol: Math.max(range.anchorCol, range.focusCol),
};
}
function getSelectedColumnIndexes(
range: SelectionRange | null,
visibleColumnIndexes: number[],
): number[] {
if (!range) return [];
const anchorIndex = visibleColumnIndexes.indexOf(range.anchorCol);
const focusIndex = visibleColumnIndexes.indexOf(range.focusCol);
if (anchorIndex === -1 || focusIndex === -1) return [];
const minIndex = Math.min(anchorIndex, focusIndex);
const maxIndex = Math.max(anchorIndex, focusIndex);
return visibleColumnIndexes.slice(minIndex, maxIndex + 1);
}
function sanitizeForClipboard(value: string): string {
const inspectedValue = value.replace(
/[\u200B-\u200D\u2060\u00AD\uFEFF]/g,
@ -301,10 +335,8 @@ function getSelectionText(
visibleColumnIndexes: number[],
): string {
if (!range) return '';
const { minRow, maxRow, minCol, maxCol } = getSelectionBounds(range);
const selectedColumns = visibleColumnIndexes.filter(
(columnIndex) => columnIndex >= minCol && columnIndex <= maxCol,
);
const { minRow, maxRow } = getSelectionRowBounds(range);
const selectedColumns = getSelectedColumnIndexes(range, visibleColumnIndexes);
if (selectedColumns.length === 0) return '';
const lines: string[] = [];
@ -350,13 +382,59 @@ function selectionSize(
visibleColumnIndexes: number[],
): number {
if (!range) return 0;
const { minRow, maxRow, minCol, maxCol } = getSelectionBounds(range);
const selectedColumnCount = visibleColumnIndexes.filter(
(columnIndex) => columnIndex >= minCol && columnIndex <= maxCol,
const { minRow, maxRow } = getSelectionRowBounds(range);
const selectedColumnCount = getSelectedColumnIndexes(
range,
visibleColumnIndexes,
).length;
return (maxRow - minRow + 1) * selectedColumnCount;
}
function moveColumn(order: number[], fromColumn: number, toColumn: number) {
if (fromColumn === toColumn) return order;
const fromIndex = order.indexOf(fromColumn);
const toIndex = order.indexOf(toColumn);
if (fromIndex === -1 || toIndex === -1) return order;
const next = [...order];
const [moved] = next.splice(fromIndex, 1);
if (moved === undefined) return order;
next.splice(toIndex, 0, moved);
return next;
}
function moveVisibleColumn(
order: number[],
fromColumn: number,
toColumn: number,
hiddenColumns: Set<number>,
) {
if (hiddenColumns.size === 0) return moveColumn(order, fromColumn, toColumn);
const visibleColumns = order.filter(
(columnIndex) => !hiddenColumns.has(columnIndex),
);
const nextVisibleColumns = moveColumn(
visibleColumns,
fromColumn,
toColumn,
);
if (nextVisibleColumns === visibleColumns) return order;
let visibleIndex = 0;
return order.map((columnIndex) => {
if (hiddenColumns.has(columnIndex)) return columnIndex;
const nextColumn = nextVisibleColumns[visibleIndex];
visibleIndex += 1;
return nextColumn ?? columnIndex;
});
}
function initialColumnOrder(columnCount: number): number[] {
return Array.from({ length: columnCount }, (_, index) => index);
}
function hasColumnDragData(dataTransfer: DataTransfer): boolean {
return Array.from(dataTransfer.types).includes(COLUMN_DRAG_MIME);
}
function isFilterActive(filter: ColumnFilter | undefined): boolean {
if (!filter) return false;
if (filter.selectedValues !== undefined) return true;
@ -1071,6 +1149,14 @@ export function EnhancedTable({
const [hiddenColumns, setHiddenColumns] = useState<Set<number>>(
() => new Set(),
);
const [columnWidths, setColumnWidths] = useState<Record<number, number>>({});
const [columnOrder, setColumnOrder] = useState<number[]>(() =>
initialColumnOrder(table.columnCount),
);
const [activeColumn, setActiveColumn] = useState<number | null>(null);
const [freezeFirstColumn, setFreezeFirstColumn] = useState(false);
const [resizingColumn, setResizingColumn] =
useState<ColumnResizeState | null>(null);
const [detailRowKey, setDetailRowKey] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [copiedVisible, setCopiedVisible] = useState(false);
@ -1095,6 +1181,7 @@ export function EnhancedTable({
columnIndex: number;
} | null>(null);
const selectionFrameRef = useRef(0);
const draggingColumnRef = useRef<number | null>(null);
const tableStructureKey = useMemo(
() =>
`${table.columnCount}\0${table.headers.map((header) => header.text).join('\0')}`,
@ -1189,12 +1276,23 @@ export function EnhancedTable({
setSelection(null);
setOpenFilterMenu(null);
setHiddenColumns(new Set());
setColumnWidths({});
setColumnOrder(initialColumnOrder(table.columnCount));
setActiveColumn(null);
setFreezeFirstColumn(false);
setResizingColumn(null);
setDetailRowKey(null);
resetCopiedVisible();
resetCopiedSelection();
draggingRef.current = false;
draggingColumnRef.current = null;
setIsDragging(false);
}, [resetCopiedSelection, resetCopiedVisible, tableStructureKey]);
}, [
resetCopiedSelection,
resetCopiedVisible,
table.columnCount,
tableStructureKey,
]);
useEffect(() => {
resetCopiedSelection();
@ -1303,17 +1401,54 @@ export function EnhancedTable({
),
[table.headers, table.rows],
);
const visibleColumnIndexes = useMemo(
const orderedVisibleColumnIndexes = useMemo(
() =>
table.headers
.map((_, index) => index)
columnOrder
.filter((index) => index >= 0 && index < table.columnCount)
.filter((index) => !hiddenColumns.has(index)),
[hiddenColumns, table.headers],
[columnOrder, hiddenColumns, table.columnCount],
);
const frozenColumnIndex = freezeFirstColumn
? orderedVisibleColumnIndexes[0]
: undefined;
useEffect(() => {
resetCopiedVisible();
}, [resetCopiedVisible, visibleColumnIndexes, visibleRows]);
}, [resetCopiedVisible, orderedVisibleColumnIndexes, visibleRows]);
useEffect(() => {
if (!resizingColumn) return;
const resizeColumn = (event: MouseEvent) => {
const nextWidth = clampColumnWidth(
resizingColumn.startWidth + event.clientX - resizingColumn.startX,
);
setColumnWidths((current) => {
const currentWidth =
current[resizingColumn.columnIndex] ?? DEFAULT_COLUMN_WIDTH;
if (currentWidth === nextWidth) return current;
if (nextWidth === DEFAULT_COLUMN_WIDTH) {
const next = { ...current };
delete next[resizingColumn.columnIndex];
return next;
}
return {
...current,
[resizingColumn.columnIndex]: nextWidth,
};
});
};
const stopResize = () => setResizingColumn(null);
window.addEventListener('mousemove', resizeColumn);
window.addEventListener('mouseup', stopResize);
window.addEventListener('blur', stopResize);
document.addEventListener('visibilitychange', stopResize);
return () => {
window.removeEventListener('mousemove', resizeColumn);
window.removeEventListener('mouseup', stopResize);
window.removeEventListener('blur', stopResize);
document.removeEventListener('visibilitychange', stopResize);
};
}, [resizingColumn]);
useEffect(() => {
if (detailRowKey && !visibleRows.some((row) => row.key === detailRowKey)) {
@ -1343,11 +1478,13 @@ export function EnhancedTable({
direction: SortState['direction'] | null,
) => {
setSelection(null);
setActiveColumn(columnIndex);
setSort(direction ? { columnIndex, direction } : null);
};
const toggleSort = (columnIndex: number) => {
setSelection(null);
setActiveColumn(columnIndex);
setSort((current) => {
if (current?.columnIndex !== columnIndex) {
return { columnIndex, direction: 'asc' };
@ -1364,6 +1501,7 @@ export function EnhancedTable({
columnIndex: number,
) => {
setSelection(null);
setActiveColumn(columnIndex);
filterTriggerRef.current = event.currentTarget;
const buttonRect = event.currentTarget.getBoundingClientRect();
const menuWidth = 300;
@ -1389,8 +1527,9 @@ export function EnhancedTable({
};
const hideColumn = (columnIndex: number) => {
if (visibleColumnIndexes.length <= 1) return;
if (orderedVisibleColumnIndexes.length <= 1) return;
setSelection(null);
setActiveColumn((current) => (current === columnIndex ? null : current));
closeFilterMenu();
setFilters((current) => {
const next = { ...current };
@ -1412,24 +1551,146 @@ export function EnhancedTable({
setHiddenColumns(new Set());
};
const toggleFreezeFirstColumn = () => {
setFreezeFirstColumn((current) => !current);
};
const toggleRowDetail = (rowKey: string) => {
setSelection(null);
setDetailRowKey((current) => (current === rowKey ? null : rowKey));
};
const selectionBounds = useMemo(
() => (selection ? getSelectionBounds(selection) : null),
const selectionRowBounds = useMemo(
() => (selection ? getSelectionRowBounds(selection) : null),
[selection],
);
const selectedColumnIndexSet = useMemo(
() =>
new Set(getSelectedColumnIndexes(selection, orderedVisibleColumnIndexes)),
[selection, orderedVisibleColumnIndexes],
);
const isCellSelected = (rowIndex: number, columnIndex: number): boolean => {
if (!selectionBounds) return false;
const { minRow, maxRow, minCol, maxCol } = selectionBounds;
if (!selectionRowBounds) return false;
const { minRow, maxRow } = selectionRowBounds;
return (
rowIndex >= minRow &&
rowIndex <= maxRow &&
columnIndex >= minCol &&
columnIndex <= maxCol
selectedColumnIndexSet.has(columnIndex)
);
};
const columnStyle = (
columnIndex: number,
extra?: CSSProperties,
): CSSProperties => {
const width = columnWidths[columnIndex];
if (width === undefined) {
return extra
? { ...DEFAULT_COLUMN_STYLE, ...extra }
: DEFAULT_COLUMN_STYLE;
}
return {
width,
minWidth: width,
maxWidth: width,
...extra,
};
};
const startColumnResize = (
event: ReactMouseEvent<HTMLButtonElement>,
columnIndex: number,
) => {
event.preventDefault();
event.stopPropagation();
setResizingColumn({
columnIndex,
startX: event.clientX,
startWidth: columnWidths[columnIndex] ?? DEFAULT_COLUMN_WIDTH,
});
};
const resizeColumnWithKeyboard = (
event: ReactKeyboardEvent<HTMLButtonElement>,
columnIndex: number,
) => {
let delta = 0;
if (event.key === 'ArrowRight') {
delta = KEYBOARD_COLUMN_RESIZE_STEP;
} else if (event.key === 'ArrowLeft') {
delta = -KEYBOARD_COLUMN_RESIZE_STEP;
}
if (delta === 0) return;
event.preventDefault();
event.stopPropagation();
setColumnWidths((current) => {
const width = current[columnIndex] ?? DEFAULT_COLUMN_WIDTH;
const nextWidth = clampColumnWidth(width + delta);
if (width === nextWidth) return current;
if (nextWidth === DEFAULT_COLUMN_WIDTH) {
const next = { ...current };
delete next[columnIndex];
return next;
}
return {
...current,
[columnIndex]: nextWidth,
};
});
};
const startColumnDrag = (
event: ReactDragEvent<HTMLButtonElement>,
columnIndex: number,
) => {
event.stopPropagation();
setActiveColumn(columnIndex);
draggingColumnRef.current = columnIndex;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData(COLUMN_DRAG_MIME, String(columnIndex));
};
const stopColumnDrag = () => {
draggingColumnRef.current = null;
};
const dragOverColumn = (event: ReactDragEvent<HTMLElement>) => {
if (
draggingColumnRef.current === null ||
!hasColumnDragData(event.dataTransfer)
) {
return;
}
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
};
const dropColumn = (
event: ReactDragEvent<HTMLElement>,
targetColumnIndex: number,
) => {
const sourceColumnIndex = draggingColumnRef.current;
stopColumnDrag();
if (
sourceColumnIndex === null ||
!hasColumnDragData(event.dataTransfer)
) {
return;
}
event.preventDefault();
event.stopPropagation();
setSelection(null);
setActiveColumn(sourceColumnIndex);
setColumnOrder((current) =>
moveVisibleColumn(
current,
sourceColumnIndex,
targetColumnIndex,
hiddenColumns,
),
);
};
@ -1523,7 +1784,11 @@ export function EnhancedTable({
};
const copySelection = () => {
const text = getSelectionText(selection, visibleRows, visibleColumnIndexes);
const text = getSelectionText(
selection,
visibleRows,
orderedVisibleColumnIndexes,
);
if (!text || !navigator.clipboard) return;
const copyGeneration = copiedSelectionGenRef.current;
void navigator.clipboard
@ -1549,7 +1814,7 @@ export function EnhancedTable({
const text = getVisibleTableText(
table.headers,
visibleRows,
visibleColumnIndexes,
orderedVisibleColumnIndexes,
);
if (!text || !navigator.clipboard) return;
const copyGeneration = copiedVisibleGenRef.current;
@ -1574,13 +1839,17 @@ export function EnhancedTable({
const handleCopy = (event: ClipboardEvent<HTMLDivElement>) => {
if (hasNativeSelection()) return;
const text = getSelectionText(selection, visibleRows, visibleColumnIndexes);
const text = getSelectionText(
selection,
visibleRows,
orderedVisibleColumnIndexes,
);
if (!text) return;
event.preventDefault();
event.clipboardData.setData('text/plain', text);
};
const selectedCount = selectionSize(selection, visibleColumnIndexes);
const selectedCount = selectionSize(selection, orderedVisibleColumnIndexes);
const activeFilterCount =
Object.values(filters).filter(isFilterActive).length;
const rowSummary =
@ -1633,6 +1902,17 @@ export function EnhancedTable({
})}
</button>
)}
{orderedVisibleColumnIndexes.length > 0 && (
<button
className={styles.copyButton}
type="button"
onClick={toggleFreezeFirstColumn}
>
{freezeFirstColumn
? t('markdownTable.unfreezeFirstColumn')
: t('markdownTable.freezeFirstColumn')}
</button>
)}
{activeFilterCount > 0 && (
<span className={styles.selection}>
{t('markdownTable.filtersActive', { count: activeFilterCount })}
@ -1670,10 +1950,14 @@ export function EnhancedTable({
<table className={styles.table}>
<thead>
<tr>
<th className={`${styles.headerCell} ${styles.actionHeaderCell}`}>
<th
className={`${styles.headerCell} ${styles.actionHeaderCell} ${
freezeFirstColumn ? styles.stickyActionHeaderCell : ''
}`}
>
{t('markdownTable.actions')}
</th>
{visibleColumnIndexes.map((columnIndex) => {
{orderedVisibleColumnIndexes.map((columnIndex) => {
const header = table.headers[columnIndex];
if (!header) return null;
const isSorted = sort?.columnIndex === columnIndex;
@ -1704,12 +1988,18 @@ export function EnhancedTable({
const headerAlignStyle = header.textAlign
? { textAlign: header.textAlign }
: undefined;
const isFrozenColumn = frozenColumnIndex === columnIndex;
const isActiveColumn = activeColumn === columnIndex;
return (
<th
key={header.key}
className={styles.headerCell}
className={`${styles.headerCell} ${
isFrozenColumn ? styles.frozenHeaderCell : ''
} ${isActiveColumn ? styles.activeHeaderCell : ''}`}
aria-sort={ariaSort}
style={headerAlignStyle}
onDragOver={dragOverColumn}
onDrop={(event) => dropColumn(event, columnIndex)}
style={columnStyle(columnIndex, headerAlignStyle)}
>
<div className={styles.headerControls}>
<button
@ -1729,6 +2019,23 @@ export function EnhancedTable({
{sortLabel}
</span>
</button>
<button
className={`${styles.reorderHandle} ${
isActiveColumn ? styles.reorderHandleVisible : ''
}`}
type="button"
draggable
tabIndex={isActiveColumn ? 0 : -1}
onDragStart={(event) =>
startColumnDrag(event, columnIndex)
}
onDragEnd={stopColumnDrag}
aria-label={t('markdownTable.moveColumn', {
column: columnName,
})}
>
</button>
<button
className={`${styles.filterTrigger} ${
isFiltered ? styles.filterTriggerActive : ''
@ -1746,6 +2053,26 @@ export function EnhancedTable({
</button>
</div>
<button
className={styles.resizeHandle}
type="button"
onMouseDown={(event) =>
startColumnResize(event, columnIndex)
}
onKeyDown={(event) =>
resizeColumnWithKeyboard(event, columnIndex)
}
role="separator"
aria-label={t('markdownTable.resizeColumn', {
column: columnName,
})}
aria-orientation="vertical"
aria-valuemin={MIN_COLUMN_WIDTH}
aria-valuemax={MAX_COLUMN_WIDTH}
aria-valuenow={
columnWidths[columnIndex] ?? DEFAULT_COLUMN_WIDTH
}
/>
</th>
);
})}
@ -1760,7 +2087,11 @@ export function EnhancedTable({
<tr
className={rowIndex % 2 === 1 ? styles.evenRow : undefined}
>
<td className={`${styles.cell} ${styles.actionCell}`}>
<td
className={`${styles.cell} ${styles.actionCell} ${
freezeFirstColumn ? styles.stickyActionCell : ''
}`}
>
<button
className={styles.rowDetailButton}
type="button"
@ -1777,12 +2108,13 @@ export function EnhancedTable({
{t('markdownTable.rowDetails')}
</button>
</td>
{visibleColumnIndexes.map((columnIndex) => {
{orderedVisibleColumnIndexes.map((columnIndex) => {
const cell = row.cells[columnIndex];
if (!cell) return null;
const cellAlignStyle = cell.textAlign
? { textAlign: cell.textAlign }
: undefined;
const isFrozenColumn = frozenColumnIndex === columnIndex;
return (
<td
key={cell.key}
@ -1790,8 +2122,8 @@ export function EnhancedTable({
isCellSelected(rowIndex, columnIndex)
? styles.selectedCell
: ''
}`}
style={cellAlignStyle}
} ${isFrozenColumn ? styles.frozenCell : ''}`}
style={columnStyle(columnIndex, cellAlignStyle)}
data-row-index={rowIndex}
data-column-index={columnIndex}
onMouseDown={(event) =>
@ -1816,13 +2148,13 @@ export function EnhancedTable({
<tr id={detailId} className={styles.detailRow}>
<td
className={styles.detailCell}
colSpan={visibleColumnIndexes.length + 1}
colSpan={orderedVisibleColumnIndexes.length + 1}
>
<div className={styles.detailPanel}>
<div className={styles.detailTitle}>
{t('markdownTable.detailsHeader')}
</div>
{visibleColumnIndexes.map((columnIndex) => {
{orderedVisibleColumnIndexes.map((columnIndex) => {
const header = table.headers[columnIndex];
const cell = row.cells[columnIndex];
if (!header || !cell) return null;
@ -1881,7 +2213,7 @@ export function EnhancedTable({
sort={sort}
style={{ left: openFilterMenu.left, top: openFilterMenu.top }}
menuRef={filterMenuRef}
canHideColumn={visibleColumnIndexes.length > 1}
canHideColumn={orderedVisibleColumnIndexes.length > 1}
onApply={setColumnFilter}
onClose={closeFilterMenu}
onHideColumn={hideColumn}

View file

@ -4,6 +4,7 @@ import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { ACPToolCall } from '../../adapters/types';
import { I18nProvider } from '../../i18n';
import { WebShellCustomizationProvider } from '../../customization';
vi.mock('../../App', async () => {
const { createContext } = await import('react');
@ -19,7 +20,6 @@ const {
extractDiff,
fencedCodeBlock,
formatSingleToolSummary,
formatRunningSingleToolSummary,
formatToolGroupSummary,
getActiveTool,
getRawFileDiff,
@ -56,14 +56,17 @@ function makeTool(overrides: Partial<ACPToolCall> = {}): ACPToolCall {
};
}
function renderToolLine(tool: ACPToolCall): HTMLElement {
function renderToolLine(
tool: ACPToolCall,
props: Partial<Parameters<typeof ToolLine>[0]> = {},
): HTMLElement {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<I18nProvider language="en">
<ToolLine tool={tool} />
<ToolLine tool={tool} {...props} />
</I18nProvider>,
);
});
@ -71,14 +74,19 @@ function renderToolLine(tool: ACPToolCall): HTMLElement {
return container;
}
function renderToolGroup(tools: ACPToolCall[]): HTMLElement {
function renderToolGroup(
tools: ACPToolCall[],
customization = {},
): HTMLElement {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<I18nProvider language="en">
<ToolGroup tools={tools} />
<WebShellCustomizationProvider value={customization}>
<ToolGroup tools={tools} />
</WebShellCustomizationProvider>
</I18nProvider>,
);
});
@ -185,18 +193,51 @@ describe('tool group summary logic', () => {
);
});
it('formats a single tool summary from the tool itself', () => {
it('formats a single shell summary as only the semantic description', () => {
expect(
formatSingleToolSummary(
makeTool({
toolName: 'run_shell_command',
args: {
command: 'dataworks-infra workspace list',
description: '查询用户工作空间列表',
timeout: 30000,
},
}),
t,
),
).toBe('查询用户工作空间列表');
});
it('falls back to command text for shell summaries without descriptions', () => {
expect(
formatSingleToolSummary(
makeTool({
toolName: 'Shell',
args: { command: 'npm run build' },
args: { command: 'npm run build', timeout: 30000 },
}),
t,
),
).toBe('Shell npm run build');
});
it('uses only skill names in single tool summaries', () => {
expect(
formatSingleToolSummary(
makeTool({
toolName: 'skill',
title:
'Skill: Use skill: "qc-helper" with args: "weather in Hangzhou next 5 days"',
args: {
skill: 'qc-helper',
args: 'weather in Hangzhou next 5 days',
},
}),
t,
),
).toBe('Skill qc-helper');
});
it('uses action summaries for single todo and ask-user tools', () => {
expect(
formatSingleToolSummary(makeTool({ toolName: 'todo_write' }), t),
@ -206,20 +247,6 @@ describe('tool group summary logic', () => {
).toBe('Asked user');
});
it('keeps running state and command details for a single active tool', () => {
expect(
formatRunningSingleToolSummary(
makeTool({
toolName: 'Shell',
status: 'in_progress',
args: { command: 'npm run build' },
}),
t,
'0:03',
),
).toBe('Running Shell npm run build 0:03');
});
it('truncates long single tool descriptions in the chat summary', () => {
const summary = formatSingleToolSummary(
makeTool({
@ -232,6 +259,61 @@ describe('tool group summary logic', () => {
expect(summary.length).toBeLessThan(140);
expect(summary).toContain('...');
});
it('lets custom tool header extras render single-tool chat summaries', () => {
const container = renderToolGroup(
[
makeTool({
toolName: 'run_shell_command',
args: {
command: 'dataworks-infra workspace list',
description: '查询用户工作空间列表',
timeout: 30000,
},
}),
],
{
renderToolHeaderExtra: (info) => (
<span data-testid="custom-summary">
{info.kind}:{info.description}
</span>
),
},
);
const summary = container.querySelector('button');
expect(summary?.textContent).not.toContain('Shell');
expect(summary?.textContent).toContain('shell:查询用户工作空间列表');
expect(summary?.textContent).not.toContain('timeout: 30000ms');
});
it('uses action descriptions for shell rows inside grouped summaries', () => {
const container = renderToolGroup([
makeTool({
callId: 'shell',
toolName: 'run_shell_command',
title:
'Shell: dataworks-infra workspace list [timeout: 30000ms] (查询用户工作空间列表)',
args: {
command: 'dataworks-infra workspace list',
description: '查询用户工作空间列表',
timeout: 30000,
},
}),
makeTool({
callId: 'read',
toolName: 'read_file',
args: { file_path: 'README.md' },
}),
]);
expect(container.textContent).toContain('Shell');
expect(container.textContent).toContain('查询用户工作空间列表');
expect(container.textContent).not.toContain(
'dataworks-infra workspace list',
);
expect(container.textContent).not.toContain('timeout: 30000ms');
});
});
describe('tool expandability', () => {
@ -253,6 +335,32 @@ describe('tool expandability', () => {
),
).toBe(false);
});
it('does not expand skill rows that only have the skill name', () => {
expect(
hasExpandableContent(
makeTool({
toolName: 'skill',
title: 'Skill: Use skill: "review"',
args: { skill: 'review' },
}),
),
).toBe(false);
expect(
hasExpandableContent(
makeTool({
toolName: 'skill',
args: { skill: 'review' },
content: [
{
type: 'content',
content: { type: 'text', text: '# Code Review' },
},
],
}),
),
).toBe(true);
});
});
describe('tool kind logic', () => {
@ -363,6 +471,84 @@ describe('tool row rendering', () => {
expect(header.textContent).toContain(pattern);
expect(header.textContent).toContain('packages/web-shell/client');
});
it('uses the shell tool name for expanded cards from action summaries', () => {
const container = renderToolLine(
makeTool({
toolName: 'run_shell_command',
args: {
command: 'dataworks-infra workspace list',
description: '查询用户工作空间列表',
timeout: 30000,
},
content: [
{
type: 'content',
content: { type: 'text', text: 'failed\nwith details' },
},
],
}),
{ summaryOnly: true },
);
const header = container.querySelector('[role="button"]') as HTMLElement;
expect(header.textContent).toContain('Shell');
expect(header.textContent).toContain('查询用户工作空间列表');
act(() => header.click());
const cardTitle = container.querySelector('[class*="expandedCardTitle"]');
expect(cardTitle?.textContent).toBe('Shell');
});
it('shows complete skill content in the expanded card body', () => {
const container = renderToolLine(
makeTool({
toolName: 'skill',
title: 'Skill: Use skill: "review" with args: "check the current diff"',
args: {
skill: 'review',
},
content: [
{
type: 'content',
content: {
type: 'text',
text: 'Base directory for this skill: /repo\n# Code Review',
},
},
],
}),
);
const header = container.querySelector('[role="button"]') as HTMLElement;
expect(header.textContent).toContain('Skill');
expect(header.textContent).toContain('review');
expect(header.textContent).not.toContain('check the current diff');
act(() => header.click());
const output = container.querySelector('pre');
expect(output?.textContent).toBe(
'Base directory for this skill: /repo\n# Code Review',
);
});
it('keeps running state for single todo summaries', () => {
const container = renderToolGroup([
makeTool({
toolName: 'todo_write',
status: 'in_progress',
args: {
todos: [{ id: '1', content: 'Check UI', status: 'in_progress' }],
},
}),
]);
const summary = container.querySelector('button');
expect(summary?.textContent).toContain('Running');
expect(summary?.textContent).toContain('Updated task list');
});
});
describe('tool output logic', () => {

View file

@ -42,9 +42,12 @@ import {
getAgentDisplayStatus,
getAgentType,
getTaskExecutionRecord,
getShellToolSemanticDescription,
getToolDescription,
getToolSummaryDescription,
getToolResultSummary,
isAskUserQuestionToolName,
isSkillToolName,
isShellToolName,
toolContainsCallId,
} from './toolFormatting';
@ -77,6 +80,9 @@ export function hasExpandableContent(tool: ACPToolCall): boolean {
const text = extractText(tool);
return !!text && text.trim().length > 0 && text.split('\n').length > 1;
}
if (isSkillToolName(name)) {
return !!getFirstToolContentText(tool);
}
if (name === 'edit' || name === 'write' || name === 'editfile') {
return hasEditContent(tool);
}
@ -104,6 +110,7 @@ function hasDetailView(tool: ACPToolCall): boolean {
name === 'read' ||
name === 'read_file' ||
name === 'readfile' ||
isSkillToolName(name) ||
isAskUserQuestionToolName(tool.toolName)
);
}
@ -477,6 +484,22 @@ function ExpandedAskUserQuestionOutput({ tool }: { tool: ACPToolCall }) {
return <pre className={styles.expandedOutput}>{text}</pre>;
}
function ExpandedSkillOutput({ tool }: { tool: ACPToolCall }) {
const content =
getFirstToolContentText(tool) ||
(typeof tool.args?.args === 'string' && tool.args.args.trim()
? tool.args.args.trim()
: (tool.title ?? ''));
return <pre className={styles.expandedOutput}>{content}</pre>;
}
function getFirstToolContentText(tool: ACPToolCall): string {
const block = tool.content?.[0];
if (block?.type !== 'content') return '';
return typeof block.content?.text === 'string' ? block.content.text : '';
}
export function getToolHeaderKind(tool: ACPToolCall): ToolHeaderKind {
const name = tool.toolName.toLowerCase();
if (isSubAgentToolCall(tool)) return 'agent';
@ -575,22 +598,74 @@ export function formatSingleToolSummary(
return t('toolGroup.summary.askedUser', { count: 1 });
}
const displayName = localizeToolDisplayName(tool.toolName, t);
const description = truncateText(getToolDescription(tool, workspaceCwd), 120);
return [displayName, description].filter(Boolean).join(' ');
const { displayName, description, hideDisplayName } =
getSingleToolSummaryInfo(tool, t, workspaceCwd);
return [hideDisplayName ? '' : displayName, description]
.filter(Boolean)
.join(' ');
}
export function formatRunningSingleToolSummary(
function getSingleToolSummaryInfo(
tool: ACPToolCall,
t: ReturnType<typeof useI18n>['t'],
duration?: string,
workspaceCwd?: string,
): string {
return t('toolGroup.running', {
name: formatSingleToolSummary(tool, t, workspaceCwd),
count: 1,
duration: duration ?? '',
});
): ToolHeaderExtraRenderInfo & { hideDisplayName: boolean } {
const displayName = localizeToolDisplayName(tool.toolName, t);
const description = truncateText(
getToolSummaryDescription(tool, workspaceCwd),
120,
);
return {
kind: getToolHeaderKind(tool),
tool,
displayName,
description,
elapsed: '',
workspaceCwd,
hideDisplayName: !!getShellToolSemanticDescription(tool),
};
}
function SingleToolSummary({
tool,
runningDuration,
workspaceCwd,
}: {
tool: ACPToolCall;
runningDuration?: string;
workspaceCwd?: string;
}) {
const { t } = useI18n();
const runningPrefix =
isActiveToolStatus(tool.status) && t('toolGroup.runningPrefix').trim();
if (
isTodoWriteToolName(tool.toolName) ||
isAskUserQuestionToolName(tool.toolName)
) {
return (
<>
{runningPrefix && <span>{runningPrefix} </span>}
{formatSingleToolSummary(tool, t, workspaceCwd)}
{runningDuration && <span> {runningDuration}</span>}
</>
);
}
const info = getSingleToolSummaryInfo(tool, t, workspaceCwd);
return (
<>
{runningPrefix && <span>{runningPrefix} </span>}
<span className={styles.chatSummaryInline}>
{!info.hideDisplayName && (
<span className={styles.lineName}>{info.displayName}</span>
)}
<ToolHeaderExtra info={info} />
</span>
{runningDuration && <span> {runningDuration}</span>}
</>
);
}
function formatCompletedToolSummary(
@ -1046,6 +1121,12 @@ export const ToolLine = memo(function ToolLine({
workspaceCwd,
}}
/>
<span
className={
expanded ? styles.lineChevronDown : styles.lineChevronRight
}
aria-hidden="true"
/>
</div>
)}
{showExpanded && (
@ -1061,8 +1142,12 @@ export const ToolLine = memo(function ToolLine({
);
}
const description = getToolDescription(tool, workspaceCwd);
const fullDescription = getToolDescription(tool, workspaceCwd);
const result = getToolResultSummary(tool);
const summaryShell = summaryOnly && isShellToolName(tool.toolName);
const description = summaryShell
? getToolSummaryDescription(tool, workspaceCwd)
: fullDescription;
const displayName = localizeToolDisplayName(tool.toolName, t);
const elapsed =
isShellToolName(tool.toolName) || isWebFetchToolName(tool.toolName)
@ -1100,7 +1185,7 @@ export const ToolLine = memo(function ToolLine({
const useMarkdownDetail = isRead;
const hideDescriptionInHeader =
showDescriptionInDetail && !isShell && !isSearch && !isRead;
const expandedCardDetail = description;
const expandedCardDetail = fullDescription;
const showExpandedSummaryPanel =
!isTodo && expanded && !detailView && (showDescriptionInDetail || result);
@ -1158,6 +1243,14 @@ export const ToolLine = memo(function ToolLine({
workspaceCwd,
}}
/>
{expandable && (
<span
className={
expanded ? styles.lineChevronDown : styles.lineChevronRight
}
aria-hidden="true"
/>
)}
</div>
)}
{(!summaryOnly || expanded) && isTodo && hasTodoList && (
@ -1222,6 +1315,7 @@ export const ToolLine = memo(function ToolLine({
{isAskUserQuestionToolName(tool.toolName) && (
<ExpandedAskUserQuestionOutput tool={tool} />
)}
{isSkillToolName(name) && <ExpandedSkillOutput tool={tool} />}
</ToolExpandedCard>
)}
</div>
@ -1292,16 +1386,15 @@ export const ToolGroup = memo(function ToolGroup({
: styles.chatSummaryText
}
>
{singleTool
? hasRunningTool
? formatRunningSingleToolSummary(
singleTool,
t,
runningDuration,
workspaceCwd,
)
: formatSingleToolSummary(singleTool, t, workspaceCwd)
: formatToolGroupSummary(tools, t, runningDuration)}
{singleTool ? (
<SingleToolSummary
tool={singleTool}
runningDuration={hasRunningTool ? runningDuration : undefined}
workspaceCwd={workspaceCwd}
/>
) : (
formatToolGroupSummary(tools, t, runningDuration)
)}
</span>
<span
className={

View file

@ -5,6 +5,7 @@ import {
getAgentCurrentToolHint,
getToolDescription,
getToolResultSummary,
getToolSummaryDescription,
localizeToolDisplayName,
TOOL_DISPLAY_NAMES,
} from './toolFormatting';
@ -264,6 +265,52 @@ describe('toolFormatting', () => {
).toBe('cat ~/.qwen/settings.json (查看 ~/.qwen/settings.json 文件内容)');
});
it('uses semantic shell descriptions for summaries', () => {
const shellTool = tool({
toolName: 'run_shell_command',
title:
'Shell: dataworks-infra workspace list [timeout: 30000ms] (查询用户工作空间列表)',
args: {
command: 'dataworks-infra workspace list',
description: '查询用户工作空间列表',
timeout: 30000,
},
});
expect(getToolSummaryDescription(shellTool)).toBe('查询用户工作空间列表');
expect(getToolDescription(shellTool)).toBe(
'dataworks-infra workspace list [timeout: 30000ms] (查询用户工作空间列表)',
);
});
it('falls back to shell commands in summaries without timeout metadata', () => {
expect(
getToolSummaryDescription(
tool({
toolName: 'run_shell_command',
args: {
command: 'npm test',
timeout: 1000,
},
}),
),
).toBe('npm test');
});
it('describes skill calls from raw input', () => {
expect(
getToolDescription(
tool({
toolName: 'skill',
args: {
skill: 'qc-helper',
args: 'weather in Hangzhou next 5 days',
},
}),
),
).toBe('qc-helper');
});
it('summarizes read_file rawOutput by line count', () => {
expect(
getToolResultSummary(

View file

@ -100,6 +100,10 @@ export function getToolDescription(
tool: ACPToolCall,
workspaceCwd?: string,
): string {
if (isSkillToolName(tool.toolName)) {
const skillName = getStringArg(tool.args, 'skill');
if (skillName) return truncateText(skillName, MAX_DESCRIPTION_LENGTH);
}
const fromTitle = getDescriptionFromTitle(tool, workspaceCwd);
if (fromTitle) return truncateText(fromTitle, MAX_DESCRIPTION_LENGTH);
const fromArgs = getDescriptionFromArgs(tool, workspaceCwd);
@ -107,6 +111,29 @@ export function getToolDescription(
return '';
}
export function getToolSummaryDescription(
tool: ACPToolCall,
workspaceCwd?: string,
): string {
if (!isShellToolName(tool.toolName)) {
return getToolDescription(tool, workspaceCwd);
}
const description = getStringArg(tool.args, 'description');
if (description) return truncateText(description, MAX_DESCRIPTION_LENGTH);
const fromArgs = getDescriptionFromArgs(tool, workspaceCwd, {
includeTimeout: false,
});
if (fromArgs) return truncateText(fromArgs, MAX_DESCRIPTION_LENGTH);
return '';
}
export function getShellToolSemanticDescription(tool: ACPToolCall): string {
if (!isShellToolName(tool.toolName)) return '';
return getStringArg(tool.args, 'description');
}
export function extractText(tool: ACPToolCall): string | null {
if (!tool.content) {
return extractRawOutputText(tool.rawOutput);
@ -223,9 +250,11 @@ function parseGrepSummary(text: string): string | null {
function getDescriptionFromArgs(
tool: ACPToolCall,
workspaceCwd?: string,
options: { includeTimeout?: boolean } = {},
): string {
const args = tool.args || {};
const name = tool.toolName.toLowerCase();
const includeTimeout = options.includeTimeout ?? true;
if (args.command) {
let description = String(args.command);
@ -234,11 +263,12 @@ function getDescriptionFromArgs(
}
if (args.is_background) {
description += ' [background]';
} else if (args.timeout) {
} else if (includeTimeout && args.timeout) {
description += ` [timeout: ${String(args.timeout)}ms]`;
}
if (args.description) {
description += ` (${String(args.description).replace(/\n/g, ' ')})`;
const argDescription = getStringArg(args, 'description');
if (argDescription) {
description += ` (${argDescription})`;
}
return truncateText(description, MAX_DESCRIPTION_LENGTH);
}
@ -285,6 +315,14 @@ function getDescriptionFromArgs(
return '';
}
function getStringArg(
args: Record<string, unknown> | undefined,
key: string,
): string {
const value = args?.[key];
return typeof value === 'string' ? value.trim().replace(/\n/g, ' ') : '';
}
export function isShellToolName(name: string): boolean {
const normalized = name.toLowerCase();
return (
@ -295,6 +333,10 @@ export function isShellToolName(name: string): boolean {
);
}
export function isSkillToolName(name: string): boolean {
return name.toLowerCase() === 'skill';
}
export function toolContainsCallId(
tool: ACPToolCall,
toolCallId: string,

View file

@ -82,6 +82,16 @@
font-weight: 400;
}
.chatSummaryInline {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
max-width: 100%;
color: inherit;
vertical-align: bottom;
}
.chatSummaryTextActive {
color: var(--muted-foreground);
background-image: linear-gradient(
@ -193,12 +203,12 @@
align-items: center;
gap: 8px;
min-width: 0;
}
.icon {
color: var(--muted-foreground);
font-size: 13px;
font-weight: 400;
}
.icon {
flex-shrink: 0;
white-space: nowrap;
}
@ -208,15 +218,10 @@
}
.lineName {
font-weight: 400;
color: var(--muted-foreground);
font-size: 13px;
flex-shrink: 0;
}
.lineArg {
color: var(--muted-foreground);
font-size: 13px;
min-width: 0;
flex: 1;
overflow: hidden;
@ -226,7 +231,6 @@
.lineElapsed {
margin-left: auto;
color: var(--muted-foreground);
font-size: 12px;
flex-shrink: 0;
}
@ -242,12 +246,53 @@
cursor: pointer;
}
.lineExpandable:hover .lineName,
.lineExpandable:hover .lineArg,
.lineExpandable:hover .lineElapsed {
.lineExpandable .lineArg {
flex: 0 1 auto;
}
.lineExpandable:hover,
.lineExpandable:focus-visible {
color: var(--foreground);
}
.lineChevronRight,
.lineChevronDown {
width: 12px;
height: 12px;
display: inline-block;
flex-shrink: 0;
opacity: 0;
transition: opacity 120ms ease;
}
.lineChevronRight::before,
.lineChevronDown::before {
content: '';
display: block;
width: 5px;
height: 5px;
border-right: 1.25px solid currentColor;
border-bottom: 1.25px solid currentColor;
border-radius: 0.5px;
margin: 3px 0 0 2px;
}
.lineChevronRight::before {
transform: rotate(-45deg);
}
.lineChevronDown::before {
margin: 2px 0 0 3px;
transform: rotate(45deg);
}
.lineExpandable:hover .lineChevronRight,
.lineExpandable:hover .lineChevronDown,
.lineExpandable:focus-visible .lineChevronRight,
.lineExpandable:focus-visible .lineChevronDown {
opacity: 1;
}
.lineOutput {
margin-left: 16px;
font-size: 12px;
@ -386,6 +431,9 @@
align-items: baseline;
gap: 8px;
min-width: 0;
color: var(--muted-foreground);
font-size: 13px;
font-weight: 400;
}
.compactCount {

View file

@ -14,6 +14,7 @@ export function useMessages(
promptCancelled: t('request.cancelled'),
branchSuccess: (name) => t('branch.success', { name }),
midTurnInserted: (message) => t('midTurn.inserted', { message }),
modelStreamInterrupted: t('error.modelStreamInterrupted'),
},
}),
[blocks, t],

View file

@ -265,11 +265,15 @@ const EN: Messages = {
},
'markdownTable.copyTsv': 'Copy TSV',
'markdownTable.copyVisible': 'Quick copy',
'markdownTable.freezeFirstColumn': 'Freeze first column',
'markdownTable.unfreezeFirstColumn': 'Unfreeze first column',
'markdownTable.hideColumn': 'Hide column',
'markdownTable.showHiddenColumns': (v) => {
const count = Number(v?.count ?? 0);
return `Show ${count} hidden column${count === 1 ? '' : 's'}`;
},
'markdownTable.moveColumn': (v) => `Move ${v?.column ?? ''}`,
'markdownTable.resizeColumn': (v) => `Resize ${v?.column ?? ''}`,
'markdownTable.rowDetails': 'Details',
'markdownTable.rowDetailsAria': (v) =>
`View details for row ${v?.index ?? ''}`,
@ -827,6 +831,8 @@ const EN: Messages = {
'bug.submitted': 'Bug report opened in a new tab.',
'clear.blocked': 'Cannot clear while streaming — cancel first (Esc).',
'error.unknown': 'Unknown error',
'error.modelStreamInterrupted':
'Model response stream was interrupted. Please retry.',
'shell.command': 'Shell Command',
'compact.enabled': 'Compact mode enabled',
'compact.disabled': 'Compact mode disabled',
@ -1550,6 +1556,7 @@ const EN: Messages = {
`Running ${v?.name ?? 'tool'}${v?.duration ? ` ${v.duration}` : ''}${
Number(v?.count ?? 0) > 1 ? ` · ${v?.count ?? 0} tools` : ''
}`,
'toolGroup.runningPrefix': 'Running',
'thinking.expand': 'Expand thinking',
'thinking.collapse': 'Collapse thinking',
'thinking.running': (v) => `Thinking${v?.duration ? ` ${v.duration}` : ''}`,
@ -1641,7 +1648,7 @@ const ZH: Messages = {
'toolName.todo_write': '任务清单',
'toolName.save_memory': '保存记忆',
'toolName.agent': 'Agent',
'toolName.skill': '技能',
'toolName.skill': '查看技能',
'toolName.enter_plan_mode': '进入计划模式',
'toolName.exit_plan_mode': '退出计划模式',
'toolName.web_fetch': '网络搜索',
@ -1907,8 +1914,12 @@ const ZH: Messages = {
'markdownTable.cellsSelected': (v) => `${v?.count ?? 0} 个单元格已选中`,
'markdownTable.copyTsv': '复制 TSV',
'markdownTable.copyVisible': '快捷复制',
'markdownTable.freezeFirstColumn': '冻结首列',
'markdownTable.unfreezeFirstColumn': '取消冻结首列',
'markdownTable.hideColumn': '隐藏列',
'markdownTable.showHiddenColumns': (v) => `显示 ${v?.count ?? 0} 个隐藏列`,
'markdownTable.moveColumn': (v) => `移动 ${v?.column ?? ''}`,
'markdownTable.resizeColumn': (v) => `调整 ${v?.column ?? ''} 列宽`,
'markdownTable.rowDetails': '详情',
'markdownTable.rowDetailsAria': (v) => `查看第 ${v?.index ?? ''} 行详情`,
'markdownTable.closeRowDetailsAria': (v) => `收起第 ${v?.index ?? ''} 行详情`,
@ -2439,6 +2450,7 @@ const ZH: Messages = {
'bug.submitted': 'Bug 报告已在新标签页中打开。',
'clear.blocked': '流式输出中无法清屏 — 先按 Esc 取消。',
'error.unknown': '未知错误',
'error.modelStreamInterrupted': '模型响应流已中断,请重试。',
'shell.command': 'Shell 命令',
'compact.enabled': '紧凑模式已开启',
'compact.disabled': '紧凑模式已关闭',
@ -3115,6 +3127,7 @@ const ZH: Messages = {
`正在执行 ${v?.name ?? '工具'}${v?.duration ? ` ${v.duration}` : ''}${
Number(v?.count ?? 0) > 1 ? ` · 共 ${v?.count ?? 0} 个工具` : ''
}`,
'toolGroup.runningPrefix': '正在执行',
'thinking.expand': '展开思考',
'thinking.collapse': '收起思考',
'thinking.running': (v) => `正在思考${v?.duration ? ` ${v.duration}` : ''}`,

View file

@ -31,6 +31,89 @@ describe('daemonTranscriptToUnifiedMessages', () => {
});
});
it('renders model stream interruption turn errors with user-facing text', () => {
const [message] = daemonTranscriptToUnifiedMessages([
{
id: 'error-1',
kind: 'error',
source: 'turn_error',
errorKind: 'model_stream_interrupted',
text: 'terminated',
clientReceivedAt: 1,
createdAt: 1,
updatedAt: 1,
},
]);
expect(message).toMatchObject({
type: 'tool_call',
toolCall: {
kind: 'system_error',
rawOutput: 'Model response stream was interrupted. Please retry.',
content: [
{
content: {
text: 'Model response stream was interrupted. Please retry.',
error: 'Model response stream was interrupted. Please retry.',
},
},
],
},
});
});
it('renders older daemon terminated turn errors with user-facing text', () => {
const [message] = daemonTranscriptToUnifiedMessages([
{
id: 'error-legacy',
kind: 'error',
source: 'turn_error',
text: 'terminated',
clientReceivedAt: 1,
createdAt: 1,
updatedAt: 1,
},
]);
expect(message).toMatchObject({
type: 'tool_call',
toolCall: {
kind: 'system_error',
rawOutput: 'Model response stream was interrupted. Please retry.',
content: [
{
content: {
text: 'Model response stream was interrupted. Please retry.',
error: 'Model response stream was interrupted. Please retry.',
},
},
],
},
});
});
it('passes non-terminated turn error text through unchanged', () => {
const [message] = daemonTranscriptToUnifiedMessages([
{
id: 'error-other',
kind: 'error',
source: 'turn_error',
text: 'context deadline exceeded',
clientReceivedAt: 1,
createdAt: 1,
updatedAt: 1,
},
]);
expect(message).toMatchObject({
type: 'tool_call',
toolCall: {
kind: 'system_error',
rawOutput: 'context deadline exceeded',
},
});
});
it('maps daemon tool statuses without leaving terminal states spinning', () => {
const messages = daemonTranscriptToUnifiedMessages([
createToolBlock('cancelled-tool', 'cancelled'),

View file

@ -10,6 +10,7 @@ import {
isDaemonUiSensitiveKey,
sanitizeDaemonTerminalText,
type DaemonTranscriptBlock,
type DaemonStatusTranscriptBlock,
type DaemonToolTranscriptBlock,
} from '@qwen-code/sdk/daemon';
import type { UnifiedMessage } from '../adapters/types.js';
@ -43,6 +44,9 @@ export interface DaemonTranscriptAdapterOptions {
enrichToolDetailsWithPreview?: boolean;
}
const MODEL_STREAM_INTERRUPTED_MESSAGE =
'Model response stream was interrupted. Please retry.';
export function daemonTranscriptToUnifiedMessages(
blocks: readonly DaemonTranscriptBlock[],
options: DaemonTranscriptAdapterOptions = {},
@ -145,7 +149,8 @@ export function daemonTranscriptToUnifiedMessages(
isLast,
},
];
case 'error':
case 'error': {
const text = getErrorDisplayText(block);
return [
{
id: block.id,
@ -156,14 +161,14 @@ export function daemonTranscriptToUnifiedMessages(
kind: 'system_error',
title: 'System error',
status: 'failed',
rawOutput: sanitizeDisplayText(block.text),
rawOutput: sanitizeDisplayText(text),
content: [
{
type: 'content',
content: {
type: 'error',
text: sanitizeDisplayText(block.text),
error: sanitizeDisplayText(block.text),
text: sanitizeDisplayText(text),
error: sanitizeDisplayText(text),
},
},
],
@ -172,6 +177,7 @@ export function daemonTranscriptToUnifiedMessages(
isLast,
},
];
}
case 'status':
return [
{
@ -196,6 +202,17 @@ export function daemonTranscriptToUnifiedMessages(
});
}
function getErrorDisplayText(block: DaemonStatusTranscriptBlock): string {
if (
block.errorKind === 'model_stream_interrupted' ||
(block.source === 'turn_error' &&
block.text.trim().toLowerCase() === 'terminated')
) {
return MODEL_STREAM_INTERRUPTED_MESSAGE;
}
return block.text;
}
function daemonToolBlockToToolCallData(
block: DaemonToolTranscriptBlock,
enrichDetails: boolean = false,

View file

@ -7,6 +7,7 @@
import { execFileSync, spawnSync } from 'node:child_process';
import {
chmodSync,
existsSync,
mkdtempSync,
readFileSync,
rmSync,
@ -895,6 +896,109 @@ describe('qwen-autofix workflow', () => {
);
});
it('posts a human-handoff marker when review addressing reaches a terminal handoff', () => {
expect(reviewAddressReportStep).toContain(
"GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}'",
);
expect(reviewAddressReportStep).toContain(
"NEWEST: '${{ steps.prepare.outputs.newest }}'",
);
expect(reviewAddressReportStep).toContain('"${DRY_RUN}" != "true"');
expect(reviewAddressReportStep).toContain('-s "${WORKDIR}/handoff.md"');
expect(reviewAddressReportStep).toContain(
'<!-- autofix-eval ts=${NEWEST} acted=false round=${ROUND} -->',
);
expect(reviewAddressReportStep).toContain(
'Could not address the latest review feedback automatically',
);
expect(reviewAddressReportStep).toContain('gh pr comment "${PR}"');
expect(reviewAddressReportStep).toContain(
'GH_TOKEN="${GITHUB_TOKEN}" gh api user --jq \'.login\'',
);
expect(reviewAddressReportStep).toContain(
'CI_DEV_BOT_PAT authenticates as ${bot_actor}',
);
expect(reviewAddressReportStep).toContain(
'::warning::Failed to post handoff comment on PR #${PR}',
);
expect(reviewAddressReportStep).toContain('human should take over');
expect(reviewAddressReportStep).toContain("sed 's/<!--[^>]*-->//g'");
});
it('writes agent output to a log and marks loop guard failures for handoff', () => {
withRunnerDir((dir) => {
writeFileSync(join(dir, 'feedback.md'), 'feedback\n');
const stub = writeQwenStub(dir, [
"process.stderr.write('turn_tool_call_cap: too many tool calls\\n');",
'process.exit(1);',
]);
const result = runAddressReview(dir, stub);
expect(result.status).not.toBe(0);
expect(readFileSync(join(dir, 'agent.log'), 'utf8')).toContain(
'turn_tool_call_cap',
);
expect(readFileSync(join(dir, 'failure.md'), 'utf8')).toContain(
'Qwen hit the tool-call loop guard',
);
expect(readFileSync(join(dir, 'handoff.md'), 'utf8')).toContain(
'human should take over',
);
});
});
it('handles agent log stream errors without crashing immediately', () => {
expect(readFileSync(autofixRunnerScriptPath, 'utf8')).toContain(
"log.on('error', () => {});",
);
expect(readFileSync(autofixRunnerScriptPath, 'utf8')).toContain(
'if (log.destroyed)',
);
});
it('detects loop guard output before it falls out of the log tail', () => {
withRunnerDir((dir) => {
writeFileSync(join(dir, 'feedback.md'), 'feedback\n');
const stub = writeQwenStub(dir, [
"process.stderr.write('Loop detection halted the run\\n');",
"process.stdout.write('x'.repeat(21_000));",
'process.exit(1);',
]);
const result = runAddressReview(dir, stub);
expect(result.status).not.toBe(0);
expect(readFileSync(join(dir, 'failure.md'), 'utf8')).toContain(
'Qwen hit the tool-call loop guard',
);
expect(readFileSync(join(dir, 'handoff.md'), 'utf8')).toContain(
'human should take over',
);
});
});
it('does not mark generic qwen subprocess failures for handoff', () => {
withRunnerDir((dir) => {
writeFileSync(join(dir, 'feedback.md'), 'feedback\n');
const stub = writeQwenStub(dir, [
"process.stderr.write('temporary upstream error\\n');",
'process.exit(1);',
]);
const result = runAddressReview(dir, stub);
expect(result.status).not.toBe(0);
expect(readFileSync(join(dir, 'agent.log'), 'utf8')).toContain(
'temporary upstream error',
);
expect(readFileSync(join(dir, 'failure.md'), 'utf8')).toContain(
'Qwen failed during address-review',
);
expect(existsSync(join(dir, 'handoff.md'))).toBe(false);
});
});
it('preserves agent-written failure details when the qwen subprocess fails', () => {
withRunnerDir((dir) => {
writeFileSync(join(dir, 'candidates.json'), '[]\n');
@ -909,14 +1013,60 @@ describe('qwen-autofix workflow', () => {
expect(readFileSync(join(dir, 'failure.md'), 'utf8')).toContain(
'agent detail',
);
expect(readFileSync(join(dir, 'handoff.md'), 'utf8')).toContain(
'human should take over',
);
});
});
it('bounds qwen subprocess runtime', () => {
const runner = readFileSync(autofixRunnerScriptPath, 'utf8');
expect(runner).toContain('const QWEN_TIMEOUT_MS = 50 * 60 * 1000');
expect(runner).toContain('timeout: QWEN_TIMEOUT_MS');
expect(runner).toContain('50 * 60 * 1000');
expect(runner).toContain('setTimeout(() =>');
expect(runner).toContain("killQwen(child, 'SIGKILL')");
expect(runner).toContain('}, QWEN_TIMEOUT_MS)');
});
it('kills qwen subprocess descendants on timeout', () => {
withRunnerDir((dir) => {
writeFileSync(join(dir, 'feedback.md'), 'feedback\n');
const stub = writeQwenStub(dir, [
"import { spawn } from 'node:child_process';",
"spawn(process.execPath, ['-e', 'setTimeout(() => {}, 3000)'], {",
" stdio: ['ignore', 'inherit', 'inherit'],",
'});',
'setTimeout(() => process.exit(0), 3000);',
]);
const result = spawnSync(
process.execPath,
[
autofixRunnerScriptPath,
'--mode',
'address-review',
'--pr',
'5678',
'--issue',
'1234',
'--workdir',
dir,
'--qwen-bin',
stub,
],
{
encoding: 'utf8',
env: { ...process.env, QWEN_TIMEOUT_MS: '100' },
timeout: 2000,
},
);
expect(result.error).toBeUndefined();
expect(result.status).not.toBe(0);
expect(readFileSync(join(dir, 'failure.md'), 'utf8')).toContain(
'timeout (100ms)',
);
});
});
it('reports external qwen subprocess signals without calling them timeouts', () => {
@ -976,6 +1126,9 @@ describe('qwen-autofix workflow', () => {
expect(readFileSync(join(dir, 'failure.md'), 'utf8')).toContain(
'cannot proceed',
);
expect(readFileSync(join(dir, 'handoff.md'), 'utf8')).toContain(
'human should take over',
);
});
});