From 4dab39c8d7442c2c67404d54296de89ab597a12b Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Thu, 30 Jul 2026 17:46:45 +0800 Subject: [PATCH 01/17] fix(autofix): answer round-cap refusals on the PR instead of only in logs (#8067) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(autofix): answer cap-gate refusals on the PR instead of only in logs Observed on #7836: the fleet shepherd detected a merge conflict, posted 'dispatched the autofix loop to resolve it', and the dispatch died at the scan's round-cap gate with only a log line — the PR page showed a promise, the run showed green, and the conflict sat unhandled for hours. Three silences stacked: the standard-management cap itself is silent (the pause notice was takeover-only, so #7836 hit 10/10 with zero PR-visible notice), the forced-dispatch refusal is silent, and the shepherd dedups per head SHA — a capped PR gets no pushes, so its head never changes and conflict handling froze permanently. Two scan-side changes (the shepherd stays untouched — the windowed round computation lives in the scan and duplicating it would drift): - A FORCED dispatch (shepherd conflict lever or a human) refused at the cap gate now answers on the PR: cap value, what stays unhandled, and the two recovery commands (/retry for a fresh window, /takeover for the raised cap). No dedup — the shepherd sends at most one dispatch per head, and a human asking twice deserves two answers. - The cap pause notice covers ALL managed PRs: the takeover variant keeps its wording, standard bot PRs get their own (/retry or /takeover). Same marker, same once-per-window dedup, same consent and PAT-identity checks — skip wins everywhere, and only the takeover variant requires the label to still be present. After a re-arm the next scheduled scan picks the PR up normally (conflict targets are label-independent), so the frozen-head loop resolves without any shepherd change. * test(autofix): replay the cap-notice consent gate across label/takeover permutations (#8067) * fix(autofix): gate the loud cap-refusal on workflow_dispatch (#8067) FORCED_PR is populated for every trusted pull_request_review (route emits pr_number for those), not just workflow_dispatch, so on a capped PR each review submission landed in the un-deduped refusal branch — 7 "Dispatch refused" comments on #7836 where 2 carried the information. Answer only workflow_dispatch (the shepherd lever or a human); review submissions stay covered by the once-per-window pause notice. Adds a verbatim behavioral replay of the guard so a dropped EVENT_NAME condition fails the test. --------- Co-authored-by: verify Co-authored-by: qwen-code-dev-bot --- .github/workflows/qwen-autofix.yml | 107 ++++++++++++++------ scripts/tests/qwen-autofix-workflow.test.js | 103 ++++++++++++++++++- 2 files changed, 176 insertions(+), 34 deletions(-) diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index d6211713e8..9479cc3b0f 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -1689,6 +1689,7 @@ jobs: GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}' FORCED_PR: '${{ needs.route.outputs.pr_number }}' DRY_RUN: '${{ needs.route.outputs.dry_run }}' + EVENT_NAME: '${{ github.event_name }}' run: |- # Fleet visibility: every per-PR decision below also records a row so # the run summary shows the WHOLE managed fleet in one table. @@ -2247,43 +2248,85 @@ jobs: if [[ "${ROUND}" -ge "${EFF_MAX_ROUNDS}" ]]; then echo "🚧 #${PR}: hit the round cap (${ROUND}/${EFF_MAX_ROUNDS}) — leaving for a human" fleet_row "${PR}" 'round-capped' "round ${ROUND}/${EFF_MAX_ROUNDS} - needs a human or @qwen-code /retry" - # A MANAGED PR pausing at its cap deserves a visible reminder — - # maintainers otherwise learn about it only from workflow logs. - # Once per counting window: re-arming opens a fresh window and, - # if the cap is hit again, a fresh reminder. A failed post - # retries naturally on the next scan (marker still absent). - if [[ "${HAS_TAKEOVER}" == "true" ]]; then - # Dedup boundary = the current window key; with no engage ack - # yet (key 'none') fall back to LIFETIME dedup — created_at is - # never > 'none' lexically, which would flip this into posting - # every scan. - NOTICE_RT="${REARM_KEY}" - [[ "${NOTICE_RT}" == "none" ]] && NOTICE_RT='' - CAP_NOTICED="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg rt "${NOTICE_RT}" ' - [ .[] | select((.user.login // "") == $ab) - | select((.body // "") | contains("")) - | select((.created_at // "") > $rt) ] | length' "${WORKDIR}/ic.json")" + # A FORCED dispatch refused here answers OUT LOUD. Observed on + # #7836: the fleet shepherd detected a merge conflict, posted + # "dispatched the autofix loop to resolve it", and the dispatch + # died right here with only the log line above — the PR page + # showed a promise, the run showed green, and the conflict sat + # unhandled for hours. The shepherd also dedups per head SHA, + # and a capped PR gets no pushes, so its head never changes: + # silence here freezes conflict handling until a human notices + # by accident. Gate on workflow_dispatch — that is the explicit + # dispatch lever (the shepherd's `gh workflow run` or a human). + # FORCED_PR is ALSO set for every trusted pull_request_review + # (route emits pr_number for those), which is not an explicit + # dispatch: answering each one here spammed 7 refusals on + # #7836, so review submissions stay covered by the + # once-per-window pause notice below. No dedup on the dispatch + # itself: the shepherd sends at most one per head, and a human + # asking twice deserves two answers. + if [[ -n "${FORCED_PR}" && "${FORCED_PR}" == "${PR}" && "${EVENT_NAME}" == 'workflow_dispatch' ]]; then if [[ "${DRY_RUN}" == "true" ]]; then - echo "🧪 DRY-RUN: would post cap-paused notice on #${PR}" - elif [[ "${CAP_NOTICED}" == "0" ]]; then - # Consent may have moved since PR_META: a takeover label - # removed (or skip added) moments ago must not receive a - # stale 'paused' notice. - LIVE_LABELS="$(gh pr view "${PR}" --repo "${REPO}" --json labels 2> /dev/null | jq -r '[.labels[]?.name] | join(" ")' || echo '')" - if [[ " ${LIVE_LABELS} " != *" ${TAKEOVER_LABEL} "* || " ${LIVE_LABELS} " == *" ${SKIP_LABEL} "* ]]; then - echo "🧭 cap notice skipped: consent changed since the snapshot (labels: ${LIVE_LABELS:-unreadable})" - continue - fi - # Convention: verify the PAT identity before ANY write. A - # rotated PAT would post under a foreign login the dedup - # (which counts AUTOFIX_BOT comments only) can never see — - # reposting the notice every scan. Memoized per scan run. + echo "🧪 DRY-RUN: would post cap-refused notice on #${PR}" + else if [[ -z "${SCAN_BOT_ACTOR:-}" ]]; then SCAN_BOT_ACTOR="$(gh api user --jq '.login' 2> /dev/null || echo 'unknown')" fi if [[ "${SCAN_BOT_ACTOR}" != "${AUTOFIX_BOT}" ]]; then - echo "::warning::cap-paused notice skipped: PAT authenticates as '${SCAN_BOT_ACTOR}', expected ${AUTOFIX_BOT}" - elif ! gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '⏸️ Takeover paused: this PR reached its round cap (%s/%s). Comment `%s` to re-arm a fresh window and continue management, or `%s stop` to release.\n\n
\n中文说明\n\n⏸️ 托管已暂停:本 PR 达到轮次上限(%s/%s)。评论 `%s` 可重新武装、开启新窗口继续托管;或评论 `%s stop` 释放。\n\n
\n\n' "${ROUND}" "${EFF_MAX_ROUNDS}" "${TAKEOVER_COMMAND}" "${TAKEOVER_COMMAND}" "${ROUND}" "${EFF_MAX_ROUNDS}" "${TAKEOVER_COMMAND}" "${TAKEOVER_COMMAND}")"; then + echo "::warning::cap-refused notice skipped: PAT authenticates as '${SCAN_BOT_ACTOR}', expected ${AUTOFIX_BOT}" + elif ! gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '⏸️ Dispatch refused: this PR has exhausted its automatic round cap (%s/%s), so the loop will not touch it — whatever triggered this dispatch (a merge conflict, new feedback) stays unhandled. Comment `%s` to re-arm a fresh window, or `%s` for the raised takeover cap; the next scheduled scan then picks it up.\n\n
\n中文说明\n\n⏸️ 已拒绝本次调度:本 PR 的自动轮次上限已用完(%s/%s),循环不会介入——触发本次调度的事项(合并冲突、新反馈)仍未处理。评论 `%s` 可重置计数窗口,或 `%s` 获得更高的接管上限;随后下一次定时扫描会接手。\n\n
\n\n' "${ROUND}" "${EFF_MAX_ROUNDS}" "${RETRY_COMMAND}" "${TAKEOVER_COMMAND}" "${ROUND}" "${EFF_MAX_ROUNDS}" "${RETRY_COMMAND}" "${TAKEOVER_COMMAND}")"; then + echo "::warning::cap-refused notice failed for #${PR}" + fi + fi + fi + # A MANAGED PR pausing at its cap deserves a visible reminder — + # maintainers otherwise learn about it only from workflow logs. + # ALL managed PRs, not just takeover: the takeover-only gate + # left standard bot PRs capping in silence (#7836 hit 10/10 + # with zero PR-visible notice), which is the root of the + # frozen-conflict chain above. Once per counting window: + # re-arming opens a fresh window and, if the cap is hit again, + # a fresh reminder. A failed post retries naturally on the + # next scan (marker still absent). + # Dedup boundary = the current window key; with no engage ack + # or re-arm yet (key 'none') fall back to LIFETIME dedup — + # created_at is never > 'none' lexically, which would flip + # this into posting every scan. + NOTICE_RT="${REARM_KEY}" + [[ "${NOTICE_RT}" == "none" ]] && NOTICE_RT='' + CAP_NOTICED="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg rt "${NOTICE_RT}" ' + [ .[] | select((.user.login // "") == $ab) + | select((.body // "") | contains("")) + | select((.created_at // "") > $rt) ] | length' "${WORKDIR}/ic.json")" + if [[ "${DRY_RUN}" == "true" ]]; then + echo "🧪 DRY-RUN: would post cap-paused notice on #${PR}" + elif [[ "${CAP_NOTICED}" == "0" ]]; then + # Consent may have moved since PR_META: skip wins everywhere, + # and a takeover notice additionally requires the label to + # still be present — a label removed (or skip added) moments + # ago must not receive a stale 'paused' notice. + LIVE_LABELS="$(gh pr view "${PR}" --repo "${REPO}" --json labels 2> /dev/null | jq -r '[.labels[]?.name] | join(" ")' || echo '')" + if [[ " ${LIVE_LABELS} " == *" ${SKIP_LABEL} "* ]] \ + || [[ "${HAS_TAKEOVER}" == "true" && " ${LIVE_LABELS} " != *" ${TAKEOVER_LABEL} "* ]]; then + echo "🧭 cap notice skipped: consent changed since the snapshot (labels: ${LIVE_LABELS:-unreadable})" + continue + fi + # Convention: verify the PAT identity before ANY write. A + # rotated PAT would post under a foreign login the dedup + # (which counts AUTOFIX_BOT comments only) can never see — + # reposting the notice every scan. Memoized per scan run. + if [[ -z "${SCAN_BOT_ACTOR:-}" ]]; then + SCAN_BOT_ACTOR="$(gh api user --jq '.login' 2> /dev/null || echo 'unknown')" + fi + if [[ "${SCAN_BOT_ACTOR}" != "${AUTOFIX_BOT}" ]]; then + echo "::warning::cap-paused notice skipped: PAT authenticates as '${SCAN_BOT_ACTOR}', expected ${AUTOFIX_BOT}" + else + if [[ "${HAS_TAKEOVER}" == "true" ]]; then + CAP_BODY="$(printf '⏸️ Takeover paused: this PR reached its round cap (%s/%s). Comment `%s` to re-arm a fresh window and continue management, or `%s stop` to release.\n\n
\n中文说明\n\n⏸️ 托管已暂停:本 PR 达到轮次上限(%s/%s)。评论 `%s` 可重新武装、开启新窗口继续托管;或评论 `%s stop` 释放。\n\n
\n\n' "${ROUND}" "${EFF_MAX_ROUNDS}" "${TAKEOVER_COMMAND}" "${TAKEOVER_COMMAND}" "${ROUND}" "${EFF_MAX_ROUNDS}" "${TAKEOVER_COMMAND}" "${TAKEOVER_COMMAND}")" + else + CAP_BODY="$(printf '⏸️ AutoFix paused: this PR reached its automatic round cap (%s/%s) and the loop will not manage it further — new feedback and base conflicts stay unhandled. Comment `%s` to re-arm a fresh window under the same cap, or `%s` to take it over with the raised cap.\n\n
\n中文说明\n\n⏸️ AutoFix 已暂停:本 PR 达到自动轮次上限(%s/%s),循环不再管理——新反馈与 base 冲突将无人处理。评论 `%s` 可在同一上限下重置计数窗口,或评论 `%s` 以更高上限接管。\n\n
\n\n' "${ROUND}" "${EFF_MAX_ROUNDS}" "${RETRY_COMMAND}" "${TAKEOVER_COMMAND}" "${ROUND}" "${EFF_MAX_ROUNDS}" "${RETRY_COMMAND}" "${TAKEOVER_COMMAND}")" + fi + if ! gh pr comment "${PR}" --repo "${REPO}" --body "${CAP_BODY}"; then echo "::warning::cap-paused notice failed for #${PR}; will retry next scan" fi fi diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index e60ca42462..4683e252fb 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -1961,7 +1961,8 @@ describe('qwen-autofix workflow', () => { // honest bot-PR release, skip-labeled bot-PR release, human-PR // release, re-arm, fork allow-edits refusal, two skip-blocked refusals, // the label-path non-main base refusal, the command-path non-main base - // refusal, the cap pause, the command-path direct engage ack (the + // refusal, the takeover cap pause, the standard-mode cap pause, the + // forced-dispatch cap refusal, the command-path direct engage ack (the // labeled event has been observed to not fire — #7999, #8002 — so the // command acks itself), the command-path direct release acks (all three // variants, mirroring the ack job — a loud add next to a mute stop @@ -1971,7 +1972,7 @@ describe('qwen-autofix workflow', () => { const ackBodies = workflow.match( /printf '[^']*takeover-(?:ack|cap)[^']*'/g, ); - expect(ackBodies).toHaveLength(17); + expect(ackBodies).toHaveLength(19); for (const body of ackBodies) { expect(body).toContain('中文说明'); } @@ -2392,6 +2393,65 @@ describe('qwen-autofix workflow', () => { // guidance in the body. expect(reviewScanJob).toContain(''); expect(reviewScanJob).toContain('Takeover paused'); + // The cap notice covers ALL managed PRs, not just takeover: standard + // bot PRs used to cap in silence (#7836 hit 10/10 with zero PR-visible + // notice), which let the shepherd's conflict dispatch die silently on + // a cap the PR page never mentioned. Same marker → same + // once-per-window dedup for both variants. + expect(reviewScanJob).toContain('AutoFix paused'); + // Three sites: the dedup census read + BOTH notice bodies — the shared + // marker is what gives the two variants one once-per-window dedup. + expect( + reviewScanJob.split('').length - 1, + ).toBe(3); + // A FORCED dispatch (shepherd conflict lever or a human) refused at the + // cap gate answers on the PR — observed on #7836: '🐑 dispatched the + // autofix loop' followed by a green run that did nothing, with the + // refusal visible only in the Actions log. Gated on workflow_dispatch: + // FORCED_PR is ALSO set for trusted pull_request_review submissions + // (route emits pr_number for those), and answering each one loudly + // spammed 7 refusals on #7836 — those stay covered by the + // once-per-window pause notice. No dedup on the dispatch itself: the + // shepherd sends at most one per head, and a human asking twice + // deserves two answers. + expect(reviewScanJob).toContain( + 'if [[ -n "${FORCED_PR}" && "${FORCED_PR}" == "${PR}" && "${EVENT_NAME}" == \'workflow_dispatch\' ]]; then', + ); + expect(reviewScanJob).toContain(''); + expect(reviewScanJob).toContain('Dispatch refused'); + expect(reviewScanJob).toContain('DRY-RUN: would post cap-refused notice'); + expect(reviewScanJob).toContain( + 'cap-refused notice skipped: PAT authenticates as', + ); + // The loud refusal is gated on workflow_dispatch (FORCED_PR is also set + // for trusted review submissions). Replay the guard VERBATIM so a + // dropped EVENT_NAME condition fails the test, not just a substring: a + // dispatch is answered, a review submission is left to the pause notice. + const refusedGuard = reviewScanJob.match( + /(if \[\[ -n "\$\{FORCED_PR\}" && "\$\{FORCED_PR\}" == "\$\{PR\}" && "\$\{EVENT_NAME\}" == 'workflow_dispatch' \]\]; then)/, + )?.[1]; + expect(refusedGuard).toBeTruthy(); + const refuses = (eventName) => + execFileSync('bash', ['-c', `${refusedGuard}\necho REFUSED\nfi`], { + env: { + ...process.env, + FORCED_PR: '7836', + PR: '7836', + EVENT_NAME: eventName, + }, + encoding: 'utf8', + }).trim(); + expect(refuses('workflow_dispatch')).toContain('REFUSED'); + expect(refuses('pull_request_review')).not.toContain('REFUSED'); + // The standard-mode pause and the refusal both point at the actual + // recovery command as a printf ARG (the takeover variant keeps its + // takeover-command-only wording). + const capBodies = + reviewScanJob.match(/printf '[^']*takeover-cap-re[^']*'[^\n]*/g) ?? []; + expect(capBodies).toHaveLength(3); + expect( + capBodies.filter((b) => b.includes('"${RETRY_COMMAND}"')), + ).toHaveLength(2); expect(reviewScanJob).toMatch( /CAP_NOTICED=[\s\S]*?contains\(""\)[\s\S]*?> \$rt/, ); @@ -2476,6 +2536,45 @@ describe('qwen-autofix workflow', () => { expect(noticed('2026-07-18T11:00:00Z', '2026-07-18T10:00:00Z')).toBe('1'); // No key yet (lifetime dedup, rt='') → any prior notice suppresses. expect(noticed('2026-07-18T09:00:00Z', '')).toBe('1'); + // The consent gate is the core behavioral change: standard bot PRs now + // receive cap notices (they used to be takeover-only). Replay the gate + // VERBATIM under all four label/takeover permutations so a dropped + // HAS_TAKEOVER guard or a reverted skip-wins condition fails the test, + // not just a substring assertion. + const consentGate = reviewScanJob.match( + /(if \[\[ " \$\{LIVE_LABELS\} " == \*" \$\{SKIP_LABEL\} "\* \]\] \\\n[\s\S]*?continue\n {16}fi)/, + )?.[1]; + expect(consentGate).toBeTruthy(); + const gate = (liveLabels, hasTakeover) => + execFileSync( + 'bash', + [ + '-c', + `for _ in 1; do\n${consentGate.replace(/\n {16}/g, '\n')}\necho PROCEED\ndone`, + ], + { + env: { + ...process.env, + LIVE_LABELS: liveLabels, + HAS_TAKEOVER: hasTakeover, + SKIP_LABEL: 'autofix/skip', + TAKEOVER_LABEL: 'autofix/takeover', + }, + encoding: 'utf8', + }, + ).trim(); + // Standard bot PR, no skip label → NOT skipped (the #7836 case). + expect(gate('autofix/managed', 'false')).toContain('PROCEED'); + // Standard bot PR + skip label → skipped everywhere. + expect(gate('autofix/managed autofix/skip', 'false')).toContain( + 'cap notice skipped', + ); + // Takeover PR with its label removed → skipped (stale consent). + expect(gate('autofix/managed', 'true')).toContain('cap notice skipped'); + // Takeover PR with the label still present → NOT skipped. + expect(gate('autofix/managed autofix/takeover', 'true')).toContain( + 'PROCEED', + ); // Candidates drain newest-first, and the free busy skip never consumes // inspection budget. expect(reviewScanJob).toContain('sort_by(-.number)'); From f6ae9202b134189a744062e38f56ef2693cf79ef Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Thu, 30 Jul 2026 19:38:40 +0800 Subject: [PATCH 02/17] feat(autofix): per-source feedback budget in Critical-only mode (#8071) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(autofix): never defer maintainer feedback in Critical-only mode Critical-only mode (after 5 change-producing rounds) classifies feedback lexically: only a literal **[Critical]** tag or a CHANGES_REQUESTED review survives; everything else is deferred before the agent reads a word of it. That rule was built to stop the review bot's suggestion ping-pong, but it catches maintainers too. Observed four times in two days (#8037, #7944, #7885, #7799): a maintainer's review with explicit merge-blocking findings — #8037's said 'I'd fix before merge' on a correctness bug and a security-adjacent one — was wholesale-deferred as one 'non-Critical item', and the bot then reported 'No Critical feedback. The Issue-level comments sections are empty', which was lexically true and substantively false. The bot's own advertised definition ('correctness bugs, security issues, or formally requested changes') is exactly what the deferred comments contained; the agent that could have applied that definition never saw them. The lexical test now applies exclusively to the review bot's output: - All three actionable filters (reviews, inline, issue-level) pass anything not authored by the review bot straight through in Critical-only mode — the agent judges maintainer feedback on content, as everywhere else. - All three deferred-list builders keep only review-bot items, so a maintainer comment can never appear as an 'audit record'. - The deferral note says what is actually deferred (the automated reviewer's non-Critical suggestions), states that maintainer feedback is never deferred, and names the exit (@qwen-code /retry opens a fresh counting window). - SKILL.md's Critical-only policy now marks everything rendered in the actionable sections as in scope, so the agent does not re-refuse what the filter passed through. Behavioral test updated both ways: maintainer comments/reviews stay actionable in Critical-only mode across all three sources, bot suggestions still defer, and structural pins hold the bypass and the bot-only select in all six filters. * feat(autofix): per-author feedback budget in Critical-only mode Follow-up to the author-based split, prompted by the obvious counterexample: a human account can host an automated reviewer loop with the exact regeneration property the review bot has — feedback re-generated after every push at zero marginal cost — so 'not the bot' cannot mean 'never throttled'. An account is an accountability unit, not a throttle; the brake has to key on measured regeneration. Unified model: once Critical-only engages, every source has a bounded budget of untagged feedback batches per counting window. The review bot's budget is zero (all deferred, as before). A human's is CRITICAL_ONLY_HUMAN_BATCHES (2) CONSUMED batches: feedback items are bucketed into the (prev marker ts, marker ts] span that evaluated them, only spans from Critical-only rounds count, and an author needs K distinct consumed spans before their new untagged feedback defers. Fresh unevaluated feedback never counts against its own author, and the census is window-scoped, so /retry resets the budget with the window. The observed cases (#8037/#7944/#7885/#7799 — one or two late verification reports each) stay fully served under K=2; a looped reviewer is throttled after 5+K driven rounds instead of grinding to the 100-round cap. Past the budget, continuing requires one conscious act — **[Critical]**, a Request changes review, or /retry — which is precisely what separates intent from automation. Over-budget authors are named in the deferral note with those exact escapes. Tests: the six filter replays gain over-budget cases both ways (the tagged/CR escapes survive even over budget), and the budget census itself is replayed over fixture files — two consumed critical-tail batches list the author; one batch, pre-Critical batches, unconsumed feedback, untrusted authors, and command comments never count. * fix(autofix): fix deferred-feedback bash quoting and drop a dead jq binding (#8071) * test(autofix): exercise census window-isolation guard with a stale-window fixture (#8071) * test(autofix): make census command-exclusion observable; surface census stderr (#8071) * fix(autofix): exclude never-deferrable feedback from the budget census (#8071) The Critical-only per-author budget census counted every trusted review, inline comment, and issue comment, including feedback the deferred renderer would never defer: **[Critical]**-tagged comments, Request changes / APPROVED reviews, inline replies rooted at a Critical comment, and inline comments attached to a Request changes review. A maintainer who followed the documented escape hatches (tag Critical, request changes) thereby spent their own budget and had later untagged feedback silently deferred — the exact bug this PR fixes, re-created one level down. Mirror the three deferred-builder predicates in the census item filter so a batch is counted only when it is actually deferrable. Extend the census replay test with protected authors (Critical-only, Request changes, APPROVED, Critical-rooted replies, Request-changes-review inlines, the review bot as a trusted MEMBER, and a sentinel-ts marker probe) that each carry two consumed-span batches yet must stay absent, so dropping any one exclusion now fails the suite. Also fold bash's stderr into the bash -n guard assertion so a future quoting regression reports the syntax error, not just a non-zero exit. --------- Co-authored-by: verify Co-authored-by: qwen-code-dev-bot --- .github/workflows/qwen-autofix.yml | 105 +++- .qwen/skills/autofix/SKILL.md | 11 +- scripts/tests/qwen-autofix-workflow.test.js | 560 ++++++++++++++++---- 3 files changed, 561 insertions(+), 115 deletions(-) diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index 9479cc3b0f..727de93e89 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -119,6 +119,17 @@ env: # changes, failed checks, and base conflicts may drive code changes; # lower-severity feedback is recorded and left open. CRITICAL_ONLY_AFTER_ROUND: '5' + # Per-author tail budget inside Critical-only mode. An account is an + # ACCOUNTABILITY unit, not a throttle: a human login can host an automated + # reviewer loop with the exact regeneration property the review bot has + # (feedback re-generated after every push, at zero marginal cost). So the + # brake keys on measured regeneration, not identity: every source gets a + # bounded number of untagged feedback batches per counting window once + # Critical-only engages — the review bot's budget is zero (all deferred), + # a human's is this many CONSUMED batches. Past it, continuing requires + # one conscious act (**[Critical]**, a Request changes review, or /retry), + # which is precisely what separates intent from automation. + CRITICAL_ONLY_HUMAN_BATCHES: '2' # An auth/access model error (401/402/403, "no access"/"does not exist") # never self-heals - only a maintainer can fix the key - and every retry # costs an agent run AND a PR comment. Cap those attempts far below @@ -3017,6 +3028,70 @@ jobs: if [[ "${ROUND}" -ge "${CRITICAL_ONLY_AFTER_ROUND}" ]]; then CRITICAL_ONLY='true' fi + # Which trusted humans have exhausted their per-window regular + # feedback budget (see CRITICAL_ONLY_HUMAN_BATCHES). A batch is + # COUNTED only when a Critical-only round actually consumed it: + # feedback items are bucketed into the (prev marker ts, marker ts] + # span that evaluated them, spans are kept only for markers that + # ran in Critical-only territory (acted rounds numbered past the + # threshold, no-change rounds at it), and an author needs >= K + # distinct consumed spans to land here. Fresh, not-yet-evaluated + # feedback never counts against its own author, and everything is + # window-scoped so a /retry resets the budget with the window. + # Only feedback the deferred renderer below would actually defer is + # counted: Critical-tagged items, Request changes / APPROVED reviews, + # and inline comments rooted at a Critical comment or attached to a + # Request changes review are never deferrable, so they must not burn + # an author's budget — the item filter mirrors those predicates. + OVER_BUDGET_AUTHORS='[]' + if [[ "${CRITICAL_ONLY}" == "true" ]]; then + OVER_BUDGET_AUTHORS="$(jq -n \ + --arg key "${LIVE_REARM_KEY}" --arg ab "${AUTOFIX_BOT}" --arg rb "${REVIEW_BOT}" \ + --argjson trust "${TRUSTED_ASSOC}" \ + --argjson r5 "${CRITICAL_ONLY_AFTER_ROUND}" \ + --argjson k "${CRITICAL_ONLY_HUMAN_BATCHES}" \ + --slurpfile rv "${WORKDIR}/rv.json" --slurpfile rc "${WORKDIR}/rc.json" --slurpfile ic "${WORKDIR}/ic.json" ' + ([ ($ic | add)[] | select((.user.login // "") == $ab) | . as $c | ($c.body // "") + | [ scan("") ] | .[] + | {ts: .[0], acted: .[1], round: (.[2] | tonumber), win: (.[3] // "none"), at: ($c.created_at // "")} ] + | map(select(.win == $key) | select(.ts != "9999-12-31T23:59:59Z")) + | sort_by(.at)) as $ms + | ([ range(0; ($ms | length)) as $i + | ($ms[$i] + | select((.acted == "true" and .round > $r5) or (.acted == "false" and .round >= $r5)) + | {lo: (if $i == 0 then "" else ($ms[$i - 1].ts) end), hi: .ts}) ]) as $spans + | ($rv | add) as $reviews + | ($rc | add) as $comments + | ([ $reviews[] + | select((.state // "") == "COMMENTED") + | select(((.body // "") | contains("**[Critical]**")) | not) + | {at: (.submitted_at // ""), login: (.user.login // ""), assoc: (.author_association // "")} ] + + [ $comments[] + | select(( + ((.body // "") | contains("**[Critical]**")) + or ((.in_reply_to_id // null) as $root + | $root != null + and any($comments[]; .id == $root and ((.body // "") | contains("**[Critical]**")))) + or ((.pull_request_review_id // null) as $review + | $review != null + and any($reviews[]; .id == $review and ((.state // "") == "CHANGES_REQUESTED"))) + ) | not) + | {at: (.created_at // ""), login: (.user.login // ""), assoc: (.author_association // "")} ] + + [ ($ic | add)[] + | select((.body // "") | test("`, + }); + const humanC = (login, at, assoc = 'MEMBER', body = 'feedback') => ({ + user: { login }, + created_at: at, + author_association: assoc, + body, + }); + const budgetDir = mkdtempSync(join(tmpdir(), 'over-budget-')); + try { + writeFileSync( + join(budgetDir, 'ic.json'), + JSON.stringify([ + markerC('2026-07-02T00:00:00Z', 'true', 5, '2026-07-02T01:00:00Z'), + markerC('2026-07-03T00:00:00Z', 'true', 6, '2026-07-03T01:00:00Z'), + markerC('2026-07-04T00:00:00Z', 'false', 6, '2026-07-04T01:00:00Z'), + // Stale-window marker: qualifies for a span but win != WKEY. + markerC( + '2026-06-15T00:00:00Z', + 'true', + 6, + '2026-06-15T01:00:00Z', + '2026-06-01T00:00:00Z', + ), + // Sentinel-ts marker: filtered out, so it opens no span. Dropping + // the guard would open a (2026-07-04, 9999] span that absorbs + // sentinelvictim's second batch below and surface them. + markerC('9999-12-31T23:59:59Z', 'true', 6, '2026-07-04T02:00:00Z'), + humanC('looper', '2026-07-02T12:00:00Z'), + humanC('looper', '2026-07-03T12:00:00Z'), + humanC('onetime', '2026-07-03T13:00:00Z'), + // Stale-window human inside the stale span; must not count. + humanC('onetime', '2026-06-14T12:00:00Z'), + humanC('looper', '2026-07-01T12:00:00Z'), + humanC('looper', '2026-07-05T00:00:00Z'), + humanC('rando', '2026-07-02T13:00:00Z', 'NONE'), + humanC( + 'looper', + '2026-07-02T13:00:00Z', + 'MEMBER', + '@qwen-code /review', + ), + // Command-only author: both items are /commands, so with the + // command-exclusion filter they count 0 consumed spans and stay + // absent; dropping the filter would surface them and fail the + // assertion, which is what makes the guard observable. + humanC( + 'commander', + '2026-07-02T14:00:00Z', + 'MEMBER', + '@qwen-code /review', + ), + humanC( + 'commander', + '2026-07-03T14:00:00Z', + 'MEMBER', + '@qwen-code /retry', + ), + // Critical-only author: both batches are **[Critical]**-tagged, so + // they are never deferrable and must not count (absent). Dropping the + // Critical exclusion would surface them. + humanC( + 'crit', + '2026-07-02T15:00:00Z', + 'MEMBER', + '**[Critical]** fix X', + ), + humanC( + 'crit', + '2026-07-03T15:00:00Z', + 'MEMBER', + '**[Critical]** still broken', + ), + // Review bot, even carrying a trusted association, is excluded by + // login (its budget is zero). Dropping `.login != $rb` surfaces it. + humanC( + 'qwen-code-ci-bot', + '2026-07-02T18:00:00Z', + 'MEMBER', + 'bot suggestion', + ), + humanC( + 'qwen-code-ci-bot', + '2026-07-03T18:00:00Z', + 'MEMBER', + 'bot suggestion 2', + ), + // Sentinel-span probe: the first batch lands in span B; the second + // falls after span B and only counts if the sentinel marker above + // wrongly opens a span. With the guard, stays at one batch (absent). + humanC('sentinelvictim', '2026-07-03T12:30:00Z'), + humanC('sentinelvictim', '2026-07-04T12:00:00Z'), + ]), + ); + // A second author whose two consumed batches arrive through the review + // (.submitted_at) and inline-comment (.created_at) branches, not ic.json: + // both are untagged, so they count under either census semantic. + writeFileSync( + join(budgetDir, 'rv.json'), + JSON.stringify([ + { + user: { login: 'reviewer2' }, + author_association: 'MEMBER', + state: 'COMMENTED', + submitted_at: '2026-07-02T12:30:00Z', + body: 'feedback delivered through a review', + }, + // Request changes / APPROVED reviews are never deferrable, so two + // consumed-span batches of each must not count (both authors absent): + // the census mirrors the deferred renderer's `state == "COMMENTED"`. + { + user: { login: 'cr' }, + author_association: 'MEMBER', + state: 'CHANGES_REQUESTED', + submitted_at: '2026-07-02T16:00:00Z', + body: 'changes requested', + }, + { + user: { login: 'cr' }, + author_association: 'MEMBER', + state: 'CHANGES_REQUESTED', + submitted_at: '2026-07-03T16:00:00Z', + body: 'changes requested again', + }, + { + user: { login: 'appr' }, + author_association: 'MEMBER', + state: 'APPROVED', + submitted_at: '2026-07-02T17:00:00Z', + body: 'lgtm', + }, + { + user: { login: 'appr' }, + author_association: 'MEMBER', + state: 'APPROVED', + submitted_at: '2026-07-03T17:00:00Z', + body: 'lgtm again', + }, + // Request changes review container (id 801) that the crinline + // comments below attach to; itself never deferrable. + { + id: 801, + user: { login: 'crreviewer' }, + author_association: 'MEMBER', + state: 'CHANGES_REQUESTED', + submitted_at: '2026-07-02T10:00:00Z', + body: 'requesting changes', + }, + ]), + ); + writeFileSync( + join(budgetDir, 'rc.json'), + JSON.stringify([ + { + user: { login: 'reviewer2' }, + author_association: 'MEMBER', + created_at: '2026-07-03T13:30:00Z', + body: 'feedback delivered through an inline comment', + }, + // Critical root comment (id 901) that replyguy's replies attach to. + { + id: 901, + user: { login: 'somecrit' }, + author_association: 'MEMBER', + created_at: '2026-07-02T11:00:00Z', + body: '**[Critical]** root finding', + }, + // Inline replies rooted at a Critical comment are never deferrable, + // so two consumed-span replies must not count (replyguy absent). + { + user: { login: 'replyguy' }, + author_association: 'MEMBER', + in_reply_to_id: 901, + created_at: '2026-07-02T12:45:00Z', + body: 'me too', + }, + { + user: { login: 'replyguy' }, + author_association: 'MEMBER', + in_reply_to_id: 901, + created_at: '2026-07-03T12:45:00Z', + body: 'me too again', + }, + // Inline comments under a Request changes review are never + // deferrable, so two consumed-span comments must not count + // (crinline absent). + { + user: { login: 'crinline' }, + author_association: 'MEMBER', + pull_request_review_id: 801, + created_at: '2026-07-02T13:45:00Z', + body: 'inline under CR review', + }, + { + user: { login: 'crinline' }, + author_association: 'MEMBER', + pull_request_review_id: 801, + created_at: '2026-07-03T13:45:00Z', + body: 'inline under CR review 2', + }, + ]), + ); + const overOut = execFileSync( + 'bash', + [ + '-c', + [ + 'set -uo pipefail', + `WORKDIR='${budgetDir}'`, + `LIVE_REARM_KEY='${WKEY}'`, + "AUTOFIX_BOT='qwen-code-dev-bot'", + "REVIEW_BOT='qwen-code-ci-bot'", + `TRUSTED_ASSOC='["OWNER","MEMBER","COLLABORATOR"]'`, + 'CRITICAL_ONLY_AFTER_ROUND=5', + 'CRITICAL_ONLY_HUMAN_BATCHES=2', + censusBlock.replace(/\n {10}/g, '\n'), + 'printf %s "${OVER_BUDGET_AUTHORS}"', + ].join('\n'), + ], + { encoding: 'utf8' }, + ); + // Only the two looped authors land here; every protected author above + // (crit/cr/appr/replyguy/crinline/qwen-code-ci-bot/sentinelvictim) has + // two consumed-span batches yet stays absent — dropping any one of the + // census's deferral-mirroring exclusions surfaces one of them and fails. + expect(JSON.parse(overOut)).toEqual(['looper', 'reviewer2']); + } finally { + rmSync(budgetDir, { recursive: true, force: true }); + } + // The deferral note names over-budget authors with the escapes. + expect(prepareBranchAndFeedbackStep).toContain('is at this window'); + expect(prepareBranchAndFeedbackStep).toContain('regular-feedback budget'); expect(inlineFilter).toContain('pull_request_review_id'); // Scan still selects fresh suggestions so a no-op report can advance the From cc508dd7baecdcda9e9ed7ffebace1b00cdbeb29 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Thu, 30 Jul 2026 19:38:47 +0800 Subject: [PATCH 03/17] fix(autofix): salvage race-lost pushes by merging the moved head and retrying (#8042) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(autofix): salvage race-lost pushes by merging the moved head and retrying The review-address push is one-shot: when anything pushes to the PR head during the agent's ~50-minute window, the final push dies 'fetch first' and the entire verified agent run is discarded. The per-PR head-write concurrency group cannot prevent this — it serialises this repo's workflows, not the PR author or the fork side. Observed twice in one day (#7983 after a 56-minute run, #7985 after 43 minutes). On rejection, fetch the moved head, merge it into the local line, and retry (bounded at 3 attempts). Merge rather than rebase: the agent's own conflict-resolution rounds create merge commits, and a rebase would flatten them and can silently re-introduce the conflicts they resolved. The merge result descends from the remote head, so the retried push is a fast-forward and rewrites nothing. A genuine content conflict aborts the merge and falls through to the existing failure path unchanged. When a salvage merge happened, the round report discloses that the round's verification predates the merge so mid-run commits get re-checked by a human. * fix(autofix): address salvage-loop review findings - Gate the PUSH_RACE_MERGED disclosure on HEAD actually advancing: a transient push failure (upload timeout, 503) on an unmoved branch no-ops the merge ('Already up to date') and must not tell the reviewer to re-check mid-run commits that never existed. - Annotate the salvage fetch failure with ::error:: like the two adjacent failure paths, so a deleted fork branch or network error does not kill the step with an unannotated exit 128 under bash -e. - Re-pin the same-repo push URL construction in tests: it lost its old 'origin "${BRANCH}"' pin in this rework, leaving a ${REPO}→${HEAD_REPO} mutation (malformed remote in the same-repo case) unkillable. * test(autofix): restore dropped mutation-killing pins and add structural assertions (#8042) * test(autofix): pin exit 1 in the give-up guard regex to kill the deletion mutation (#8042) * test(autofix): pin exit 1 in the fetch-failure and merge-conflict salvage paths (#8042) * test(autofix): strengthen salvage-test pins to kill init-value and capture-order mutations (#8042) --------- Co-authored-by: verify Co-authored-by: qwen-code-dev-bot --- .github/workflows/qwen-autofix.yml | 52 ++++++++++- scripts/tests/qwen-autofix-workflow.test.js | 98 ++++++++++++++++++++- 2 files changed, 146 insertions(+), 4 deletions(-) diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index 727de93e89..6f1a2054cb 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -3669,10 +3669,54 @@ jobs: # Push back to the FORK branch via allow-edits (PAT has push # rights on the upstream, which GitHub extends to the fork's # PR branch when the author ticked the box). - git push --no-verify "https://x-access-token:${GITHUB_TOKEN}@github.com/${HEAD_REPO}.git" HEAD:"${BRANCH}" + PUSH_URL="https://x-access-token:${GITHUB_TOKEN}@github.com/${HEAD_REPO}.git" else - git push --no-verify origin "${BRANCH}" + PUSH_URL="https://x-access-token:${GITHUB_TOKEN}@github.com/${REPO}.git" fi + # Salvage a race-lost push instead of discarding the run. The + # per-PR head-write concurrency group serialises THIS repo's + # workflows, but it cannot stop the PR author (or anything on the + # fork side) pushing during the agent's ~50-minute window — + # observed twice in one day (#7983, #7985): a one-shot push died + # `fetch first` and a full verified agent run was thrown away. + # On rejection, fetch the moved head and MERGE it into the local + # line (merge, not rebase: the agent's own conflict-resolution + # rounds create merge commits, and a rebase would flatten them + # and can silently re-introduce the conflicts it resolved). The + # merge result descends from the remote head, so the retried push + # is a fast-forward. A genuine content conflict aborts and falls + # through to the existing failure path — same as today. + PUSH_RACE_MERGED='false' + for push_attempt in 1 2 3; do + if git push --no-verify "${PUSH_URL}" HEAD:"${BRANCH}"; then + break + fi + if [[ "${push_attempt}" == 3 ]]; then + echo "::error::push rejected ${push_attempt} times; giving up" + exit 1 + fi + echo "⚠️ push rejected (attempt ${push_attempt}) — branch moved during the run; merging the new head and retrying" + if ! git fetch "${PUSH_URL}" "refs/heads/${BRANCH}"; then + echo "::error::could not fetch the moved head (attempt ${push_attempt}) — cannot salvage this push" + exit 1 + fi + # The disclosure flag keys on HEAD actually advancing: a push + # can fail transiently (upload timeout, 503) with the branch + # unmoved, and the merge then no-ops "Already up to date" — + # flagging that would tell the reviewer to re-check mid-run + # commits that never existed. + PRE_MERGE_HEAD="$(git rev-parse HEAD)" + if ! git -c user.name="${AUTOFIX_BOT}" \ + -c user.email="${AUTOFIX_BOT}@users.noreply.github.com" \ + merge --no-edit FETCH_HEAD; then + git merge --abort || true + echo "::error::the commits pushed during the run conflict with this fix — handing off instead of overwriting either side" + exit 1 + fi + if [[ "$(git rev-parse HEAD)" != "${PRE_MERGE_HEAD}" ]]; then + PUSH_RACE_MERGED='true' + fi + done # Resolve the review threads whose findings the agent actually # IMPLEMENTED, so a human re-reviewing sees only what is still open # instead of re-reading every thread to work out what was handled. @@ -3788,6 +3832,10 @@ jobs: fi echo echo "Base-conflict check · 基分支冲突检查: $([[ "${CONFLICT}" == "true" ]] && echo 'conflicted with main — resolved in this push. · 与 main 有冲突——已在本次推送中解决。' || echo 'no conflict with main. · 与 main 无冲突。')" + if [[ "${PUSH_RACE_MERGED}" == 'true' ]]; then + echo + echo "⚠️ The branch received new commits while this round ran; they were merged into this push, but this round's verification predates that merge — re-check anything that landed mid-run. · 本轮运行期间分支收到了新的提交;本次推送已将其合并,但本轮验证在合并之前完成——请复查运行期间落地的改动。" + fi echo echo "Re-review when you have a moment. After round ${MAX_ROUNDS} this bot stops and leaves the PR for a human. · 有空请复审;第 ${MAX_ROUNDS} 轮后本 bot 停止并将 PR 交给人工。" echo diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 3a62670c7e..2414f9368f 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -2202,7 +2202,10 @@ describe('qwen-autofix workflow', () => { 'git fetch "https://github.com/${HEAD_REPO}.git" "refs/heads/${BRANCH}"', ); expect(workflow).toContain( - 'git push --no-verify "https://x-access-token:${GITHUB_TOKEN}@github.com/${HEAD_REPO}.git" HEAD:"${BRANCH}"', + 'PUSH_URL="https://x-access-token:${GITHUB_TOKEN}@github.com/${HEAD_REPO}.git"', + ); + expect(workflow).toContain( + 'git push --no-verify "${PUSH_URL}" HEAD:"${BRANCH}"', ); // The allow-edits grant rides the classic-PAT path only — prepare must // prove push access BEFORE an agent round is spent, discarding @@ -5823,6 +5826,97 @@ describe('qwen-autofix workflow', () => { expect(pushAndReportStep).toContain('milestone digest failed to post'); }); + it('salvages a race-lost push by merging the moved head instead of discarding the run', () => { + // A one-shot push dies `fetch first` whenever anything pushes to the PR + // head during the agent's ~50-minute window (observed twice in one day, + // #7983/#7985 — a full verified agent run thrown away each time). The + // per-PR head-write concurrency group cannot prevent this: it only + // serialises THIS repo's workflows, not the PR author or the fork side. + // On rejection the report step fetches the moved head, MERGES it into + // the local line, and retries a bounded number of times; the merge + // result descends from the remote head so the retry is a fast-forward. + expect(pushAndReportStep).toContain('for push_attempt in 1 2 3; do'); + // A successful push breaks out of the loop immediately — without this + // pin, deleting the break survives: the loop range and give-up message + // are still asserted as strings, but a successful push then re-runs + // git push twice more and the salvage legs execute against a branch + // that was already pushed. + expect(pushAndReportStep).toMatch( + /if git push --no-verify "\$\{PUSH_URL\}" HEAD:"\$\{BRANCH\}"; then\n\s+break/, + ); + // BOTH push-URL constructions stay pinned — the fork one is pinned by + // the fork-plumbing test, and the same-repo one lost its old + // `origin "${BRANCH}"` pin in this rework: a mutation swapping ${REPO} + // for ${HEAD_REPO} (empty in the same-repo case → a malformed + // `github.com/.git` remote) must not survive. + expect(pushAndReportStep).toContain( + 'PUSH_URL="https://x-access-token:${GITHUB_TOKEN}@github.com/${REPO}.git"', + ); + expect(pushAndReportStep).toContain( + 'git fetch "${PUSH_URL}" "refs/heads/${BRANCH}"', + ); + // Every failure path in the salvage loop is ::error::-annotated — a + // deleted fork branch (or transient network error) must not kill the + // step with an unannotated exit 128 under bash -e. The structural pin + // connects the message to its exit 1: deleting that exit 1 proceeds to + // `git merge FETCH_HEAD` against a stale FETCH_HEAD. + expect(pushAndReportStep).toMatch( + /echo "::error::could not fetch the moved head \(attempt \$\{push_attempt\}\)[^\n]*"\n\s+exit 1/, + ); + // The disclosure flag keys on HEAD actually advancing: a transient + // push failure on an unmoved branch no-ops the merge ("Already up to + // date") and must NOT tell the reviewer to re-check commits that + // never existed. + expect(pushAndReportStep).toMatch( + /PRE_MERGE_HEAD="\$\(git rev-parse HEAD\)"\n\s+if ! git -c user\.name=/, + ); + expect(pushAndReportStep).toMatch( + /if \[\[ "\$\(git rev-parse HEAD\)" != "\$\{PRE_MERGE_HEAD\}" \]\]; then\n\s+PUSH_RACE_MERGED='true'/, + ); + // Merge, never rebase: the agent's own conflict-resolution rounds create + // merge commits, and a rebase would flatten them and can silently + // re-introduce the conflicts they resolved. + expect(pushAndReportStep).toContain('merge --no-edit FETCH_HEAD'); + expect(pushAndReportStep).not.toContain('git rebase'); + // The merge commit needs an explicit identity on a bare runner. + expect(pushAndReportStep).toContain('-c user.name="${AUTOFIX_BOT}"'); + expect(pushAndReportStep).toContain( + '-c user.email="${AUTOFIX_BOT}@users.noreply.github.com"', + ); + // A genuine content conflict aborts cleanly and falls through to the + // existing failure path — the salvage must never overwrite either side. + expect(pushAndReportStep).toContain('git merge --abort || true'); + // Structural pin: deleting this exit 1 falls through to the report + // section and posts a round-complete comment as if the push succeeded. + expect(pushAndReportStep).toMatch( + /echo "::error::the commits pushed during the run conflict with this fix[^\n]*"\n\s+exit 1/, + ); + // The salvage is disclosed in the round report: the round's verification + // ran before the merge, so mid-run commits deserve a re-check. The + // structure pin keeps the warning conditional — making it unconditional + // (printed every round) passes presence-only checks but is the exact + // false-disclosure this head's own fix commit closes from the other side. + expect(pushAndReportStep).toMatch( + /if \[\[ "\$\{PUSH_RACE_MERGED\}" == .true. \]\]; then\n\s+echo\n\s+echo "⚠️ The branch received new commits/, + ); + expect(pushAndReportStep).toMatch( + /PUSH_RACE_MERGED='false'\n\s+for push_attempt in 1 2 3; do/, + ); + expect(pushAndReportStep).toContain('verification predates that merge'); + // Bounded: the loop gives up after the last attempt instead of spinning. + // The structural pin connects the guard value to the error exit — a + // mutation of == 3 to == 4 survives presence-only checks: the loop + // range string is unchanged and the error message still exists as dead + // code, but execution falls through to the report section after 3 + // failed attempts and posts a round-complete comment as if the push + // succeeded. Deleting exit 1 alone also survives without this pin: + // the guard fires and prints the error but execution continues past + // fi, the for-loop exhausts, and the report section runs. + expect(pushAndReportStep).toMatch( + /if \[\[ "\$\{push_attempt\}" == 3 \]\]; then\n\s+echo "::error::push rejected \$\{push_attempt\} times; giving up"\n\s+exit 1/, + ); + }); + it('pushes autofix branches without rewriting remote history', () => { expect(workflow).not.toMatch(/\bgit push\b[^\n]*--force(?:-with-lease)?/); // No bare -f / +refspec force forms either. (--no-verify is NOT a force @@ -5834,7 +5928,7 @@ describe('qwen-autofix workflow', () => { expect(workflow).not.toMatch(/\bgit push\b[^\n]* \+\S/); expect(publishPrStep).toContain('git push --no-verify origin "${BRANCH}"'); expect(pushAndReportStep).toContain( - 'git push --no-verify origin "${BRANCH}"', + 'git push --no-verify "${PUSH_URL}" HEAD:"${BRANCH}"', ); // Five sites now: both PAT pushes, the PAT-bearing prepare checkout, // AND both no-secret verification checkouts (convention: every host From 953c9d8177b322255deafcaff477d974ba8e6a5a Mon Sep 17 00:00:00 2001 From: zjgzx1988 Date: Thu, 30 Jul 2026 19:45:23 +0800 Subject: [PATCH 04/17] feat(core): tag UserPromptSubmit hook context and record display provenance (#7956) * feat(core): tag UserPromptSubmit hook context and record display provenance UserPromptSubmit additionalContext was appended to the request as a bare text part and persisted verbatim, so hook-injected text was indistinguishable from user-authored text in the transcript, polluted resumed sessions, telemetry, and auto-memory recall queries. - Wrap injected context in a reserved tag (hook output already escapes angle brackets, so the tag cannot be forged from inside). - Record the pre-injection user prompt as systemPayload.displayText plus the injected string as hookContext on the user record; the model-bound message stays verbatim for faithful resume replay. - Use the pre-injection prompt text for telemetry prompt attributes and managed auto-memory recall. - Resume projection prefers displayText, strips a trailing whole-part tagged block when no payload exists, and leaves legacy bare-injected records unchanged. - Apply the same tag wrapping on the ACP session injection path, which already records the pre-injection prompt. Closes #7940 Co-authored-by: Cursor * docs: note UPS promptText TDZ ordering and sole-part resume guard Document the conflict-resolution constraint that promptText must be declared before the injection assignment, and the sole-part read-path guard that keeps a user-authored whole-tag message intact. Co-authored-by: Cursor * test(cli): cover at_command resume with tagged UPS context Confirm the at_command branch still prefers payload.userText when a paired user record carries a trailing tagged hook-context part, and falls back to the tag-stripping projection only when userText is absent. Co-authored-by: Cursor * fix(core): address PR 7956 review findings and Goal recording spy Omit the optional UserPromptRecordPayload third arg when no hook injected, so Goal admission spies expecting two args stay exact and CI client-goal.test.ts passes. Project plain UserPromptSubmit-augmented records through transcript-replay with the same displayText / trailing-tag strip fallback as the TUI, covering ACP/export surfaces. Strengthen the displayText preference fixture so it disagrees with the tag-strip path, and use the named UserPromptRecordPayload type in resume. Co-authored-by: Cursor * fix(acp-bridge): import UPS tag helper via Node-free package export transcript-replay is inlined into the browser daemon/transcript SDK bundle. Importing isUserPromptSubmitContextPartText from the core package barrel pulled the whole Node-bound core graph into that bundle and failed CI (esbuild Could not resolve "node:*") across Test, web-shell E2E, and Real daemon E2E. Export the pure helper as @qwen-code/qwen-code-core/userPromptSubmitContext and import that path instead. Co-authored-by: Cursor * fix(test): alias userPromptSubmitContext for Vitest source resolution CLI and acp-bridge Vitest configs already map goalWire/transcriptRecords to TypeScript sources; without the same alias the new package export fails import analysis and breaks dozens of CLI suites. Co-authored-by: Cursor * fix(acp-bridge): keep images when projecting displayText user records Preferring UserPromptSubmit displayText previously returned early and skipped projectMessageParts, dropping multimodal inlineData. Rebuild parts so displayText replaces text while images keep their order. Co-authored-by: Cursor * fix(core): drop unused hookContext and cover image-only displayText UserPromptRecordPayload.hookContext had no read sites; keep displayText only and recover injected text from the tagged message part. Also cover the image-only !replaced append path and simplify the recording guard. Co-authored-by: Cursor * test: cover remaining UserPromptSubmit provenance Suggestions Share stripTrailingUserPromptSubmitContextPart between TUI resume and ACP replay, assert ACP Session tags additionalContext, and lock telemetry to the pre-injection prompt text. Co-authored-by: Cursor --------- Co-authored-by: Cursor Co-authored-by: Shaojin Wen Co-authored-by: qwen-code-dev-bot Co-authored-by: Qwen-Coder --- ...8-user-prompt-submit-context-provenance.md | 86 ++++++++++ docs/users/features/hooks.md | 2 + .../acp-bridge/src/transcript-replay.test.ts | 129 ++++++++++++++ packages/acp-bridge/src/transcript-replay.ts | 95 ++++++++++ packages/acp-bridge/vitest.config.ts | 4 + .../acp-integration/session/Session.test.ts | 40 +++++ .../src/acp-integration/session/Session.ts | 10 +- .../src/ui/utils/resumeHistoryUtils.test.ts | 141 +++++++++++++++ .../cli/src/ui/utils/resumeHistoryUtils.ts | 34 +++- packages/cli/vitest.config.ts | 4 + packages/core/package.json | 4 + packages/core/src/core/client.test.ts | 162 ++++++++++++++++++ packages/core/src/core/client.ts | 52 ++++-- .../hooks/user-prompt-submit-context.test.ts | 100 +++++++++++ .../src/hooks/user-prompt-submit-context.ts | 71 ++++++++ packages/core/src/index.ts | 7 + .../src/services/chatRecordingService.test.ts | 26 +++ .../core/src/services/chatRecordingService.ts | 16 ++ 18 files changed, 964 insertions(+), 19 deletions(-) create mode 100644 docs/design/2026-07-28-user-prompt-submit-context-provenance.md create mode 100644 packages/core/src/hooks/user-prompt-submit-context.test.ts create mode 100644 packages/core/src/hooks/user-prompt-submit-context.ts diff --git a/docs/design/2026-07-28-user-prompt-submit-context-provenance.md b/docs/design/2026-07-28-user-prompt-submit-context-provenance.md new file mode 100644 index 0000000000..c1e299874d --- /dev/null +++ b/docs/design/2026-07-28-user-prompt-submit-context-provenance.md @@ -0,0 +1,86 @@ +# UserPromptSubmit hook context provenance + +Issue: https://github.com/QwenLM/qwen-code/issues/7940 + +## Problem + +`UserPromptSubmit` hooks can return `additionalContext`, which the client +appends to the outgoing request as a bare text part. Because +`recordUserMessage` persists the augmented request, the injected text lands in +the user record's `message.parts` indistinguishable from user-authored text. + +Consequences: + +- **Resume**: the UI projection concatenates all text parts, so resumed + sessions display hook-injected context as if the user typed it. +- **Offline analysis / downstream consumers**: the JSONL transcript cannot + separate user text from injection; consumers resort to fragile custom + marker-stripping heuristics. +- **Telemetry & auto-memory recall**: both consumed `partToString(request)` + after injection, polluting the prompt attribute and the recall query. + +The live TUI is unaffected (it builds its history item from the pre-hook +input), which is exactly the asymmetry that made the polluted transcript easy +to miss. + +## Design + +Isomorphic to two existing patterns: `SessionStart` context is injected as a +tagged block into the system instruction, and mid-turn/notification records +separate the model-bound `message` from a `systemPayload.displayText` +projection. + +### Write path + +1. **Tagged injection** (`client.ts`): the sanitized `additionalContext` is + appended as its own part wrapped in + `...`. + `getAdditionalContext()` escapes `<`/`>` in hook output, so the wrapper + cannot be closed or forged from inside. User-authored text is never + rewritten or escaped. `promptText` must be declared before the injection + assignment that captures it into `preInjectionPromptText` (avoids a TDZ + if the surrounding Goal try/catch is later reshuffled). +2. **Display provenance** (`chatRecordingService.ts`): `recordUserMessage` + accepts an optional `UserPromptRecordPayload { displayText? }` stored as + `systemPayload`. `message` keeps the exact model-bound Content — resume + must replay what the model actually saw — while `displayText` preserves + the pre-injection user projection. Hook-injected text remains in the + tagged `message.parts` entry (machine-parseable). The payload is only + written when a hook actually injected context. +3. **Telemetry & recall** (`client.ts`): `addUserPromptAttributes` and + `MemoryManager.recall` use the pre-injection prompt text when injection + occurred. + +### Read path (resume projection) + +`resumeHistoryUtils` projects plain user records through a three-shape +fallback: + +- (a) new records: prefer `systemPayload.displayText`; +- (b) tag-only records (no payload): drop a trailing part that is, in its + entirety, a tagged block — whole-part strict match only, so user prose that + merely contains the tag is never stripped. A sole part matching the tag + shape is also kept (injection always appends after the user's own part(s), + so a single-part record can only be user-authored); +- (c) legacy bare-injected records: unchanged concatenation. + +The `@`-command resume branch still prefers `AtCommandRecordPayload.userText` +when present; only the absent-`userText` fallback goes through +`extractUserRecordDisplayText`, so a trailing tagged part does not override +the `@`-command display text. + +## Scope notes + +- Focused on the interactive `UserPromptSubmit` path. The ACP session path + already records the pre-injection prompt text, so it only needed the same + tag wrapping on its model-bound injection (included here). Subagent context + injection (`SubagentStart` via `contextState`) needs its own investigation + and is a follow-up. +- Other transcript consumers (desktop, web UI) can adopt `displayText` in + follow-ups; until then they see the tagged shape, which is at least + mechanically identifiable. + +ACP/export/daemon consumers that go through `transcript-replay`'s +`projectUserRecord` also prefer `displayText` and strip a trailing tagged +part for subtype-less user records (same three-shape fallback as the TUI +resume path). diff --git a/docs/users/features/hooks.md b/docs/users/features/hooks.md index ebc285911f..ad83ba2c9c 100644 --- a/docs/users/features/hooks.md +++ b/docs/users/features/hooks.md @@ -557,6 +557,8 @@ Sequential UserPromptSubmit hooks can append `additionalContext` to `prompt`; `s - `reason`: human-readable explanation for the decision - `hookSpecificOutput.additionalContext`: additional context to append to the prompt (optional) +When sent to the model, injected `additionalContext` is appended as its own message part wrapped in a reserved `...` tag, so it stays distinguishable from user-authored text in model history and session transcripts. Angle brackets in hook output are escaped before wrapping, so hook content cannot close or forge the tag. The session transcript also records the user's original prompt text separately; the interactive TUI and the ACP/export transcript-replay path display that original text rather than the injected context. + **Note**: Since UserPromptSubmitOutput extends HookOutput, all standard fields are available but only additionalContext in hookSpecificOutput is specifically defined for this event. **Example Output**: diff --git a/packages/acp-bridge/src/transcript-replay.test.ts b/packages/acp-bridge/src/transcript-replay.test.ts index bc29c7c643..20ca9d6c06 100644 --- a/packages/acp-bridge/src/transcript-replay.test.ts +++ b/packages/acp-bridge/src/transcript-replay.test.ts @@ -193,6 +193,135 @@ describe('createTranscriptReplayMachine', () => { ]); }); + describe('UserPromptSubmit hook context provenance', () => { + const tagged = + '\ninjected hook context\n'; + + it('prefers displayText over the tag-strip fallback and keeps image parts', () => { + // Without displayText the tag-strip path would also emit the middle + // "expanded extra" text part. displayText must win, and the image + // part must survive (the previous early-return path dropped it). + const projected = updates( + createTranscriptReplayMachine(), + record('user-1', 'user', { + message: { + role: 'user', + parts: [ + { + inlineData: { + data: 'abc123', + mimeType: 'image/png', + }, + }, + { text: 'my prompt' }, + { text: 'expanded extra' }, + { text: tagged }, + ], + }, + systemPayload: { + displayText: 'my prompt', + }, + }), + ); + + expect(projected).toMatchObject([ + { + sessionUpdate: 'user_message_chunk', + content: { + type: 'image', + data: 'abc123', + mimeType: 'image/png', + }, + }, + { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'my prompt' }, + }, + ]); + expect(projected).toHaveLength(2); + }); + + it('appends displayText after images when the record has no text part to replace', () => { + // Exercises the !replaced fallback: after stripping the trailing tagged + // block, only the image remains, so displayText is appended. + const projected = updates( + createTranscriptReplayMachine(), + record('user-img-only', 'user', { + message: { + role: 'user', + parts: [ + { + inlineData: { + data: 'abc', + mimeType: 'image/png', + }, + }, + { text: tagged }, + ], + }, + systemPayload: { + displayText: 'my image prompt', + }, + }), + ); + + expect(projected).toMatchObject([ + { + sessionUpdate: 'user_message_chunk', + content: { + type: 'image', + data: 'abc', + mimeType: 'image/png', + }, + }, + { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'my image prompt' }, + }, + ]); + expect(projected).toHaveLength(2); + }); + + it('strips a trailing whole-part tagged block when displayText is absent', () => { + const projected = updates( + createTranscriptReplayMachine(), + record('user-2', 'user', { + message: { + role: 'user', + parts: [{ text: 'my prompt' }, { text: tagged }], + }, + }), + ); + + expect(projected).toMatchObject([ + { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'my prompt' }, + }, + ]); + expect(projected).toHaveLength(1); + }); + + it('keeps a sole part that matches the tag shape', () => { + const projected = updates( + createTranscriptReplayMachine(), + record('user-3', 'user', { + message: { + role: 'user', + parts: [{ text: tagged }], + }, + }), + ); + + expect(projected).toMatchObject([ + { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: tagged }, + }, + ]); + }); + }); + it('projects ordered message parts with source metadata', () => { const machine = createTranscriptReplayMachine(); const projected = updates( diff --git a/packages/acp-bridge/src/transcript-replay.ts b/packages/acp-bridge/src/transcript-replay.ts index d0cb810091..ecef60c951 100644 --- a/packages/acp-bridge/src/transcript-replay.ts +++ b/packages/acp-bridge/src/transcript-replay.ts @@ -21,6 +21,10 @@ import { projectGoalStateToLegacy, type GoalSnapshotV2, } from '@qwen-code/qwen-code-core/goalWire'; +// Narrow path — the helper is Node-free. Importing the core package barrel +// here would pull the whole Node-bound core graph into the browser +// transcript bundle (sdk-typescript daemon/transcript). +import { stripTrailingUserPromptSubmitContextPart } from '@qwen-code/qwen-code-core/userPromptSubmitContext'; export const MISSING_TRANSCRIPT_TOOL_RESULT_MESSAGE = 'Tool result missing from saved history; the previous run likely ended ' + @@ -513,10 +517,101 @@ class DefaultTranscriptReplayMachine implements TranscriptReplayMachine { return; } if (record.subtype !== 'mid_turn_user_message') return; + } else if (!record.subtype) { + // Plain user records — including UserPromptSubmit-augmented ones — + // prefer the recorded display projection, then strip a trailing + // whole-part tagged hook-context block. Matches resumeHistoryUtils. + // Always go through projectMessageParts so multimodal inlineData + // (images) survives even when displayText replaces the text parts. + const payload = isObjectRecord(record.systemPayload) + ? record.systemPayload + : undefined; + const displayText = + payload && typeof payload['displayText'] === 'string' + ? payload['displayText'] + : undefined; + yield* this.projectMessageParts( + displayText + ? this.withUserPromptDisplayText(record, displayText) + : this.withoutTrailingUserPromptSubmitContext(record), + 'user', + emit, + meta, + ); + return; } yield* this.projectMessageParts(record, 'user', emit, meta); } + /** + * Drops a trailing message part that is entirely a tagged UserPromptSubmit + * context block. Injection always appends after the user's own part(s), so + * a sole matching part is treated as user-authored and kept. + */ + private withoutTrailingUserPromptSubmitContext( + record: TranscriptRecordInput, + ): TranscriptRecordInput { + const parts = record.message?.parts; + if (!Array.isArray(parts)) { + return record; + } + const nextParts = stripTrailingUserPromptSubmitContextPart(parts); + if (nextParts === parts) { + return record; + } + return { + ...record, + message: { + ...record.message, + parts: [...nextParts], + }, + }; + } + + /** + * Rebuilds a plain user record for display: strip trailing tagged hook + * context, then replace every text part with a single `displayText` part at + * the first text position so images keep their relative order. + */ + private withUserPromptDisplayText( + record: TranscriptRecordInput, + displayText: string, + ): TranscriptRecordInput { + const stripped = this.withoutTrailingUserPromptSubmitContext(record); + const parts = stripped.message?.parts; + if (!Array.isArray(parts) || parts.length === 0) { + return { + ...stripped, + message: { + ...stripped.message, + parts: [{ text: displayText }], + }, + }; + } + let replaced = false; + const nextParts: unknown[] = []; + for (const part of parts) { + if (isObjectRecord(part) && typeof part['text'] === 'string') { + if (!replaced) { + nextParts.push({ text: displayText }); + replaced = true; + } + continue; + } + nextParts.push(part); + } + if (!replaced) { + nextParts.push({ text: displayText }); + } + return { + ...stripped, + message: { + ...stripped.message, + parts: nextParts, + }, + }; + } + private *projectAssistantRecord( record: TranscriptRecordInput, emit: (update: SessionUpdate) => TranscriptReplayEmission, diff --git a/packages/acp-bridge/vitest.config.ts b/packages/acp-bridge/vitest.config.ts index 48e2356ea4..731a7b5b03 100644 --- a/packages/acp-bridge/vitest.config.ts +++ b/packages/acp-bridge/vitest.config.ts @@ -18,6 +18,10 @@ export default defineConfig({ __dirname, '../core/src/utils/transcript-records.ts', ), + '@qwen-code/qwen-code-core/userPromptSubmitContext': path.resolve( + __dirname, + '../core/src/hooks/user-prompt-submit-context.ts', + ), }, }, test: { diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index aefd1b475b..992db0643c 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -13749,6 +13749,46 @@ describe('Session', () => { expect(mockChat.sendMessageStream).not.toHaveBeenCalled(); expect(result.stopReason).toBe('end_turn'); }); + + it('wraps additionalContext in the reserved tag before sending', async () => { + const messageBus = { + request: vi.fn().mockResolvedValue({ + success: true, + output: { + hookSpecificOutput: { + hookEventName: 'UserPromptSubmit', + additionalContext: 'extra hook context', + }, + }, + }), + }; + mockConfig.getMessageBus = vi.fn().mockReturnValue(messageBus); + mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(false); + mockConfig.hasHooksForEvent = vi.fn().mockReturnValue(true); + + mockChat.sendMessageStream = vi.fn().mockResolvedValue( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + candidates: [{ content: { parts: [{ text: 'response' }] } }], + }, + }, + ]), + ); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + const sent = firstSentMessage(); + expect(textParts(sent)[0]).toBe('hello'); + expect( + core.isUserPromptSubmitContextPartText(textParts(sent).at(-1)!), + ).toBe(true); + expect(textParts(sent).at(-1)).toContain('extra hook context'); + }); }); describe('Stop hook', () => { diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 71194357f1..0abb734eb3 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -86,6 +86,7 @@ import { NotificationType, persistPermissionOutcome, createHookOutput, + wrapUserPromptSubmitContext, generateToolUseId, MessageBusType, MessageDisplayDispatcher, @@ -2852,10 +2853,15 @@ export class Session implements SessionContext { return { stopReason: 'end_turn' }; } - // Add additional context from hooks to the request + // Add additional context from hooks to the request, wrapped in + // the reserved tag so it stays distinguishable from + // user-authored text (same shape as the interactive path). const additionalContext = hookOutput?.getAdditionalContext(); if (additionalContext) { - parts = [...parts, { text: additionalContext }]; + parts = [ + ...parts, + { text: wrapUserPromptSubmitContext(additionalContext) }, + ]; } } diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts index 1e76ed0802..a463372447 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts @@ -118,6 +118,147 @@ describe('resumeHistoryUtils', () => { expect(userItem.text).toBe('post-gap message'); }); + describe('UserPromptSubmit hook context provenance', () => { + const tagged = + '\ninjected hook context\n'; + + const buildUserItems = (record: Record) => { + const conversation = { + messages: [record], + } as unknown as ConversationRecord; + const session: ResumedSessionData = { + conversation, + } as ResumedSessionData; + return buildResumedHistoryItems(session, makeConfig({}), 1_000); + }; + + it('prefers recorded displayText over the augmented parts', () => { + const items = buildUserItems({ + type: 'user', + message: { parts: [{ text: 'my prompt' }, { text: tagged }] }, + systemPayload: { + displayText: 'my prompt', + }, + }); + expect(items).toEqual([{ id: 1_001, type: 'user', text: 'my prompt' }]); + }); + + it('prefers displayText over the tag-strip fallback', () => { + // Fixture where the two branches disagree: without displayText the + // tag-strip path would expose the middle "expanded extra" part. + const items = buildUserItems({ + type: 'user', + message: { + parts: [ + { text: 'my prompt' }, + { text: 'expanded extra' }, + { text: tagged }, + ], + }, + systemPayload: { + displayText: 'my prompt', + }, + }); + expect(items).toEqual([{ id: 1_001, type: 'user', text: 'my prompt' }]); + }); + + it('strips a trailing whole-part tagged block when no displayText is recorded', () => { + const items = buildUserItems({ + type: 'user', + message: { parts: [{ text: 'my prompt' }, { text: tagged }] }, + }); + expect(items).toEqual([{ id: 1_001, type: 'user', text: 'my prompt' }]); + }); + + it('keeps user-authored text that merely contains the tag', () => { + const items = buildUserItems({ + type: 'user', + message: { parts: [{ text: `quote: ${tagged} end` }] }, + }); + expect(items).toEqual([ + { id: 1_001, type: 'user', text: `quote: ${tagged} end` }, + ]); + }); + + it('keeps a sole part that matches the tag shape (user-authored)', () => { + const items = buildUserItems({ + type: 'user', + message: { parts: [{ text: tagged }] }, + }); + expect(items).toEqual([{ id: 1_001, type: 'user', text: tagged }]); + }); + + it('falls back to raw concatenation for legacy bare-injected records', () => { + const items = buildUserItems({ + type: 'user', + message: { + parts: [{ text: 'my prompt' }, { text: 'bare injected context' }], + }, + }); + expect(items).toEqual([ + { id: 1_001, type: 'user', text: 'my prompt\nbare injected context' }, + ]); + }); + + it('prefers at_command userText even when the paired user record has a trailing tagged part', () => { + const conversation = { + messages: [ + { + type: 'system', + subtype: 'at_command', + systemPayload: { + userText: '@file.ts summarize this', + filesRead: ['/tmp/file.ts'], + status: 'success', + }, + }, + { + type: 'user', + message: { + parts: [{ text: 'expanded model prompt' }, { text: tagged }], + }, + }, + ], + } as unknown as ConversationRecord; + const items = buildResumedHistoryItems( + { conversation } as ResumedSessionData, + makeConfig({}), + 1_000, + ); + const userItem = items.find((i) => i.type === 'user') as { text: string }; + expect(userItem.text).toBe('@file.ts summarize this'); + expect(userItem.text).not.toContain('qwen:user-prompt-submit-context'); + }); + + it('strips a trailing tagged part when at_command userText is absent', () => { + const conversation = { + messages: [ + { + type: 'system', + subtype: 'at_command', + systemPayload: { + filesRead: ['/tmp/file.ts'], + status: 'success', + }, + }, + { + type: 'user', + message: { + parts: [{ text: 'my prompt' }, { text: tagged }], + }, + }, + ], + } as unknown as ConversationRecord; + const items = buildResumedHistoryItems( + { conversation } as ResumedSessionData, + makeConfig({}), + 1_000, + ); + const userItem = items.find((i) => i.type === 'user') as { text: string }; + expect(userItem.text).toBe('my prompt'); + }); + }); + it('converts conversation into history items with incremental ids', () => { const conversation = { messages: [ diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.ts index 394141ecaa..d712269362 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.ts @@ -15,8 +15,12 @@ import type { SlashCommandRecordPayload, AtCommandRecordPayload, HistoryGap, + UserPromptRecordPayload, +} from '@qwen-code/qwen-code-core'; +import { + getToolResponseDisplayText, + stripTrailingUserPromptSubmitContextPart, } from '@qwen-code/qwen-code-core'; -import { getToolResponseDisplayText } from '@qwen-code/qwen-code-core'; import type { HistoryItem, HistoryItemInfo, @@ -31,6 +35,28 @@ import { indexGapsByChild, } from './history-gap-notice.js'; +/** + * Projects a plain user record to its display text. + * + * Prefers the `displayText` recorded when a UserPromptSubmit hook augmented + * the model-bound parts. For records that carry the reserved tag but no + * payload (written by other/newer writers), drops a trailing part that is + * entirely a tagged hook-context block. Legacy records with bare injected + * text fall back to the raw part concatenation. + */ +function extractUserRecordDisplayText( + record: ConversationRecord['messages'][number], +): string { + const payload = record.systemPayload as UserPromptRecordPayload | undefined; + if (payload?.displayText) { + return payload.displayText; + } + const parts = (record.message?.parts as Part[] | undefined) ?? []; + return extractTextFromParts([ + ...stripTrailingUserPromptSubmitContextPart(parts), + ]); +} + /** * Extracts text content from a Content object's parts (excluding thought parts). */ @@ -356,9 +382,7 @@ function convertToHistoryItems( } const payload = pendingAtCommands.shift()!; - const text = - payload.userText || - extractTextFromParts(record.message?.parts as Part[]); + const text = payload.userText || extractUserRecordDisplayText(record); if (text) { items.push({ type: 'user', text }); } @@ -381,7 +405,7 @@ function convertToHistoryItems( currentToolGroup = []; } - const text = extractTextFromParts(record.message?.parts as Part[]); + const text = extractUserRecordDisplayText(record); if (text) { items.push({ type: 'user', text }); } diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index d6cfd6cefd..316f679956 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -19,6 +19,10 @@ export default defineConfig({ __dirname, '../core/src/utils/transcript-records.ts', ), + '@qwen-code/qwen-code-core/userPromptSubmitContext': path.resolve( + __dirname, + '../core/src/hooks/user-prompt-submit-context.ts', + ), '@qwen-code/qwen-code-core': path.resolve(__dirname, '../core/index.ts'), // cli's daemon-status-provider.test.ts imports `FakeAgent` / // `makeChannel` from acp-bridge's package-private diff --git a/packages/core/package.json b/packages/core/package.json index dffe771c14..8f9e9a40e1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -21,6 +21,10 @@ "types": "./dist/src/goals/goal-wire.d.ts", "import": "./dist/src/goals/goal-wire.js" }, + "./userPromptSubmitContext": { + "types": "./dist/src/hooks/user-prompt-submit-context.d.ts", + "import": "./dist/src/hooks/user-prompt-submit-context.js" + }, "./package.json": "./package.json", "./dist/*": "./dist/*", "./src/*": "./src/*" diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index e219c8d2cb..2704db6e4a 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -348,6 +348,8 @@ vi.mock('../telemetry/loggers.js', () => ({ logLoopDetectionDisabled: vi.fn(), })); +import * as telemetryIndex from '../telemetry/index.js'; + const { mockClientDebugLogger } = vi.hoisted(() => ({ mockClientDebugLogger: { isEnabled: vi.fn().mockReturnValue(false), @@ -9657,6 +9659,166 @@ Other open files: }); }); + it('wraps injected additionalContext in the reserved tag and records display provenance', async () => { + const mockMessageBus = { + request: vi.fn().mockResolvedValue({ + output: { + hookSpecificOutput: { + hookEventName: 'UserPromptSubmit', + additionalContext: 'extra hook context', + }, + }, + }), + response: vi.fn(), + }; + vi.mocked(mockConfig.getDisableAllHooks).mockReturnValue(false); + vi.mocked(mockConfig.getMessageBus).mockReturnValue( + mockMessageBus as unknown as ReturnType, + ); + vi.mocked(mockConfig.hasHooksForEvent).mockImplementation( + (event: string) => event === 'UserPromptSubmit', + ); + const recordUserMessage = vi.fn(); + vi.mocked(mockConfig.getChatRecordingService).mockReturnValue({ + recordUserMessage, + recordCronPrompt: vi.fn(), + recordAttributionSnapshot: vi.fn(), + } as unknown as ReturnType); + mockTurnRunFn.mockReturnValue( + (async function* () { + yield { type: GeminiEventType.Content, value: 'ok' }; + })(), + ); + + await fromAsync( + client.sendMessageStream( + [{ text: 'my prompt' }], + new AbortController().signal, + 'prompt-hook-context-tag', + ), + ); + + const taggedContext = + '\nextra hook context\n'; + + // The model-bound request keeps the user prompt intact and carries + // the injected context inside the reserved tag. + const requestText = getLastTurnRequestText(); + expect(requestText).toContain('my prompt'); + expect(requestText).toContain(taggedContext); + + // The recorded message is the exact model-bound request, with the + // user-authored projection preserved separately. + expect(recordUserMessage).toHaveBeenCalledWith( + [{ text: 'my prompt' }, { text: taggedContext }], + undefined, + { displayText: 'my prompt' }, + ); + }); + + it('uses the pre-injection prompt for managed auto-memory recall', async () => { + const mockMessageBus = { + request: vi.fn().mockResolvedValue({ + output: { + hookSpecificOutput: { + hookEventName: 'UserPromptSubmit', + additionalContext: 'extra hook context', + }, + }, + }), + response: vi.fn(), + }; + vi.mocked(mockConfig.getDisableAllHooks).mockReturnValue(false); + vi.mocked(mockConfig.getMessageBus).mockReturnValue( + mockMessageBus as unknown as ReturnType, + ); + vi.mocked(mockConfig.hasHooksForEvent).mockImplementation( + (event: string) => event === 'UserPromptSubmit', + ); + mockTurnRunFn.mockReturnValue( + (async function* () { + yield { type: GeminiEventType.Content, value: 'ok' }; + })(), + ); + + await fromAsync( + client.sendMessageStream( + [{ text: 'my prompt' }], + new AbortController().signal, + 'prompt-hook-context-recall', + ), + ); + + expect(mockMemoryManager.recall).toHaveBeenCalledWith( + '/test/project/root', + 'my prompt', + expect.any(Object), + ); + }); + + it('uses the pre-injection prompt for telemetry user-prompt attributes', async () => { + const mockMessageBus = { + request: vi.fn().mockResolvedValue({ + output: { + hookSpecificOutput: { + hookEventName: 'UserPromptSubmit', + additionalContext: 'extra hook context', + }, + }, + }), + response: vi.fn(), + }; + vi.mocked(mockConfig.getDisableAllHooks).mockReturnValue(false); + vi.mocked(mockConfig.getMessageBus).mockReturnValue( + mockMessageBus as unknown as ReturnType, + ); + vi.mocked(mockConfig.hasHooksForEvent).mockImplementation( + (event: string) => event === 'UserPromptSubmit', + ); + Object.assign(mockConfig, { + getTelemetryIncludeSensitiveSpanAttributes: vi + .fn() + .mockReturnValue(true), + }); + const startSpy = vi + .spyOn(telemetryIndex, 'startInteractionSpan') + .mockImplementation(() => {}); + const spanSpy = vi + .spyOn(telemetryIndex, 'getActiveInteractionSpan') + .mockReturnValue({} as never); + const addSpy = vi + .spyOn(telemetryIndex, 'addUserPromptAttributes') + .mockImplementation(() => {}); + mockTurnRunFn.mockReturnValue( + (async function* () { + yield { type: GeminiEventType.Content, value: 'ok' }; + })(), + ); + + try { + await fromAsync( + client.sendMessageStream( + [{ text: 'my prompt' }], + new AbortController().signal, + 'prompt-hook-context-telemetry', + ), + ); + + expect(addSpy).toHaveBeenCalledWith( + mockConfig, + expect.anything(), + 'my prompt', + ); + const promptArg = addSpy.mock.calls[0]?.[2] as string; + expect(promptArg).not.toContain('extra hook context'); + expect(promptArg).not.toContain('qwen:user-prompt-submit-context'); + } finally { + startSpy.mockRestore(); + spanSpy.mockRestore(); + addSpy.mockRestore(); + } + }); + it.each([ { name: 'empty UserQuery value', diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index f2af5cd93d..55ced685ae 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -50,6 +50,7 @@ import { } from '../goals/goalHook.js'; import { formatStopHookBlockingCapWarning } from '../hooks/stopHookCap.js'; import { buildContextUsage } from '../hooks/context-usage.js'; +import { wrapUserPromptSubmitContext } from '../hooks/user-prompt-submit-context.js'; import { DEFAULT_TOKEN_LIMIT, tokenLimit } from './tokenLimits.js'; import { createSessionStartProfiler } from './session-start-profiler.js'; @@ -2270,6 +2271,11 @@ export class GeminiClient { // content's own pairing. } + // Set when the UserPromptSubmit hook injects additional context: the + // pre-injection prompt projection. Telemetry, memory recall, and chat + // recording must see the user's own text, not the augmented request. + let preInjectionPromptText: string | undefined; + // Fire UserPromptSubmit hook through MessageBus (only if hooks are enabled) let hooksEnabled: boolean; let messageBus: ReturnType; @@ -2351,11 +2357,21 @@ export class GeminiClient { return new Turn(this.getChat(), prompt_id); } - // Add additional context from hooks to the request + // Add additional context from hooks to the request. The context is + // appended as its own part, wrapped in a reserved tag so it stays + // distinguishable from user-authored text in model history, resume, + // and offline transcript analysis. `getAdditionalContext()` escapes + // `<`/`>`, so hook output cannot forge the closing tag. + // `promptText` is declared above this block so assignment here cannot + // hit a TDZ if the surrounding Goal try/catch is later reshuffled. const additionalContext = hookOutput?.getAdditionalContext(); if (additionalContext) { const requestArray = Array.isArray(request) ? request : [request]; - request = [...requestArray, { text: additionalContext }]; + request = [ + ...requestArray, + { text: wrapUserPromptSubmitContext(additionalContext) }, + ]; + preInjectionPromptText = promptText; } } } catch (error) { @@ -2502,7 +2518,7 @@ export class GeminiClient { addUserPromptAttributes( this.config, interactionSpan, - partToString(request), + preInjectionPromptText ?? partToString(request), ); } } @@ -2557,12 +2573,16 @@ export class GeminiClient { } const promise = this.config .getMemoryManager() - .recall(this.config.getProjectRoot(), partToString(request), { - config: this.config, - excludedFilePaths: this.surfacedRelevantAutoMemoryPaths, - recentTools: [...this.recentCompletedToolNames], - abortSignal: controller.signal, - }) + .recall( + this.config.getProjectRoot(), + preInjectionPromptText ?? partToString(request), + { + config: this.config, + excludedFilePaths: this.surfacedRelevantAutoMemoryPaths, + recentTools: [...this.recentCompletedToolNames], + abortSignal: controller.signal, + }, + ) .catch((error: unknown) => { // Abort sources are now numerous (caller signal, new UserQuery, // cleanup paths, safety-net timeout). Keep a debug trace so @@ -2627,9 +2647,17 @@ export class GeminiClient { goalPermit, ); } else { - this.config - .getChatRecordingService() - ?.recordUserMessage(request, goalPermit); + // Only pass the payload when a hook actually injected; omitting + // the third argument keeps existing two-arg spies/call sites + // exact (passing `undefined` would still count as a third arg). + const recordingService = this.config.getChatRecordingService(); + if (recordingService && preInjectionPromptText !== undefined) { + recordingService.recordUserMessage(request, goalPermit, { + displayText: preInjectionPromptText, + }); + } else if (recordingService) { + recordingService.recordUserMessage(request, goalPermit); + } } } diff --git a/packages/core/src/hooks/user-prompt-submit-context.test.ts b/packages/core/src/hooks/user-prompt-submit-context.test.ts new file mode 100644 index 0000000000..da826eedab --- /dev/null +++ b/packages/core/src/hooks/user-prompt-submit-context.test.ts @@ -0,0 +1,100 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + wrapUserPromptSubmitContext, + isUserPromptSubmitContextPartText, + stripTrailingUserPromptSubmitContextPart, + USER_PROMPT_SUBMIT_CONTEXT_OPEN_TAG, + USER_PROMPT_SUBMIT_CONTEXT_CLOSE_TAG, +} from './user-prompt-submit-context.js'; + +describe('wrapUserPromptSubmitContext', () => { + it('wraps context between the open and close tags', () => { + expect(wrapUserPromptSubmitContext('extra context')).toBe( + `${USER_PROMPT_SUBMIT_CONTEXT_OPEN_TAG}\nextra context\n${USER_PROMPT_SUBMIT_CONTEXT_CLOSE_TAG}`, + ); + }); + + it('produces text recognized by isUserPromptSubmitContextPartText', () => { + expect( + isUserPromptSubmitContextPartText( + wrapUserPromptSubmitContext('multi\nline\ncontext'), + ), + ).toBe(true); + }); +}); + +describe('stripTrailingUserPromptSubmitContextPart', () => { + it('drops a trailing whole-part tagged block when other parts exist', () => { + const tagged = wrapUserPromptSubmitContext('ctx'); + expect( + stripTrailingUserPromptSubmitContextPart([ + { text: 'my prompt' }, + { text: tagged }, + ]), + ).toEqual([{ text: 'my prompt' }]); + }); + + it('keeps a sole part that matches the tag shape', () => { + const tagged = wrapUserPromptSubmitContext('ctx'); + const parts = [{ text: tagged }]; + expect(stripTrailingUserPromptSubmitContextPart(parts)).toBe(parts); + }); + + it('keeps parts when the trailing part is not a whole tagged block', () => { + const parts = [ + { text: 'my prompt' }, + { text: `quote: ${wrapUserPromptSubmitContext('ctx')}` }, + ]; + expect(stripTrailingUserPromptSubmitContextPart(parts)).toBe(parts); + }); +}); + +describe('isUserPromptSubmitContextPartText', () => { + it('accepts a wrapped block with surrounding whitespace', () => { + expect( + isUserPromptSubmitContextPartText( + `\n ${wrapUserPromptSubmitContext('ctx')}\n`, + ), + ).toBe(true); + }); + + it('rejects text with user prose before the tag', () => { + expect( + isUserPromptSubmitContextPartText( + `my own text ${wrapUserPromptSubmitContext('ctx')}`, + ), + ).toBe(false); + }); + + it('rejects text with user prose after the tag', () => { + expect( + isUserPromptSubmitContextPartText( + `${wrapUserPromptSubmitContext('ctx')} trailing text`, + ), + ).toBe(false); + }); + + it('rejects an unterminated open tag', () => { + expect( + isUserPromptSubmitContextPartText( + `${USER_PROMPT_SUBMIT_CONTEXT_OPEN_TAG}\nctx`, + ), + ).toBe(false); + }); + + it('rejects a lone close tag', () => { + expect( + isUserPromptSubmitContextPartText(USER_PROMPT_SUBMIT_CONTEXT_CLOSE_TAG), + ).toBe(false); + }); + + it('rejects empty text', () => { + expect(isUserPromptSubmitContextPartText('')).toBe(false); + }); +}); diff --git a/packages/core/src/hooks/user-prompt-submit-context.ts b/packages/core/src/hooks/user-prompt-submit-context.ts new file mode 100644 index 0000000000..8ae7eafc92 --- /dev/null +++ b/packages/core/src/hooks/user-prompt-submit-context.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Reserved tag wrapping UserPromptSubmit `additionalContext` when it is + * appended to the model-bound user message. The wrapper keeps hook-injected + * text distinguishable from user-authored prose in model history, session + * transcripts, and offline analysis. + * + * `getAdditionalContext()` escapes `<`/`>` in hook output, so injected + * content can never contain a literal closing tag — a genuine wrapped part + * is always a single, whole tagged block. + */ +export const USER_PROMPT_SUBMIT_CONTEXT_OPEN_TAG = + ''; +export const USER_PROMPT_SUBMIT_CONTEXT_CLOSE_TAG = + ''; + +/** + * Wraps sanitized UserPromptSubmit additional context in the reserved tag. + */ +export function wrapUserPromptSubmitContext(context: string): string { + return `${USER_PROMPT_SUBMIT_CONTEXT_OPEN_TAG}\n${context}\n${USER_PROMPT_SUBMIT_CONTEXT_CLOSE_TAG}`; +} + +/** + * Returns true when `text` is, in its entirety, a wrapped UserPromptSubmit + * context block (allowing surrounding whitespace). + * + * Intended for display projection of records that carry the tag but no + * `UserPromptRecordPayload` metadata: injection always appends the wrapped + * context as its own whole part, so only a whole-part match may be treated + * as hook-injected. Text where the tag is mixed with other prose is + * user-authored and must never match. + */ +export function isUserPromptSubmitContextPartText(text: string): boolean { + const trimmed = text.trim(); + return ( + trimmed.startsWith(USER_PROMPT_SUBMIT_CONTEXT_OPEN_TAG) && + trimmed.endsWith(USER_PROMPT_SUBMIT_CONTEXT_CLOSE_TAG) && + trimmed.length >= + USER_PROMPT_SUBMIT_CONTEXT_OPEN_TAG.length + + USER_PROMPT_SUBMIT_CONTEXT_CLOSE_TAG.length + ); +} + +/** + * Drops a trailing part that is entirely a tagged UserPromptSubmit context + * block. Injection always appends after the user's own part(s), so a sole + * matching part is treated as user-authored and kept. Returns the same + * array reference when nothing is stripped. + */ +export function stripTrailingUserPromptSubmitContextPart( + parts: readonly T[], +): readonly T[] { + if (parts.length <= 1) { + return parts; + } + const last = parts[parts.length - 1] as { text?: unknown } | undefined; + if ( + !last || + typeof last.text !== 'string' || + !isUserPromptSubmitContextPartText(last.text) + ) { + return parts; + } + return parts.slice(0, -1); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ddaab28a00..126108aa57 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -628,6 +628,13 @@ export { } from './hooks/stopHookCap.js'; export { type StopFailureErrorType } from './hooks/types.js'; export { buildContextUsage } from './hooks/context-usage.js'; +export { + USER_PROMPT_SUBMIT_CONTEXT_OPEN_TAG, + USER_PROMPT_SUBMIT_CONTEXT_CLOSE_TAG, + wrapUserPromptSubmitContext, + isUserPromptSubmitContextPartText, + stripTrailingUserPromptSubmitContextPart, +} from './hooks/user-prompt-submit-context.js'; // ============================================================================ // Goals (/goal command runtime) diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts index 4f93ba4dda..016765dd9c 100644 --- a/packages/core/src/services/chatRecordingService.test.ts +++ b/packages/core/src/services/chatRecordingService.test.ts @@ -159,6 +159,32 @@ describe('ChatRecordingService', () => { expect(record.provenance).toBe('real_user'); }); + it('stores hook display provenance in systemPayload only when provided', async () => { + const taggedParts: Part[] = [ + { text: 'my prompt' }, + { + text: '\nextra\n', + }, + ]; + chatRecordingService.recordUserMessage(taggedParts, undefined, { + displayText: 'my prompt', + }); + chatRecordingService.recordUserMessage([{ text: 'plain prompt' }]); + await chatRecordingService.flush(); + + const calls = vi.mocked(jsonl.writeLine).mock.calls; + const augmented = calls[0][1] as ChatRecord; + const plain = calls[1][1] as ChatRecord; + + // The model-bound parts are stored verbatim; the user-authored + // projection travels separately in the payload. + expect(augmented.message).toEqual({ role: 'user', parts: taggedParts }); + expect(augmented.systemPayload).toEqual({ + displayText: 'my prompt', + }); + expect(plain.systemPayload).toBeUndefined(); + }); + it('blocks later turns after a generic durable write failure', async () => { const failure = new Error('disk full'); vi.mocked(mockLease.appendJsonLine).mockRejectedValueOnce(failure); diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 706fbe8d62..6699e448e1 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -349,6 +349,7 @@ export interface ChatRecord { | ParentSessionRecordPayload | SessionSourceRecordPayload | NotificationRecordPayload + | UserPromptRecordPayload | RewindRecordPayload | AgentBootstrapRecordPayload | FileHistorySnapshotRecordPayload @@ -391,6 +392,19 @@ export interface ChatRecord { }; } +/** + * Stored payload for user-prompt records whose model-bound parts were + * augmented by a UserPromptSubmit hook. `message` keeps the exact + * model-bound Content (resume must replay what the model actually saw); + * this payload preserves the user-authored projection for UI/resume + * display. Hook-injected text stays recoverable from the tagged part in + * `message.parts` via `isUserPromptSubmitContextPartText`. + */ +export interface UserPromptRecordPayload { + /** Pre-injection projection of the user's own prompt text. */ + displayText?: string; +} + export interface NotificationRecordPayload { displayText: string; backgroundTask?: { @@ -1276,6 +1290,7 @@ export class ChatRecordingService { recordUserMessage( message: PartListUnion, goalContext?: GoalTurnPermit, + payload?: UserPromptRecordPayload, ): void { try { this.turnParentUuids.push(this.lastRecordUuid); @@ -1283,6 +1298,7 @@ export class ChatRecordingService { ...this.createBaseRecord('user'), ...(goalContext ? { goalContext: copyGoalContext(goalContext) } : {}), message: createUserContent(message), + ...(payload ? { systemPayload: payload } : {}), }; this.appendRecord(record); } catch (error) { From 9eab8bb301b07faf4ec774109069ef3b4c1124e3 Mon Sep 17 00:00:00 2001 From: qqqys Date: Thu, 30 Jul 2026 19:50:20 +0800 Subject: [PATCH 05/17] fix(core): abort workflows during session shutdown (#8107) Co-authored-by: qwen-code-dev-bot <269191875+qwen-code-dev-bot@users.noreply.github.com> --- packages/core/src/config/config.test.ts | 32 +++++++++++++++++++++++++ packages/core/src/config/config.ts | 1 + 2 files changed, 33 insertions(+) diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 6a35d1a86c..39bdaf3f43 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -3099,6 +3099,38 @@ describe('Server Config (config.ts)', () => { expect(stop).toHaveBeenCalledOnce(); }); + it('aborts active workflows during shutdown', async () => { + const config = new Config(baseParams); + const stop = vi.fn().mockResolvedValue(undefined); + const internal = config as unknown as { + initializeInternal: () => Promise; + toolRegistry: ToolRegistry; + }; + vi.spyOn(internal, 'initializeInternal').mockImplementation(async () => { + internal.toolRegistry = { stop } as unknown as ToolRegistry; + }); + await config.initialize(); + const abortController = new AbortController(); + const registry = config.getWorkflowRunRegistry(); + registry.register({ + runId: 'wf_1234', + meta: null, + status: 'running', + startTime: Date.now(), + outputFile: '/tmp/wf_1234.jsonl', + abortController, + }); + + await config.shutdown({ + shutdownTelemetry: false, + skipSessionWriter: true, + strictResourceCleanup: true, + }); + + expect(abortController.signal.aborted).toBe(true); + expect(registry.get('wf_1234')?.status).toBe('cancelled'); + }); + it('allows a later shutdown to retry incomplete resource cleanup', async () => { const config = new Config(baseParams); const stop = vi diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 9057a5af91..f4dafa63a1 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -4816,6 +4816,7 @@ export class Config { this.backgroundTaskRegistry.abortAll(); this.monitorRegistry.abortAll({ notify: false }); this.backgroundShellRegistry.abortAll(); + this.workflowRunRegistry.abortAll(); await this.cleanupArenaRuntime(); await this.cleanupTeamRuntime(); From 467ed9884df644085e8fb501e9086a1d3344e680 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Thu, 30 Jul 2026 19:57:51 +0800 Subject: [PATCH 06/17] fix(test): resolve turn completion on result messages in setModel E2E test (#8072) (#8075) * fix(test): resolve turn completion on result messages in setModel E2E test (#8072) * fix(test): apply result-only turn completion to permission-control tests (#8072) * fix(test): add result-only turn completion comments to permission-control tests (#8072) --------- Co-authored-by: Qwen Code Autofix --- .../sdk-typescript/permission-control.test.ts | 18 +++++++++--------- .../sdk-typescript/system-control.test.ts | 5 ++--- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/integration-tests/sdk-typescript/permission-control.test.ts b/integration-tests/sdk-typescript/permission-control.test.ts index 4f06f2bfb8..fa4bd31bf6 100644 --- a/integration-tests/sdk-typescript/permission-control.test.ts +++ b/integration-tests/sdk-typescript/permission-control.test.ts @@ -359,7 +359,9 @@ describe('Permission Control (E2E)', () => { (async () => { for await (const message of q) { - if (isSDKAssistantMessage(message) || isSDKResultMessage(message)) { + if (isSDKResultMessage(message)) { + // Resolve on result (one per turn), not assistant message + // (which may fire multiple times per turn: thinking + text) if (!firstResponseReceived) { firstResponseReceived = true; resolvers.first?.(); @@ -367,8 +369,6 @@ describe('Permission Control (E2E)', () => { secondResponseReceived = true; resolvers.second?.(); } - } - if (isSDKResultMessage(message)) { resultWaiter.notifyResult(); } } @@ -440,7 +440,9 @@ describe('Permission Control (E2E)', () => { (async () => { for await (const message of q) { - if (isSDKAssistantMessage(message) || isSDKResultMessage(message)) { + if (isSDKResultMessage(message)) { + // Resolve on result (one per turn), not assistant message + // (which may fire multiple times per turn: thinking + text) if (!firstResponseReceived) { firstResponseReceived = true; resolvers.first?.(); @@ -448,8 +450,6 @@ describe('Permission Control (E2E)', () => { secondResponseReceived = true; resolvers.second?.(); } - } - if (isSDKResultMessage(message)) { resultWaiter.notifyResult(); } } @@ -521,7 +521,9 @@ describe('Permission Control (E2E)', () => { (async () => { for await (const message of q) { - if (isSDKAssistantMessage(message) || isSDKResultMessage(message)) { + if (isSDKResultMessage(message)) { + // Resolve on result (one per turn), not assistant message + // (which may fire multiple times per turn: thinking + text) if (!firstResponseReceived) { firstResponseReceived = true; resolvers.first?.(); @@ -529,8 +531,6 @@ describe('Permission Control (E2E)', () => { secondResponseReceived = true; resolvers.second?.(); } - } - if (isSDKResultMessage(message)) { resultWaiter.notifyResult(); } } diff --git a/integration-tests/sdk-typescript/system-control.test.ts b/integration-tests/sdk-typescript/system-control.test.ts index 212dad8390..5872549c6d 100644 --- a/integration-tests/sdk-typescript/system-control.test.ts +++ b/integration-tests/sdk-typescript/system-control.test.ts @@ -6,7 +6,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { query, - isSDKAssistantMessage, isSDKSystemMessage, isSDKResultMessage, type SDKUserMessage, @@ -139,8 +138,8 @@ describe('System Control (E2E)', () => { } if (isSDKResultMessage(message)) { resultWaiter.notifyResult(); - } - if (isSDKAssistantMessage(message)) { + // Resolve on result (one per turn), not assistant message + // (which may fire multiple times per turn: thinking + text) if (!firstResponseReceived) { firstResponseReceived = true; resolvers.first?.(); From f3ad4fcffb3598bf2f510fb40f156811461b24c2 Mon Sep 17 00:00:00 2001 From: jinye Date: Thu, 30 Jul 2026 20:07:05 +0800 Subject: [PATCH 07/17] feat(serve): page large text files by byte cursor (#8002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(serve): allow bounded reads of large text files * fix(serve): bound large-text reads by scan cost, not by which knob was set Follow-up to the bounded large-text read path. Three changes: Gate on any explicit window argument, not on `limit`. Gating on `limit` had the cost model backwards in both directions: `{ line: 900_000_000, limit: 20 }` was admitted despite walking the whole file, while `{ maxBytes: 4096 }` — satisfiable from the first 4 KiB — was refused. A read with no window argument at all still fails, since a caller that believes it holds the whole file may write it back truncated. Add MAX_TEXT_SCAN_BYTES (8 MiB). MAX_READ_BYTES caps what a read returns; nothing capped what it cost. Line offsets are resolved by scanning from byte 0, so a query param could turn into an uninterruptible multi-second scan of an arbitrarily large file — and on Windows hold a read handle for that span, blocking renames and deletes. Past the budget the read is refused with `file_too_large` pointing at readBytes, which reaches any offset in O(1). Tolerate appends on streamed windows. Requiring whole-file size/mtime stability after reading a prefix rejected reads whose returned bytes were still valid, and the case it rejected — tailing a live log — is the one this path exists for. Streamed windows now assert inode identity plus "did not shrink"; truncation and replacement are still rejected. Also: non-UTF-8 large text now returns `binary_file` rather than `file_too_large`, so a client retrying on 413 with a smaller window can't loop forever; and `readFileWithLineAndLimit` throws instead of silently ignoring a caller-supplied `fileHandle` on the by-path fallback. Co-Authored-By: Claude Opus 5 * refactor(core): thread the descriptor instead of forking text-read helpers PR #7947 pinned large-text reads to one inode by threading a caller-owned FileHandle into readTextRange as an optional field, plus a second field, forceStreaming, to suppress the buffering fast path. Two optional fields produced four combinations: one meaningful, one used by a single test, one unreachable, and — in readFileWithLineAndLimit — one that silently fell through to a by-path read, defeating the reason the caller opened a handle. Unify the two encoding detectors. detectFileEncoding now takes a path or a borrowed handle, so detectFileHandleEncoding is deleted along with the message discrepancy between them: an encoding iconv-lite cannot load now raises LargeNonUtf8TextError naming that encoding rather than deferring to the decoder's generic invalid-utf8 variant. Both still refuse the file, and the Serve boundary maps both to binary_file. Split the reader into readTextRange (path) and readTextRangeFromHandle (always streams, both byte bounds required). The unreachable combination and its untested readFileHandleBuffer are gone, and with no fileHandle parameter left for readFileWithLineAndLimit to ignore, the RangeError guarding that fallthrough is deleted too — the trap can no longer be expressed. CoreReadTextFileHandleRequest drops its required stats field. Nothing downstream read it, and because the ACP request type it extends permits extra properties, TypeScript accepted the dead argument silently. readFileHandleChunks becomes chunksFromHandle(fh, from) — the one seam byte-cursor text paging needs. No observable change at the Serve boundary: its 222 tests pass unmodified. Two fileSystemService tests were deleted rather than repaired; they asserted the arguments readFileWithLineAndLimit received, which is nothing once the handle path stops calling it. Their coverage lives in read-text-range.test.ts against real files and in workspace-file-system.test.ts at the real boundary. 258 production lines in core, net -71 overall. Co-Authored-By: Claude Opus 5 * refactor(core): make CoreReadTextFileHandleRequest standalone Self-audit follow-up to f55c867a. Two fields survived the reshape that the handle path never reads: - `stats` was documented as required ("must pass the Stats captured from that handle") and nothing downstream read it. The handle path always streams, so it never needs a size to choose a strategy, and the encoding probe does its own fstat. - `path` became dead once readTextRangeFromHandle replaced the path-plus-handle call. Errors are labelled with the path by the Serve boundary that owns it. Neither was caught by the compiler: the ACP ReadTextFileRequest the type derived from permits extra properties, so the CLI kept passing both silently. That is the argument for declaring the type standalone rather than Omit-ing four of six inherited fields and quietly re-admitting the rest. Also record the second behaviour delta of the detector merge in the design doc: detectFileEncoding catches I/O errors and falls back to 'utf-8', where detectFileHandleEncoding let them propagate. The failure is not lost — a handle that fails the 8 KiB probe fails the streaming read immediately after — but a different call now reports it. Co-Authored-By: Claude Opus 5 * feat(serve): page large text files by byte cursor Line offsets address a byte stream, so `readText` resolves them by scanning from byte 0. Paging a large log that way is O(n^2) across pages, and past MAX_TEXT_SCAN_BYTES (8 MiB) a deep page is refused outright — agents had no O(1) path short of dropping to GET /file/bytes and splitting lines themselves, losing encoding handling, multibyte safety, and the binary_file refusal. A response that leaves content behind now returns `hasMore`, and where a file byte offset is derivable, an opaque `nextCursor`. Passing it back as `cursor` resumes in O(1). Page 1 is an ordinary `limit` read, so clients never compute byte offsets themselves, and a paging loop does not break when a file happens to be small. The cursor is unsigned base64url JSON carrying {off, size, dev, ino}, matching encodeOrganizedCursor rather than the HMAC-signed transcript codec: the path is re-resolved through the workspace boundary on every request, so a forged cursor can only move the offset within a file the caller may already read — what GET /file/bytes?offset= allows today. What the payload is for is staleness: a replaced or truncated file yields hash_mismatch instead of bytes from the wrong place, while an append leaves an outstanding cursor valid — the case the feature exists for. Every minted cursor points at the start of a line. When a single line exceeds maxOutputBytes the reader emits a truncated prefix and skips to the next line rather than resuming mid-line, because a mid-line cursor makes the following page snap forward and silently drop the rest of that line at the seam. Windows cut mid-line by a byte cap therefore report hasMore with no cursor, as do non-UTF-8 snapshot reads whose decoded text is a UTF-8 re-encoding with no mapping back to file offsets. That is why hasMore is a field rather than a restatement of nextCursor. Cursor reads branch before the size check, not by widening the window gate: a cursor read of a file under MAX_READ_BYTES would otherwise land on the snapshot path, which knows only line/limit, and silently return line 0. Adds the workspace_file_read_cursor capability, per the convention that new behavior gets a new tag, and retargets the scan-budget hint at cursor paging. Co-Authored-By: Claude Opus 5 * fix(core): advance UTF-8 cursors after truncation Co-authored-by: Qwen-Coder * docs(serve): clarify cursor bootstrap limits Co-authored-by: Qwen-Coder * fix(sdk): raise daemon browser bundle budget Co-authored-by: Qwen-Coder * test(serve): cover ACP cursor dispatch and cursor binary_file mapping (#8002) * fix(core): only set sawCrlf for emitted lines in cursor paging (#8002) --------- Co-authored-by: Claude Opus 5 Co-authored-by: Qwen-Coder Co-authored-by: Qwen Code Bot --- .../2026-07-28-serve-large-text-range-read.md | 43 +- ...026-07-29-handle-bound-text-range-reads.md | 189 +++++++ .../daemon/07-workspace-filesystem.md | 43 +- .../daemon/11-capabilities-versioning.md | 2 +- docs/developers/qwen-serve-protocol.md | 58 +- .../cli/qwen-serve-routes.test.ts | 1 + packages/cli/src/serve/acp-http/dispatch.ts | 26 +- .../cli/src/serve/acp-http/transport.test.ts | 39 ++ .../serve/bridge-file-system-adapter.test.ts | 16 +- packages/cli/src/serve/capabilities.ts | 8 + packages/cli/src/serve/fs/index.ts | 1 + packages/cli/src/serve/fs/policy.ts | 20 + packages/cli/src/serve/fs/text-cursor.ts | 128 +++++ .../serve/fs/workspace-file-system.test.ts | 329 ++++++++++- .../cli/src/serve/fs/workspace-file-system.ts | 463 ++++++++++++--- .../serve/routes/workspace-file-read.test.ts | 82 ++- .../src/serve/routes/workspace-file-read.ts | 26 +- packages/cli/src/serve/server.test.ts | 1 + packages/core/src/index.ts | 7 +- .../src/services/fileSystemService.test.ts | 154 +++-- .../core/src/services/fileSystemService.ts | 169 ++++-- packages/core/src/utils/fileUtils.ts | 38 +- .../core/src/utils/read-text-range.test.ts | 533 ++++++++++++++++-- packages/core/src/utils/read-text-range.ts | 525 ++++++++++++++--- packages/sdk-typescript/scripts/build.js | 4 +- .../serve-bridge/tools/workspaceRead.ts | 7 + .../sdk-typescript/src/daemon/DaemonClient.ts | 18 +- .../src/daemon/acpRouteTable.ts | 1 + packages/sdk-typescript/src/daemon/types.ts | 8 + .../test/unit/DaemonClient.test.ts | 30 + .../test/unit/acpRouteTable.test.ts | 5 +- 31 files changed, 2568 insertions(+), 406 deletions(-) create mode 100644 docs/design/2026-07-29-handle-bound-text-range-reads.md create mode 100644 packages/cli/src/serve/fs/text-cursor.ts diff --git a/.qwen/e2e-tests/2026-07-28-serve-large-text-range-read.md b/.qwen/e2e-tests/2026-07-28-serve-large-text-range-read.md index 359bd78c70..0ed9ea4f3e 100644 --- a/.qwen/e2e-tests/2026-07-28-serve-large-text-range-read.md +++ b/.qwen/e2e-tests/2026-07-28-serve-large-text-range-read.md @@ -13,9 +13,14 @@ Run the WorkspaceFileSystem, ACP adapter, and HTTP route tests: ```bash cd packages/cli -npx vitest run src/serve/fs/workspace-file-system.test.ts -npx vitest run src/serve/bridge-file-system-adapter.test.ts -npx vitest run src/serve/routes/workspace-file-read.test.ts +npx vitest run src/serve/fs/workspace-file-system.test.ts src/serve/bridge-file-system-adapter.test.ts src/serve/routes/workspace-file-read.test.ts +``` + +Run the SDK query-serialization tests: + +```bash +cd packages/sdk-typescript +npx vitest run test/unit/DaemonClient.test.ts test/unit/acpRouteTable.test.ts ``` Then run repository verification: @@ -37,29 +42,37 @@ npm run build returns the requested CSV lines. 5. Confirm the agent does not need a shell command such as `head`, `sed`, or `awk` as a fallback. -6. Request a later finite line window and confirm it also succeeds without - returning more than 256 KiB. +6. Follow the returned `nextCursor` until `hasMore` is false. Confirm the + rejoined pages equal the original file and no page returns more than + 256 KiB. +7. Append rows after the first page and confirm the outstanding cursor remains + valid. ## Regression checks -- No-limit, line-only, maxBytes-only, and line-plus-maxBytes requests against - the same large file remain `file_too_large`. +- A no-window read against the same large file remains `file_too_large`; + line-only, maxBytes-only, and line-plus-maxBytes requests are admitted as + explicit bounded windows. - A finite line window over a large binary file remains `binary_file`. -- A large non-UTF-8 text window remains `file_too_large` with a UTF-8 - conversion hint. +- A large non-UTF-8 text window is `binary_file` with a UTF-8 conversion or + `readBytes` hint. - A supported non-UTF-8 file within the full-snapshot cap still obeys `maxBytes` after its content is decoded to UTF-8. - A partial large-file response reports the complete `sizeBytes`, sets `truncated: true`, omits the full-file hash, and exposes `originalLineCount: null` until EOF is known. -- Replacing the pathname, appending, truncating, or overwriting the opened file - during the read is rejected instead of returning a mixed or stale result. -- A deep offset beyond 10 MiB still succeeds when the request has a finite - line limit. +- Replacing the pathname, truncating, or overwriting the opened file during the + read is rejected; append-only growth remains readable. +- A deep line offset beyond the 8 MiB scan budget returns `file_too_large` and + points the client at cursor paging or `readBytes`. +- A malformed cursor or a cursor combined with `line` returns `parse_error`; + a cursor for a replaced or truncated file returns `hash_mismatch`. ## Baseline status Before the fix, Core could read the requested range from the 406,892-byte CSV, but Serve rejected the file at its 256 KiB full-snapshot gate before slicing. -The focused automated tests cover the corrected Core, WorkspaceFileSystem, ACP, -and HTTP paths; the manual ACP/model scenario remains the release smoke test. +The first bounded-window implementation then required repeated line scans and +could not reach pages beyond the scan budget. The focused automated tests cover +the corrected Core, WorkspaceFileSystem, ACP, HTTP, and SDK paths; the manual +ACP/model scenario remains the release smoke test. diff --git a/docs/design/2026-07-29-handle-bound-text-range-reads.md b/docs/design/2026-07-29-handle-bound-text-range-reads.md new file mode 100644 index 0000000000..41b48f95d7 --- /dev/null +++ b/docs/design/2026-07-29-handle-bound-text-range-reads.md @@ -0,0 +1,189 @@ +# Handle-Bound Text Range Reads + +## Context + +PR #7947 let the Serve workspace filesystem return bounded line windows from +text files above `MAX_READ_BYTES` (256 KiB). To keep those reads pinned to one +inode across validation, binary probing, and streaming, it threaded a +caller-owned `FileHandle` down into `readTextRange` as an optional field, and +added a second optional field, `forceStreaming`, to suppress the buffering fast +path that would otherwise defeat the memory bound. + +Two optional fields on one entry point produced four combinations, of which one +is meaningful, one is unreachable, and one is unsafe: + +| `fileHandle` | `forceStreaming` | Result | +| ------------ | ---------------- | ---------------------------------------------------------------------- | +| unset | unset | ordinary path read | +| unset | set | streams a small file — used by one test | +| set | set | the Serve boundary's read | +| set | unset | buffers the whole file through the handle — **no caller can reach it** | + +The unreachable combination carried a dedicated helper, `readFileHandleBuffer`, +with no test coverage. Separately, `readFileWithLineAndLimit` accepted the same +`fileHandle` but could only honor it on its range branch: an unbounded read fell +through to a by-path `readFileWithEncodingInfo`, silently returning bytes from +whatever the path resolved to at that moment rather than from the pinned inode. +PR #7947's follow-up commit guarded that with a runtime `RangeError`, which +documented the trap without removing it. + +Encoding detection had forked for the same reason. `detectFileEncoding` takes a +path and opens its own descriptor, so the handle path could not use it; a +private `detectFileHandleEncoding` was added alongside, deriving the encoding +name from `decodeBufferWithEncodingInfoAsync(...).encoding` instead of from +chardet directly. The two disagree when chardet names an encoding `iconv-lite` +cannot load: the path variant returns that name, the handle variant returns +`'utf-8'` and defers to the streaming decoder's `fatal: true` failure. Both +refuse the file, with different messages. + +## Goals + +- One encoding detector, usable from a path or a borrowed descriptor. +- No mode flags on the range reader; make the unreachable combination + unrepresentable rather than merely unused. +- Make the by-path fallthrough structurally impossible instead of guarded. +- No observable change at the Serve boundary or in the `read_file` tool. + +## Non-goals + +- Collapsing `decodeBufferWithEncodingInfo` (sync) into its async twin. The sync + variant is a deliberate public-API compatibility shim + ([`lazy-first-use-dependencies.md`](./lazy-first-use-dependencies.md)) pinned + by a parity test. +- Any change to what the Serve boundary returns. This is preparation for + byte-cursor paging, not that feature. + +## Design + +### One detector + +`detectFileEncoding(source: string | FileHandle)`. A supplied handle is +_borrowed_: reads use explicit positions so the caller's file position is +untouched, and the `finally` block closes only a descriptor this function +itself opened. `detectFileHandleEncoding` is deleted, and the open-coded +BOM-to-name switch is replaced with the existing `bomEncodingToName`. + +This makes the handle path slightly stricter, which is the intended direction: +an encoding `iconv-lite` cannot load now raises +`LargeNonUtf8TextError(detected)` naming that encoding, rather than reaching the +decoder and raising the generic `'invalid-utf8'` variant. The refusal is +unchanged; the message improves. The Serve boundary maps both to `binary_file`, +so nothing downstream moves. + +A second, smaller delta comes with the merge: `detectFileEncoding` catches all +errors and falls back to `'utf-8'`, whereas `detectFileHandleEncoding` had no +handler and let an I/O failure propagate. The failure is not lost — a handle bad +enough to fail the 8 KiB probe fails the streaming read immediately after, and a +file that is not really UTF-8 is still refused by the `fatal: true` decoder — so +the error surfaces from a different call rather than disappearing. Accepted for +the single fallback policy; noted because it is a real change in which call +reports the problem. + +### Two entry points + +```ts +readTextRange(request: ReadTextRangeRequest) // path +readTextRangeFromHandle(fh, request: ReadTextRangeFromHandleRequest) +``` + +The handle variant always streams — there is no flag, because a caller reaches +for a handle precisely when it needs the read bounded, and the buffering fast +path would read the whole file. Its request type has no `path` (nothing for one +to disambiguate), retains the numeric `fileSize` captured from the opening +`fstat`, and makes both byte bounds required rather than optional. +`maxOutputBytes` caps what the read returns, `maxScanBytes` caps what it costs, +and `fileSize` prevents an append from widening the descriptor snapshot while +the read is in flight. A handle-bound read exists because a security boundary +needs all three bounds. + +`maxScanBytes` stays optional on the path variant, where it defaults to +`Infinity` so the `read_file` tool is unchanged. + +Both delegate to the same streaming implementation, which now takes +`source: string | FileHandle` and selects `createReadStream` or +`chunksFromHandle` accordingly. `readFileHandleBuffer` and the branch that +called it are deleted. + +### The fallthrough disappears + +`readFileWithLineAndLimit` loses `fileHandle`, `forceStreaming`, and +`maxScanBytes` — its single production caller passes none of them. +`StandardFileSystemService.readTextFileFromHandle` now calls +`readTextRangeFromHandle` directly, and the two read paths share a +`toReadTextFileResponse` helper so their metadata shaping cannot drift. With no +`fileHandle` parameter left to ignore, the `RangeError` guard is removed: the +trap it described can no longer be expressed. + +`readTextFileFromHandle` stays off the `FileSystemService` interface, so +`AcpFileSystemService` and the typed fallback mock in `filesystem.test.ts` are +untouched. + +## Blast radius + +- `readTextRange` is not exported from `packages/core/src/index.ts`; the three + boundary-facing error classes are. The reshaped reader surface is + core-internal. +- `readTextRange` and `readFileWithLineAndLimit` have exactly one production + caller each (`fileUtils.ts`, `fileSystemService.ts`). +- `detectFileEncoding` is public via `export * from './utils/fileUtils.js'`. + Widening a parameter is source-compatible. +- The only cross-package importer of the touched modules is + `packages/cli/src/serve/fs/workspace-file-system.ts`. Its only change is + dropping two arguments the handle path no longer accepts — see below; the + `decodeBufferWithEncodingInfoAsync` import it also carries is untouched. + +### `CoreReadTextFileHandleRequest` becomes standalone + +It was `Omit & +{...}`, which left two fields the handle path never reads: + +- **`stats`** was documented as required — "must pass the Stats captured from + that handle" — and nothing downstream read the object. The final API retains + only its numeric `fileSize`: the handle path does not need metadata to choose + a strategy, but it does need the opening size to keep reads bounded when the + file is appended to concurrently. +- **`path`** became dead once `readTextRangeFromHandle` replaced the + path-plus-handle call: the read is bound to the descriptor, and errors are + labelled with the path by the Serve boundary that owns it. + +Neither was caught by the compiler. The ACP `ReadTextFileRequest` this type +derived from permits extra properties, so passing a field the type had removed +raised nothing. That is the argument for declaring the type standalone rather +than deriving it: the `Omit` chain was stripping four of six inherited fields +and quietly re-admitting the rest. + +At the refactor commit, 282 production logic lines changed in `packages/core`; +the later cursor follow-up adds behavior and tests on top of that baseline. + +## Testing + +At the refactor commit, the existing suites were the specification: the whole +point was that the Serve boundary could not tell. The later cursor follow-up +adds boundary behavior and its own tests. + +Three tests in `read-text-range.test.ts` moved to `readTextRangeFromHandle`. Two +used `fileHandle` directly. The third used a _path_ with `forceStreaming: true` +to force streaming on a file too small to leave the fast path, so that it could +exercise the budget-at-EOF boundary; with the flag gone, the handle variant is +the only thing that always streams. + +One of the moved tests changed meaning. It previously passed a handle for one +file and a path naming a different file, asserting the handle won — a test for +the confusion the old signature permitted. The handle variant has no `path`, so +that confusion is now unrepresentable and the test would assert nothing. It was +rewritten to cover the property that actually motivated the API: open a handle, +rename another file over the path, and confirm the read still follows the inode. + +Two tests in `fileSystemService.test.ts` were deleted rather than repaired. They +mocked `readFileWithLineAndLimit` and asserted the argument object it received; +since `readTextFileFromHandle` no longer calls it, they could only have been +kept by re-pointing them at a new mock, which would again assert only that one +function passes arguments to another. The behaviour they nominally covered is +tested against real files in `read-text-range.test.ts` and at the real boundary +in `workspace-file-system.test.ts`. The argument-validation tests beside them +are kept — they need no mock. + +## Follow-up + +`chunksFromHandle` gained a `from` parameter as the single seam byte-cursor text +paging needed. The follow-up now uses it to resume from a non-zero byte offset. diff --git a/docs/developers/daemon/07-workspace-filesystem.md b/docs/developers/daemon/07-workspace-filesystem.md index d4c99ea8bb..a0f6f3a270 100644 --- a/docs/developers/daemon/07-workspace-filesystem.md +++ b/docs/developers/daemon/07-workspace-filesystem.md @@ -6,7 +6,7 @@ The daemon never lets HTTP routes or ACP-side agent calls touch the host filesys - **Path resolution** — canonicalize paths and reject anything escaping the bound workspace, including via symlinks. - **Trust gating** — refuse writes when the workspace is not trusted (`untrusted_workspace`). -- **Size & content policy** — full-snapshot/output cap (`MAX_READ_BYTES = 256 KiB`), bounded large-text windows, write cap (`MAX_WRITE_BYTES = 5 MiB`), binary detection. +- **Size & content policy** — full-snapshot/output cap (`MAX_READ_BYTES = 256 KiB`), large-text windows bounded in both output and scan cost (`MAX_TEXT_SCAN_BYTES = 8 MiB`), write cap (`MAX_WRITE_BYTES = 5 MiB`), binary detection. - **Atomicity** — write-then-rename with target mode preservation and `0o600` default for new files. - **Audit** — every access / denial emits a structured event for `PermissionAuditRing` / monitoring. - **Typed errors** — closed `FsErrorKind` union mapped to HTTP statuses. @@ -17,7 +17,7 @@ The HTTP file routes (`GET /file`, `GET /file/bytes`, `POST /file/write`, `POST - Resolve user-supplied paths into branded `ResolvedPath` values that the rest of the boundary can safely use. - Refuse paths outside the bound workspace (`path_outside_workspace`) and paths whose target is a symlink (`symlink_escape`). -- Refuse full-snapshot reads above `MAX_READ_BYTES`, while allowing finite line windows with output capped at `MAX_READ_BYTES`; refuse writes above `MAX_WRITE_BYTES` and binary files (`binary_file`). +- Refuse full-snapshot reads above `MAX_READ_BYTES`, while allowing explicit windows with output capped at `MAX_READ_BYTES` and scan cost capped at `MAX_TEXT_SCAN_BYTES`; refuse writes above `MAX_WRITE_BYTES` and binary files (`binary_file`). - Refuse writes/edits when the workspace is untrusted (`untrusted_workspace`) — gated by `assertTrustedForIntent(trusted, intent)`. - Honor `.gitignore` / `.qwenignore` patterns via `shouldIgnore`. - Perform atomic write-then-rename with target mode preservation; default new file mode is `0o600`. @@ -31,7 +31,7 @@ The HTTP file routes (`GET /file`, `GET /file/bytes`, `POST /file/write`, `POST | File | Purpose | | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `paths.ts` | `canonicalizeWorkspace`, `resolveWithinWorkspace`, `hasSuspiciousPathPattern`, branded `ResolvedPath`, `Intent` union (`read \| write \| list \| stat \| glob`). | -| `policy.ts` | `MAX_READ_BYTES`, `MAX_WRITE_BYTES`, `BINARY_PROBE_BYTES`, `assertTrustedForIntent`, `detectBinary`, `enforceReadBytesSize`, `enforceReadSize`, `enforceWriteSize`, `shouldIgnore`. | +| `policy.ts` | `MAX_READ_BYTES`, `MAX_TEXT_SCAN_BYTES`, `MAX_WRITE_BYTES`, `BINARY_PROBE_BYTES`, `assertTrustedForIntent`, `detectBinary`, `enforceReadBytesSize`, `enforceReadSize`, `enforceWriteSize`, `shouldIgnore`. | | `audit.ts` | `FS_ACCESS_EVENT_TYPE`, `FS_DENIED_EVENT_TYPE`, `createAuditPublisher`, audit payload types. | | `errors.ts` | `FsError` class, `isFsError`, `FsErrorKind` union (14 kinds), `FsErrorStatus` union (`400 / 403 / 404 / 409 / 413 / 422 / 500 / 503`). | | `workspace-file-system.ts` | `createWorkspaceFileSystemFactory`, `WorkspaceFileSystem` (the orchestrator that reads/writes/lists), `WriteMode`, `ContentHash`, `FsEntry`, `FsStat`, `ListOptions`, `GlobOptions`, `ReadTextOptions`, `ReadBytesOptions`, `WriteTextAtomicOptions`. | @@ -43,8 +43,8 @@ The HTTP file routes (`GET /file`, `GET /file/bytes`, `POST /file/write`, `POST | `path_outside_workspace` | 400 | Resolved path is outside the bound workspace. | | `symlink_escape` | 400 | Target is a symlink (rejected per the conservative PR 18 + PR 20 posture). | | `path_not_found` | 404 | `ENOENT`. | -| `binary_file` | 422 | Content sniffed binary on a text route. | -| `file_too_large` | 413 | Unbounded/full-snapshot text above `MAX_READ_BYTES`, unsupported large non-UTF-8 text, or a write above `MAX_WRITE_BYTES`. | +| `binary_file` | 422 | Content sniffed binary on a text route, or large text in an encoding the text route cannot decode. | +| `file_too_large` | 413 | Windowless/full-snapshot text above `MAX_READ_BYTES`, a line offset beyond `MAX_TEXT_SCAN_BYTES`, or a write above `MAX_WRITE_BYTES`. | | `hash_mismatch` | 409 | Optimistic-concurrency `expectedSha256` failed, or the file changed during a stable read. | | `file_already_exists` | 409 | `mode: 'create'` against an existing file. | | `text_not_found` | 422 | `POST /file/edit`'s search string wasn't in the file. | @@ -139,22 +139,24 @@ sequenceDiagram FS->>FSP: stat(path) FSP-->>FS: stats FS->>FS: reject if not regular file (describeStatKind) - alt file <= 256 KiB + alt cursor supplied + FS->>FSP: open stable FileHandle + FS->>FS: validate cursor {dev,ino,size}; seek to the byte offset + FS->>FS: return whole lines; emit the next cursor + else file <= 256 KiB FS->>FSP: open + read stable full snapshot FSP-->>FS: buffer - FS->>POL: detectBinary(buffer) - FS->>FS: reject if binary FS->>FS: hash full snapshot; apply line/output limits - else file > 256 KiB AND finite limit + else file > 256 KiB AND an explicit window arg FS->>FSP: open stable FileHandle - FS->>POL: detectBinary(handle sample) - FS->>FS: reject if binary FS->>FS: stream requested lines from the same inode - FS->>FS: recheck size + mtime + ctime + device/inode - FS->>FS: cap output at 256 KiB; omit full-file hash - else unbounded large read + FS->>FS: cap output at 256 KiB and scan at 8 MiB; omit full-file hash + else windowless large read FS-->>R: file_too_large end + FS->>POL: detectBinary(sample) + POL-->>FS: isBinary? + FS->>FS: reject if binary FS->>FS: shouldIgnore? → annotate meta.matchedIgnore FS->>FS: audit fs.access FS-->>R: { content, optional sha256, truncated?, meta } @@ -227,7 +229,8 @@ flowchart LR | Source | Knob | Effect | | ------------------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | `WorkspaceFileSystemFactoryDeps.trusted: boolean` | Constructor input | Whether writes are allowed; defaults to `true` from `runQwenServe`, `false` from `createServeApp` (with warning). | -| Constant | `MAX_READ_BYTES = 256 KiB` | Full-snapshot and returned-text cap; larger text requires a finite line limit. | +| Constant | `MAX_READ_BYTES = 256 KiB` | Full-snapshot and returned-text cap; larger text requires an explicit window argument. | +| Constant | `MAX_TEXT_SCAN_BYTES = 8 MiB` | Bytes a large-text read may scan to locate a line offset; past it, `file_too_large`. | | Constant | `MAX_WRITE_BYTES = 5 MiB` | Write cap; sized below `express.json({ limit: '10mb' })`. | | Constant | `BINARY_PROBE_BYTES = 4096` | Sample size for content-based binary detection. | | Capability tags | `workspace_file_read`, `workspace_file_bytes`, `workspace_file_write` | See [`11-capabilities-versioning.md`](./11-capabilities-versioning.md). | @@ -239,10 +242,12 @@ flowchart LR - **`io_error` vs `permission_denied` are distinct.** Do not conflate them. Monitoring pipelines key on `errorKind` for alerting — folding ENOSPC into permission_denied would page security responders for `df -h` problems. - **New file mode defaults to `0o600`, not umask defaults.** The write syscall's `mode` arg bypasses umask. Agents writing public files should explicitly pass a mode override. - **`createServeApp` default `trusted: false`** silently rejects ACP writes with `untrusted_workspace` for embedders that do not inject a custom `fsFactory` or `bridge`. A one-time stderr warning fires the first time; further callers see no reminder. See [`02-serve-runtime.md`](./02-serve-runtime.md). -- **Large text requires a finite line limit.** No-limit reads, line-only reads, and maxBytes-only reads above `MAX_READ_BYTES` remain `file_too_large`. Finite windows stream from an inode-bound handle and never return more than `MAX_READ_BYTES`. -- **Streamed windows require a stable file snapshot.** The open handle pins the inode but does not freeze its bytes, so a successful streamed response requires device/inode identity, size, modification time, and change time to remain unchanged through the read. A detected mutation takes precedence over a simultaneous decode failure and returns `hash_mismatch`. -- **Large partial reads omit the full-file hash.** They retain the complete `sizeBytes`; `originalLineCount` is omitted when streaming stops before EOF. -- **`BridgeFileSystem` adapter MUST preserve both inline-proxy safety properties** (non-regular-file refusal + bounded buffering/streaming). The inline path is fully bypassed when the adapter is injected. +- **Large text requires an explicit window argument**, any of `line` / `limit` / `maxBytes`. A read with none of them stays `file_too_large`, because a caller that believes it holds the whole file may write it back truncated. Windows stream from an inode-bound handle and never return more than `MAX_READ_BYTES`. +- **`MAX_READ_BYTES` caps what a read returns; `MAX_TEXT_SCAN_BYTES` caps what it costs.** Line offsets are resolved by scanning from byte 0, so `{ line: 900_000_000, limit: 20 }` returns almost nothing and still walks the file. Past 8 MiB of scanning the read is refused with `file_too_large` pointing at `readBytes`, which reaches any offset in O(1). +- **Streamed windows tolerate appends, not truncation.** The full-snapshot path can demand byte-for-byte stability because it returns the whole file; a prefix window cannot, or every read of a live log fails. The streamed path asserts inode identity plus "did not shrink", so appends pass and truncation / replacement are still rejected. `sizeBytes` reports the size at `open`, describing the snapshot the window was cut from. +- **Large partial reads omit the full-file hash.** `originalLineCount` is omitted when streaming stops before EOF. +- **Paging is by byte cursor, not by line.** A read that leaves content behind returns `hasMore` and, where a byte offset is derivable, an opaque `nextCursor`. Resuming from it is O(1); resuming by `line` re-scans from byte 0 and is refused past `MAX_TEXT_SCAN_BYTES`. The cursor carries `{dev, ino, size}`, so a replaced or truncated file yields `hash_mismatch` rather than bytes from the wrong place, while an append leaves it valid. Non-UTF-8 snapshot reads report `hasMore` but no cursor — their decoded text is a UTF-8 re-encoding whose lengths do not map back to file offsets. +- **`BridgeFileSystem` adapter MUST replicate both inline-proxy gates** (non-regular-file refusal + bounded buffering/streaming). The inline path is fully bypassed when the adapter is injected. ## References diff --git a/docs/developers/daemon/11-capabilities-versioning.md b/docs/developers/daemon/11-capabilities-versioning.md index 3f292d7914..beacb87d90 100644 --- a/docs/developers/daemon/11-capabilities-versioning.md +++ b/docs/developers/daemon/11-capabilities-versioning.md @@ -122,7 +122,7 @@ Extension management: `extension_management_v2` adds the global `/extensions/*` Workspace-qualified session reads: `workspace_persisted_transcript`, `workspace_session_export`, `workspace_archived_session_export`. The active and archived export tags are independent from each other and from `session_export` and `workspace_qualified_rest_core`, so clients must pre-flight the exact storage state they intend to export. Persisted transcript paging permits an untrusted secondary under its bounded read policy; both full export paths remain trusted-only. -Workspace mutation (Wave 4+): `workspace_memory`, `workspace_agents`, `workspace_agent_generate`, `workspace_acp_preheat`, `workspace_tool_toggle`, **`workspace_settings`** (conditional), `workspace_permissions`, `workspace_init`, `workspace_github_setup`, `workspace_trust`, `workspace_mcp_restart`, `workspace_mcp_manage`, `workspace_file_read`, `workspace_file_bytes`, `workspace_file_write`, **`workspace_reload`** (conditional). +Workspace mutation (Wave 4+): `workspace_memory`, `workspace_agents`, `workspace_agent_generate`, `workspace_acp_preheat`, `workspace_tool_toggle`, **`workspace_settings`** (conditional), `workspace_permissions`, `workspace_init`, `workspace_github_setup`, `workspace_trust`, `workspace_mcp_restart`, `workspace_mcp_manage`, `workspace_file_read`, `workspace_file_bytes`, `workspace_file_read_cursor`, `workspace_file_write`, **`workspace_reload`** (conditional). MCP guardrails: **`mcp_guardrails`** (`modes: ['warn', 'enforce']`), `mcp_guardrail_events`, `mcp_server_runtime_mutation`, **`mcp_workspace_pool`** (conditional), **`mcp_pool_restart`** (conditional). diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index 19d68385eb..de94c453a0 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -1578,24 +1578,56 @@ Filesystem errors use this JSON shape: #### `GET /file` -Reads a text file. Query params: `path` (required), `maxBytes`, `line`, and -`limit`. The daemon rejects binary files. Files above the 256 KiB full-snapshot -cap require a finite `limit`; no-limit, line-only, and maxBytes-only requests -remain `file_too_large`. A finite large-file window is streamed and its returned -UTF-8 content remains capped at 256 KiB. `maxBytes` always applies to the UTF-8 -response bytes after decoding, including when the source uses another supported -encoding within the full-snapshot cap. +Reads a text file. Query params: `path` (required), `maxBytes`, `line`, `limit`, +and `cursor`. The daemon rejects binary files. Files above the 256 KiB +full-snapshot +cap require at least one explicit window argument (`line`, `limit`, or +`maxBytes`); a request with none of them remains `file_too_large`. Such a +window is streamed, and its returned UTF-8 content stays capped at 256 KiB. +`maxBytes` always applies to the UTF-8 response bytes after decoding, including +when the source uses another supported encoding within the full-snapshot cap. + +Line offsets are resolved by scanning from the start of the file, so a window +is also refused with `file_too_large` when reaching it would read more than +8 MiB (`MAX_TEXT_SCAN_BYTES`). Use `GET /file/bytes` to reach a deeper offset +directly. Large text in an encoding the route cannot decode returns +`binary_file`, not `file_too_large` — retrying with a smaller window cannot +help, and `readBytes` is the same remedy that already applies to binary. For files within the full-snapshot cap, the response includes `hash`, a SHA-256 digest over the raw on-disk bytes for the whole file, even when `line`, `limit`, or `maxBytes` returned a slice. Large partial windows omit `hash`, retain the complete `sizeBytes`, set `truncated: true`, and return -`originalLineCount: null` when the stream stops before EOF. A streamed result -is returned only when the file remains stable. Concurrent changes detected by -the post-read device/inode, size, modification-time, and change-time checks -return `hash_mismatch`, including when the same mutation also causes decoding -to fail. Stable binary content remains `binary_file`, and path replacement -retains the existing `symlink_escape` protection. +`originalLineCount: null` when the stream stops before EOF. + +##### Paging with `cursor` + +Requires the `workspace_file_read_cursor` capability. A response that has more +to give returns `hasMore: true` and, when a file byte offset is derivable, a +`nextCursor` token. Passing it back as `cursor` resumes in O(1), where a deep +`line` offset costs a scan from byte 0 and is refused past 8 MiB. + +``` +GET /file?path=big.log&limit=500 → { content, nextCursor, hasMore: true } +GET /file?path=big.log&limit=500&cursor=… → next page +``` + +`cursor` and `line` are mutually exclusive (`parse_error`) — both name a +starting point. A malformed or over-long cursor is `parse_error`; a cursor +whose file has been replaced or truncated is `hash_mismatch` (409). Appending +does **not** invalidate an outstanding cursor, which is the case the feature +exists for. + +`content` omits the terminating newline of its last line, as every other read +does, so a client reassembling pages joins them with `\n`. `hasMore` is not a +restatement of `nextCursor`: a small non-UTF-8 file read with a `limit` has +more content but no derivable byte offset, so it reports `hasMore: true` with +`nextCursor: null`. The cursor is also null when the byte cap cuts the current +line, because resuming from that offset would return a partial line. For many +short lines, lower `limit` until the page ends before the byte cap and returns +a cursor. For a single oversized line, request the following line explicitly +(for example, `line=2` when starting at line 1), then continue with cursors; +use `GET /file/bytes` when the complete oversized line is required. ```json { diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index 2ab3f55d2d..95eaf0afec 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -357,6 +357,7 @@ describe('qwen serve — capabilities envelope', () => { 'mcp_server_runtime_mutation', 'workspace_file_read', 'workspace_file_bytes', + 'workspace_file_read_cursor', 'workspace_file_write', 'session_approval_mode_control', 'workspace_tool_toggle', diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 6abac99af8..c02c8b8114 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -60,6 +60,7 @@ import { writeStderrLine } from '../../utils/stdioHelpers.js'; import { MAX_WORKSPACE_PATH_LENGTH } from '../fs/paths.js'; import { MAX_READ_BYTES, + MAX_TEXT_CURSOR_CHARS, type WorkspaceFileSystemFactory, } from '../fs/index.js'; import { @@ -3334,8 +3335,31 @@ export class AcpDispatcher { ); return; } + const rawCursor = params['cursor']; + if ( + rawCursor !== undefined && + (typeof rawCursor !== 'string' || + rawCursor.length === 0 || + rawCursor.length > MAX_TEXT_CURSOR_CHARS) + ) { + if (id !== undefined) + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + `\`cursor\` must be a non-empty string of at most ${MAX_TEXT_CURSOR_CHARS} characters`, + ), + ); + return; + } + const cursor = rawCursor as string | undefined; const resolved = await fs.resolve(p, 'read'); - const out = await fs.readText(resolved, { maxBytes, line, limit }); + const out = await fs.readText(resolved, { + maxBytes, + line, + limit, + cursor, + }); this.replyConn(conn, id, { path: p, content: out.content, diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index a7dc629e17..17ca376f68 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -53,6 +53,7 @@ import { } from '../../services/setup-github.js'; import { MAX_READ_BYTES, + MAX_TEXT_CURSOR_CHARS, type ResolvedPath, type WorkspaceFileSystem, type WorkspaceFileSystemFactory, @@ -8141,6 +8142,41 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { maxBytes: undefined, line: undefined, limit: undefined, + cursor: undefined, + }); + }); + + it('_qwen/file/read forwards a valid cursor and returns paged content', async () => { + const readText = vi.fn(async () => ({ + content: 'page-two', + meta: { truncated: true, nextCursor: 'cursor-2' }, + })); + await restartServer({ + fsFactory: makeFileFsFactory({ readText }), + }); + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 93, + method: '_qwen/file/read', + params: { path: 'test.txt', cursor: 'cursor-1' }, + }); + const frames = await takeFrames(await streamRes, 1); + expect(frames[0]).toMatchObject({ + result: { + path: 'test.txt', + content: 'page-two', + truncated: true, + nextCursor: 'cursor-2', + }, + }); + expect(readText).toHaveBeenCalledWith(resolvedPath('/ws/test.txt'), { + maxBytes: undefined, + line: undefined, + limit: undefined, + cursor: 'cursor-1', }); }); @@ -8160,6 +8196,9 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { { limit: 1.5 }, { limit: '1' }, { limit: null }, + { cursor: '' }, + { cursor: 123 }, + { cursor: 'x'.repeat(MAX_TEXT_CURSOR_CHARS + 1) }, ])('_qwen/file/read rejects invalid window params (%j)', async (params) => { const readText = vi.fn(async () => ({ content: 'hello', diff --git a/packages/cli/src/serve/bridge-file-system-adapter.test.ts b/packages/cli/src/serve/bridge-file-system-adapter.test.ts index d6468db8cd..f7fbbbdd7a 100644 --- a/packages/cli/src/serve/bridge-file-system-adapter.test.ts +++ b/packages/cli/src/serve/bridge-file-system-adapter.test.ts @@ -271,7 +271,7 @@ describe('createBridgeFileSystemAdapter', () => { expect(response.content).toBe(lines.slice(2, 22).join('\n')); }); - it('keeps an oversized ACP line-only read behind the snapshot cap', async () => { + it('serves an oversized ACP line-only read as a bounded window', async () => { const { MAX_READ_BYTES } = await import('./fs/policy.js'); const target = path.join(tmpDir, 'large-line-only.txt'); await fsp.writeFile(target, 'x'.repeat(MAX_READ_BYTES + 1), 'utf8'); @@ -279,15 +279,13 @@ describe('createBridgeFileSystemAdapter', () => { buildFactory({ trusted: true }), ); - const err = await adapter - .readText({ - path: target, - sessionId: 'sess:test', - line: 2, - }) - .catch((error: unknown) => error); + const response = await adapter.readText({ + path: target, + sessionId: 'sess:test', + line: 2, + }); - expect((err as { kind?: string }).kind).toBe('file_too_large'); + expect(response.content).toBe(''); }); it('treats null line/limit as undefined (ACP wire compatibility)', async () => { diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index e3d1f644af..177bd5cdcc 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -135,6 +135,14 @@ export const SERVE_CAPABILITY_REGISTRY = { // advertise the text/list/stat/glob surface without byte-window // support. workspace_file_bytes: { since: 'v1' }, + // Daemon supports byte-cursor paging on `GET /file`: responses carry + // `nextCursor`/`hasMore` and requests accept `cursor`. A separate tag from + // `workspace_file_read` because the convention here is that new behavior + // gets a new tag — a client that preflighted the old one must not silently + // receive a surface it cannot recognise. Same split as + // `workspace_file_bytes` from `workspace_file_read`, and + // `session_transcript_pagination` from `session_transcript`. + workspace_file_read_cursor: { since: 'v1' }, // Daemon supports hash-aware text mutation routes // (`POST /file/write`, `POST /file/edit`) behind the strict mutation // gate. Clients should still pre-flight `require_auth` separately for diff --git a/packages/cli/src/serve/fs/index.ts b/packages/cli/src/serve/fs/index.ts index 7fa064ca22..3fcc24498f 100644 --- a/packages/cli/src/serve/fs/index.ts +++ b/packages/cli/src/serve/fs/index.ts @@ -62,3 +62,4 @@ export { type WriteTextAtomicOptions, type WriteTextAtomicOutcome, } from './workspace-file-system.js'; +export { MAX_TEXT_CURSOR_CHARS } from './text-cursor.js'; diff --git a/packages/cli/src/serve/fs/policy.ts b/packages/cli/src/serve/fs/policy.ts index 938cb6adb7..472299c804 100644 --- a/packages/cli/src/serve/fs/policy.ts +++ b/packages/cli/src/serve/fs/policy.ts @@ -31,6 +31,26 @@ import type { Intent, ResolvedPath } from './paths.js'; */ export const MAX_READ_BYTES = 256 * 1024; +/** + * Upper bound on bytes read off disk to locate a line window above + * `MAX_READ_BYTES`. + * + * `MAX_READ_BYTES` caps what a read *returns*; it says nothing about what a + * read *costs*. Line offsets address a byte stream, so `{ line: 900_000_000, + * limit: 20 }` returns almost nothing and still walks the file from byte 0. + * Without this cap a single query param turns into an uninterruptible + * multi-second scan of an arbitrarily large file, and on Windows it holds a + * read handle (opened without `FILE_SHARE_DELETE`) for that entire span, + * blocking renames and deletes of the target. + * + * 8 MiB is ~25 ms at the ~300 MB/s this streams at — small enough that the + * cost is bounded and the handle-hold window stays negligible, large enough + * to cover the head and tail-ish regions agents actually ask for. Requests + * past it get `file_too_large` pointing at `readBytes`, which reaches any + * offset in O(1). + */ +export const MAX_TEXT_SCAN_BYTES = 8 * 1024 * 1024; + /** * Maximum bytes accepted by `writeText` / `edit`. Sized below the * `express.json({ limit: '10mb' })` middleware cap so a request diff --git a/packages/cli/src/serve/fs/text-cursor.ts b/packages/cli/src/serve/fs/text-cursor.ts new file mode 100644 index 0000000000..524330bad4 --- /dev/null +++ b/packages/cli/src/serve/fs/text-cursor.ts @@ -0,0 +1,128 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Opaque resume token for `readText` byte-cursor paging. + * + * Unsigned `base64url(JSON)`, matching `encodeOrganizedCursor` in + * `server/session-list.ts`. Deliberately *not* the HMAC-signed scheme used by + * `session-transcript-reader.ts`: that cursor addresses a persisted session + * file the caller names only indirectly, whereas here the path is re-resolved + * through the workspace boundary on every request. A forged cursor can + * therefore only move the byte offset within a file the caller is already + * authorised to read — precisely what `GET /file/bytes?offset=` already allows + * — so signing would buy a key schedule and no boundary. + * + * What the payload *is* for is staleness: `{dev, ino}` catches a replaced file + * and `size` catches a truncated one, turning a stale cursor into a typed + * error instead of bytes from the wrong place. + */ + +import { FsError } from './errors.js'; + +const CURSOR_VERSION = 1; + +/** + * A well-formed cursor is ~120 bytes of base64url. The cap exists so a + * hostile client cannot make us parse megabytes before rejecting. + */ +export const MAX_TEXT_CURSOR_CHARS = 1024; + +export interface TextCursorState { + /** Byte offset the next page starts at. */ + off: number; + /** File size when the cursor was minted, for shrink detection. */ + size: number; + /** Device and inode as decimal strings — `Stats` fields may be `bigint`. */ + dev: string; + ino: string; +} + +export function encodeTextCursor(state: TextCursorState): string { + return Buffer.from( + JSON.stringify({ v: CURSOR_VERSION, ...state }), + 'utf8', + ).toString('base64url'); +} + +/** + * Decode a client-supplied cursor. Shape problems are the client's fault + * (`parse_error`); a cursor that decodes but no longer matches the file is a + * concurrency problem (`hash_mismatch`), and that distinction is checked by + * {@link assertCursorMatchesFile} once the file has been opened. + */ +export function decodeTextCursor(cursor: string): TextCursorState { + if (cursor.length === 0 || cursor.length > MAX_TEXT_CURSOR_CHARS) { + throw new FsError( + 'parse_error', + `cursor must be a non-empty string of at most ${MAX_TEXT_CURSOR_CHARS} characters`, + { hint: 'pass a cursor returned by a previous read' }, + ); + } + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')); + } catch { + throw new FsError('parse_error', 'cursor is not a valid read cursor', { + hint: 'pass a cursor returned by a previous read', + }); + } + if (typeof parsed !== 'object' || parsed === null) { + throw new FsError('parse_error', 'cursor is not a valid read cursor', { + hint: 'pass a cursor returned by a previous read', + }); + } + const raw = parsed as Record; + const off = raw['off']; + const size = raw['size']; + const dev = raw['dev']; + const ino = raw['ino']; + if ( + raw['v'] !== CURSOR_VERSION || + !Number.isSafeInteger(off) || + (off as number) < 0 || + !Number.isSafeInteger(size) || + (size as number) < 0 || + typeof dev !== 'string' || + typeof ino !== 'string' + ) { + throw new FsError('parse_error', 'cursor is not a valid read cursor', { + hint: 'pass a cursor returned by a previous read', + }); + } + return { off: off as number, size: size as number, dev, ino }; +} + +/** + * Reject a cursor known stale through replacement or shrinkage. + * + * Growth is fine and is the point: appending to a log does not move the lines + * an outstanding cursor points at. Shrinking is not — the offset may now land + * mid-line or past the end, and the bytes there are not the ones the client + * was reading. + * + * Residual: a same-inode rewrite that keeps or grows the file, or a + * delete-and-recreate that reuses the inode, passes both checks. `mtimeMs` + * cannot close that gap because both those cases and a valid append advance + * it; hashing the prefix would make every page O(n), defeating the cursor. + */ +export function assertCursorMatchesFile( + cursor: TextCursorState, + stats: { dev: number | bigint; ino: number | bigint; size: number }, + path: string, +): void { + if ( + String(stats.dev) !== cursor.dev || + String(stats.ino) !== cursor.ino || + stats.size < cursor.size + ) { + throw new FsError( + 'hash_mismatch', + `cursor no longer matches the file it was issued for: ${path}`, + { hint: 're-read the file from the beginning to get a fresh cursor' }, + ); + } +} diff --git a/packages/cli/src/serve/fs/workspace-file-system.test.ts b/packages/cli/src/serve/fs/workspace-file-system.test.ts index 8258731fec..ee1fb0b1de 100644 --- a/packages/cli/src/serve/fs/workspace-file-system.test.ts +++ b/packages/cli/src/serve/fs/workspace-file-system.test.ts @@ -10,6 +10,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { createHash, randomBytes } from 'node:crypto'; import { Ignore, StandardFileSystemService } from '@qwen-code/qwen-code-core'; +import { encodeTextCursor } from './text-cursor.js'; import { FS_ACCESS_EVENT_TYPE, FS_DENIED_EVENT_TYPE, @@ -199,21 +200,14 @@ describe('WorkspaceFileSystem - readText', () => { expect(expanded.meta.truncated).toBe(true); }); - it('throws file_too_large for an oversized read without a finite line limit', async () => { + it('throws file_too_large for an oversized read with no window argument', async () => { const big = path.join(h.workspace, 'huge.txt'); const bytes = (await import('./policy.js')).MAX_READ_BYTES + 1; await fsp.writeFile(big, 'a'.repeat(bytes)); const r = await h.fs.resolve('huge.txt', 'read'); - for (const opts of [ - {}, - { line: 2 }, - { maxBytes: 1024 }, - { line: 2, maxBytes: 1024 }, - ]) { - const err = await h.fs.readText(r, opts).catch((e: unknown) => e); - expect(isFsError(err)).toBe(true); - expect((err as { kind: string }).kind).toBe('file_too_large'); - } + const err = await h.fs.readText(r).catch((e: unknown) => e); + expect(isFsError(err)).toBe(true); + expect((err as { kind: string }).kind).toBe('file_too_large'); // Audit was recorded for the denial (P0 silent-failure fix). const denied = h.events.find((e) => e.type === FS_DENIED_EVENT_TYPE); expect(denied).toBeDefined(); @@ -222,6 +216,262 @@ describe('WorkspaceFileSystem - readText', () => { ); }); + it('serves oversized text for any explicit window argument, not just limit', async () => { + // `maxBytes` and `line` bound the response just as much as `limit` does; + // refusing them while admitting a deep `line` had the cost model backwards. + const big = path.join(h.workspace, 'huge-window.txt'); + const maxReadBytes = (await import('./policy.js')).MAX_READ_BYTES; + const line = `${'a'.repeat(99)}\n`; + await fsp.writeFile(big, line.repeat(Math.ceil(maxReadBytes / 100) + 10)); + const r = await h.fs.resolve('huge-window.txt', 'read'); + + const capped = await h.fs.readText(r, { maxBytes: 1024 }); + expect(Buffer.byteLength(capped.content)).toBeLessThanOrEqual(1024); + expect(capped.meta.truncated).toBe(true); + expect(capped.meta.hasMore).toBe(true); + expect(capped.meta.nextCursor).toBeUndefined(); + expect(capped.meta.hash).toBeUndefined(); + + const fromLine = await h.fs.readText(r, { line: 2 }); + expect(fromLine.content.startsWith('a'.repeat(99))).toBe(true); + expect(Buffer.byteLength(fromLine.content)).toBeLessThanOrEqual( + maxReadBytes, + ); + expect(fromLine.meta.truncated).toBe(true); + }); + + it('refuses a line offset beyond MAX_TEXT_SCAN_BYTES', async () => { + const { MAX_TEXT_SCAN_BYTES } = await import('./policy.js'); + const big = path.join(h.workspace, 'deep-offset.txt'); + const line = `${'a'.repeat(99)}\n`; + const lineCount = Math.ceil((MAX_TEXT_SCAN_BYTES / 100) * 1.5); + await fsp.writeFile(big, line.repeat(lineCount)); + const r = await h.fs.resolve('deep-offset.txt', 'read'); + + // A shallow window on the same file is still cheap and still works. + const head = await h.fs.readText(r, { limit: 2 }); + expect(head.content.split('\n')).toHaveLength(2); + + // The deep one is refused rather than silently costing a full scan. + const err = await h.fs + .readText(r, { line: lineCount - 5, limit: 2 }) + .catch((e: unknown) => e); + expect(isFsError(err)).toBe(true); + expect((err as { kind: string }).kind).toBe('file_too_large'); + expect((err as { hint?: string }).hint).toMatch(/readBytes/); + }); + + it('pages a large log by cursor and reassembles it exactly', async () => { + const target = path.join(h.workspace, 'cursor-page.log'); + const lines = Array.from( + { length: 6_000 }, + (_, index) => `row-${index + 1} ${'x'.repeat(60)}`, + ); + const body = lines.join('\n'); + const maxReadBytes = (await import('./policy.js')).MAX_READ_BYTES; + expect(Buffer.byteLength(body)).toBeGreaterThan(maxReadBytes); + await fsp.writeFile(target, body); + const r = await h.fs.resolve('cursor-page.log', 'read'); + + const pages: string[] = []; + let out = await h.fs.readText(r, { limit: 500 }); + pages.push(out.content); + expect(out.meta.hasMore).toBe(true); + expect(out.meta.nextCursor).toBeDefined(); + + let guard = 0; + while (out.meta.nextCursor !== undefined) { + if (guard++ > 100) throw new Error('paging did not terminate'); + out = await h.fs.readText(r, { + cursor: out.meta.nextCursor, + limit: 500, + }); + pages.push(out.content); + } + expect(out.meta.hasMore).toBe(false); + expect(pages.join('\n')).toBe(body); + }); + + it('serves a cursor read of a file below MAX_READ_BYTES', async () => { + // The dispatch must branch on `cursor` before the size check; otherwise a + // small file lands on the snapshot path and silently returns line 0. + const target = path.join(h.workspace, 'small-cursor.txt'); + await fsp.writeFile(target, 'one\ntwo\nthree\nfour\n'); + const r = await h.fs.resolve('small-cursor.txt', 'read'); + + const first = await h.fs.readText(r, { limit: 2 }); + expect(first.content).toBe('one\ntwo'); + expect(first.meta.nextCursor).toBeDefined(); + + const second = await h.fs.readText(r, { + cursor: first.meta.nextCursor!, + limit: 2, + }); + expect(second.content).toBe('three\nfour'); + expect(second.meta.hasMore).toBe(false); + expect(second.meta.nextCursor).toBeUndefined(); + + const completeSnapshot = await h.fs.readText(r, { limit: 4 }); + expect(completeSnapshot.content).toBe('one\ntwo\nthree\nfour'); + expect(completeSnapshot.meta.hasMore).toBe(false); + expect(completeSnapshot.meta.nextCursor).toBeUndefined(); + }); + + it('reports remaining content when a cursor page truncates its final line', async () => { + const target = path.join(h.workspace, 'cursor-long-final-line.txt'); + await fsp.writeFile(target, 'x'.repeat(5_000)); + const stats = await fsp.stat(target); + const r = await h.fs.resolve('cursor-long-final-line.txt', 'read'); + + const page = await h.fs.readText(r, { + cursor: encodeTextCursor({ + off: 0, + size: stats.size, + dev: String(stats.dev), + ino: String(stats.ino), + }), + maxBytes: 100, + }); + expect(page.content).toBe('x'.repeat(100)); + expect(page.meta.hasMore).toBe(true); + expect(page.meta.nextCursor).toBeUndefined(); + }); + + it('keeps an outstanding cursor valid across an append', async () => { + const target = path.join(h.workspace, 'cursor-append.log'); + await fsp.writeFile(target, 'a\nb\nc\nd\n'); + const r = await h.fs.resolve('cursor-append.log', 'read'); + + const first = await h.fs.readText(r, { limit: 2 }); + await fsp.appendFile(target, 'e\nf\n'); + + const second = await h.fs.readText(r, { + cursor: first.meta.nextCursor!, + limit: 2, + }); + expect(second.content).toBe('c\nd'); + }); + + it('rejects a cursor after the file is replaced or truncated', async () => { + const target = path.join(h.workspace, 'cursor-stale.log'); + await fsp.writeFile(target, 'a\nb\nc\nd\n'); + const r = await h.fs.resolve('cursor-stale.log', 'read'); + const first = await h.fs.readText(r, { limit: 2 }); + + // Replace via write-new + rename so the inode genuinely changes. + const replacement = path.join(h.workspace, 'cursor-stale.new'); + await fsp.writeFile(replacement, 'z\ny\nx\nw\n'); + await fsp.rename(replacement, target); + + const err = await h.fs + .readText(r, { cursor: first.meta.nextCursor! }) + .catch((e: unknown) => e); + expect(isFsError(err)).toBe(true); + expect((err as { kind: string }).kind).toBe('hash_mismatch'); + + // And a shrink on a stable inode is rejected too. + await fsp.writeFile(target, 'a\nb\nc\nd\n'); + const fresh = await h.fs.readText(r, { limit: 2 }); + await fsp.truncate(target, 2); + const shrunk = await h.fs + .readText(r, { cursor: fresh.meta.nextCursor! }) + .catch((e: unknown) => e); + expect(isFsError(shrunk)).toBe(true); + expect((shrunk as { kind: string }).kind).toBe('hash_mismatch'); + }); + + it('rejects malformed cursors and cursor+line together', async () => { + const target = path.join(h.workspace, 'cursor-bad.txt'); + await fsp.writeFile(target, 'a\nb\n'); + const r = await h.fs.resolve('cursor-bad.txt', 'read'); + + for (const cursor of ['', 'not-base64url!!', 'x'.repeat(2_000)]) { + const err = await h.fs.readText(r, { cursor }).catch((e: unknown) => e); + expect(isFsError(err)).toBe(true); + expect((err as { kind: string }).kind).toBe('parse_error'); + } + + const good = await h.fs.readText(r, { limit: 1 }); + const conflict = await h.fs + .readText(r, { cursor: good.meta.nextCursor!, line: 2 }) + .catch((e: unknown) => e); + expect(isFsError(conflict)).toBe(true); + expect((conflict as { kind: string }).kind).toBe('parse_error'); + }); + + it('maps a cursor that points inside a line to parse_error', async () => { + const target = path.join(h.workspace, 'cursor-mid-line.txt'); + await fsp.writeFile(target, 'alpha'); + const stats = await fsp.stat(target); + const r = await h.fs.resolve('cursor-mid-line.txt', 'read'); + + const err = await h.fs + .readText(r, { + cursor: encodeTextCursor({ + off: 1, + size: stats.size, + dev: String(stats.dev), + ino: String(stats.ino), + }), + }) + .catch((e: unknown) => e); + + expect(isFsError(err)).toBe(true); + expect((err as { kind: string }).kind).toBe('parse_error'); + }); + + it('refuses a cursor read of oversized non-UTF-8 text', async () => { + const target = path.join(h.workspace, 'cursor-utf16.txt'); + const body = Buffer.concat([ + Buffer.from([0xff, 0xfe]), + Buffer.from('中文日志行\n'.repeat(30_000), 'utf16le'), + ]); + await fsp.writeFile(target, body); + const r = await h.fs.resolve('cursor-utf16.txt', 'read'); + + const err = await h.fs + .readText(r, { + cursor: encodeTextCursor({ + off: 0, + size: body.length, + dev: '0', + ino: '0', + }), + }) + .catch((e: unknown) => e); + expect(isFsError(err)).toBe(true); + // dev/ino are placeholders, so the staleness gate fires before decoding. + expect((err as { kind: string }).kind).toBe('hash_mismatch'); + }); + + it('maps a cursor read of oversized non-UTF-8 text to binary_file', async () => { + const target = path.join(h.workspace, 'cursor-utf16-real.txt'); + const body = Buffer.concat([ + Buffer.from([0xff, 0xfe]), + Buffer.from('中文日志行\n'.repeat(30_000), 'utf16le'), + ]); + await fsp.writeFile(target, body); + const stats = await fsp.stat(target); + const r = await h.fs.resolve('cursor-utf16-real.txt', 'read'); + + const err = await h.fs + .readText(r, { + cursor: encodeTextCursor({ + off: 0, + size: stats.size, + dev: String(stats.dev), + ino: String(stats.ino), + }), + }) + .catch((e: unknown) => e); + expect(isFsError(err)).toBe(true); + // Real dev/ino clear the staleness gate, so decoding starts and the + // non-UTF-8 content is reclassified — not `file_too_large`, which a + // client would retry forever on. + expect((err as { kind: string }).kind).toBe('binary_file'); + expect((err as { hint?: string }).hint).toMatch(/convert.*UTF-8/i); + }); + it('streams bounded line windows from text above MAX_READ_BYTES', async () => { const target = path.join(h.workspace, 'large-window.txt'); const lines = Array.from( @@ -271,7 +521,7 @@ describe('WorkspaceFileSystem - readText', () => { expect((err as { kind: string }).kind).toBe('binary_file'); }); - it('maps oversized non-UTF-8 text windows to file_too_large', async () => { + it('maps oversized non-UTF-8 text windows to binary_file', async () => { const target = path.join(h.workspace, 'large-utf16.txt'); const body = Buffer.concat([ Buffer.from([0xff, 0xfe]), @@ -284,7 +534,10 @@ describe('WorkspaceFileSystem - readText', () => { const err = await h.fs.readText(r, { limit: 20 }).catch((e: unknown) => e); expect(isFsError(err)).toBe(true); - expect((err as { kind: string }).kind).toBe('file_too_large'); + // Not `file_too_large`: shrinking the window can never make a GBK file + // decodable, so a client retrying on 413 would loop forever. 422 with the + // readBytes hint is the same remedy that already works for binary. + expect((err as { kind: string }).kind).toBe('binary_file'); expect((err as { hint?: string }).hint).toMatch(/convert.*UTF-8/i); }); @@ -377,7 +630,7 @@ describe('WorkspaceFileSystem - readText', () => { } }); - it('rejects in-place changes while a large range is being read', async () => { + it('rejects a truncation while a large range is being read', async () => { const target = path.join(h.workspace, 'large-change.txt'); const lines = Array.from( { length: 4_000 }, @@ -393,7 +646,7 @@ describe('WorkspaceFileSystem - readText', () => { params, ) { const result = await original.call(this, params); - await fsp.appendFile(target, '\nchanged'); + await fsp.truncate(target, 1_000); return result; }); @@ -408,6 +661,40 @@ describe('WorkspaceFileSystem - readText', () => { } }); + it('serves a prefix window from a file being appended to during the read', async () => { + // The whole point of the feature: tailing a live log. A prefix window + // does not depend on the tail, so an append must not fail the read. + const target = path.join(h.workspace, 'large-append.txt'); + const lines = Array.from( + { length: 4_000 }, + (_, index) => `line-${index + 1} ${'x'.repeat(80)}`, + ); + await fsp.writeFile(target, lines.join('\n')); + const resolved = await h.fs.resolve('large-append.txt', 'read'); + const original = StandardFileSystemService.prototype.readTextFileFromHandle; + const sizeBefore = (await fsp.stat(target)).size; + const readSpy = vi + .spyOn(StandardFileSystemService.prototype, 'readTextFileFromHandle') + .mockImplementation(async function ( + this: StandardFileSystemService, + params, + ) { + const result = await original.call(this, params); + await fsp.appendFile(target, `\n${'appended '.repeat(50)}`); + return result; + }); + + try { + const out = await h.fs.readText(resolved, { limit: 20 }); + expect(out.content).toBe(lines.slice(0, 20).join('\n')); + // sizeBytes describes the snapshot the window was cut from, not the + // file as it stands after the concurrent append. + expect(out.meta.sizeBytes).toBe(sizeBefore); + } finally { + readSpy.mockRestore(); + } + }); + it('rejects same-size in-place overwrites during a large range read', async () => { const target = path.join(h.workspace, 'large-overwrite.txt'); const lines = Array.from( @@ -447,10 +734,8 @@ describe('WorkspaceFileSystem - readText', () => { } finally { await writer.close(); } - // Restore mtime to prove ctime still detects a same-size overwrite - // that size+mtime checks alone would accept. Pause first so the - // change-time lands in a later timestamp quantum than the pre-read - // snapshot even on coarse-resolution filesystems. + // Restore mtime after ctime has advanced so the stability check + // proves that ctime alone detects the same-size overwrite. await new Promise((resolve) => setTimeout(resolve, 50)); await fsp.utimes(target, before.atime, before.mtime); } @@ -515,10 +800,8 @@ describe('WorkspaceFileSystem - readText', () => { } finally { await writer.close(); } - // Pause so the overwrite's change-time lands in a later timestamp - // quantum than the pre-read snapshot even on coarse-resolution - // filesystems; detection here relies on ctime since mtime is - // restored. + // Restore mtime after ctime has advanced so the stability check + // proves that ctime alone detects the same-size overwrite. await new Promise((resolve) => setTimeout(resolve, 50)); await fsp.utimes(target, before.atime, before.mtime); } diff --git a/packages/cli/src/serve/fs/workspace-file-system.ts b/packages/cli/src/serve/fs/workspace-file-system.ts index d0cfcd3d30..f49478f77a 100644 --- a/packages/cli/src/serve/fs/workspace-file-system.ts +++ b/packages/cli/src/serve/fs/workspace-file-system.ts @@ -18,11 +18,14 @@ import { glob as globAsync } from 'glob'; // don't repeat the regression. import { + CursorNotAtLineBoundaryError, LargeNonUtf8TextError, StandardFileSystemService, + TextScanBudgetExceededError, decodeBufferWithEncodingInfoAsync, detectLineEnding, encodeTextFileContentAsync, + isUtf8CompatibleEncoding, loadIgnoreRules, isWithinRoot, type Ignore, @@ -36,6 +39,11 @@ import { createAuditPublisher, } from './audit.js'; import { FsError, wrapAsFsError, type FsErrorKind } from './errors.js'; +import { + assertCursorMatchesFile, + decodeTextCursor, + encodeTextCursor, +} from './text-cursor.js'; import { canonicalizeWorkspaces, resolveWithinWorkspace, @@ -45,6 +53,7 @@ import { import { BINARY_PROBE_BYTES, MAX_READ_BYTES, + MAX_TEXT_SCAN_BYTES, assertTrustedForIntent, enforceReadSize, enforceWriteSize, @@ -83,11 +92,33 @@ export interface ReadMeta { truncated?: boolean; matchedIgnore?: 'file' | 'directory'; originalLineCount?: number; + /** + * Resume token for the next page. Present only when content remains *and* a + * file byte offset is derivable — a non-UTF-8 snapshot read has more to give + * but cannot be paged by byte, which is why `hasMore` is a separate field + * rather than a restatement of this one. + */ + nextCursor?: string; + /** Whether content remains beyond what was returned, for any reason. */ + hasMore?: boolean; } +/** + * Above `MAX_READ_BYTES` at least one of these must be set. Any of them is + * the caller stating it accepts partial content, which is all the streamed + * path returns; with none of them the read is refused rather than silently + * handing back a truncated "whole file". Which one is set does not affect + * cost — that is bounded by `MAX_TEXT_SCAN_BYTES`. + */ export interface ReadTextOptions { /** Returned-byte cap in [1, MAX_READ_BYTES]; defaults to MAX_READ_BYTES. */ maxBytes?: number; + /** + * Opaque resume token from a previous read's `meta.nextCursor`. Mutually + * exclusive with `line` — both name a starting point. Reaches any offset in + * O(1), where `line` must scan from byte 0. + */ + cursor?: string; /** * 1-based starting line for partial reads. `1` returns the file * from its first line. The boundary converts to the 0-based slice @@ -473,6 +504,14 @@ class WorkspaceFileSystemImpl implements WorkspaceFileSystem { `limit must be a positive integer, got ${opts.limit}`, ); } + // Both name a starting point; honouring one and ignoring the other + // would silently return the wrong window. + if (opts.cursor !== undefined && opts.line !== undefined) { + throw new FsError( + 'parse_error', + 'cursor and line are mutually exclusive; a cursor already encodes where to resume', + ); + } if ( opts.maxBytes !== undefined && (!Number.isSafeInteger(opts.maxBytes) || @@ -1375,13 +1414,34 @@ async function readTextFromResolvedFile( throw new FsError('parse_error', `path is not a regular file: ${p}`); } - if (pre.size > MAX_READ_BYTES && opts.limit !== undefined) { - return readLargeTextWindowFromResolvedFile( - p, - pre, - { ...opts, limit: opts.limit }, - lowFs, - ); + // Any explicit window argument is the caller stating it accepts partial + // content, which is what the large-file path returns. Gating on `limit` + // alone got this backwards in both directions: `{ line: 900_000_000, + // limit: 20 }` was admitted despite costing a full scan, while + // `{ maxBytes: 4096 }` — satisfiable from the first 4 KiB — was refused. + // Cost is bounded by MAX_TEXT_SCAN_BYTES, not by which knob was set. + // + // A read with no window argument at all still fails: an agent that + // believes it holds the whole file may write it back truncated. The + // omitted `hash` blocks that for `editText`/`writeTextAtomic`, but + // `writeTextOverwrite` takes no hash, so `truncated: true` is the only + // signal on that path — refusing the unbounded read keeps the caller + // from ever being in that position by accident. + // Cursor reads branch before the size check, not by widening `wantsWindow`. + // Adding `cursor` there would fix only large files: a cursor read of a file + // *under* MAX_READ_BYTES would still land on the snapshot path, which knows + // only `line`/`limit` and would silently ignore the cursor and return from + // line 0 — a wrong answer, worse than the refusal the large case would give. + if (opts.cursor !== undefined) { + return readTextCursorWindowFromResolvedFile(p, pre, opts, lowFs); + } + + const wantsWindow = + opts.limit !== undefined || + opts.maxBytes !== undefined || + opts.line !== undefined; + if (pre.size > MAX_READ_BYTES && wantsWindow) { + return readLargeTextWindowFromResolvedFile(p, pre, opts, lowFs); } return readTextSnapshotFromResolvedFile(p, opts, pre); } @@ -1430,6 +1490,7 @@ async function readTextSnapshotFromResolvedFile( const maxOutputBytes = opts.maxBytes ?? MAX_READ_BYTES; const sizeOutcome = enforceReadSize(raw.length, maxOutputBytes); let content = sliced.content; + let byteTruncated = false; const meta: TextSnapshot['meta'] = { encoding: decoded.encoding, bom: decoded.bom, @@ -1444,6 +1505,7 @@ async function readTextSnapshotFromResolvedFile( content = safeUtf8Truncate(output, maxOutputBytes).toString('utf-8'); meta.lineEnding = detectLineEnding(content); meta.truncated = true; + byteTruncated = true; } if (sizeOutcome.truncated) { meta.truncated = true; @@ -1457,30 +1519,231 @@ async function readTextSnapshotFromResolvedFile( meta.truncated = true; } + const pageableLineCount = + sliced.originalLineCount - (decoded.content.endsWith('\n') ? 1 : 0); + meta.hasMore = byteTruncated || sliced.endLine < pageableLineCount; + // A byte offset into the file is only derivable when the decoded text and + // the file agree byte-for-byte. For GBK, Shift_JIS, or UTF-16 the decoded + // string is a UTF-8 re-encoding whose lengths are unrelated to the file's, + // so a cursor built from it would point at the wrong byte. Such a read still + // reports `hasMore` honestly — it has more to give, it just cannot be paged. + // A byte-truncated slice ends mid-line, so there is no line start to resume + // from; `hasMore` still says content remains. Every cursor this boundary + // mints points at a line start, so a client following cursors never skips + // the tail of a line it was only shown part of. + const bomBytes = decoded.bom ? 3 : 0; + const decodedBytesMatchSource = + isUtf8CompatibleEncoding(decoded.encoding) && + Buffer.from(decoded.content, 'utf-8').equals(raw.subarray(bomBytes)); + if (meta.hasMore && !byteTruncated && decodedBytesMatchSource) { + // `decodeBufferWithEncodingInfoAsync` strips the BOM, so decoded offsets + // run short by its length. A BOM on a byte-compatible encoding is UTF-8, + // whose marker is three bytes. + const startByte = bomBytes + sliced.startByteOffset; + const contentBytes = Buffer.byteLength(content, 'utf-8'); + // Whole lines consumed their terminator; a byte-truncated slice stopped + // mid-line and resumes at exactly what was returned. + const nextOffset = startByte + contentBytes + 1; + if (nextOffset < raw.length) { + meta.nextCursor = encodeTextCursor({ + off: nextOffset, + size: raw.length, + dev: String(pre.dev), + ino: String(pre.ino), + }); + } + } + return { content, meta }; } +/** + * Stability check for a streamed *prefix* window. + * + * The full-snapshot path can demand byte-for-byte stability (`size` and + * `mtimeMs` unchanged) because it returns the whole file: any change + * invalidates the result. A line window does not return the whole file, so + * demanding whole-file stability rejects reads whose returned bytes are + * still perfectly valid — and the case it rejects is the one this feature + * exists for. Appending to a log does not change lines 1-20, but under an + * equality check every read of a live log is a coin flip. + * + * So the streamed path accepts growth, but rejects shrinkage and same-size + * version changes. The latter preserves the stable-read protection against + * in-place overwrites while still allowing append-only logs. + * + * The residual gap is a writer that changes existing bytes and grows past the + * original size inside one read window while keeping the same inode. Metadata + * cannot distinguish that from a pure append; hashing the prefix would make + * every page O(n), defeating the cursor. + */ +function assertStreamWindowStable( + before: { + size: number | bigint; + mtimeMs: number | bigint; + ctimeMs: number | bigint; + }, + after: { + size: number | bigint; + mtimeMs: number | bigint; + ctimeMs: number | bigint; + }, + p: ResolvedPath, + reason: string, +): void { + const beforeSize = toBigInt(before.size); + const afterSize = toBigInt(after.size); + if ( + afterSize < beforeSize || + (afterSize === beforeSize && + (after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs)) + ) { + throw new FsError('hash_mismatch', `${reason}: ${p}`, { + hint: 'retry after re-reading the latest file', + }); + } +} + +/** + * Byte-cursor page. Reaches any offset in O(1), so `MAX_TEXT_SCAN_BYTES` does + * not apply here — that budget exists only because line offsets must be + * resolved by scanning. + * + * The fd-bound TOCTOU discipline is lifted verbatim from + * `readLargeTextWindowFromResolvedFile`. It is deliberately *not* copied from + * `readBytesWindow`, which sits next door and looks like the closer model but + * still demands `size`/`mtimeMs` equality after the read — the check `e784e6d` + * relaxed precisely because it fails every page of an actively-written log. + */ +async function readTextCursorWindowFromResolvedFile( + p: ResolvedPath, + pre: Awaited>, + opts: ReadTextOptions, + lowFs: StandardFileSystemService, +): Promise { + const cursor = decodeTextCursor(opts.cursor as string); + const fh = await fsp.open(p as string, 'r'); + let opened: Awaited> | undefined; + let afterRead: Awaited> | undefined; + let window: + | Awaited> + | undefined; + let primaryError: unknown; + let hasPrimaryError = false; + try { + opened = await fh.stat(); + assertSameFile(pre, opened, p as string, 'read'); + assertStreamWindowStable(pre, opened, p, 'file changed before read'); + assertCursorMatchesFile(cursor, opened, p as string); + + try { + const probe = Buffer.alloc(Math.min(BINARY_PROBE_BYTES, opened.size)); + if (probe.length > 0) { + const { bytesRead } = await fh.read(probe, 0, probe.length, 0); + if (looksBinary(probe.subarray(0, bytesRead))) { + throw new FsError('binary_file', `binary file: ${p}`, { + hint: 'use readBytes for binary content', + }); + } + } + + window = await lowFs.readTextCursorFromHandle({ + fileHandle: fh, + startOffset: cursor.off, + fileSize: opened.size, + maxOutputBytes: opts.maxBytes ?? MAX_READ_BYTES, + maxSnapBytes: MAX_TEXT_SCAN_BYTES, + ...(opts.limit !== undefined ? { limit: opts.limit } : {}), + }); + } catch (err) { + hasPrimaryError = true; + primaryError = err; + } + + afterRead = await fh.stat(); + } finally { + await fh.close(); + } + + if (opened === undefined || afterRead === undefined) { + throw new FsError('internal_error', `failed to stat opened file: ${p}`); + } + const post = await fsp.lstat(p as string); + if (post.isSymbolicLink()) { + throw new FsError( + 'symlink_escape', + `path was replaced with a symlink during read: ${p}`, + { hint: 'TOCTOU swap detected via post-read lstat' }, + ); + } + assertSameFile(opened, afterRead, p as string, 'read'); + assertStreamWindowStable(opened, afterRead, p, 'file changed during read'); + assertSameFile(opened, post, p as string, 'read'); + assertStreamWindowStable(opened, post, p, 'file changed during read'); + + if (hasPrimaryError) { + if (primaryError instanceof LargeNonUtf8TextError) { + throw new FsError('binary_file', primaryError.message, { + cause: primaryError, + hint: 'convert the file to UTF-8, or use readBytes for the raw bytes', + }); + } + // The offset is malformed, not the file oversized — a cursor this daemon + // issued always lands on a line start. + if (primaryError instanceof CursorNotAtLineBoundaryError) { + throw new FsError('parse_error', primaryError.message, { + cause: primaryError, + hint: 'pass a cursor returned by a previous read', + }); + } + throw primaryError; + } + if (window === undefined) { + throw new FsError( + 'internal_error', + `cursor text read returned no result: ${p}`, + ); + } + + const meta: TextReadOutcome['meta'] = { + encoding: window.encoding, + bom: window.bom, + lineEnding: window.lineEnding, + sizeBytes: opened.size, + truncated: true, + hasMore: + window.nextOffset !== undefined || window.truncatedByBytes === true, + }; + if (window.nextOffset !== undefined) { + meta.nextCursor = encodeTextCursor({ + off: window.nextOffset, + size: opened.size, + dev: String(opened.dev), + ino: String(opened.ino), + }); + } + return { content: window.content, meta }; +} + async function readLargeTextWindowFromResolvedFile( p: ResolvedPath, pre: Awaited>, - opts: ReadTextOptions & { limit: number }, + opts: ReadTextOptions, lowFs: StandardFileSystemService, ): Promise { const fh = await fsp.open(p as string, 'r'); + let opened: Awaited> | undefined; + let afterRead: Awaited> | undefined; + let result: + | Awaited> + | undefined; + let primaryError: unknown; + let hasPrimaryError = false; try { - const opened = await fh.stat(); + opened = await fh.stat(); assertSameFile(pre, opened, p as string, 'read'); - if (didFileVersionChange(pre, opened)) { - throw new FsError('hash_mismatch', `file changed before read: ${p}`, { - hint: 'retry after re-reading the latest file', - }); - } + assertStreamWindowStable(pre, opened, p, 'file changed before read'); - let result: - | Awaited> - | undefined; - let primaryError: unknown; - let hasPrimaryError = false; try { const probe = Buffer.alloc(Math.min(BINARY_PROBE_BYTES, opened.size)); if (probe.length > 0) { @@ -1493,91 +1756,94 @@ async function readLargeTextWindowFromResolvedFile( } result = await lowFs.readTextFileFromHandle({ - path: p as string, fileHandle: fh, - stats: opened, - limit: opts.limit, + fileSize: opened.size, + limit: opts.limit ?? Number.POSITIVE_INFINITY, line: opts.line !== undefined ? opts.line - 1 : 0, maxOutputBytes: opts.maxBytes ?? MAX_READ_BYTES, + maxScanBytes: MAX_TEXT_SCAN_BYTES, }); } catch (err) { hasPrimaryError = true; primaryError = err; } - const afterRead = await fh.stat(); - const post = await fsp.lstat(p as string); - if (post.isSymbolicLink()) { - throw new FsError( - 'symlink_escape', - `path was replaced with a symlink during read: ${p}`, - { hint: 'TOCTOU swap detected via post-read lstat' }, - ); - } - assertSameFile(opened, afterRead, p as string, 'read'); - assertSameFile(opened, post, p as string, 'read'); - if ( - didFileVersionChange(opened, afterRead) || - didFileVersionChange(opened, post) - ) { - throw new FsError('hash_mismatch', `file changed during read: ${p}`, { - hint: 'retry after re-reading the latest file', - }); - } - - if (hasPrimaryError) { - if (primaryError instanceof LargeNonUtf8TextError) { - throw new FsError('file_too_large', primaryError.message, { - cause: primaryError, - hint: 'convert the file to UTF-8 before requesting a large line window', - }); - } - throw primaryError; - } - - if (result === undefined) { - throw new FsError( - 'internal_error', - `large text range read returned no result: ${p}`, - ); - } - - const meta: TextReadOutcome['meta'] = { - encoding: result._meta?.encoding, - bom: result._meta?.bom, - lineEnding: detectLineEnding(result.content), - sizeBytes: opened.size, - truncated: true, - }; - if ( - result._meta?.originalLineCountExact === true && - result._meta.originalLineCount !== undefined - ) { - meta.originalLineCount = result._meta.originalLineCount; - } - return { content: result.content, meta }; + afterRead = await fh.stat(); } finally { await fh.close(); } -} -function didFileVersionChange( - before: { - size: number | bigint; - mtimeMs: number | bigint; - ctimeMs: number | bigint; - }, - after: { - size: number | bigint; - mtimeMs: number | bigint; - ctimeMs: number | bigint; - }, -): boolean { - return ( - after.size !== before.size || - after.mtimeMs !== before.mtimeMs || - after.ctimeMs !== before.ctimeMs - ); + if (opened === undefined || afterRead === undefined) { + throw new FsError('internal_error', `failed to stat opened file: ${p}`); + } + const post = await fsp.lstat(p as string); + if (post.isSymbolicLink()) { + throw new FsError( + 'symlink_escape', + `path was replaced with a symlink during read: ${p}`, + { hint: 'TOCTOU swap detected via post-read lstat' }, + ); + } + assertSameFile(opened, afterRead, p as string, 'read'); + assertStreamWindowStable(opened, afterRead, p, 'file changed during read'); + assertSameFile(opened, post, p as string, 'read'); + assertStreamWindowStable(opened, post, p, 'file changed during read'); + + if (hasPrimaryError) { + // An encoding the text route can't represent is the same class of refusal + // as sniffed-binary content, and `binary_file` already tells clients to + // fall back to `readBytes`. + if (primaryError instanceof LargeNonUtf8TextError) { + throw new FsError('binary_file', primaryError.message, { + cause: primaryError, + hint: 'convert the file to UTF-8, or use readBytes for the raw bytes', + }); + } + if (primaryError instanceof TextScanBudgetExceededError) { + throw new FsError('file_too_large', primaryError.message, { + cause: primaryError, + hint: `line offsets are resolved by scanning from byte 0 and stop after ${MAX_TEXT_SCAN_BYTES} bytes; page with the cursor from a shallower read to reach this offset in O(1), or use readBytes for raw bytes`, + }); + } + throw primaryError; + } + if (result === undefined) { + throw new FsError( + 'internal_error', + `large text range read returned no result: ${p}`, + ); + } + const content = result.content; + const readMeta = result._meta; + + const meta: TextReadOutcome['meta'] = { + encoding: readMeta?.encoding, + bom: readMeta?.bom, + lineEnding: readMeta?.lineEnding ?? detectLineEnding(content), + // Size as of `open`, not as of now: it describes the snapshot the + // returned window was cut from. A file that grew during the read + // reports the smaller, consistent number. + sizeBytes: opened.size, + truncated: true, + hasMore: + readMeta?.nextByteOffset !== undefined || + readMeta?.truncatedByBytes === true, + }; + if (readMeta?.nextByteOffset !== undefined) { + meta.nextCursor = encodeTextCursor({ + off: readMeta.nextByteOffset, + size: opened.size, + dev: String(opened.dev), + ino: String(opened.ino), + }); + } + if ( + readMeta?.originalLineCountExact === true && + readMeta?.originalLineCount !== undefined + ) { + meta.originalLineCount = readMeta.originalLineCount; + } + return { content, meta }; } async function readStableRegularFileBuffer( @@ -1632,14 +1898,27 @@ function sliceDecodedText( content: string, startLine: number, limit: number, -): { content: string; originalLineCount: number } { +): { + content: string; + originalLineCount: number; + /** Byte offset of `startLine` within the decoded text (BOM excluded). */ + startByteOffset: number; + /** Index just past the last returned line. */ + endLine: number; +} { const lines = content.split('\n'); const originalLineCount = lines.length; const endLine = Math.min(startLine + limit, originalLineCount); const actualStartLine = Math.min(startLine, originalLineCount); + let startByteOffset = 0; + for (let i = 0; i < actualStartLine; i++) { + startByteOffset += Buffer.byteLength(lines[i]!, 'utf-8') + 1; + } return { content: lines.slice(actualStartLine, endLine).join('\n'), originalLineCount, + startByteOffset, + endLine, }; } diff --git a/packages/cli/src/serve/routes/workspace-file-read.test.ts b/packages/cli/src/serve/routes/workspace-file-read.test.ts index 7fccfbe704..a11ce89499 100644 --- a/packages/cli/src/serve/routes/workspace-file-read.test.ts +++ b/packages/cli/src/serve/routes/workspace-file-read.test.ts @@ -147,6 +147,61 @@ describe('GET /file', () => { }); }); + it('pages a large file over HTTP with nextCursor', async () => { + const lines = Array.from( + { length: 4_000 }, + (_, index) => `line-${index + 1} ${'x'.repeat(80)}`, + ); + const body = lines.join('\n'); + await fsp.writeFile(path.join(h.workspace, 'paged.log'), body); + + const first = await request(h.app) + .get('/file?path=paged.log&limit=500') + .set('Host', loopbackHost()); + expect(first.status).toBe(200); + expect(first.body.hasMore).toBe(true); + expect(typeof first.body.nextCursor).toBe('string'); + + const pages: string[] = [first.body.content]; + let cursor: string | null = first.body.nextCursor; + let guard = 0; + while (cursor) { + if (guard++ > 50) throw new Error('paging did not terminate'); + const next = await request(h.app) + .get( + `/file?path=paged.log&limit=500&cursor=${encodeURIComponent(cursor)}`, + ) + .set('Host', loopbackHost()); + expect(next.status).toBe(200); + pages.push(next.body.content); + cursor = next.body.nextCursor; + } + expect(pages.join('\n')).toBe(body); + }); + + it('rejects a malformed cursor with 400', async () => { + await fsp.writeFile(path.join(h.workspace, 'c.txt'), 'a\nb\n'); + const res = await request(h.app) + .get('/file?path=c.txt&cursor=not-a-cursor') + .set('Host', loopbackHost()); + expect(res.status).toBe(400); + expect(res.body.errorKind).toBe('parse_error'); + }); + + it('rejects cursor combined with line', async () => { + await fsp.writeFile(path.join(h.workspace, 'cl.txt'), 'a\nb\nc\n'); + const first = await request(h.app) + .get('/file?path=cl.txt&limit=1') + .set('Host', loopbackHost()); + const res = await request(h.app) + .get( + `/file?path=cl.txt&line=2&cursor=${encodeURIComponent(first.body.nextCursor)}`, + ) + .set('Host', loopbackHost()); + expect(res.status).toBe(400); + expect(res.body.errorKind).toBe('parse_error'); + }); + it('returns a bounded line window for text above MAX_READ_BYTES', async () => { const { MAX_READ_BYTES } = await import('../fs/policy.js'); const lines = Array.from( @@ -173,23 +228,20 @@ describe('GET /file', () => { expect(res.body.hash).toBeUndefined(); }); - it.each(['', '&line=2', '&maxBytes=1024', '&line=2&maxBytes=1024'])( - 'keeps oversized reads without a finite limit behind the snapshot cap (%s)', - async (query) => { - const { MAX_READ_BYTES } = await import('../fs/policy.js'); - await fsp.writeFile( - path.join(h.workspace, 'large-no-limit.txt'), - 'x'.repeat(MAX_READ_BYTES + 1), - ); + it('keeps an oversized read without a window behind the snapshot cap', async () => { + const { MAX_READ_BYTES } = await import('../fs/policy.js'); + await fsp.writeFile( + path.join(h.workspace, 'large-no-window.txt'), + 'x'.repeat(MAX_READ_BYTES + 1), + ); - const res = await request(h.app) - .get(`/file?path=large-no-limit.txt${query}`) - .set('Host', loopbackHost()); + const res = await request(h.app) + .get('/file?path=large-no-window.txt') + .set('Host', loopbackHost()); - expect(res.status).toBe(413); - expect(res.body.errorKind).toBe('file_too_large'); - }, - ); + expect(res.status).toBe(413); + expect(res.body.errorKind).toBe('file_too_large'); + }); it('attaches Cache-Control: no-store and X-Content-Type-Options: nosniff', async () => { await fsp.writeFile(path.join(h.workspace, 'a.txt'), 'x'); diff --git a/packages/cli/src/serve/routes/workspace-file-read.ts b/packages/cli/src/serve/routes/workspace-file-read.ts index aa972c2b3a..785d989c1f 100644 --- a/packages/cli/src/serve/routes/workspace-file-read.ts +++ b/packages/cli/src/serve/routes/workspace-file-read.ts @@ -10,6 +10,7 @@ import { writeStderrLine } from '../../utils/stdioHelpers.js'; import { FsError, MAX_READ_BYTES, + MAX_TEXT_CURSOR_CHARS, canonicalizeWorkspace, isFsError, type WorkspaceFileSystemFactory, @@ -256,13 +257,34 @@ async function handleGetFile( }); return; } + const rawCursor = req.query['cursor']; + if ( + rawCursor !== undefined && + (typeof rawCursor !== 'string' || + rawCursor.length === 0 || + rawCursor.length > MAX_TEXT_CURSOR_CHARS) + ) { + applyReadHeaders(res); + res.status(400).json({ + errorKind: 'parse_error', + error: `\`cursor\` must be a non-empty string of at most ${MAX_TEXT_CURSOR_CHARS} characters`, + status: 400, + }); + return; + } + const cursor = rawCursor as string | undefined; const fs = factory.forRequest({ originatorClientId: clientId ?? undefined, route: ROUTE, }); try { const resolved = await fs.resolve(queryPath, 'read'); - const out = await fs.readText(resolved, { maxBytes, line, limit }); + const out = await fs.readText(resolved, { + maxBytes, + line, + limit, + cursor, + }); const returnedBytes = Buffer.byteLength(out.content, 'utf-8'); applyReadHeaders(res); res.status(200).json({ @@ -278,6 +300,8 @@ async function handleGetFile( hash: out.meta.hash, matchedIgnore: out.meta.matchedIgnore ?? null, originalLineCount: out.meta.originalLineCount ?? null, + nextCursor: out.meta.nextCursor ?? null, + hasMore: out.meta.hasMore === true, }); } catch (err) { sendFsError(res, err, ROUTE); diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 7c357a3092..450706e5b8 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -393,6 +393,7 @@ const EXPECTED_STAGE1_FEATURES = [ // Issue #4175 PR 20. Always-on. Daemon exposes raw byte windows and // hash-aware text mutation routes behind the strict mutation gate. 'workspace_file_bytes', + 'workspace_file_read_cursor', 'workspace_file_write', // Mutation control routes (approval mode, workspace tool/skill toggles, // init scaffold, and MCP server restart). diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 126108aa57..1b8482c7a0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -266,7 +266,12 @@ export { decodeBufferWithEncodingInfo, encodeTextFileContent, } from './utils/sync-file-encoding.js'; -export { LargeNonUtf8TextError } from './utils/read-text-range.js'; +export { + CursorNotAtLineBoundaryError, + LargeNonUtf8TextError, + TextScanBudgetExceededError, +} from './utils/read-text-range.js'; +export { isUtf8CompatibleEncoding } from './utils/encoding.js'; export * from './services/gitWorktreeService.js'; export { DEFAULT_MAX_TOOL_CALLS_PER_TURN } from './services/loopDetectionService.js'; export * from './services/visionBridge/vision-bridge-service.js'; diff --git a/packages/core/src/services/fileSystemService.test.ts b/packages/core/src/services/fileSystemService.test.ts index ab822b1533..952156b63f 100644 --- a/packages/core/src/services/fileSystemService.test.ts +++ b/packages/core/src/services/fileSystemService.test.ts @@ -56,17 +56,7 @@ vi.mock('../utils/fileUtils.js', async (importOriginal) => { }; }); -vi.mock('../utils/read-text-range.js', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - readTextRange: vi.fn(), - }; -}); - import { readFileWithLineAndLimit } from '../utils/fileUtils.js'; -import { readTextRange } from '../utils/read-text-range.js'; describe('StandardFileSystemService', () => { let fileSystem: StandardFileSystemService; @@ -193,77 +183,129 @@ describe('StandardFileSystemService', () => { }); }); - it('should route handle-bound reads through the bounded range path', async () => { + // Handle-bound reads no longer route through `readFileWithLineAndLimit`, + // so asserting the arguments it was called with would test nothing. The + // behaviour is covered against real files in `read-text-range.test.ts` + // and at the real boundary in `workspace-file-system.test.ts`; only the + // argument validation below needs a unit test, and it needs no mock. + it.each([ + ['maxOutputBytes', { maxOutputBytes: Number.POSITIVE_INFINITY }], + ['maxScanBytes', { maxScanBytes: Number.POSITIVE_INFINITY }], + ['maxOutputBytes', { maxOutputBytes: 0 }], + ['maxScanBytes', { maxScanBytes: -1 }], + ])('should reject a handle read with unbounded %s', async (bound, over) => { const fileHandle = {} as import('node:fs/promises').FileHandle; - const stats = { size: 300_000 } as import('node:fs').Stats; - vi.mocked(readTextRange).mockResolvedValue({ - content: 'line 2', - bom: false, - encoding: 'utf-8', - originalLineCount: 3, - originalLineCountExact: false, - truncatedByBytes: false, - }); - - const result = await fileSystem.readTextFileFromHandle({ - path: '/test/large.txt', - fileHandle, - stats, - limit: 1, - line: 1, - maxOutputBytes: 262_144, - }); - - expect(readTextRange).toHaveBeenCalledWith({ - path: '/test/large.txt', - fileHandle, - stats, - limit: 1, - offset: 1, - maxOutputBytes: 262_144, - }); - expect(result.content).toBe('line 2'); - expect(result._meta?.originalLineCountExact).toBe(false); - }); - - it('should reject unbounded handle reads', async () => { - const fileHandle = {} as import('node:fs/promises').FileHandle; - const stats = { size: 300_000 } as import('node:fs').Stats; await expect( fileSystem.readTextFileFromHandle({ - path: '/test/large.txt', fileHandle, - stats, - limit: Number.POSITIVE_INFINITY, + fileSize: 300_000, + limit: 20, maxOutputBytes: 262_144, + maxScanBytes: 8 * 1024 * 1024, + ...over, }), - ).rejects.toThrow(/positive finite limit/); - expect(readTextRange).not.toHaveBeenCalled(); + ).rejects.toThrow(new RegExp(`positive finite ${bound}`)); }); it.each([ - ['maxOutputBytes', { maxOutputBytes: 0 }], - ['maxOutputBytes', { maxOutputBytes: Number.POSITIVE_INFINITY }], + ['a fractional limit', 2.5], + ['a zero limit', 0], + ['a negative limit', -1], + ])('should reject %s on a handle read', async (_label, limit) => { + const fileHandle = {} as import('node:fs/promises').FileHandle; + + await expect( + fileSystem.readTextFileFromHandle({ + fileHandle, + fileSize: 300_000, + limit, + maxOutputBytes: 262_144, + maxScanBytes: 8 * 1024 * 1024, + }), + ).rejects.toThrow(/positive integer limit or Infinity/); + }); + + it.each([ + ['fileSize', { fileSize: -1 }], + ['fileSize', { fileSize: 1.5 }], ['line', { line: -1 }], ['line', { line: 1.5 }], ])('should reject invalid handle-bound %s', async (field, over) => { const fileHandle = {} as import('node:fs/promises').FileHandle; - const stats = { size: 300_000 } as import('node:fs').Stats; await expect( fileSystem.readTextFileFromHandle({ - path: '/test/large.txt', fileHandle, - stats, + fileSize: 300_000, limit: 1, maxOutputBytes: 262_144, + maxScanBytes: 8 * 1024 * 1024, ...over, }), ).rejects.toThrow(new RegExp(field)); - expect(readTextRange).not.toHaveBeenCalled(); }); + it.each([ + ['maxOutputBytes', { maxOutputBytes: Number.POSITIVE_INFINITY }], + ['maxOutputBytes', { maxOutputBytes: 0 }], + ['maxSnapBytes', { maxSnapBytes: Number.POSITIVE_INFINITY }], + ['maxSnapBytes', { maxSnapBytes: 0 }], + ])('should reject invalid cursor-bound %s', async (bound, over) => { + const fileHandle = {} as import('node:fs/promises').FileHandle; + + await expect( + fileSystem.readTextCursorFromHandle({ + fileHandle, + startOffset: 0, + fileSize: 300_000, + limit: 20, + maxOutputBytes: 262_144, + maxSnapBytes: 8 * 1024 * 1024, + ...over, + }), + ).rejects.toThrow(new RegExp(`positive finite ${bound}`)); + }); + + it.each([ + ['startOffset', { startOffset: -1 }], + ['startOffset', { startOffset: 1.5 }], + ['fileSize', { fileSize: -1 }], + ['fileSize', { fileSize: 1.5 }], + ])('should reject invalid cursor-bound %s', async (field, over) => { + const fileHandle = {} as import('node:fs/promises').FileHandle; + + await expect( + fileSystem.readTextCursorFromHandle({ + fileHandle, + startOffset: 0, + fileSize: 300_000, + limit: 20, + maxOutputBytes: 262_144, + maxSnapBytes: 8 * 1024 * 1024, + ...over, + }), + ).rejects.toThrow(new RegExp(field)); + }); + + it.each([2.5, 0, -1])( + 'should reject invalid cursor-bound limit %s', + async (limit) => { + const fileHandle = {} as import('node:fs/promises').FileHandle; + + await expect( + fileSystem.readTextCursorFromHandle({ + fileHandle, + startOffset: 0, + fileSize: 300_000, + limit, + maxOutputBytes: 262_144, + maxSnapBytes: 8 * 1024 * 1024, + }), + ).rejects.toThrow(/positive integer limit/); + }, + ); + it('should return encoding info for GBK file', async () => { vi.mocked(readFileWithLineAndLimit).mockResolvedValue({ content: '你好世界', diff --git a/packages/core/src/services/fileSystemService.ts b/packages/core/src/services/fileSystemService.ts index e2b24af819..de7f56eb6b 100644 --- a/packages/core/src/services/fileSystemService.ts +++ b/packages/core/src/services/fileSystemService.ts @@ -12,8 +12,9 @@ import { globSync } from 'glob'; import { atomicWriteFile } from '../utils/atomicFileWrite.js'; import { readFileWithLineAndLimit } from '../utils/fileUtils.js'; import { - readTextRange, - type ReadTextRangeResult, + readTextCursorWindowFromHandle, + readTextRangeFromHandle, + type ReadTextCursorWindowResult, } from '../utils/read-text-range.js'; import { isUtf8CompatibleEncoding } from '../utils/encoding.js'; import { loadIconvLite, type IconvLite } from '../utils/load-iconv-lite.js'; @@ -35,6 +36,8 @@ export type ReadTextFileResponse = { originalLineCountExact?: boolean; lineEnding?: LineEnding; truncatedByBytes?: boolean; + /** Byte offset to resume from; absent once the read reached EOF. */ + nextByteOffset?: number; }; }; @@ -54,18 +57,47 @@ export type CoreReadTextFileRequest = Omit< /** * Handle-bound range read used by filesystem security boundaries. The caller - * owns the handle lifecycle and must pass the Stats captured from that handle. + * opens the descriptor, keeps it open for the duration, and closes it; this + * request never transfers ownership. + * + * Declared standalone rather than derived from {@link CoreReadTextFileRequest}: + * a handle-bound read shares only `line` and `signal` with a path-bound one, so + * an `Omit` chain would strip more than it kept and would keep re-admitting + * fields that this path has no use for. `fileSize` is the one value retained + * from the descriptor's opening stat because it bounds reads against appends. + * + * Both byte bounds are required rather than optional: what makes a large-file + * read safe at a boundary is that the *returned* bytes and the *scanned* bytes + * are each capped. A finite `limit` is not one of those bounds — `limit: 20` at + * `line: 900_000_000` still walks the whole file — so it stays optional and + * `maxScanBytes` is what actually keeps the read affordable. */ -export type CoreReadTextFileHandleRequest = Omit< - CoreReadTextFileRequest, - 'limit' | 'line' | 'maxOutputBytes' | 'stats' -> & { +export interface CoreReadTextFileHandleRequest { fileHandle: FileHandle; - stats: Stats; - line?: number; - limit: number; + /** File size captured from the opened descriptor before reading. */ + fileSize: number; + /** 0-based start line, matching {@link CoreReadTextFileRequest}. */ + line?: number | null; + limit?: number; maxOutputBytes: number; -}; + maxScanBytes: number; + signal?: AbortSignal; +} + +/** + * Byte-cursor read used by filesystem security boundaries to page text without + * re-scanning from byte 0. Same borrowed-descriptor contract as + * {@link CoreReadTextFileHandleRequest}. + */ +export interface CoreReadTextCursorRequest { + fileHandle: FileHandle; + startOffset: number; + fileSize: number; + limit?: number; + maxOutputBytes: number; + maxSnapBytes: number; + signal?: AbortSignal; +} /** * Supported file encodings for new files. @@ -320,24 +352,15 @@ export class StandardFileSystemService implements FileSystemService { async readTextFile( params: CoreReadTextFileRequest, ): Promise { - 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 } : {}), - }); - return toReadTextFileResponse(readResult); + return readTextFileStandard(params); } async readTextFileFromHandle( params: CoreReadTextFileHandleRequest, ): Promise { - if (!isPositiveSafeInteger(params.limit)) { + if (!Number.isSafeInteger(params.fileSize) || params.fileSize < 0) { throw new RangeError( - `handle-bound text reads require a positive finite limit, got ${params.limit}`, + `handle-bound text reads require a non-negative integer fileSize, got ${params.fileSize}`, ); } if (!isPositiveSafeInteger(params.maxOutputBytes)) { @@ -345,24 +368,76 @@ export class StandardFileSystemService implements FileSystemService { `handle-bound text reads require a positive finite maxOutputBytes, got ${params.maxOutputBytes}`, ); } + if (!isPositiveSafeInteger(params.maxScanBytes)) { + throw new RangeError( + `handle-bound text reads require a positive finite maxScanBytes, got ${params.maxScanBytes}`, + ); + } + if ( + params.limit !== undefined && + params.limit !== Number.POSITIVE_INFINITY && + !isPositiveSafeInteger(params.limit) + ) { + throw new RangeError( + `handle-bound text reads require a positive integer limit or Infinity, got ${params.limit}`, + ); + } if ( params.line !== undefined && + params.line !== null && (!Number.isSafeInteger(params.line) || params.line < 0) ) { throw new RangeError( `handle-bound text reads require a non-negative integer line, got ${params.line}`, ); } - const readResult = await readTextRange({ - path: params.path, - fileHandle: params.fileHandle, - stats: params.stats, - limit: params.limit, + const range = await readTextRangeFromHandle(params.fileHandle, { + offset: params.line ?? 0, + limit: params.limit ?? Number.POSITIVE_INFINITY, + fileSize: params.fileSize, maxOutputBytes: params.maxOutputBytes, - ...(params.line !== undefined ? { offset: params.line } : {}), + maxScanBytes: params.maxScanBytes, + ...(params.signal !== undefined ? { signal: params.signal } : {}), + }); + return toReadTextFileResponse(range); + } + + async readTextCursorFromHandle( + params: CoreReadTextCursorRequest, + ): Promise { + if (!isPositiveSafeInteger(params.maxOutputBytes)) { + throw new RangeError( + `cursor reads require a positive finite maxOutputBytes, got ${params.maxOutputBytes}`, + ); + } + if (!isPositiveSafeInteger(params.maxSnapBytes)) { + throw new RangeError( + `cursor reads require a positive finite maxSnapBytes, got ${params.maxSnapBytes}`, + ); + } + if ( + !Number.isSafeInteger(params.startOffset) || + params.startOffset < 0 || + !Number.isSafeInteger(params.fileSize) || + params.fileSize < 0 + ) { + throw new RangeError( + `cursor reads require non-negative integer startOffset and fileSize, got ${params.startOffset}/${params.fileSize}`, + ); + } + if (params.limit !== undefined && !isPositiveSafeInteger(params.limit)) { + throw new RangeError( + `cursor reads require a positive integer limit, got ${params.limit}`, + ); + } + return readTextCursorWindowFromHandle(params.fileHandle, { + startOffset: params.startOffset, + fileSize: params.fileSize, + maxOutputBytes: params.maxOutputBytes, + maxSnapBytes: params.maxSnapBytes, + ...(params.limit !== undefined ? { limit: params.limit } : {}), ...(params.signal !== undefined ? { signal: params.signal } : {}), }); - return toReadTextFileResponse(readResult); } async writeTextFile( @@ -399,11 +474,32 @@ function isPositiveSafeInteger(value: unknown): value is number { return typeof value === 'number' && Number.isSafeInteger(value) && value >= 1; } -function toReadTextFileResponse( - readResult: - | Awaited> - | ReadTextRangeResult, -): ReadTextFileResponse { +async function readTextFileStandard( + params: CoreReadTextFileRequest, +): Promise { + 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 } : {}), + }); + return toReadTextFileResponse(readResult); +} + +/** Shared metadata shaping so both read paths report identically. */ +function toReadTextFileResponse(readResult: { + content: string; + bom?: boolean; + encoding?: string; + originalLineCount: number; + originalLineCountExact?: boolean; + lineEnding?: LineEnding; + truncatedByBytes?: boolean; + nextByteOffset?: number; +}): ReadTextFileResponse { const detectedLineEnding = readResult.lineEnding ?? detectLineEnding(readResult.content); return { @@ -417,6 +513,9 @@ function toReadTextFileResponse( ...(readResult.truncatedByBytes !== undefined ? { truncatedByBytes: readResult.truncatedByBytes } : {}), + ...(readResult.nextByteOffset !== undefined + ? { nextByteOffset: readResult.nextByteOffset } + : {}), }, }; } diff --git a/packages/core/src/utils/fileUtils.ts b/packages/core/src/utils/fileUtils.ts index 4ca6f020bc..809bdf0887 100644 --- a/packages/core/src/utils/fileUtils.ts +++ b/packages/core/src/utils/fileUtils.ts @@ -385,11 +385,21 @@ export async function readFileWithLineAndLimit(params: { * Detect the encoding of a file by reading a sample from its beginning. * Returns the encoding name (e.g. 'utf-8', 'gbk', 'shift_jis'). * Uses BOM detection first, then UTF-8 validation, then chardet as fallback. + * + * Accepts an already-open handle so a caller that has pinned an inode can be + * told the encoding of *that* inode rather than of whatever the path resolves + * to now. A supplied handle is borrowed: reads go through explicit positions so + * the caller's file position is untouched, and it is never closed here. */ -export async function detectFileEncoding(filePath: string): Promise { - let fh: fs.promises.FileHandle | null = null; +export async function detectFileEncoding( + source: string | fs.promises.FileHandle, +): Promise { + let opened: fs.promises.FileHandle | null = null; try { - fh = await fs.promises.open(filePath, 'r'); + const fh = + typeof source === 'string' + ? (opened = await fs.promises.open(source, 'r')) + : source; const stats = await fh.stat(); if (stats.size === 0) return 'utf-8'; @@ -402,22 +412,7 @@ export async function detectFileEncoding(filePath: string): Promise { // 1. Check for BOM const bom = detectBOM(sample); - if (bom) { - switch (bom.encoding) { - case 'utf8': - return 'utf-8'; - case 'utf16le': - return 'utf-16le'; - case 'utf16be': - return 'utf-16be'; - case 'utf32le': - return 'utf-32le'; - case 'utf32be': - return 'utf-32be'; - default: - return 'utf-8'; - } - } + if (bom) return bomEncodingToName(bom.encoding); // 2. Validate UTF-8 if (isValidUtf8(sample)) return 'utf-8'; @@ -433,9 +428,10 @@ export async function detectFileEncoding(filePath: string): Promise { // If file can't be read, default to UTF-8 return 'utf-8'; } finally { - if (fh) { + // Only what we opened. A borrowed handle outlives this call. + if (opened) { try { - await fh.close(); + await opened.close(); } catch { // Ignore close errors } diff --git a/packages/core/src/utils/read-text-range.test.ts b/packages/core/src/utils/read-text-range.test.ts index 2079ce36c5..2789682fcf 100644 --- a/packages/core/src/utils/read-text-range.test.ts +++ b/packages/core/src/utils/read-text-range.test.ts @@ -9,7 +9,13 @@ 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'; +import { + CursorNotAtLineBoundaryError, + LargeNonUtf8TextError, + readTextCursorWindowFromHandle, + readTextRange, + readTextRangeFromHandle, +} from './read-text-range.js'; describe('readTextRange', () => { let tempDir: string; @@ -97,13 +103,12 @@ describe('readTextRange', () => { const readSpy = vi.spyOn(fileHandle, 'read'); try { const stats = await fileHandle.stat(); - const result = await readTextRange({ - path: filePath, - fileHandle, - stats, + const result = await readTextRangeFromHandle(fileHandle, { offset: 1_500, limit: 3, + fileSize: stats.size, maxOutputBytes: 10_000, + maxScanBytes: Number.MAX_SAFE_INTEGER, }); expect(result.content.split('\n')).toEqual([ @@ -113,13 +118,12 @@ describe('readTextRange', () => { ]); expect(result.originalLineCountExact).toBe(false); - const beyondEof = await readTextRange({ - path: filePath, - fileHandle, - stats, + const beyondEof = await readTextRangeFromHandle(fileHandle, { offset: 10_000, limit: 3, + fileSize: stats.size, maxOutputBytes: 10_000, + maxScanBytes: Number.MAX_SAFE_INTEGER, }); expect(beyondEof.content).toBe(''); expect(beyondEof.originalLineCount).toBe(2_000); @@ -144,9 +148,7 @@ describe('readTextRange', () => { let appended = false; const streamBuffers: Buffer[] = []; const streamReads: Array<{ position: number; length: number }> = []; - const stats = { size: original.length } as import('node:fs').Stats; const fileHandle = { - stat: vi.fn(async () => stats), read: vi.fn( async ( buffer: Buffer, @@ -173,13 +175,12 @@ describe('readTextRange', () => { ), } as unknown as import('node:fs/promises').FileHandle; - const result = await readTextRange({ - path: '/snapshot.log', - fileHandle, - stats, + const result = await readTextRangeFromHandle(fileHandle, { offset: 7_000, limit: 1, + fileSize: original.length, maxOutputBytes: 10_000, + maxScanBytes: Number.MAX_SAFE_INTEGER, }); expect(result.content).toBe(''); @@ -204,13 +205,12 @@ describe('readTextRange', () => { const fileHandle = await fs.open(filePath, 'r'); try { const stats = await fileHandle.stat(); - const result = await readTextRange({ - path: filePath, - fileHandle, - stats, + const result = await readTextRangeFromHandle(fileHandle, { offset: 60_000, limit: 3, + fileSize: stats.size, maxOutputBytes: 256, + maxScanBytes: Number.MAX_SAFE_INTEGER, }); expect(result.content).toMatch(/^line-60001 /); @@ -235,13 +235,12 @@ describe('readTextRange', () => { const fileHandle = await fs.open(filePath, 'r'); try { const stats = await fileHandle.stat(); - const result = await readTextRange({ - path: filePath, - fileHandle, - stats, + const result = await readTextRangeFromHandle(fileHandle, { offset: 0, limit: 1, + fileSize: stats.size, maxOutputBytes: 16_384, + maxScanBytes: Number.MAX_SAFE_INTEGER, }); expect(result.content).toBe(firstLine); @@ -280,13 +279,12 @@ describe('readTextRange', () => { } as unknown as import('node:fs/promises').FileHandle; try { - const result = await readTextRange({ - path: filePath, - fileHandle: boundedHandle, - stats, + const result = await readTextRangeFromHandle(boundedHandle, { offset: 1, limit: 2, + fileSize: stats.size, maxOutputBytes: 100_000, + maxScanBytes: Number.MAX_SAFE_INTEGER, }); expect(appended).toBe(true); @@ -302,8 +300,8 @@ describe('readTextRange', () => { } }); - it('uses only the supplied file handle when the path names another file', async () => { - const originalPath = await writeFile( + it('reads the pinned inode after the path is replaced underneath it', async () => { + const targetPath = await writeFile( 'original.log', 'safe-one\nsafe-two\nsafe-three\n', ); @@ -311,15 +309,17 @@ describe('readTextRange', () => { 'replacement.log', 'secret-one\nsecret-two\n', ); - const fileHandle = await fs.open(originalPath, 'r'); + const fileHandle = await fs.open(targetPath, 'r'); try { - const result = await readTextRange({ - path: replacementPath, - fileHandle, - stats: await fileHandle.stat(), + const stats = await fileHandle.stat(); + await fs.rename(replacementPath, targetPath); + + const result = await readTextRangeFromHandle(fileHandle, { offset: 0, limit: 2, + fileSize: stats.size, maxOutputBytes: 1_024, + maxScanBytes: Number.MAX_SAFE_INTEGER, }); expect(result.content).toBe('safe-one\nsafe-two'); @@ -329,6 +329,68 @@ describe('readTextRange', () => { } }); + it('refuses a line offset that cannot be reached within maxScanBytes', async () => { + const filePath = await writeFile('budget.log', largeUtf8Lines(5_000)); + + await expect( + readTextRange({ + path: filePath, + offset: 4_000, + limit: 20, + maxOutputBytes: 262_144, + maxScanBytes: 100_000, + }), + ).rejects.toMatchObject({ + name: 'TextScanBudgetExceededError', + scannedBytes: 100_000, + maxScanBytes: 100_000, + }); + }); + + it('serves a shallow window from a file far larger than maxScanBytes', async () => { + const filePath = await writeFile('budget-head.log', largeUtf8Lines(5_000)); + + const result = await readTextRange({ + path: filePath, + offset: 0, + limit: 3, + maxOutputBytes: 262_144, + maxScanBytes: 100_000, + }); + + expect(result.content.split('\n')).toEqual([ + expect.stringContaining('line-1 '), + expect.stringContaining('line-2 '), + expect.stringContaining('line-3 '), + ]); + }); + + it('does not charge a budget failure to a file that ends within it', async () => { + // The scan reaches EOF on the same chunk that exhausts the budget; the + // window was fully satisfied, so there is nothing to refuse. + // Goes through the handle variant purely because that is the one that + // always streams: this file is far too small to leave the path variant's + // buffering fast path, and the buffered path never consults the budget. + const body = largeUtf8Lines(100); + const filePath = await writeFile('budget-exact.log', body); + const fileHandle = await fs.open(filePath, 'r'); + + const result = await readTextRangeFromHandle(fileHandle, { + offset: 98, + limit: 10, + fileSize: Buffer.byteLength(body), + maxOutputBytes: 262_144, + maxScanBytes: Buffer.byteLength(body), + }).finally(() => fileHandle.close()); + + expect(result.content.split('\n')).toEqual([ + expect.stringContaining('line-99 '), + expect.stringContaining('line-100 '), + ]); + expect(result.originalLineCount).toBe(100); + expect(result.originalLineCountExact).toBe(true); + }); + 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)); @@ -404,6 +466,43 @@ describe('readTextRange', () => { expect(result.content).toContain('\r\nsecond'); }); + it('reports the next byte offset when a skipped line spans chunks', async () => { + const firstLine = 'a'.repeat(512 * 1024 + 10); + const body = `${firstLine}\nsecond\nthird`; + const filePath = await writeFile('split-line-offset.log', body); + const fileHandle = await fs.open(filePath, 'r'); + + const result = await readTextRangeFromHandle(fileHandle, { + offset: 1, + limit: 1, + fileSize: Buffer.byteLength(body), + maxOutputBytes: 1_024, + maxScanBytes: Buffer.byteLength(body), + }).finally(() => fileHandle.close()); + + expect(result.content).toBe('second'); + expect(result.nextByteOffset).toBe( + Buffer.byteLength(`${firstLine}\nsecond\n`), + ); + }); + + it('does not report a cursor at EOF when the limit ends on the final newline', async () => { + const body = 'first\nsecond\n'; + const filePath = await writeFile('exact-page.log', body); + const fileHandle = await fs.open(filePath, 'r'); + + const result = await readTextRangeFromHandle(fileHandle, { + offset: 0, + limit: 2, + fileSize: Buffer.byteLength(body), + maxOutputBytes: 1_024, + maxScanBytes: Buffer.byteLength(body), + }).finally(() => fileHandle.close()); + + expect(result.content).toBe('first\nsecond'); + expect(result.nextByteOffset).toBeUndefined(); + }); + it('strips UTF-8 BOM from large file content and reports BOM metadata', async () => { const body = largeUtf8Lines(65_000); const filePath = await writeFile( @@ -530,3 +629,369 @@ describe('readTextRange', () => { await expect(promise).rejects.toThrow(/abort/i); }); }); + +describe('readTextCursorWindowFromHandle', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'text-cursor-')); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + async function withHandle( + name: string, + data: string | Buffer, + run: (fh: fs.FileHandle, size: number) => Promise, + ): Promise { + const filePath = path.join(tempDir, name); + await fs.writeFile(filePath, data); + const size = (await fs.stat(filePath)).size; + const fh = await fs.open(filePath, 'r'); + try { + return await run(fh, size); + } finally { + await fh.close(); + } + } + + /** Page to exhaustion, returning the pages and the byte spans they covered. */ + async function pageAll( + fh: fs.FileHandle, + fileSize: number, + opts: { limit?: number; maxOutputBytes?: number } = {}, + ): Promise<{ pages: string[]; spans: Array<[number, number]> }> { + const pages: string[] = []; + const spans: Array<[number, number]> = []; + let offset = 0; + for (let guard = 0; guard < 10_000; guard++) { + const page = await readTextCursorWindowFromHandle(fh, { + startOffset: offset, + fileSize, + limit: opts.limit ?? 3, + maxOutputBytes: opts.maxOutputBytes ?? 262_144, + maxSnapBytes: 1_048_576, + }); + pages.push(page.content); + spans.push([page.startOffset, page.nextOffset ?? fileSize]); + if (page.nextOffset === undefined) return { pages, spans }; + expect(page.nextOffset).toBeGreaterThan(offset); + offset = page.nextOffset; + } + throw new Error('paging did not terminate'); + } + + it('reconstructs the file exactly from the spans it reports', async () => { + const body = Array.from( + { length: 200 }, + (_, i) => `line-${i} ${'x'.repeat(i % 40)}`, + ).join('\n'); + await withHandle('span.log', body, async (fh, size) => { + const { spans } = await pageAll(fh, size); + // Spans must tile [0, size) with no gap and no overlap. + expect(spans[0][0]).toBe(0); + for (let i = 1; i < spans.length; i++) { + expect(spans[i][0]).toBe(spans[i - 1][1]); + } + expect(spans[spans.length - 1][1]).toBe(size); + + const raw = await fs.readFile(path.join(tempDir, 'span.log')); + const rebuilt = spans + .map(([from, to]) => raw.subarray(from, to).toString('utf8')) + .join(''); + expect(rebuilt).toBe(body); + }); + }); + + it('round-trips content when pages are rejoined with a newline', async () => { + // No trailing newline: `content` drops the terminator of its last line, + // matching the line-addressed readers, so a page boundary that lands + // exactly on EOF would otherwise swallow the file's final newline. Byte + // spans, asserted above, are the lossless reassembly path. + const body = 'alpha\nbeta\ngamma\ndelta'; + await withHandle('join.log', body, async (fh, size) => { + const { pages } = await pageAll(fh, size, { limit: 2 }); + expect(pages.join('\n')).toBe(body); + }); + }); + + it('preserves a trailing newline as split semantics do', async () => { + await withHandle('trailing.log', 'a\nb\n', async (fh, size) => { + const page = await readTextCursorWindowFromHandle(fh, { + startOffset: 0, + fileSize: size, + maxOutputBytes: 1_024, + maxSnapBytes: 1_024, + }); + expect(page.content).toBe('a\nb\n'); + expect(page.nextOffset).toBeUndefined(); + }); + }); + + it('snaps a mid-line offset forward to the next line start', async () => { + await withHandle('snap.log', 'alpha\nbeta\ngamma\n', async (fh, size) => { + const page = await readTextCursorWindowFromHandle(fh, { + startOffset: 2, // inside "alpha" + fileSize: size, + limit: 1, + maxOutputBytes: 1_024, + maxSnapBytes: 1_024, + }); + expect(page.startOffset).toBe(6); + expect(page.content).toBe('beta'); + }); + }); + + it('refuses a mid-line offset when no line break is within maxSnapBytes', async () => { + await withHandle('one-line.log', 'x'.repeat(5_000), async (fh, size) => { + await expect( + readTextCursorWindowFromHandle(fh, { + startOffset: 10, + fileSize: size, + maxOutputBytes: 1_024, + maxSnapBytes: 64, + }), + ).rejects.toBeInstanceOf(CursorNotAtLineBoundaryError); + }); + }); + + it('makes progress when a single line exceeds maxOutputBytes', async () => { + const body = `${'y'.repeat(5_000)}\ntail\n`; + await withHandle('long-line.log', body, async (fh, size) => { + const first = await readTextCursorWindowFromHandle(fh, { + startOffset: 0, + fileSize: size, + maxOutputBytes: 100, + maxSnapBytes: 1_024, + }); + expect(first.truncatedByBytes).toBe(true); + expect(first.content).toBe('y'.repeat(100)); + // The cursor skips to the start of the *next* line rather than stopping + // mid-line. Resuming mid-line would make the following call snap forward + // and silently drop the rest of this line at the page seam; skipping it + // here loses the same bytes but says so via `truncatedByBytes`. + expect(first.nextOffset).toBe(5_001); + + const second = await readTextCursorWindowFromHandle(fh, { + startOffset: first.nextOffset!, + fileSize: size, + maxOutputBytes: 1_024, + maxSnapBytes: 1_024, + }); + expect(second.startOffset).toBe(5_001); + expect(second.content).toBe('tail\n'); + }); + }); + + it('stops decoding an oversized line before reading the next full chunk', async () => { + const body = `${'z'.repeat(2 * 1024 * 1024)}\ntail\n`; + await withHandle('bounded-line.log', body, async (fh, size) => { + const readSpy = vi.spyOn(fh, 'read'); + const page = await readTextCursorWindowFromHandle(fh, { + startOffset: 0, + fileSize: size, + maxOutputBytes: 100, + maxSnapBytes: 1_024, + }); + + expect(page.content).toBe('z'.repeat(100)); + expect(page.nextOffset).toBe(2 * 1024 * 1024 + 1); + const readCalls = readSpy.mock.calls as unknown as ReadonlyArray< + readonly unknown[] + >; + expect(readCalls.map((call) => call[3])).not.toContain(512 * 1024); + }); + }); + + it('mints only line-start cursors, so paging never straddles a line', async () => { + const body = `${'q'.repeat(300)}\nshort\n`; + await withHandle('seam.log', body, async (fh, size) => { + let offset = 0; + const starts: number[] = []; + for (let i = 0; i < 10; i++) { + const page = await readTextCursorWindowFromHandle(fh, { + startOffset: offset, + fileSize: size, + maxOutputBytes: 40, + maxSnapBytes: 4_096, + }); + starts.push(page.startOffset); + // A cursor that already points at a line start needs no snapping, so + // the reader begins exactly where it was told to. + expect(page.startOffset).toBe(offset); + if (page.nextOffset === undefined) break; + offset = page.nextOffset; + } + expect(starts).toEqual([0, 301]); + }); + }); + + it('does not split a multibyte character when truncating', async () => { + await withHandle( + 'multibyte.log', + `${'中'.repeat(50)}\n`, + async (fh, size) => { + const page = await readTextCursorWindowFromHandle(fh, { + startOffset: 0, + fileSize: size, + maxOutputBytes: 7, // two 3-byte chars fit, the third does not + maxSnapBytes: 1_024, + }); + expect(page.content).toBe('中中'); + expect(page.content).not.toContain('\uFFFD'); + expect(page.truncatedByBytes).toBe(true); + // The file is a single line, so skipping its dropped remainder lands at + // EOF and there is no next page. + expect(page.nextOffset).toBeUndefined(); + }, + ); + }); + + it('advances when no multibyte character fits in maxOutputBytes', async () => { + await withHandle('tiny-budget.log', '中\nnext\n', async (fh, size) => { + const first = await readTextCursorWindowFromHandle(fh, { + startOffset: 0, + fileSize: size, + maxOutputBytes: 1, + maxSnapBytes: 1_024, + }); + expect(first.content).toBe(''); + expect(first.truncatedByBytes).toBe(true); + expect(first.nextOffset).toBe(Buffer.byteLength('中\n')); + expect(first.nextOffset).toBeGreaterThan(first.startOffset); + + const second = await readTextCursorWindowFromHandle(fh, { + startOffset: first.nextOffset!, + fileSize: size, + maxOutputBytes: 1_024, + maxSnapBytes: 1_024, + }); + expect(second.content).toBe('next\n'); + }); + }); + + it('ends paging after truncating a final line without a newline', async () => { + await withHandle( + 'unterminated-long-line.log', + 'x'.repeat(5_000), + async (fh, size) => { + const page = await readTextCursorWindowFromHandle(fh, { + startOffset: 0, + fileSize: size, + maxOutputBytes: 100, + maxSnapBytes: 1_024, + }); + expect(page.content).toBe('x'.repeat(100)); + expect(page.truncatedByBytes).toBe(true); + expect(page.nextOffset).toBeUndefined(); + }, + ); + }); + + it('reports the BOM and keeps offsets absolute across pages', async () => { + const body = Buffer.concat([ + Buffer.from([0xef, 0xbb, 0xbf]), + Buffer.from('one\ntwo\nthree\n'), + ]); + await withHandle('bom.log', body, async (fh, size) => { + const first = await readTextCursorWindowFromHandle(fh, { + startOffset: 0, + fileSize: size, + limit: 1, + maxOutputBytes: 1_024, + maxSnapBytes: 1_024, + }); + expect(first.bom).toBe(true); + expect(first.content).toBe('one'); + // 3 BOM bytes + "one\n" + expect(first.nextOffset).toBe(7); + + const second = await readTextCursorWindowFromHandle(fh, { + startOffset: first.nextOffset!, + fileSize: size, + limit: 1, + maxOutputBytes: 1_024, + maxSnapBytes: 1_024, + }); + expect(second.bom).toBe(true); + expect(second.content).toBe('two'); + }); + }); + + it('keeps CRLF terminators in the returned text', async () => { + await withHandle('crlf.log', 'one\r\ntwo\r\n', async (fh, size) => { + const page = await readTextCursorWindowFromHandle(fh, { + startOffset: 0, + fileSize: size, + limit: 1, + maxOutputBytes: 1_024, + maxSnapBytes: 1_024, + }); + expect(page.content).toBe('one\r'); + expect(page.lineEnding).toBe('crlf'); + expect(page.nextOffset).toBe(5); + }); + }); + + it('does not let a budget-excluded CRLF line flip lineEnding', async () => { + // "aaa" (3) + sep (1) + "bbb" (3) = 7 <= 8; "ccc\r" would need 7+1+4 = 12 > 8. + await withHandle( + 'crlf-budget.log', + 'aaa\nbbb\nccc\r\n', + async (fh, size) => { + const page = await readTextCursorWindowFromHandle(fh, { + startOffset: 0, + fileSize: size, + maxOutputBytes: 8, + maxSnapBytes: 1_024, + }); + expect(page.content).toBe('aaa\nbbb'); + expect(page.lineEnding).toBe('lf'); + expect(page.nextOffset).toBe(8); + }, + ); + }); + + it('returns nothing for an offset at or past EOF', async () => { + await withHandle('eof.log', 'a\nb\n', async (fh, size) => { + const page = await readTextCursorWindowFromHandle(fh, { + startOffset: size, + fileSize: size, + maxOutputBytes: 1_024, + maxSnapBytes: 1_024, + }); + expect(page.content).toBe(''); + expect(page.nextOffset).toBeUndefined(); + }); + }); + + it('refuses a non-UTF-8 file', async () => { + const gbk = iconvEncode('中文日志\n'.repeat(100), 'gbk'); + await withHandle('gbk.log', gbk, async (fh, size) => { + await expect( + readTextCursorWindowFromHandle(fh, { + startOffset: 0, + fileSize: size, + maxOutputBytes: 1_024, + maxSnapBytes: 1_024, + }), + ).rejects.toBeInstanceOf(LargeNonUtf8TextError); + }); + }); + + it('pages a file larger than one read chunk', async () => { + // Forces lines to span chunk boundaries (chunks are 512 KiB). + const body = Array.from( + { length: 20_000 }, + (_, i) => `row-${i} ${'z'.repeat(60)}`, + ).join('\n'); + await withHandle('big.log', body, async (fh, size) => { + expect(size).toBeGreaterThan(1024 * 1024); + const { pages, spans } = await pageAll(fh, size, { limit: 500 }); + expect(spans[spans.length - 1][1]).toBe(size); + expect(pages.join('\n')).toBe(body); + }); + }); +}); diff --git a/packages/core/src/utils/read-text-range.ts b/packages/core/src/utils/read-text-range.ts index fa6189297e..d341bfdfb6 100644 --- a/packages/core/src/utils/read-text-range.ts +++ b/packages/core/src/utils/read-text-range.ts @@ -7,11 +7,7 @@ import { createReadStream, type Stats } from 'node:fs'; import { stat, type FileHandle } from 'node:fs/promises'; import { TextDecoder } from 'node:util'; -import { - decodeBufferWithEncodingInfoAsync, - detectFileEncoding, - readFileWithEncodingInfo, -} from './fileUtils.js'; +import { detectFileEncoding, readFileWithEncodingInfo } from './fileUtils.js'; import { isUtf8CompatibleEncoding } from './encoding.js'; import { DEFAULT_RANGE_READ_BYTES, @@ -26,16 +22,43 @@ export interface ReadTextRangeRequest { signal?: AbortSignal; stats?: Stats; /** - * Optional caller-owned handle. When present, every read is bound to this - * already-open inode and uses the streaming path; this function never closes - * the handle. + * Upper bound on bytes read off disk while locating the requested window. + * Line offsets address a byte stream, so a deep `offset` costs a scan from + * byte 0 — this is what keeps that scan from being unbounded. Defaults to + * `Infinity` so non-boundary callers (the `read_file` tool) are unchanged; + * security boundaries must pass a finite value. */ - fileHandle?: FileHandle; + maxScanBytes?: number; +} + +/** + * Request shape for {@link readTextRangeFromHandle}. + * + * No `path`: the read is bound to the descriptor, so there is nothing for a + * path to disambiguate. Both byte bounds are required rather than optional — + * a handle-bound read exists because some caller pinned an inode at a security + * boundary, and what makes such a read safe is that the bytes it *returns* and + * the bytes it *scans* are each capped. + */ +export interface ReadTextRangeFromHandleRequest { + offset?: number; + limit?: number; + /** Upper bound captured from the opened descriptor before reading. */ + fileSize: number; + maxOutputBytes: number; + maxScanBytes: number; + signal?: AbortSignal; } export interface ReadTextRangeResult { content: string; originalLineCount: number; + /** + * Byte offset just past the last line the scanner passed, or `undefined` if + * the stream reached EOF. Lets a line-addressed read hand back a byte cursor + * so the *next* page costs O(1) instead of another scan from byte 0. + */ + nextByteOffset?: number; encoding?: string; bom?: boolean; lineEnding?: 'crlf' | 'lf'; @@ -43,6 +66,61 @@ export interface ReadTextRangeResult { truncatedByBytes: boolean; } +/** + * Request for {@link readTextCursorWindowFromHandle}. + * + * `startOffset` is a byte offset, which is the whole point: a line offset has + * to be resolved by scanning from byte 0, so paging by line is O(n²) across + * pages. A byte offset is O(1), and `maxScanBytes` therefore does not apply. + */ +export interface ReadTextCursorWindowRequest { + /** Byte offset to resume from. Expected to be the start of a line. */ + startOffset: number; + /** File size as of the caller's `fstat`; bounds and EOF are relative to it. */ + fileSize: number; + /** Maximum whole lines to return. */ + limit?: number; + maxOutputBytes: number; + /** + * Bound on the forward scan used to reach a line boundary when + * `startOffset` lands mid-line. A cursor this reader minted always points at + * a line start, so the snap is a single byte comparison; the bound only + * exists to stop a hand-written offset into a file with one enormous line + * from scanning without limit. + */ + maxSnapBytes: number; + signal?: AbortSignal; +} + +export interface ReadTextCursorWindowResult { + content: string; + /** Where reading actually began — differs from the request only after a snap. */ + startOffset: number; + /** Byte offset of the next unreturned line. Absent once the file is exhausted. */ + nextOffset?: number; + encoding: string; + bom: boolean; + lineEnding: 'crlf' | 'lf'; + truncatedByBytes: boolean; +} + +/** + * Raised when `startOffset` is not a line boundary and one cannot be reached + * within `maxSnapBytes`. A malformed request, not an oversized file — the + * caller supplied an offset this reader never would have. + */ +export class CursorNotAtLineBoundaryError extends Error { + constructor( + readonly startOffset: number, + readonly maxSnapBytes: number, + ) { + super( + `Byte offset ${startOffset} is not the start of a line, and no line break was found within ${maxSnapBytes} bytes after it. Resume from a cursor this reader returned.`, + ); + this.name = 'CursorNotAtLineBoundaryError'; + } +} + export class LargeNonUtf8TextError extends Error { constructor( readonly encoding: string, @@ -57,20 +135,39 @@ export class LargeNonUtf8TextError extends Error { } } +/** + * Raised when locating the requested line window would require reading more + * than `maxScanBytes`. Distinct from `LargeNonUtf8TextError`: the file is + * readable, the *offset* is what cannot be reached affordably. + */ +export class TextScanBudgetExceededError extends Error { + constructor( + readonly scannedBytes: number, + readonly maxScanBytes: number, + ) { + super( + `Locating the requested line window would read more than ${maxScanBytes} bytes (line offsets are resolved by scanning from the start of the file). Use a byte-offset read to reach this part of the file.`, + ); + this.name = 'TextScanBudgetExceededError'; + } +} + export async function readTextRange( request: ReadTextRangeRequest, ): Promise { request.signal?.throwIfAborted(); - const stats = - request.stats ?? - (request.fileHandle !== undefined - ? await request.fileHandle.stat() - : await stat(request.path)); + const stats = request.stats ?? (await stat(request.path)); const maxOutputBytes = normalizeMaxBytes(request.maxOutputBytes); + const maxScanBytes = request.maxScanBytes ?? Number.POSITIVE_INFINITY; + // The fast path buffers the whole file, so it reads `stats.size` bytes no + // matter how small the window is — a budget that only constrained the + // streaming path would not be a budget. Falling through to streaming lets + // the same bound apply, and raises `TextScanBudgetExceededError` if the + // window really is out of reach. if ( - request.fileHandle === undefined && - stats.size < TEXT_RANGE_FAST_PATH_MAX_SIZE + stats.size < TEXT_RANGE_FAST_PATH_MAX_SIZE && + stats.size <= maxScanBytes ) { const { content, encoding, bom } = await readFileWithEncodingInfo( request.path, @@ -91,7 +188,275 @@ export async function readTextRange( }; } - return readLargeUtf8Range(request, maxOutputBytes, stats.size); + return readLargeUtf8Range( + request.path, + request, + maxOutputBytes, + maxScanBytes, + stats.size, + ); +} + +/** + * Range read bound to a caller-owned descriptor. + * + * Always streams: the buffering fast path would read the whole file, and a + * caller reaches for a handle precisely when it needs the read bounded. The + * handle is borrowed — every read uses an explicit position, and this function + * never closes it. + */ +export async function readTextRangeFromHandle( + fileHandle: FileHandle, + request: ReadTextRangeFromHandleRequest, +): Promise { + request.signal?.throwIfAborted(); + return readLargeUtf8Range( + fileHandle, + request, + normalizeMaxBytes(request.maxOutputBytes), + request.maxScanBytes, + request.fileSize, + ); +} + +/** + * Read whole lines starting at a byte offset, and report where the next line + * begins. + * + * This is the O(1)-per-page counterpart to the line-addressed readers: it seeks + * rather than counting newlines from byte 0, so paging a large log costs + * O(file) in total instead of O(file²). + */ +export async function readTextCursorWindowFromHandle( + fileHandle: FileHandle, + request: ReadTextCursorWindowRequest, +): Promise { + request.signal?.throwIfAborted(); + + // Same refusal as the streamed line path. Without it a large GBK file — which + // that path already rejects — would be byte-paged and decoded as UTF-8 + // garbage, which is worse than the error it replaces. + const encoding = await detectFileEncoding(fileHandle); + request.signal?.throwIfAborted(); + if (!isUtf8CompatibleEncoding(encoding)) { + throw new LargeNonUtf8TextError(encoding); + } + + const bom = await hasUtf8Bom(fileHandle, request.fileSize); + const maxOutputBytes = normalizeMaxBytes(request.maxOutputBytes); + const startOffset = await snapToLineStart(fileHandle, request); + + if (startOffset >= request.fileSize) { + return { + content: '', + startOffset, + encoding: 'utf-8', + bom, + lineEnding: 'lf', + truncatedByBytes: false, + }; + } + + const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); + const decode = (chunk?: Buffer, options?: TextDecodeOptions): string => { + try { + return decoder.decode(chunk, options); + } catch { + throw new LargeNonUtf8TextError(encoding, 'invalid-utf8'); + } + }; + + const lines: string[] = []; + let contentBytes = 0; + // Bytes of the file consumed, relative to `startOffset`. Counts the newline + // that terminates each emitted line, which `contentBytes` does not — the + // next page begins after that byte, but the returned text carries no + // trailing newline (matching the line-addressed readers). + let consumedBytes = 0; + let truncatedByBytes = false; + let stop = false; + let skipRestOfLine = false; + // A CRLF line arrives here as text ending in '\r' with the '\n' consumed as + // its terminator, so the returned text never contains the pair — testing + // `content` for '\r\n' would report 'lf' for every single-line page. + let sawCrlf = false; + + const emit = (line: string, hadNewline: boolean): void => { + const separator = lines.length > 0 ? 1 : 0; + const lineBytes = Buffer.byteLength(line, 'utf8'); + if (contentBytes + separator + lineBytes > maxOutputBytes) { + if (lines.length > 0) { + // Whole lines only: leave this one for the next page. + stop = true; + return; + } + // Except when the very first line does not fit. Emitting nothing would + // return an empty page whose cursor had not advanced, and a client + // following cursors would loop on it forever. + const cut = truncateUtf8(line, maxOutputBytes); + lines.push(cut.content); + if (hadNewline && line.endsWith('\r')) sawCrlf = true; + contentBytes = Buffer.byteLength(cut.content, 'utf8'); + consumedBytes += contentBytes; + truncatedByBytes = true; + // The rest of this line is dropped, and the cursor must skip it: every + // cursor this reader mints points at a line start, so that resuming + // never lands mid-line and quietly re-snaps over content. + skipRestOfLine = true; + stop = true; + return; + } + lines.push(line); + if (hadNewline && line.endsWith('\r')) sawCrlf = true; + contentBytes += separator + lineBytes; + consumedBytes += lineBytes + (hadNewline ? 1 : 0); + if (request.limit !== undefined && lines.length >= request.limit) { + stop = true; + } + }; + + let pending = ''; + let firstChunk = true; + let reachedEof = true; + + for await (const raw of chunksFromHandle( + fileHandle, + startOffset, + request.fileSize, + request.signal, + )) { + request.signal?.throwIfAborted(); + let text = decode(raw, { stream: true }); + if (firstChunk) { + firstChunk = false; + // A BOM only exists at byte 0, so it is only in our way when the window + // starts there. Charge its bytes to `consumedBytes` so offsets stay + // absolute even though the text drops it. + if (startOffset === 0 && text.charCodeAt(0) === 0xfeff) { + text = text.slice(1); + consumedBytes += UTF8_BOM_BYTES; + } + } + pending += text; + + let newline = pending.indexOf('\n'); + const pendingLine = newline === -1 ? pending : pending.slice(0, newline); + const pendingSeparator = lines.length > 0 ? 1 : 0; + if ( + contentBytes + pendingSeparator + Buffer.byteLength(pendingLine, 'utf8') > + maxOutputBytes + ) { + if (lines.length === 0) { + emit(pendingLine, newline !== -1); + } else { + stop = true; + } + reachedEof = false; + break; + } + while (newline !== -1) { + emit(pending.slice(0, newline), true); + pending = pending.slice(newline + 1); + if (stop) break; + newline = pending.indexOf('\n'); + } + if (stop) { + reachedEof = false; + break; + } + } + + if (reachedEof) { + decode(); + // `pending` is whatever followed the last newline, and under + // `split('\n')` semantics that is a line even when it is empty — which is + // how a file ending in a newline keeps it. + if (!stop) emit(pending, false); + } + + if (skipRestOfLine) { + const resumeAt = await snapToLineStart( + fileHandle, + { + ...request, + startOffset: startOffset + Math.max(consumedBytes, 1), + // This offset was produced internally after returning a truncated prefix, + // so it must advance past the rest of that line. `maxSnapBytes` protects + // only client-supplied offsets. + maxSnapBytes: request.fileSize, + }, + true, + ); + consumedBytes = resumeAt - startOffset; + } + + const content = lines.join('\n'); + const nextOffset = startOffset + consumedBytes; + return { + content, + startOffset, + ...(nextOffset < request.fileSize ? { nextOffset } : {}), + encoding: 'utf-8', + bom, + lineEnding: sawCrlf ? 'crlf' : 'lf', + truncatedByBytes, + }; +} + +const UTF8_BOM_BYTES = 3; + +/** Byte 0x0A never appears inside a multi-byte UTF-8 sequence. */ +const LINE_FEED = 0x0a; + +async function hasUtf8Bom( + fileHandle: FileHandle, + fileSize: number, +): Promise { + if (fileSize < UTF8_BOM_BYTES) return false; + const probe = Buffer.alloc(UTF8_BOM_BYTES); + const { bytesRead } = await fileHandle.read(probe, 0, UTF8_BOM_BYTES, 0); + return ( + bytesRead === UTF8_BOM_BYTES && + probe[0] === 0xef && + probe[1] === 0xbb && + probe[2] === 0xbf + ); +} + +/** + * Move `startOffset` forward to the beginning of a line. + * + * Searching the raw bytes for `0x0A` is safe without decoding: that byte + * cannot occur inside a multi-byte UTF-8 sequence, so a character split across + * the boundary cannot be mistaken for a line break. + */ +async function snapToLineStart( + fileHandle: FileHandle, + request: ReadTextCursorWindowRequest, + allowEof = false, +): Promise { + const { startOffset, fileSize, maxSnapBytes, signal } = request; + if (startOffset <= 0) return 0; + if (startOffset >= fileSize) return startOffset; + + const previous = Buffer.alloc(1); + const { bytesRead } = await fileHandle.read(previous, 0, 1, startOffset - 1); + if (bytesRead === 1 && previous[0] === LINE_FEED) return startOffset; + + let scanned = 0; + const snapEnd = Math.min(fileSize, startOffset + maxSnapBytes); + for await (const chunk of chunksFromHandle( + fileHandle, + startOffset, + snapEnd, + signal, + )) { + const index = chunk.indexOf(LINE_FEED); + if (index !== -1) return startOffset + scanned + index + 1; + scanned += chunk.length; + } + if (allowEof) return fileSize; + throw new CursorNotAtLineBoundaryError(startOffset, maxSnapBytes); } function normalizeMaxBytes(maxOutputBytes: number): number { @@ -135,18 +500,16 @@ function sliceDecodedContent( } async function readLargeUtf8Range( - request: ReadTextRangeRequest, + source: string | FileHandle, + request: { offset?: number; limit?: number; signal?: AbortSignal }, maxOutputBytes: number, - sourceSize: number, + maxScanBytes: number, + sourceSize?: number, ): Promise { - const encoding = - request.fileHandle === undefined - ? await detectFileEncoding(request.path) - : await detectFileHandleEncoding( - request.fileHandle, - sourceSize, - request.signal, - ); + const encoding = await detectFileEncoding(source); + // Detection is one bounded 8 KiB read, but check here anyway so an abort + // that lands during it is still observed before the streaming loop starts. + request.signal?.throwIfAborted(); if (!isUtf8CompatibleEncoding(encoding)) { throw new LargeNonUtf8TextError(encoding); } @@ -164,21 +527,39 @@ async function readLargeUtf8Range( let previousChunkEndedWithCR = false; let originalLineCountExact = true; let stoppedEarly = false; + let scannedBytes = 0; + // Bytes of the file the decoder has walked past. `scannedBytes` is + // chunk-granular and cannot locate a line boundary, while mapping a decoded + // string index back into its raw chunk is wrong because streaming decode can + // hold an incomplete trailing sequence. Re-encoding each decoded fragment is + // exact here because this path has already refused non-UTF-8. + let consumedBytes = 0; const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true, }); - const pathStream = - request.fileHandle === undefined - ? createReadStream(request.path, { - highWaterMark: 512 * 1024, - signal: request.signal, - }) - : undefined; - const chunks = - pathStream ?? - readFileHandleChunks(request.fileHandle!, sourceSize, request.signal); + let pathStream: ReturnType | undefined; + let chunks: AsyncIterable; + const sourceEnd = Math.min( + sourceSize ?? Number.POSITIVE_INFINITY, + maxScanBytes, + ); + if (sourceEnd <= 0 && (sourceSize ?? 0) > 0) { + throw new TextScanBudgetExceededError(0, maxScanBytes); + } + if (typeof source === 'string') { + pathStream = createReadStream(source, { + highWaterMark: 512 * 1024, + signal: request.signal, + ...(Number.isFinite(sourceEnd) + ? { end: Math.max(0, sourceEnd - 1) } + : {}), + }); + chunks = pathStream; + } else { + chunks = chunksFromHandle(source, 0, sourceEnd, request.signal); + } function appendSelected(fragment: string): void { if (fragment.length === 0 || truncatedByBytes) { @@ -217,18 +598,21 @@ async function readLargeUtf8Range( try { for await (const rawChunk of chunks) { request.signal?.throwIfAborted(); + scannedBytes += (rawChunk as Buffer).length; let chunk = decodeUtf8Chunk(rawChunk as Buffer, { stream: true }); if (firstChunk) { firstChunk = false; if (chunk.charCodeAt(0) === 0xfeff) { chunk = chunk.slice(1); bom = true; + consumedBytes += 3; } } if ( - (previousChunkEndedWithCR && chunk.startsWith('\n')) || - chunk.includes('\r\n') + isSelectedLine() && + previousChunkEndedWithCR && + chunk.startsWith('\n') ) { lineEnding = 'crlf'; } @@ -238,11 +622,15 @@ async function readLargeUtf8Range( let newline = chunk.indexOf('\n', start); while (newline !== -1) { if (isSelectedLine()) { - appendSelected(chunk.slice(start, newline)); + const fragment = chunk.slice(start, newline); + if (fragment.endsWith('\r')) lineEnding = 'crlf'; + appendSelected(fragment); if (currentLine + 1 < endLine) { appendSelected('\n'); } } + consumedBytes += + Buffer.byteLength(chunk.slice(start, newline), 'utf8') + 1; currentLine++; start = newline + 1; if (currentLine >= endLine || truncatedByBytes) { @@ -253,8 +641,12 @@ async function readLargeUtf8Range( newline = chunk.indexOf('\n', start); } - if (start < chunk.length && isSelectedLine()) { - appendSelected(chunk.slice(start)); + if (!stoppedEarly && start < chunk.length) { + const tail = chunk.slice(start); + if (isSelectedLine()) { + appendSelected(tail); + } + consumedBytes += Buffer.byteLength(tail, 'utf8'); } if (currentLine >= endLine || truncatedByBytes) { originalLineCountExact = false; @@ -268,6 +660,15 @@ async function readLargeUtf8Range( } } + const budgetExhausted = + !stoppedEarly && + sourceSize !== undefined && + sourceSize > maxScanBytes && + scannedBytes >= maxScanBytes; + if (budgetExhausted) { + throw new TextScanBudgetExceededError(scannedBytes, maxScanBytes); + } + if (!stoppedEarly) { decodeUtf8Chunk(); } @@ -275,6 +676,11 @@ async function readLargeUtf8Range( return { content: output, originalLineCount: currentLine + 1, + ...(stoppedEarly && + !truncatedByBytes && + consumedBytes < (sourceSize ?? Number.POSITIVE_INFINITY) + ? { nextByteOffset: consumedBytes } + : {}), encoding: 'utf-8', bom, lineEnding, @@ -283,17 +689,24 @@ async function readLargeUtf8Range( }; } -async function* readFileHandleChunks( +/** + * Sequential chunks off a borrowed descriptor in `[from, toExclusive)`. + * + * Reads use explicit positions, so the caller's file position is untouched and + * two readers can share one handle. + */ +async function* chunksFromHandle( fileHandle: FileHandle, - sourceSize: number, + from = 0, + toExclusive = Number.POSITIVE_INFINITY, signal?: AbortSignal, ): AsyncGenerator { const highWaterMark = 512 * 1024; const buffer = Buffer.allocUnsafe(highWaterMark); - let position = 0; - while (position < sourceSize) { + let position = from; + while (position < toExclusive) { signal?.throwIfAborted(); - const bytesToRead = Math.min(highWaterMark, sourceSize - position); + const bytesToRead = Math.min(highWaterMark, toExclusive - position); const { bytesRead } = await fileHandle.read( buffer, 0, @@ -310,24 +723,6 @@ async function* readFileHandleChunks( } } -async function detectFileHandleEncoding( - fileHandle: FileHandle, - sourceSize: number, - signal?: AbortSignal, -): Promise { - signal?.throwIfAborted(); - if (sourceSize === 0) return 'utf-8'; - - const sample = Buffer.alloc(Math.min(8192, sourceSize)); - const { bytesRead } = await fileHandle.read(sample, 0, sample.length, 0); - signal?.throwIfAborted(); - if (bytesRead === 0) return 'utf-8'; - return ( - (await decodeBufferWithEncodingInfoAsync(sample.subarray(0, bytesRead))) - .encoding ?? 'utf-8' - ); -} - function truncateUtf8( content: string, maxBytes: number, diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index c760266a7b..883edf3fd4 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -75,7 +75,9 @@ const rootDir = join(__dirname, '..'); // Bumped from 175KB to 176KB for GitHub PR create + default-branch methods. // Bumped from 176KB to 177KB for concurrent session-cancellation coalescing in // DaemonSessionClient (#6930). -const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 177 * 1024; +// Bumped from 177KB to 178KB for workspace file byte-cursor paging after +// merging the workspace pairing approval SDK surface. +const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 178 * 1024; // The opt-in `daemon/transports` browser bundle legitimately ships the concrete // ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so // it's larger than the default barrel — but still budgeted so a future PR can't diff --git a/packages/sdk-typescript/src/daemon-mcp/serve-bridge/tools/workspaceRead.ts b/packages/sdk-typescript/src/daemon-mcp/serve-bridge/tools/workspaceRead.ts index cc66c2c944..1fc6a79841 100644 --- a/packages/sdk-typescript/src/daemon-mcp/serve-bridge/tools/workspaceRead.ts +++ b/packages/sdk-typescript/src/daemon-mcp/serve-bridge/tools/workspaceRead.ts @@ -21,12 +21,19 @@ export function workspaceReadTools(state: BridgeState): any[] { max_bytes: z.number().optional().describe('Maximum bytes to read.'), line: z.number().optional().describe('Starting line number.'), limit: z.number().optional().describe('Number of lines to read.'), + cursor: z + .string() + .optional() + .describe( + "Resume token from a previous read's nextCursor. Reaches any point in the file in constant time, unlike a large `line` offset.", + ), }, handler(async (args) => { const result = await state.client.readWorkspaceFile(args.path, { maxBytes: args.max_bytes, line: args.line, limit: args.limit, + cursor: args.cursor, }); return formatJsonResult(result); }), diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 5688f691f4..a27774c03f 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -1643,7 +1643,12 @@ export class DaemonClient { async readWorkspaceFile( filePath: string, - opts: { maxBytes?: number; line?: number; limit?: number } = {}, + opts: { + maxBytes?: number; + line?: number; + limit?: number; + cursor?: string; + } = {}, clientId?: string, ): Promise { const url = new URL(`${this.baseUrl}/file`); @@ -1657,6 +1662,9 @@ export class DaemonClient { if (opts.limit !== undefined) { url.searchParams.set('limit', String(opts.limit)); } + if (opts.cursor !== undefined) { + url.searchParams.set('cursor', opts.cursor); + } return await this.fetchWithTimeout( url.toString(), { headers: this.headers({}, clientId) }, @@ -5408,7 +5416,12 @@ export class WorkspaceDaemonClient { readWorkspaceFile( filePath: string, - opts: { maxBytes?: number; line?: number; limit?: number } = {}, + opts: { + maxBytes?: number; + line?: number; + limit?: number; + cursor?: string; + } = {}, clientId?: string, ): Promise { const query = new URLSearchParams({ path: filePath }); @@ -5416,6 +5429,7 @@ export class WorkspaceDaemonClient { query.set('maxBytes', String(opts.maxBytes)); if (opts.line !== undefined) query.set('line', String(opts.line)); if (opts.limit !== undefined) query.set('limit', String(opts.limit)); + if (opts.cursor !== undefined) query.set('cursor', opts.cursor); return this.get( `/file?${query.toString()}`, 'GET /workspaces/:workspace/file', diff --git a/packages/sdk-typescript/src/daemon/acpRouteTable.ts b/packages/sdk-typescript/src/daemon/acpRouteTable.ts index 96eb3521dd..3d301210f0 100644 --- a/packages/sdk-typescript/src/daemon/acpRouteTable.ts +++ b/packages/sdk-typescript/src/daemon/acpRouteTable.ts @@ -872,6 +872,7 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ ...numParam(q, 'maxBytes'), ...numParam(q, 'line'), ...numParam(q, 'limit'), + ...strParam(q, 'cursor'), }), }, }, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index e4beb5aa19..963cde7763 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -1744,6 +1744,14 @@ export interface DaemonWorkspaceFile { hash?: DaemonContentHash; matchedIgnore: 'file' | 'directory' | null; originalLineCount: number | null; + /** + * Resume token for the next page, or `null` at the end. Optional in the type + * because a daemon older than `workspace_file_read_cursor` sends neither + * this nor `hasMore` — same reason `hash` is optional. + */ + nextCursor?: string | null; + /** Whether content remains beyond what was returned. */ + hasMore?: boolean; } export interface DaemonWorkspaceFileBytes { diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index b24f4d8f1d..8990ba0cca 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -455,6 +455,36 @@ describe('DaemonClient', () => { expect(calls[0]?.headers['x-qwen-client-id']).toBe('client-1'); }); + it('forwards a workspace text cursor', async () => { + const payload = { + kind: 'file', + path: 'src/a.ts', + content: 'next\n', + encoding: 'utf-8', + bom: false, + lineEnding: 'lf', + sizeBytes: 20, + returnedBytes: 5, + truncated: true, + matchedIgnore: null, + originalLineCount: null, + nextCursor: null, + hasMore: false, + }; + const { fetch, calls } = recordingFetch(() => jsonResponse(200, payload)); + const client = new DaemonClient({ baseUrl: 'http://daemon/', fetch }); + + await expect( + client.readWorkspaceFile('src/a.ts', { + limit: 3, + cursor: 'cursor 1', + }), + ).resolves.toEqual(payload); + expect(calls[0]?.url).toBe( + 'http://daemon/file?path=src%2Fa.ts&limit=3&cursor=cursor+1', + ); + }); + it('reads raw bytes as base64 payloads', async () => { const payload = { kind: 'file_bytes', diff --git a/packages/sdk-typescript/test/unit/acpRouteTable.test.ts b/packages/sdk-typescript/test/unit/acpRouteTable.test.ts index 9ec49a4a61..dfab3032d7 100644 --- a/packages/sdk-typescript/test/unit/acpRouteTable.test.ts +++ b/packages/sdk-typescript/test/unit/acpRouteTable.test.ts @@ -957,9 +957,9 @@ describe('acpRouteTable – query param coercion', () => { }; } - it('GET /file forwards path (string) + maxBytes/line/limit as NUMBERS', () => { + it('GET /file forwards typed range and cursor params', () => { const { method, params } = extract( - '/file?path=src%2Fa.ts&maxBytes=123&line=4&limit=10', + '/file?path=src%2Fa.ts&maxBytes=123&line=4&limit=10&cursor=next%201', 'GET', ); expect(method).toBe('_qwen/file/read'); @@ -968,6 +968,7 @@ describe('acpRouteTable – query param coercion', () => { maxBytes: 123, line: 4, limit: 10, + cursor: 'next 1', }); // The daemon requires real numbers — a regression to strings would break it. expect(typeof params['maxBytes']).toBe('number'); From b2c77d224cf75a61763bec618fc16b37e544549d Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Thu, 30 Jul 2026 20:47:10 +0800 Subject: [PATCH 08/17] fix(test): give multi-model E2E turns more CI timeout headroom (#8108) (#8111) Co-authored-by: Qwen Code Autofix --- integration-tests/sdk-typescript/system-control.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/integration-tests/sdk-typescript/system-control.test.ts b/integration-tests/sdk-typescript/system-control.test.ts index 5872549c6d..6ee1234f35 100644 --- a/integration-tests/sdk-typescript/system-control.test.ts +++ b/integration-tests/sdk-typescript/system-control.test.ts @@ -17,7 +17,9 @@ import { } from './test-helper.js'; const SHARED_TEST_OPTIONS = createSharedTestOptions(); -const MODEL_RESPONSE_TIMEOUT_MS = process.env['CI'] ? 30000 : 15000; +// Per-turn cap. CI model responses can exceed 30s under load, and the +// suite budget is 5 minutes, so give each turn more of that headroom. +const MODEL_RESPONSE_TIMEOUT_MS = process.env['CI'] ? 60000 : 15000; /** * Factory function that creates a streaming input with a control point. From c50120985b87186e4280a2810f9e94d71f4b0a72 Mon Sep 17 00:00:00 2001 From: jinye Date: Thu, 30 Jul 2026 20:52:37 +0800 Subject: [PATCH 09/17] fix(serve): Prevent repeated workspace skill rescans (#8080) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(serve): prevent repeated workspace skill rescans Make workspace skill status reads use committed snapshots and move refresh work to explicit mutation paths. Add generation-safe daemon caching, conditional HTTP responses, SDK revalidation, and multi-session extension refresh safeguards. Refs #8079 Co-authored-by: Qwen-Coder * fix(serve): narrow the workspace-skills read model and close its regressions Follow-up to the previous commit on this branch, from reviewing it. Subtractions — these were separable from the fix and carried more surface than value, so they move out of this change: - Revert the ETag / If-None-Match layer (CORS allow+expose headers, the SDK conditional JSON cache, the browser bundle budget bump). Express already emits an ETag and answers 304 for these routes, so the only new behavior was the SDK cache. It saves transfer bytes but no daemon work — the ETag is a hash of the already-serialized body — and it shipped without a paired `Cache-Control`, which is what actually keeps an intermediary from serving a stale snapshot of an authenticated, mutable resource. The SDK cache was also unbounded, with no eviction or clear entry point. - Revert moving `extensions_final` ahead of skill initialization in `Config.initialize`. In non-safe, non-bare mode `extensions_initial` is already the same argument-less `refreshCache()`, and it runs `applyStoreActivation`, so `getActiveExtensions()` is fully populated before skills are enumerated either way. The move changed only startup event order (and pushed permissionManager past the extension refresh) for every surface including the interactive CLI. Regression fixes — the read went pure, but two of its inputs lost their only path back to disk: - Extension sources have no watcher, unlike skills. With the per-read `extensionManager.refreshCache()` gone, an extension installed, removed, enabled, or disabled outside the daemon would never reach the snapshot until the child restarted — and because extension-level skills are derived from the extension set, a skill-watcher tick could not recover it either. Adds `ExtensionManager.refreshCacheIfSourcesChanged()`: a stat-based fingerprint over the extension directory entries, each manifest, the enablement file, and the store state, which refreshes only when they moved. A status read pays one readdir plus one stat per entry instead of a directory scan and a full parse, and stays self-healing. The baseline is the pre-load fingerprint, so a change landing during a refresh stays visible to the next check instead of being masked by a post-load stat. The directory and store halves are captured at different points because a refresh writes the store itself but never the manifests. - Revalidation is skipped in safe and bare mode, and the whole of it — including that mode check — sits inside its error boundary. Those modes never populate the extension cache by design, while the snapshot derives extension skills from `getExtensions()`, so revalidating there would have loaded the extensions the mode exists to exclude. Keeping the mode check outside the boundary would also have let a config missing those accessors fail a read. - `initialized: true` with an empty list when the config has no `SkillManager` is now `initialized: false`. The daemon latches any initialized answer into `lastWorkspaceSkillsStatus` and then prefers it over its own local enumeration, so the old value could suppress the fallback permanently. Also: - The retained-snapshot path bumped the freshness timestamp without checking its generation, so a read that started before an invalidation could push out the TTL of a snapshot a later read had committed — letting a post-mutation snapshot go unrevalidated for longer than the window. - `setWorkspaceSkillEnabled` folded `configsFailed` into `sessionsFailed`, but it sends `reason: 'settings'`, which never refreshes a skill cache, so the term was structurally zero. Report `configsFailed` from the `content` path instead, where it can actually be non-zero. - Documents the settings-freshness gap this read model accepts: enablement now comes from the child's in-memory `LoadedSettings`, which `SettingsWatcher` keeps current for the User and Workspace scopes but not for System / SystemDefaults (locked-skill policy) or an untrusted workspace. Tests: adds a real-filesystem guard that drives 50 consecutive cached reads and asserts zero additional readdir/readFile calls — the mocked suites could only prove `refreshCache` was not *called*, which is not the invariant that broke. Adds coverage for the fingerprint gate (steady state, install, removal, in-place manifest edit, concurrent callers, and the mid-refresh race), for the null-manager, moved-sources, and safe/bare-mode read paths, and for the generation guard. The generation-guard and safe/bare-mode tests were each verified to fail with their fix reverted. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Qwen-Coder Co-authored-by: Claude Opus 5 --- docs/design/workspace-skills-read-model.md | 88 ++++ docs/developers/qwen-serve-protocol.md | 9 + packages/acp-bridge/src/bridge.test.ts | 13 +- packages/acp-bridge/src/bridge.ts | 114 +++-- packages/acp-bridge/src/status.ts | 5 + .../cli/src/acp-integration/acpAgent.test.ts | 406 +++++++++++++++++- packages/cli/src/acp-integration/acpAgent.ts | 183 +++++++- .../acp-integration/session/Session.test.ts | 24 ++ .../src/acp-integration/session/Session.ts | 17 +- .../routes/workspace-extensions-controller.ts | 32 +- .../src/serve/routes/workspace-extensions.ts | 16 +- .../__tests__/facade.test.ts | 299 ++++++++++++- .../cli/src/serve/workspace-service/index.ts | 179 +++++--- .../src/extension/extensionManager.test.ts | 144 +++++++ .../core/src/extension/extensionManager.ts | 128 ++++++ .../skills/skill-manager.read-model.test.ts | 135 ++++++ .../core/src/skills/skill-manager.test.ts | 17 + packages/core/src/skills/skill-manager.ts | 33 +- 18 files changed, 1686 insertions(+), 156 deletions(-) create mode 100644 docs/design/workspace-skills-read-model.md create mode 100644 packages/core/src/skills/skill-manager.read-model.test.ts diff --git a/docs/design/workspace-skills-read-model.md b/docs/design/workspace-skills-read-model.md new file mode 100644 index 0000000000..c54e129618 --- /dev/null +++ b/docs/design/workspace-skills-read-model.md @@ -0,0 +1,88 @@ +# Workspace skills read model + +## Problem + +`GET /workspace/skills` currently delegates to the ACP child. The child status +handler refreshes both the extension and skill caches before returning a +response. Web reconnects therefore turn a read-only status query into a full +extension scan and skill parse. + +## Design + +The fix is staged: + +1. Make the ACP status handler read only an already committed `SkillManager` + cache. A cold cache — or a config with no `SkillManager` at all — returns + `initialized: false`; it never scans or parses in response to a status + request. Explicit mutation and refresh commands remain the imperative + refresh paths. +2. Keep the extension half of the snapshot self-healing with a `stat`-only + validity check. Skills have a watcher; extensions do not, so a read verifies + that the extension directory entries, their manifests, the enablement file, + and the store's activation state are where the last refresh left them, and + refreshes the extension and skill caches only when they moved. This preserves + the pre-change behavior for extension install / enable / disable run outside + the daemon, at one `readdir` plus one `stat` per entry plus two, instead of a + directory scan and a full manifest and skill parse. Skipped in safe and bare + mode, which deliberately never populate the extension cache at all. +3. Retain the last initialized child or daemon-local fallback snapshot in the + workspace facade. Concurrent cold reads share one request, and a generation + guard prevents an invalidated in-flight result from being cached or from + extending the freshness window of a snapshot committed after it. The facade + revalidates against the child's in-memory snapshot every five seconds so + child watcher updates remain visible without request-triggered discovery. +4. Split explicit refreshes into settings and content reasons. Settings changes + only notify derived consumers; content changes refresh each distinct + `SkillManager` once before publishing session command updates. +5. Extension reconciliation refreshes the bootstrap extension and skill + snapshots as well as session runtimes. Multi-session refreshes nominate one + bootstrap refresh per ACP connection, retry through a successful session if + the nominated session has disappeared, and use a child-side single-flight + to coalesce overlapping requests from older parents. +6. Invalidate the daemon snapshot before and after an imperative refresh. The + first invalidation prevents a pre-mutation snapshot from being reused; the + second prevents a read that raced with the refresh from surviving after the + mutation completes. + +The child route remains available for older daemon parents. New child versions +serve it from memory, so an old parent is safe even if it continues querying on +every reconnect. + +## Invariants + +- A child status read performs no skill parse, no manifest parse, and no + settings-file load. It may `readdir` the extensions directory and `stat` a + bounded set of paths — one per entry plus two — and refreshes only when that + set moved. +- Once either the child or the daemon-local fallback has published an + initialized snapshot, repeated HTTP status reads perform no filesystem work + beyond that bounded check. +- Extension state stays eventually consistent with out-of-band mutations + without a watcher, because the check is cheap enough to run on a read. +- Revalidation can never fail a read: every part of it, including the mode + check, is inside the error boundary. +- The daemon-local fallback may perform one cold enumeration when no child has + ever published a snapshot; this preserves pre-first-prompt autocomplete + without reintroducing repeated scans. +- Cache refresh publishes a complete replacement; readers never observe a + cache being constructed. +- A missing committed cache is represented explicitly and does not trigger + lazy initialization. +- Mutation-triggered refresh is independent from status reads. + +## Known gaps + +Skill enablement is read from the child's in-memory `LoadedSettings` rather than +re-loaded per request. `SettingsWatcher` keeps the User and Workspace scopes +current, so the common paths — the daemon's own toggle, a `/skills` toggle in a +terminal, a hand edit — are covered. Two narrow cases are not: the System and +SystemDefaults scopes have no watcher, so a policy change to the locked-skill +list is not reflected until an explicit refresh; and an untrusted workspace does +not watch the Workspace scope at all. Both were previously self-healing because +every read reloaded settings from disk. + +## Compatibility + +The cached read API is additive. Existing callers of `listSkills()` keep its +lazy-load behavior. Existing HTTP and ACP response shapes remain compatible; +refresh-result fields are additive. diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index de94c453a0..83e855988f 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -1293,6 +1293,15 @@ tolerate its absence from older v1 daemons. Skill bodies, hooks, `skillRoot`, and other skill configuration remain excluded. `errors` is omitted when discovery succeeds. +Repeated reads are served from the last committed workspace snapshot, +periodically revalidated against the child's in-memory cache. A read never +scans skill directories or reparses `SKILL.md` files. The child does verify +that its extension sources are unchanged — one `readdir` of the extensions +directory plus a `stat` per entry, the enablement file, and the store's +activation state — and refreshes only when they moved, so an extension +installed or toggled outside the daemon is still picked up on the next read. +Safe and bare mode skip the check, matching their exclusion of extensions. + ### `GET /workspace/providers` ```json diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index cdb020ad80..4cebe090c0 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -2018,14 +2018,16 @@ describe('createAcpSessionBridge', () => { it('refreshes extensions across live sessions and broadcasts merged results', async () => { const handles: ChannelHandle[] = []; + let failNextExtensionRefresh = true; const bridge = makeBridge({ channelFactory: async () => { const h = makeChannel({ - extMethodImpl: (method, params) => { + extMethodImpl: (method) => { if ( method === 'qwen/control/workspace/extensions/refresh' && - String(params['sessionId']).endsWith('#2') + failNextExtensionRefresh ) { + failNextExtensionRefresh = false; throw new Error('refresh failed'); } return {}; @@ -2060,6 +2062,13 @@ describe('createAcpSessionBridge', () => { method: 'qwen/control/workspace/extensions/refresh', params: { sessionId: first.sessionId }, }, + { + method: 'qwen/control/workspace/extensions/refresh', + params: { + sessionId: second.sessionId, + refreshBootstrap: false, + }, + }, { method: 'qwen/control/workspace/extensions/refresh', params: { sessionId: second.sessionId }, diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 1ecf00acc4..8b59d4e45d 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -1913,7 +1913,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { >(); const inFlightExtensionRefreshes = new Map< string, - { connection: ClientSideConnection; promise: Promise } + { + connection: ClientSideConnection; + promise: Promise; + refreshBootstrap: boolean; + } >(); const toSessionSummary = (entry: SessionEntry): BridgeSessionSummary => { let isWaitingForPermission = false; @@ -7104,52 +7108,100 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { async refreshExtensionsForAllSessions(data) { const sessions = Array.from(byId.values()); + const bootstrapRefreshConnections = new Set< + (typeof sessions)[number]['connection'] + >(); + const refreshSession = async ( + entry: (typeof sessions)[number], + refreshBootstrap: boolean, + ) => { + let inFlight = inFlightExtensionRefreshes.get(entry.sessionId); + if ( + !inFlight || + inFlight.connection !== entry.connection || + (refreshBootstrap && !inFlight.refreshBootstrap) + ) { + const promise = (async () => { + await entry.connection.extMethod( + SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh, + { + sessionId: entry.sessionId, + ...(refreshBootstrap ? {} : { refreshBootstrap: false }), + }, + ); + })(); + inFlight = { + connection: entry.connection, + promise, + refreshBootstrap, + }; + inFlightExtensionRefreshes.set(entry.sessionId, inFlight); + const clear = () => { + if (inFlightExtensionRefreshes.get(entry.sessionId) === inFlight) { + inFlightExtensionRefreshes.delete(entry.sessionId); + } + }; + void promise.then(clear, clear); + } + await Promise.race([ + withTimeout( + inFlight.promise, + 30_000, + SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh, + ), + getTransportClosedReject(entry), + ]); + }; const results = await Promise.all( sessions.map(async (entry) => { const info = channelInfoForEntry(entry); if (!info || info.isDying) { - return { refreshed: 0, failed: 0 }; + return { + refreshed: 0, + failed: 0, + entry, + refreshBootstrap: false, + }; } + const refreshBootstrap = !bootstrapRefreshConnections.has( + entry.connection, + ); + bootstrapRefreshConnections.add(entry.connection); try { - let inFlight = inFlightExtensionRefreshes.get(entry.sessionId); - if (!inFlight || inFlight.connection !== entry.connection) { - const promise = (async () => { - await entry.connection.extMethod( - SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh, - { sessionId: entry.sessionId }, - ); - })(); - inFlight = { connection: entry.connection, promise }; - inFlightExtensionRefreshes.set(entry.sessionId, inFlight); - const clear = () => { - if ( - inFlightExtensionRefreshes.get(entry.sessionId) === inFlight - ) { - inFlightExtensionRefreshes.delete(entry.sessionId); - } - }; - void promise.then(clear, clear); - } - await Promise.race([ - withTimeout( - inFlight.promise, - 30_000, - SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh, - ), - getTransportClosedReject(entry), - ]); - return { refreshed: 1, failed: 0 }; + await refreshSession(entry, refreshBootstrap); + return { refreshed: 1, failed: 0, entry, refreshBootstrap }; } catch (err) { writeServeDebugLine( `refreshExtensions: session ${entry.sessionId} failed: ` + `${err instanceof Error ? err.message : String(err)}`, ); - return { refreshed: 0, failed: 1 }; + return { refreshed: 0, failed: 1, entry, refreshBootstrap }; } }), ); + await Promise.all( + results + .filter((result) => result.failed > 0 && result.refreshBootstrap) + .map(async (failedBootstrap) => { + const retry = results.find( + (result) => + result.refreshed > 0 && + result.entry.connection === failedBootstrap.entry.connection, + ); + if (!retry) return; + try { + await refreshSession(retry.entry, true); + } catch (err) { + writeServeDebugLine( + `refreshExtensions: bootstrap retry via session ${retry.entry.sessionId} failed: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + } + }), + ); + const refreshed = results.reduce( (sum, result) => sum + result.refreshed, 0, diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index 851cf1cd6a..1c749585cd 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -449,8 +449,13 @@ export interface ServeWorkspaceSkillStatus extends ServeStatusCell { export interface ServeWorkspaceSkillsRefreshResult { sessionsRefreshed: number; sessionsFailed: number; + configsRefreshed?: number; + configsFailed?: number; + reason?: ServeWorkspaceSkillsRefreshReason; } +export type ServeWorkspaceSkillsRefreshReason = 'settings' | 'content' | 'all'; + export interface ServeWorkspaceSkillsStatus { v: typeof STATUS_SCHEMA_VERSION; workspaceCwd: string; diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index c371e3a6d1..01de5a5ff5 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -4250,7 +4250,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { ? MCPServerStatus.DISCONNECTED : MCPServerStatus.CONNECTED, ); - const listSkills = vi.fn().mockResolvedValue([ + const cachedSkills = [ { name: 'review', description: 'Review code', @@ -4297,13 +4297,20 @@ describe('QwenAgent MCP SSE/HTTP support', () => { body: 'display stale body', filePath: '/ext/gsd-core/skills/gsd-display-stale/SKILL.md', }, - ]); + ]; + const extensionRefreshCache = vi.fn().mockResolvedValue(undefined); + const skillRefreshCache = vi.fn().mockResolvedValue(undefined); + const getCachedSkills = vi.fn().mockReturnValue(cachedSkills); + const refreshCacheIfSourcesChanged = vi.fn().mockResolvedValue(false); mockConfig = { ...mockConfig, getTargetDir: vi.fn().mockReturnValue('/work/status'), getWorkingDir: vi.fn().mockReturnValue('/work/status'), + isSafeMode: vi.fn().mockReturnValue(false), + getBareMode: vi.fn().mockReturnValue(false), getExtensionManager: vi.fn().mockReturnValue({ - refreshCache: vi.fn().mockResolvedValue(undefined), + refreshCache: extensionRefreshCache, + refreshCacheIfSourcesChanged, }), getMcpServers: vi.fn().mockReturnValue({ docs: { @@ -4335,8 +4342,8 @@ describe('QwenAgent MCP SSE/HTTP support', () => { .fn() .mockReturnValue(new Set(['disabled-skill'])), getSkillManager: vi.fn().mockReturnValue({ - refreshCache: vi.fn().mockResolvedValue(undefined), - listSkills, + refreshCache: skillRefreshCache, + getCachedSkills, }), getExtensions: vi.fn().mockReturnValue([ { @@ -4444,6 +4451,10 @@ describe('QwenAgent MCP SSE/HTTP support', () => { SERVE_STATUS_EXT_METHODS.workspaceSkills, {}, )) as unknown as ServeWorkspaceSkillsStatus; + const skillsAgain = (await agent.extMethod( + SERVE_STATUS_EXT_METHODS.workspaceSkills, + {}, + )) as unknown as ServeWorkspaceSkillsStatus; const providers = await agent.extMethod( SERVE_STATUS_EXT_METHODS.workspaceProviders, {}, @@ -4610,6 +4621,13 @@ describe('QwenAgent MCP SSE/HTTP support', () => { expect( skills.skills.filter((skill) => skill.name === 'gsd-config-only'), ).toHaveLength(1); + expect(skillsAgain).toEqual(skills); + expect(getCachedSkills).toHaveBeenCalledTimes(2); + // Each read validates that the extension sources have not moved, but an + // unchanged answer must not cascade into an extension or skill refresh. + expect(refreshCacheIfSourcesChanged).toHaveBeenCalledTimes(2); + expect(extensionRefreshCache).not.toHaveBeenCalled(); + expect(skillRefreshCache).not.toHaveBeenCalled(); expect(JSON.stringify(skills)).not.toContain('secret skill body'); expect(JSON.stringify(skills)).not.toContain('manual secret body'); expect(JSON.stringify(skills)).not.toContain('disabled secret body'); @@ -4651,6 +4669,191 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('returns an uninitialized skills snapshot without warming a cold cache', async () => { + const extensionRefreshCache = vi.fn().mockResolvedValue(undefined); + const skillRefreshCache = vi.fn().mockResolvedValue(undefined); + const listSkills = vi.fn().mockResolvedValue([]); + mockConfig = { + ...mockConfig, + getTargetDir: vi.fn().mockReturnValue('/work/status'), + getWorkingDir: vi.fn().mockReturnValue('/work/status'), + isSafeMode: vi.fn().mockReturnValue(false), + getBareMode: vi.fn().mockReturnValue(false), + getExtensionManager: vi.fn().mockReturnValue({ + refreshCache: extensionRefreshCache, + refreshCacheIfSourcesChanged: vi.fn().mockResolvedValue(false), + }), + getSkillManager: vi.fn().mockReturnValue({ + refreshCache: skillRefreshCache, + listSkills, + getCachedSkills: vi.fn().mockReturnValue(null), + }), + } as unknown as Config; + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await expect( + agent.extMethod(SERVE_STATUS_EXT_METHODS.workspaceSkills, {}), + ).resolves.toEqual({ + v: 1, + workspaceCwd: '/work/status', + initialized: false, + skills: [], + }); + expect(extensionRefreshCache).not.toHaveBeenCalled(); + expect(skillRefreshCache).not.toHaveBeenCalled(); + expect(listSkills).not.toHaveBeenCalled(); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('reports an uninitialized snapshot when the config has no skill manager', async () => { + // The daemon latches any `initialized: true` answer and then prefers it over + // its own local enumeration, so a config that can never enumerate must not + // claim to be initialized with an empty list. + mockConfig = { + ...mockConfig, + getTargetDir: vi.fn().mockReturnValue('/work/status'), + getWorkingDir: vi.fn().mockReturnValue('/work/status'), + getExtensionManager: vi.fn().mockReturnValue({ + refreshCache: vi.fn().mockResolvedValue(undefined), + refreshCacheIfSourcesChanged: vi.fn().mockResolvedValue(false), + }), + getSkillManager: vi.fn().mockReturnValue(undefined), + } as unknown as Config; + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await expect( + agent.extMethod(SERVE_STATUS_EXT_METHODS.workspaceSkills, {}), + ).resolves.toEqual({ + v: 1, + workspaceCwd: '/work/status', + initialized: false, + skills: [], + }); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('refreshes the skill cache when extension sources moved under a read', async () => { + // Extensions have no watcher, so this is the only path by which an + // extension installed outside the daemon reaches the snapshot. Extension + // skills are derived from the extension set, so the skill cache has to + // follow. + const skillRefreshCache = vi.fn().mockResolvedValue(undefined); + const refreshCacheIfSourcesChanged = vi + .fn() + .mockResolvedValueOnce(true) + .mockResolvedValue(false); + mockConfig = { + ...mockConfig, + getTargetDir: vi.fn().mockReturnValue('/work/status'), + getWorkingDir: vi.fn().mockReturnValue('/work/status'), + isSafeMode: vi.fn().mockReturnValue(false), + getBareMode: vi.fn().mockReturnValue(false), + getExtensionManager: vi.fn().mockReturnValue({ + refreshCache: vi.fn().mockResolvedValue(undefined), + refreshCacheIfSourcesChanged, + }), + getSkillManager: vi.fn().mockReturnValue({ + refreshCache: skillRefreshCache, + getCachedSkills: vi.fn().mockReturnValue([]), + }), + } as unknown as Config; + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.extMethod(SERVE_STATUS_EXT_METHODS.workspaceSkills, {}); + expect(skillRefreshCache).toHaveBeenCalledOnce(); + + // Sources settled — the second read must not refresh again. + await agent.extMethod(SERVE_STATUS_EXT_METHODS.workspaceSkills, {}); + expect(refreshCacheIfSourcesChanged).toHaveBeenCalledTimes(2); + expect(skillRefreshCache).toHaveBeenCalledOnce(); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it.each([ + ['safe mode', { isSafeMode: true, getBareMode: false }], + ['bare mode', { isSafeMode: false, getBareMode: true }], + ])('does not revalidate extension sources in %s', async (_label, modes) => { + // These modes never populate the extension cache, and the snapshot derives + // extension skills from getExtensions() — so revalidating here would load + // the extensions the mode exists to exclude. + const refreshCacheIfSourcesChanged = vi.fn().mockResolvedValue(true); + const skillRefreshCache = vi.fn().mockResolvedValue(undefined); + mockConfig = { + ...mockConfig, + getTargetDir: vi.fn().mockReturnValue('/work/status'), + getWorkingDir: vi.fn().mockReturnValue('/work/status'), + isSafeMode: vi.fn().mockReturnValue(modes.isSafeMode), + getBareMode: vi.fn().mockReturnValue(modes.getBareMode), + getExtensionManager: vi.fn().mockReturnValue({ + refreshCache: vi.fn().mockResolvedValue(undefined), + refreshCacheIfSourcesChanged, + }), + getSkillManager: vi.fn().mockReturnValue({ + refreshCache: skillRefreshCache, + getCachedSkills: vi.fn().mockReturnValue([]), + }), + } as unknown as Config; + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.extMethod(SERVE_STATUS_EXT_METHODS.workspaceSkills, {}); + + expect(refreshCacheIfSourcesChanged).not.toHaveBeenCalled(); + expect(skillRefreshCache).not.toHaveBeenCalled(); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('status ext methods return error cells when workspace snapshots fail', async () => { mockConfig = { ...mockConfig, @@ -15697,6 +15900,11 @@ describe('sessionLanguage multi-session propagation', () => { }), getFileSystemService: vi.fn().mockReturnValue(undefined), setFileSystemService: vi.fn(), + getExtensionManager: vi.fn().mockReturnValue({ + refreshCache: vi.fn().mockResolvedValue(undefined), + refreshTools: vi.fn().mockResolvedValue(undefined), + }), + getSkillManager: vi.fn().mockReturnValue(undefined), getHookSystem: vi.fn().mockReturnValue(undefined), getDisableAllHooks: vi.fn().mockReturnValue(true), hasHooksForEvent: vi.fn().mockReturnValue(false), @@ -16161,6 +16369,8 @@ describe('sessionLanguage multi-session propagation', () => { }); const refresh1 = vi.fn().mockResolvedValue(undefined); const refresh2 = vi.fn().mockRejectedValue(new Error('client closed')); + const reload1 = vi.fn(); + const reload2 = vi.fn(); vi.mocked(loadSettings).mockReturnValue(bootstrapSettings); vi.mocked(loadCliConfig) @@ -16172,6 +16382,7 @@ describe('sessionLanguage multi-session propagation', () => { getId: vi.fn().mockReturnValue(id), getConfig: vi.fn().mockReturnValue(id === 'skill-1' ? cfg1 : cfg2), isIdle: vi.fn().mockReturnValue(false), + reloadSkillSettings: id === 'skill-1' ? reload1 : reload2, refreshSkillsFromSettings: id === 'skill-1' ? refresh1 : refresh2, sendAvailableCommandsUpdate: vi.fn().mockResolvedValue(undefined), installRewriter: vi.fn(), @@ -16195,14 +16406,24 @@ describe('sessionLanguage multi-session propagation', () => { await agent.newSession({ cwd: '/skills', mcpServers: [] }); await agent.newSession({ cwd: '/skills', mcpServers: [] }); await expect( - agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceSkillsRefresh, {}), - ).resolves.toEqual({ sessionsRefreshed: 1, sessionsFailed: 1 }); + agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceSkillsRefresh, { + reason: 'settings', + }), + ).resolves.toEqual({ + sessionsRefreshed: 1, + sessionsFailed: 1, + configsRefreshed: 0, + configsFailed: 0, + reason: 'settings', + }); expect(bootstrapSettings.reloadScopeFromDisk).toHaveBeenCalledWith( SettingScope.Workspace, ); expect(refresh1).toHaveBeenCalledOnce(); expect(refresh2).toHaveBeenCalledOnce(); + expect(reload1).toHaveBeenCalledOnce(); + expect(reload2).toHaveBeenCalledOnce(); expect(mockDebugLogger.warn).toHaveBeenCalledWith( 'Session skill-2 skill refresh failed: Error: client closed', ); @@ -16211,7 +16432,109 @@ describe('sessionLanguage multi-session propagation', () => { await agentPromise; }); - it('refreshes extension state without a duplicate direct skill refresh', async () => { + it('refreshes skill content once per config before publishing session updates', async () => { + const bootstrapSettings = { + merged: {}, + reloadScopeFromDisk: vi.fn(), + getUserHooks: vi.fn().mockReturnValue({}), + getProjectHooks: vi.fn().mockReturnValue({}), + } as unknown as LoadedSettings; + const bootstrapRefresh = vi.fn().mockResolvedValue(undefined); + const sessionRefresh = vi.fn().mockResolvedValue(undefined); + const publishSessionSkills = vi.fn().mockResolvedValue(undefined); + const reloadSessionSettings = vi.fn(); + const bootstrapConfig = makeConfig({ + getSkillManager: vi + .fn() + .mockReturnValue({ refreshCache: bootstrapRefresh }), + }); + const sessionConfig = makeConfig({ + getSessionId: vi.fn().mockReturnValue('skill-content'), + getSkillManager: vi + .fn() + .mockReturnValue({ refreshCache: sessionRefresh }), + }); + + vi.mocked(loadSettings).mockReturnValue(bootstrapSettings); + vi.mocked(loadCliConfig).mockResolvedValue( + sessionConfig as unknown as Config, + ); + vi.mocked(Session).mockImplementation( + () => + ({ + getId: vi.fn().mockReturnValue('skill-content'), + getConfig: vi.fn().mockReturnValue(sessionConfig), + isIdle: vi.fn().mockReturnValue(false), + reloadSkillSettings: reloadSessionSettings, + refreshSkillsFromSettings: publishSessionSkills, + sendAvailableCommandsUpdate: vi.fn().mockResolvedValue(undefined), + installRewriter: vi.fn(), + startCronScheduler: vi.fn(), + dispose: vi.fn(), + }) as unknown as InstanceType, + ); + + const agentPromise = runAcpAgent( + bootstrapConfig as unknown as Config, + bootstrapSettings, + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }); + + await agent.newSession({ cwd: '/skills', mcpServers: [] }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceSkillsRefresh, { + reason: 'content', + }), + ).resolves.toEqual({ + sessionsRefreshed: 1, + sessionsFailed: 0, + configsRefreshed: 2, + configsFailed: 0, + reason: 'content', + }); + + expect(bootstrapRefresh).toHaveBeenCalledOnce(); + expect(sessionRefresh).toHaveBeenCalledOnce(); + expect(bootstrapSettings.reloadScopeFromDisk).not.toHaveBeenCalled(); + expect(publishSessionSkills).toHaveBeenCalledWith({ + reloadSettings: false, + notifyConfigChanged: false, + }); + + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceSkillsRefresh, {}), + ).resolves.toEqual({ + sessionsRefreshed: 1, + sessionsFailed: 0, + configsRefreshed: 2, + configsFailed: 0, + reason: 'all', + }); + expect(bootstrapRefresh).toHaveBeenCalledTimes(2); + expect(sessionRefresh).toHaveBeenCalledTimes(2); + expect(bootstrapSettings.reloadScopeFromDisk).toHaveBeenCalledWith( + SettingScope.Workspace, + ); + expect(publishSessionSkills).toHaveBeenLastCalledWith({ + reloadSettings: false, + notifyConfigChanged: false, + }); + expect(reloadSessionSettings).toHaveBeenCalledOnce(); + expect(reloadSessionSettings.mock.invocationCallOrder[0]).toBeLessThan( + sessionRefresh.mock.invocationCallOrder[1]!, + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('coalesces bootstrap extension refreshes without directly refreshing the session skills', async () => { const extensionManager = { refreshCache: vi.fn().mockResolvedValue(undefined), refreshTools: vi.fn().mockResolvedValue(undefined), @@ -16221,6 +16544,20 @@ describe('sessionLanguage multi-session propagation', () => { .fn() .mockRejectedValue(new Error('direct skill refresh should not run')), }; + let releaseBootstrapRefresh!: () => void; + const bootstrapRefreshGate = new Promise((resolve) => { + releaseBootstrapRefresh = resolve; + }); + const bootstrapExtensionManager = { + refreshCache: vi.fn().mockReturnValue(bootstrapRefreshGate), + }; + const bootstrapSkillRefresh = vi.fn().mockResolvedValue(undefined); + const bootstrapConfig = makeConfig({ + getExtensionManager: vi.fn().mockReturnValue(bootstrapExtensionManager), + getSkillManager: vi + .fn() + .mockReturnValue({ refreshCache: bootstrapSkillRefresh }), + }); const refreshHierarchicalMemory = vi.fn().mockResolvedValue(undefined); const cfg = makeConfig({ getSessionId: vi.fn().mockReturnValue('s-ext'), @@ -16252,7 +16589,7 @@ describe('sessionLanguage multi-session propagation', () => { ); const agentPromise = runAcpAgent( - makeConfig() as unknown as Config, + bootstrapConfig as unknown as Config, { merged: { mcpServers: {} } } as unknown as LoadedSettings, mockArgv, ); @@ -16264,21 +16601,54 @@ describe('sessionLanguage multi-session propagation', () => { }); await agent.newSession({ cwd: '/ext', mcpServers: [] }); - await expect( - agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh, { + await vi.waitFor(() => + expect(sendAvailableCommandsUpdate).toHaveBeenCalledOnce(), + ); + sendAvailableCommandsUpdate.mockClear(); + const firstRefresh = agent.extMethod( + SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh, + { sessionId: 's-ext', - }), - ).resolves.toEqual({ ok: true }); + }, + ); + await vi.waitFor(() => + expect(bootstrapExtensionManager.refreshCache).toHaveBeenCalledOnce(), + ); + const secondRefresh = agent.extMethod( + SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh, + { + sessionId: 's-ext', + }, + ); + await vi.waitFor(() => + expect(extensionManager.refreshTools).toHaveBeenCalledTimes(2), + ); + await Promise.resolve(); + releaseBootstrapRefresh(); + await expect(Promise.all([firstRefresh, secondRefresh])).resolves.toEqual([ + { ok: true }, + { ok: true }, + ]); - expect(extensionManager.refreshCache).toHaveBeenCalledOnce(); + expect(extensionManager.refreshCache).toHaveBeenCalledTimes(2); expect(skillManager.refreshCache).not.toHaveBeenCalled(); - expect(extensionManager.refreshTools).toHaveBeenCalledOnce(); + expect(extensionManager.refreshTools).toHaveBeenCalledTimes(2); + expect(bootstrapExtensionManager.refreshCache).toHaveBeenCalledOnce(); + expect(bootstrapSkillRefresh).toHaveBeenCalledOnce(); expect(refreshHierarchicalMemory).not.toHaveBeenCalled(); - expect(refreshSystemInstruction).toHaveBeenCalledOnce(); - expect(sendAvailableCommandsUpdate).toHaveBeenCalledOnce(); + expect(refreshSystemInstruction).toHaveBeenCalledTimes(2); + expect(sendAvailableCommandsUpdate).toHaveBeenCalledTimes(2); expect( extensionManager.refreshTools.mock.invocationCallOrder[0], - ).toBeLessThan(refreshSystemInstruction.mock.invocationCallOrder[0]!); + ).toBeLessThan( + bootstrapExtensionManager.refreshCache.mock.invocationCallOrder[0]!, + ); + expect( + bootstrapExtensionManager.refreshCache.mock.invocationCallOrder[0], + ).toBeLessThan(bootstrapSkillRefresh.mock.invocationCallOrder[0]!); + expect(bootstrapSkillRefresh.mock.invocationCallOrder[0]).toBeLessThan( + refreshSystemInstruction.mock.invocationCallOrder[0]!, + ); expect(refreshSystemInstruction.mock.invocationCallOrder[0]).toBeLessThan( sendAvailableCommandsUpdate.mock.invocationCallOrder[0]!, ); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 1448c27abb..25c4ecd9ee 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -3328,6 +3328,7 @@ class QwenAgent implements Agent { private workspaceMcpDiscoveryConfig: Config | undefined; private workspaceMcpDiscoveryPromise: Promise | undefined; private workspaceMcpDiscoveryError: string | undefined; + private workspaceExtensionStatusRefreshPromise: Promise | undefined; private readonly pendingMcpAuthentications = new Map< string, PendingMcpAuthentication @@ -3518,6 +3519,41 @@ class QwenAgent implements Agent { return this.workspaceMcpDiscoveryConfig ?? this.config; } + private refreshBootstrapExtensionStatus(): Promise { + if (this.workspaceExtensionStatusRefreshPromise) { + return this.workspaceExtensionStatusRefreshPromise; + } + + const promise = (async () => { + const errors: unknown[] = []; + try { + await this.config.getExtensionManager().refreshCache(); + } catch (error) { + errors.push(error); + } + try { + await this.config.getSkillManager()?.refreshCache(); + } catch (error) { + errors.push(error); + } + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) { + throw new AggregateError( + errors, + 'Bootstrap extension status refresh failed', + ); + } + })(); + this.workspaceExtensionStatusRefreshPromise = promise; + const clear = () => { + if (this.workspaceExtensionStatusRefreshPromise === promise) { + this.workspaceExtensionStatusRefreshPromise = undefined; + } + }; + void promise.then(clear, clear); + return promise; + } + private getLiveMcpConfigs(serverName: string): Config[] { return [ ...new Set([ @@ -5832,26 +5868,69 @@ class QwenAgent implements Agent { }; } + /** + * Keeps the extension-derived half of the skill snapshot self-healing. + * + * Skills have a watcher (`SkillManager.startWatching`); extensions do not, so + * without this the child would never notice an extension installed, removed, + * enabled, or disabled outside the daemon — and extension-level skills are + * derived from that set, so a skill-watcher tick alone cannot recover it. + * + * The check is one `readdir` plus a bounded number of `stat`s, and refreshes + * only when the sources actually moved, so a steady-state read still parses + * no manifest and no `SKILL.md`. Failures are logged and swallowed: a status + * read must not fail because revalidation could not run. + * + * Skipped in safe and bare mode. Those modes deliberately never populate the + * extension cache (`Config.initialize` omits the refresh), and the snapshot + * derives extension skills from `getExtensions()` — so revalidating here + * would load the extensions those modes exist to exclude. + */ + private async revalidateExtensionSources(config: Config): Promise { + // Everything here is inside the boundary, mode check included: this must not + // be able to fail a status read no matter which accessor misbehaves. + try { + if (config.isSafeMode() || config.getBareMode()) return; + const changed = await config + .getExtensionManager() + .refreshCacheIfSourcesChanged(); + if (!changed) return; + await config.getSkillManager()?.refreshCache(); + } catch (error) { + debugLogger.warn('Extension source revalidation failed:', error); + } + } + private async buildWorkspaceSkillsStatus( config: Config, ): Promise { const skillManager = config.getSkillManager(); if (!skillManager) { + // No manager means nothing has been enumerated and nothing ever will be + // on this config — report that rather than an empty "initialized" list, + // which the daemon would latch as a valid snapshot and then keep serving + // in preference to its own local enumeration. return { v: STATUS_SCHEMA_VERSION, workspaceCwd: this.workspaceCwd(config), - initialized: true, + initialized: false, skills: [], }; } + await this.revalidateExtensionSources(config); + try { - const resolved = resolveSkillSettings( - loadSettings(this.workspaceCwd(config), { - consumeCorruptionEnvVars: false, - skipLoadEnvironment: true, - }), - ); + const skills = skillManager.getCachedSkills(); + if (skills === null) { + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd: this.workspaceCwd(config), + initialized: false, + skills: [], + }; + } + const resolved = resolveSkillSettings(this.settings); const disablements = new Map( Array.from(config.getDisabledSkillNames(), (name) => { const normalizedName = name.trim().toLowerCase(); @@ -5862,17 +5941,6 @@ class QwenAgent implements Agent { ] as const; }), ); - try { - await config.getExtensionManager().refreshCache(); - } catch (error) { - debugLogger.warn('Extension cache refresh failed:', error); - } - try { - await skillManager.refreshCache(); - } catch (error) { - debugLogger.warn('Skill cache refresh failed:', error); - } - const skills = await skillManager.listSkills(); const inactiveSkillRefs = inactiveExtensionSkillRefs(config); const skillsByKey = new Map( skills.map((skill) => [ @@ -9648,6 +9716,16 @@ class QwenAgent implements Agent { } case SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh: { const sessionId = params['sessionId'] as string; + const rawRefreshBootstrap = params['refreshBootstrap']; + if ( + rawRefreshBootstrap !== undefined && + typeof rawRefreshBootstrap !== 'boolean' + ) { + throw RequestError.invalidParams( + undefined, + 'refreshBootstrap must be a boolean', + ); + } const session = this.sessionOrThrow(sessionId); const config = session.getConfig(); const extensionManager = config.getExtensionManager(); @@ -9661,6 +9739,12 @@ class QwenAgent implements Agent { }; await runRefresh(async () => await extensionManager.refreshCache()); await runRefresh(async () => await extensionManager.refreshTools()); + const bootstrapConfig = this.config; + if (rawRefreshBootstrap !== false && bootstrapConfig !== config) { + await runRefresh( + async () => await this.refreshBootstrapExtensionStatus(), + ); + } const discoveryConfig = this.workspaceMcpDiscoveryConfig; if (discoveryConfig && discoveryConfig !== config) { const discoveryExtensionManager = @@ -10493,10 +10577,62 @@ class QwenAgent implements Agent { }; } case SERVE_CONTROL_EXT_METHODS.workspaceSkillsRefresh: { - this.settings.reloadScopeFromDisk(SettingScope.Workspace); + const rawReason = params['reason']; + if ( + rawReason !== undefined && + rawReason !== 'settings' && + rawReason !== 'content' && + rawReason !== 'all' + ) { + throw RequestError.invalidParams( + undefined, + 'reason must be settings, content, or all', + ); + } + const reason = rawReason ?? 'all'; + const refreshSettings = reason !== 'content'; + const refreshContent = reason !== 'settings'; + if (refreshSettings) { + this.settings.reloadScopeFromDisk(SettingScope.Workspace); + } const sessions = this.getActiveSessions(); + const settingsReloadResults = refreshSettings + ? await Promise.allSettled( + sessions.map((session) => + Promise.resolve().then(() => session.reloadSkillSettings()), + ), + ) + : undefined; + let configResults: Array> = []; + if (refreshContent) { + const skillManagers = new Set( + [this.config, ...sessions.map((session) => session.getConfig())] + .map((config) => config.getSkillManager()) + .filter( + (manager): manager is NonNullable => + manager !== undefined, + ), + ); + configResults = await Promise.allSettled( + [...skillManagers].map((manager) => manager.refreshCache()), + ); + for (const result of configResults) { + if (result.status === 'rejected') { + debugLogger.warn(`Skill config refresh failed: ${result.reason}`); + } + } + } const results = await Promise.allSettled( - sessions.map((session) => session.refreshSkillsFromSettings()), + sessions.map((session, index) => { + const settingsReload = settingsReloadResults?.[index]; + if (settingsReload?.status === 'rejected') { + return Promise.reject(settingsReload.reason); + } + return session.refreshSkillsFromSettings({ + reloadSettings: false, + notifyConfigChanged: !refreshContent, + }); + }), ); for (let i = 0; i < results.length; i++) { if (results[i]!.status === 'rejected') { @@ -10513,6 +10649,13 @@ class QwenAgent implements Agent { sessionsFailed: results.filter( (result) => result.status === 'rejected', ).length, + configsRefreshed: configResults.filter( + (result) => result.status === 'fulfilled', + ).length, + configsFailed: configResults.filter( + (result) => result.status === 'rejected', + ).length, + reason, }; } default: diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 992db0643c..fae4b675d2 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -2794,6 +2794,30 @@ describe('Session', () => { expect(notifyConfigChanged).toHaveBeenCalledTimes(1); }); + it('publishes refreshed skill content without reloading settings or notifying twice', async () => { + const notifyConfigChanged = vi.fn().mockResolvedValue(undefined); + mockConfig.getSkillManager = vi.fn().mockReturnValue({ + listSkills: vi.fn().mockResolvedValue([]), + suppressNextSlashReload: vi.fn(), + notifyConfigChanged, + }); + + await session.refreshSkillsFromSettings({ + reloadSettings: false, + notifyConfigChanged: false, + }); + + expect(mockSettings.reloadScopeFromDisk).not.toHaveBeenCalled(); + expect(mockClient.sessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'available_commands_update', + }), + }), + ); + expect(notifyConfigChanged).not.toHaveBeenCalled(); + }); + it('notifies SkillManager when the command update fails', async () => { const suppressNextSlashReload = vi.fn(); const notifyConfigChanged = vi.fn().mockResolvedValue(undefined); diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 0abb734eb3..4ca6da3c54 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -6173,8 +6173,15 @@ export class Session implements SessionContext { } } - async refreshSkillsFromSettings(): Promise { - this.settings.reloadScopeFromDisk(SettingScope.Workspace); + async refreshSkillsFromSettings( + options: { + reloadSettings?: boolean; + notifyConfigChanged?: boolean; + } = {}, + ): Promise { + if (options.reloadSettings ?? true) { + this.reloadSkillSettings(); + } const skillManager = this.config.getSkillManager(); let updateFailed = false; let updateError: unknown; @@ -6184,7 +6191,7 @@ export class Session implements SessionContext { updateFailed = true; updateError = error; } - if (skillManager) { + if (skillManager && (options.notifyConfigChanged ?? true)) { try { skillManager.suppressNextSlashReload(); await skillManager.notifyConfigChanged(); @@ -6199,6 +6206,10 @@ export class Session implements SessionContext { if (updateFailed) throw updateError; } + reloadSkillSettings(): void { + this.settings.reloadScopeFromDisk(SettingScope.Workspace); + } + private async sendAvailableCommandsUpdateOrThrow(): Promise { const { availableCommands, availableSkills, availableSkillDetails } = await buildAvailableCommandsSnapshot( diff --git a/packages/cli/src/serve/routes/workspace-extensions-controller.ts b/packages/cli/src/serve/routes/workspace-extensions-controller.ts index 3cfe22174d..5da7c6a85c 100644 --- a/packages/cli/src/serve/routes/workspace-extensions-controller.ts +++ b/packages/cli/src/serve/routes/workspace-extensions-controller.ts @@ -683,14 +683,18 @@ export function createExtensionsController( const startedAt = Date.now(); try { runtime.workspaceService.invalidateWorkspaceSkillsStatus(); - return { - status: 'fulfilled' as const, - result: - await runtime.bridge.refreshExtensionsForAllSessions( - bridgeMutationEvent(event), - ), - elapsedMs: Date.now() - startedAt, - }; + try { + return { + status: 'fulfilled' as const, + result: + await runtime.bridge.refreshExtensionsForAllSessions( + bridgeMutationEvent(event), + ), + elapsedMs: Date.now() - startedAt, + }; + } finally { + runtime.workspaceService.invalidateWorkspaceSkillsStatus(); + } } catch (reason) { return { status: 'rejected' as const, @@ -771,10 +775,14 @@ export function createExtensionsController( const { result, elapsedMs } = await runReconciliation(async () => { workspace.invalidateWorkspaceSkillsStatus(); const startedAt = Date.now(); - const result = await bridge.refreshExtensionsForAllSessions( - bridgeMutationEvent(event), - ); - return { result, elapsedMs: Date.now() - startedAt }; + try { + const result = await bridge.refreshExtensionsForAllSessions( + bridgeMutationEvent(event), + ); + return { result, elapsedMs: Date.now() - startedAt }; + } finally { + workspace.invalidateWorkspaceSkillsStatus(); + } }); const warnings: NonNullable = [...commitWarnings]; diff --git a/packages/cli/src/serve/routes/workspace-extensions.ts b/packages/cli/src/serve/routes/workspace-extensions.ts index 8732f953b7..6f16eb1505 100644 --- a/packages/cli/src/serve/routes/workspace-extensions.ts +++ b/packages/cli/src/serve/routes/workspace-extensions.ts @@ -449,12 +449,16 @@ export function registerWorkspaceExtensionRoutes( await Promise.allSettled( runtimes.map(async (runtime) => { runtime.workspaceService.invalidateWorkspaceSkillsStatus(); - const result = - await runtime.bridge.refreshExtensionsForAllSessions(); - if (result.failed > 0) { - throw new Error( - `${result.failed} extension session refresh(es) failed`, - ); + try { + const result = + await runtime.bridge.refreshExtensionsForAllSessions(); + if (result.failed > 0) { + throw new Error( + `${result.failed} extension session refresh(es) failed`, + ); + } + } finally { + runtime.workspaceService.invalidateWorkspaceSkillsStatus(); } }), ), diff --git a/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts b/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts index 8eb1c49498..ed77aa1f2c 100644 --- a/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts +++ b/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts @@ -93,7 +93,10 @@ vi.mock('../../../utils/stdioHelpers.js', () => ({ const { createDaemonWorkspaceService } = await import('../index.js'); import { SessionNotFoundError } from '@qwen-code/acp-bridge/bridgeErrors'; -import { BridgeChannelClosedError } from '@qwen-code/acp-bridge/status'; +import { + BridgeChannelClosedError, + type ServeWorkspaceSkillsStatus, +} from '@qwen-code/acp-bridge/status'; import { resetHomeEnvBootstrapForTesting, SettingScope, @@ -112,6 +115,8 @@ import { } from '../types.js'; import type { DaemonWorkspaceServiceDeps, + InvokeWorkspaceCommandFn, + QueryWorkspaceStatusFn, WorkspaceRequestContext, } from '../types.js'; @@ -793,7 +798,7 @@ describe('createDaemonWorkspaceService', () => { expect(second.skills.map((s) => s.name)).toEqual(['review']); }); - it('getWorkspaceSkillsStatus refreshes the cached status on a newer live answer', async () => { + it('getWorkspaceSkillsStatus reuses the snapshot until it is invalidated', async () => { const statuses = [ { v: 1, @@ -820,8 +825,227 @@ describe('createDaemonWorkspaceService', () => { ); await svc.getWorkspaceSkillsStatus(makeCtx()); + const cached = await svc.getWorkspaceSkillsStatus(makeCtx()); + expect(cached.skills.map((s) => s.name)).toEqual(['review']); + expect(queryWorkspaceStatus).toHaveBeenCalledOnce(); + + svc.invalidateWorkspaceSkillsStatus(); const refreshed = await svc.getWorkspaceSkillsStatus(makeCtx()); expect(refreshed.skills.map((s) => s.name)).toEqual(['review', 'plan']); + expect(queryWorkspaceStatus).toHaveBeenCalledTimes(2); + }); + + it('revalidates the workspace skills snapshot after its freshness window', async () => { + let now = 10_000; + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now); + const queryWorkspaceStatus = vi + .fn() + .mockResolvedValueOnce({ + v: 1, + workspaceCwd: '/ws', + initialized: true, + skills: [ + { + kind: 'skill', + status: 'ok', + name: 'review', + description: 'Review code', + level: 'bundled', + modelInvocable: true, + }, + ], + }) + .mockResolvedValueOnce({ + v: 1, + workspaceCwd: '/ws', + initialized: true, + skills: [ + { + kind: 'skill', + status: 'ok', + name: 'plan', + description: 'Plan changes', + level: 'bundled', + modelInvocable: true, + }, + ], + }); + const svc = createDaemonWorkspaceService( + makeDeps({ queryWorkspaceStatus, boundWorkspace: '/ws' }), + ); + + try { + const initial = await svc.getWorkspaceSkillsStatus(makeCtx()); + now += 4_999; + const cached = await svc.getWorkspaceSkillsStatus(makeCtx()); + now += 1; + const refreshed = await svc.getWorkspaceSkillsStatus(makeCtx()); + + expect(initial.skills.map((skill) => skill.name)).toEqual(['review']); + expect(cached).toEqual(initial); + expect(refreshed.skills.map((skill) => skill.name)).toEqual(['plan']); + expect(queryWorkspaceStatus).toHaveBeenCalledTimes(2); + } finally { + nowSpy.mockRestore(); + } + }); + + it('does not let a superseded read extend the freshness window', async () => { + // A read that started before an invalidation still serves the snapshot a + // later read committed, but must not push that snapshot's TTL out — + // otherwise a post-mutation snapshot goes unrevalidated for longer than + // the window. + let now = 10_000; + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now); + const stale = deferred(); + const skill = (name: string) => + ({ + kind: 'skill', + status: 'ok', + name, + description: name, + level: 'bundled', + modelInvocable: true, + }) as ServeWorkspaceSkillsStatus['skills'][number]; + const fresh: ServeWorkspaceSkillsStatus = { + v: 1, + workspaceCwd: '/ws', + initialized: true, + skills: [skill('review')], + }; + const later: ServeWorkspaceSkillsStatus = { + v: 1, + workspaceCwd: '/ws', + initialized: true, + skills: [skill('plan')], + }; + const query = vi + .fn() + .mockImplementationOnce(() => stale.promise) + .mockResolvedValueOnce(fresh) + .mockResolvedValueOnce(later); + const queryWorkspaceStatus: QueryWorkspaceStatusFn = async () => + (await query()) as T; + const svc = createDaemonWorkspaceService( + makeDeps({ queryWorkspaceStatus, boundWorkspace: '/ws' }), + ); + + try { + const supersededRead = svc.getWorkspaceSkillsStatus(makeCtx()); + svc.invalidateWorkspaceSkillsStatus(); + await expect(svc.getWorkspaceSkillsStatus(makeCtx())).resolves.toEqual( + fresh, + ); + + now += 4_000; + // The superseded read answers late and uninitialized, so it falls back + // to the committed snapshot. + stale.resolve({ + v: 1, + workspaceCwd: '/ws', + initialized: false, + skills: [], + }); + await expect(supersededRead).resolves.toEqual(fresh); + + // 5_001ms after `fresh` was committed: the window is over regardless of + // when the superseded read happened to finish. + now += 1_001; + await expect(svc.getWorkspaceSkillsStatus(makeCtx())).resolves.toEqual( + later, + ); + expect(query).toHaveBeenCalledTimes(3); + } finally { + nowSpy.mockRestore(); + } + }); + + it('shares one workspace skills query between concurrent readers', async () => { + const pending = deferred(); + const query = vi.fn(() => pending.promise); + const queryWorkspaceStatus: QueryWorkspaceStatusFn = async () => + (await query()) as T; + const svc = createDaemonWorkspaceService( + makeDeps({ queryWorkspaceStatus, boundWorkspace: '/ws' }), + ); + + const first = svc.getWorkspaceSkillsStatus(makeCtx()); + const second = svc.getWorkspaceSkillsStatus(makeCtx()); + pending.resolve({ + v: 1, + workspaceCwd: '/ws', + initialized: true, + skills: [ + { + kind: 'skill', + status: 'ok', + name: 'review', + description: 'Review code', + level: 'bundled', + modelInvocable: true, + }, + ], + }); + + await expect(Promise.all([first, second])).resolves.toEqual([ + expect.objectContaining({ initialized: true }), + expect.objectContaining({ initialized: true }), + ]); + expect(query).toHaveBeenCalledOnce(); + }); + + it('does not cache a workspace skills query invalidated while in flight', async () => { + const stale = deferred(); + const freshStatus: ServeWorkspaceSkillsStatus = { + v: 1, + workspaceCwd: '/ws', + initialized: true, + skills: [ + { + kind: 'skill', + status: 'ok', + name: 'plan', + description: 'Plan changes', + level: 'bundled', + modelInvocable: true, + }, + ], + }; + const query = vi + .fn() + .mockImplementationOnce(() => stale.promise) + .mockResolvedValueOnce(freshStatus); + const queryWorkspaceStatus: QueryWorkspaceStatusFn = async () => + (await query()) as T; + const svc = createDaemonWorkspaceService( + makeDeps({ queryWorkspaceStatus, boundWorkspace: '/ws' }), + ); + + const staleRead = svc.getWorkspaceSkillsStatus(makeCtx()); + svc.invalidateWorkspaceSkillsStatus(); + const freshRead = svc.getWorkspaceSkillsStatus(makeCtx()); + + await expect(freshRead).resolves.toEqual(freshStatus); + stale.resolve({ + v: 1, + workspaceCwd: '/ws', + initialized: true, + skills: [ + { + kind: 'skill', + status: 'ok', + name: 'review', + description: 'Review code', + level: 'bundled', + modelInvocable: true, + }, + ], + }); + await expect(staleRead).resolves.toEqual(freshStatus); + await expect(svc.getWorkspaceSkillsStatus(makeCtx())).resolves.toEqual( + freshStatus, + ); + expect(query).toHaveBeenCalledTimes(2); }); it('invalidateWorkspaceSkillsStatus drops the cached child skills answer', async () => { @@ -903,10 +1127,13 @@ describe('createDaemonWorkspaceService', () => { ); const result = await svc.getWorkspaceSkillsStatus(makeCtx()); + const cached = await svc.getWorkspaceSkillsStatus(makeCtx()); expect(workspaceSkillsStatusProvider).toHaveBeenCalledWith('/ws'); + expect(workspaceSkillsStatusProvider).toHaveBeenCalledOnce(); expect(result.initialized).toBe(true); expect(result.skills.map((s) => s.name)).toEqual(['review']); + expect(cached).toEqual(result); }); it('getWorkspaceSkillsStatus prefers the cached child answer over the daemon-local provider', async () => { @@ -1405,7 +1632,7 @@ describe('createDaemonWorkspaceService', () => { expect(invalidate).toHaveBeenCalledWith('/workspace'); expect(invokeWorkspaceCommand).toHaveBeenCalledWith( 'qwen/control/workspace/skills/refresh', - { cwd: '/workspace' }, + { cwd: '/workspace', reason: 'settings' }, ); expect(result).toEqual({ skillName: 'review', @@ -1426,6 +1653,72 @@ describe('createDaemonWorkspaceService', () => { }); }); + it('does not retain a status snapshot read while a settings refresh is in flight', async () => { + const refresh = deferred<{ + sessionsRefreshed: number; + sessionsFailed: number; + }>(); + const oldSkill: ServeWorkspaceSkillsStatus['skills'][number] = { + kind: 'skill', + status: 'ok', + name: 'review', + description: 'Review changed code', + level: 'bundled', + modelInvocable: true, + }; + const oldStatus: ServeWorkspaceSkillsStatus = { + v: 1, + workspaceCwd: '/workspace', + initialized: true, + skills: [oldSkill], + }; + const newStatus: ServeWorkspaceSkillsStatus = { + ...oldStatus, + skills: [ + { + ...oldSkill, + status: 'disabled', + disabledReason: 'hard', + }, + ], + }; + const queryWorkspaceStatus = vi + .fn() + .mockResolvedValueOnce(oldStatus) + .mockResolvedValueOnce(oldStatus) + .mockResolvedValueOnce(newStatus); + const invokeWorkspaceCommand = vi.fn( + () => refresh.promise, + ) as unknown as InvokeWorkspaceCommandFn; + const svc = createDaemonWorkspaceService( + makeDeps({ + queryWorkspaceStatus, + persistDisabledSkills: vi.fn().mockResolvedValue({ + changed: true, + disabled: ['review'], + }), + invokeWorkspaceCommand, + isChannelLive: () => true, + }), + ); + + const toggle = svc.setWorkspaceSkillEnabled(makeCtx(), 'review', false); + await vi.waitFor(() => + expect(invokeWorkspaceCommand).toHaveBeenCalledOnce(), + ); + + await expect(svc.getWorkspaceSkillsStatus(makeCtx())).resolves.toEqual( + oldStatus, + ); + refresh.resolve({ sessionsRefreshed: 1, sessionsFailed: 0 }); + await toggle; + + await expect(svc.getWorkspaceSkillsStatus(makeCtx())).resolves.toEqual( + newStatus, + ); + expect(queryWorkspaceStatus).toHaveBeenCalledTimes(3); + }); + it('publishes an explicit enabled override for a default-disabled skill', async () => { const publishWorkspaceEvent = vi.fn(); const svc = createDaemonWorkspaceService( diff --git a/packages/cli/src/serve/workspace-service/index.ts b/packages/cli/src/serve/workspace-service/index.ts index f9f7fc5d39..1964438cd2 100644 --- a/packages/cli/src/serve/workspace-service/index.ts +++ b/packages/cli/src/serve/workspace-service/index.ts @@ -119,6 +119,8 @@ export { // Helpers // --------------------------------------------------------------------------- +const WORKSPACE_SKILLS_SNAPSHOT_TTL_MS = 5_000; + /** * Walk up from `inputPath` until we find an ancestor that exists on disk, * then `realpath` it. Used by `initWorkspace` to canonicalize the parent @@ -245,61 +247,129 @@ export function createDaemonWorkspaceService( // skill-backed slash commands (e.g. `/review`) keep autocompleting after // the child channel has been reaped. See `getWorkspaceSkillsStatus`. let lastWorkspaceSkillsStatus: ServeWorkspaceSkillsStatus | undefined; + let lastWorkspaceSkillsStatusAt = 0; + let workspaceSkillsGeneration = 0; + let inFlightWorkspaceSkillsStatus: + | { + generation: number; + promise: Promise; + } + | undefined; let inFlightAcpPreheat: Promise | undefined; - const getWorkspaceSkillsStatus = - async (): Promise => { - let status: ServeWorkspaceSkillsStatus; - try { - status = await queryWorkspaceStatus( - SERVE_STATUS_EXT_METHODS.workspaceSkills, - () => createIdleWorkspaceSkillsStatus(boundWorkspace), - ); - } catch (err) { - // The channel can die mid-RPC (`liveChannelInfo()` was valid at the - // check but the child exited before the call completed). Treat that - // like "no live child" and fall back to the cache / daemon-local - // enumeration below instead of failing the request — matching - // getWorkspaceEnvStatus / getWorkspacePreflightStatus. - writeStderrLine( - `qwen serve: getWorkspaceSkillsStatus query failed: ${err instanceof Error ? err.message : String(err)}`, - ); - status = createIdleWorkspaceSkillsStatus(boundWorkspace); - } - if (status.initialized) { - lastWorkspaceSkillsStatus = status; - return status; - } - // Live child unavailable. Prefer the last answer it produced (keeps the - // full, extension-aware list available across a reap)... - if (lastWorkspaceSkillsStatus) return lastWorkspaceSkillsStatus; - // ...then fall back to daemon-local enumeration, so a child that has not - // answered even once (e.g. a preheat that times out under `npm run dev`) - // still yields the on-disk skills — `/review` included. The provider - // handles its own errors, but it is injected, so guard the call too and - // degrade to the idle placeholder rather than failing the request — - // matching getWorkspaceEnvStatus / getWorkspacePreflightStatus. - if (workspaceSkillsStatusProvider) { - try { - return await workspaceSkillsStatusProvider(boundWorkspace); - } catch (err) { - writeStderrLine( - `qwen serve: getWorkspaceSkillsStatus local provider failed: ${err instanceof Error ? err.message : String(err)}`, - ); - } - } + const invalidateWorkspaceSkillsSnapshot = () => { + workspaceSkillsGeneration += 1; + lastWorkspaceSkillsStatus = undefined; + lastWorkspaceSkillsStatusAt = 0; + workspaceSkillsStatusProvider?.invalidate?.(boundWorkspace); + }; + + const readWorkspaceSkillsStatus = async ( + generation: number, + ): Promise => { + let status: ServeWorkspaceSkillsStatus; + try { + status = await queryWorkspaceStatus( + SERVE_STATUS_EXT_METHODS.workspaceSkills, + () => createIdleWorkspaceSkillsStatus(boundWorkspace), + ); + } catch (err) { + // The channel can die mid-RPC (`liveChannelInfo()` was valid at the + // check but the child exited before the call completed). Treat that + // like "no live child" and fall back to the cache / daemon-local + // enumeration below instead of failing the request — matching + // getWorkspaceEnvStatus / getWorkspacePreflightStatus. + writeStderrLine( + `qwen serve: getWorkspaceSkillsStatus query failed: ${err instanceof Error ? err.message : String(err)}`, + ); + status = createIdleWorkspaceSkillsStatus(boundWorkspace); + } + if (status.initialized && generation === workspaceSkillsGeneration) { + lastWorkspaceSkillsStatus = status; + lastWorkspaceSkillsStatusAt = Date.now(); return status; + } + // Live child unavailable. Prefer the last answer it produced (keeps the + // full, extension-aware list available across a reap)... + if (lastWorkspaceSkillsStatus) { + // Only extend the freshness window when this read still owns the current + // generation. A read that started before an invalidation must not push out + // the TTL of the snapshot some later read committed — that would let a + // post-mutation snapshot go unrevalidated for longer than the window. + if (generation === workspaceSkillsGeneration) { + lastWorkspaceSkillsStatusAt = Date.now(); + } + return lastWorkspaceSkillsStatus; + } + // ...then fall back to daemon-local enumeration, so a child that has not + // answered even once (e.g. a preheat that times out under `npm run dev`) + // still yields the on-disk skills — `/review` included. The provider + // handles its own errors, but it is injected, so guard the call too and + // degrade to the idle placeholder rather than failing the request — + // matching getWorkspaceEnvStatus / getWorkspacePreflightStatus. + if (workspaceSkillsStatusProvider) { + try { + const localStatus = await workspaceSkillsStatusProvider(boundWorkspace); + if ( + localStatus.initialized && + generation === workspaceSkillsGeneration + ) { + lastWorkspaceSkillsStatus = localStatus; + lastWorkspaceSkillsStatusAt = Date.now(); + } + return localStatus; + } catch (err) { + writeStderrLine( + `qwen serve: getWorkspaceSkillsStatus local provider failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + return status; + }; + + const getWorkspaceSkillsStatus = (): Promise => { + const cacheAgeMs = Date.now() - lastWorkspaceSkillsStatusAt; + if ( + lastWorkspaceSkillsStatus && + cacheAgeMs >= 0 && + cacheAgeMs < WORKSPACE_SKILLS_SNAPSHOT_TTL_MS + ) { + return Promise.resolve(lastWorkspaceSkillsStatus); + } + + const generation = workspaceSkillsGeneration; + if (inFlightWorkspaceSkillsStatus?.generation === generation) { + return inFlightWorkspaceSkillsStatus.promise; + } + + const promise = readWorkspaceSkillsStatus(generation); + inFlightWorkspaceSkillsStatus = { generation, promise }; + const clearInFlight = () => { + if (inFlightWorkspaceSkillsStatus?.promise === promise) { + inFlightWorkspaceSkillsStatus = undefined; + } }; + void promise.then(clearInFlight, clearInFlight); + return promise; + }; const refreshWorkspaceSkillsAfterMutation = async (): Promise => { - lastWorkspaceSkillsStatus = undefined; - workspaceSkillsStatusProvider?.invalidate?.(boundWorkspace); + invalidateWorkspaceSkillsSnapshot(); if (!(isChannelLive?.() ?? false)) return; try { - await invokeWorkspaceCommand( - SERVE_CONTROL_EXT_METHODS.workspaceSkillsRefresh, - { cwd: boundWorkspace }, - ); + const refreshed = + await invokeWorkspaceCommand( + SERVE_CONTROL_EXT_METHODS.workspaceSkillsRefresh, + { cwd: boundWorkspace, reason: 'content' }, + ); + // `content` is the only reason that refreshes skill caches, so this is + // the one path where a non-zero count is meaningful. The mutation itself + // still succeeded; surface the partial refresh rather than dropping it. + if ((refreshed.configsFailed ?? 0) > 0) { + writeStderrLine( + `qwen serve: ${refreshed.configsFailed} skill cache refresh(es) failed after mutation`, + ); + } } catch (err) { if ( !(err instanceof SessionNotFoundError) && @@ -309,6 +379,8 @@ export function createDaemonWorkspaceService( `qwen serve: workspace skill refresh after mutation failed: ${err instanceof Error ? err.message : String(err)}`, ); } + } finally { + invalidateWorkspaceSkillsSnapshot(); } }; @@ -786,17 +858,19 @@ export function createDaemonWorkspaceService( let sessionsFailed = 0; if (persisted.changed) { - lastWorkspaceSkillsStatus = undefined; - workspaceSkillsStatusProvider?.invalidate?.(boundWorkspace); + invalidateWorkspaceSkillsSnapshot(); if (channelLive) { try { const refreshed = await invokeWorkspaceCommand( SERVE_CONTROL_EXT_METHODS.workspaceSkillsRefresh, - { cwd: boundWorkspace }, + { cwd: boundWorkspace, reason: 'settings' }, ); assertActiveGeneration(); sessionsRefreshed = refreshed.sessionsRefreshed; + // `reason: 'settings'` never touches skill caches, so + // `configsFailed` is structurally 0 here — folding it in would only + // conflate two different failures behind one count. sessionsFailed = refreshed.sessionsFailed; if (sessionsFailed > 0) activation = 'partial'; } catch (err) { @@ -813,6 +887,7 @@ export function createDaemonWorkspaceService( ); } } + invalidateWorkspaceSkillsSnapshot(); } assertActiveGeneration(); @@ -1247,7 +1322,7 @@ export function createDaemonWorkspaceService( }, invalidateWorkspaceSkillsStatus() { - lastWorkspaceSkillsStatus = undefined; + invalidateWorkspaceSkillsSnapshot(); }, async refreshExtensionsForAllSessions() { @@ -1263,7 +1338,7 @@ export function createDaemonWorkspaceService( ); return { refreshed: 0, failed: 1 }; } finally { - lastWorkspaceSkillsStatus = undefined; + invalidateWorkspaceSkillsSnapshot(); } }, }; diff --git a/packages/core/src/extension/extensionManager.test.ts b/packages/core/src/extension/extensionManager.test.ts index 29fe55f582..b4bb9dcbdd 100644 --- a/packages/core/src/extension/extensionManager.test.ts +++ b/packages/core/src/extension/extensionManager.test.ts @@ -1059,6 +1059,150 @@ describe('extension tests', () => { }); }); + describe('refreshCacheIfSourcesChanged', () => { + // Extension sources have no watcher, so read-only consumers rely on this to + // stay eventually consistent with mutations made outside the process + // (`qwen extensions install` in a terminal) without scanning on every read. + // See docs/design/workspace-skills-read-model.md. + it('does not refresh while the sources are unchanged', async () => { + createExtension({ extensionsDir: userExtensionsDir, name: 'ext-a' }); + const manager = createExtensionManager(); + await manager.refreshCache(); + expect(manager.getLoadedExtensions()).toHaveLength(1); + + const refreshSpy = vi.spyOn(manager, 'refreshCache'); + for (let i = 0; i < 20; i++) { + expect(await manager.refreshCacheIfSourcesChanged()).toBe(false); + } + + expect(refreshSpy).not.toHaveBeenCalled(); + expect(manager.getLoadedExtensions()).toHaveLength(1); + }); + + it('refreshes once a new extension appears on disk', async () => { + createExtension({ extensionsDir: userExtensionsDir, name: 'ext-a' }); + const manager = createExtensionManager(); + await manager.refreshCache(); + expect(manager.getLoadedExtensions()).toHaveLength(1); + + createExtension({ extensionsDir: userExtensionsDir, name: 'ext-b' }); + + expect(await manager.refreshCacheIfSourcesChanged()).toBe(true); + expect( + manager + .getLoadedExtensions() + .map((e) => e.name) + .sort(), + ).toEqual(['ext-a', 'ext-b']); + // The refresh commits a new baseline, so the next call is a no-op again. + expect(await manager.refreshCacheIfSourcesChanged()).toBe(false); + }); + + it('refreshes after an extension is removed', async () => { + createExtension({ extensionsDir: userExtensionsDir, name: 'ext-a' }); + const manager = createExtensionManager(); + await manager.refreshCache(); + + fs.rmSync(path.join(userExtensionsDir, 'ext-a'), { + recursive: true, + force: true, + }); + + expect(await manager.refreshCacheIfSourcesChanged()).toBe(true); + expect(manager.getLoadedExtensions()).toHaveLength(0); + }); + + it('refreshes after an in-place manifest edit', async () => { + createExtension({ extensionsDir: userExtensionsDir, name: 'ext-a' }); + const manager = createExtensionManager(); + await manager.refreshCache(); + expect(manager.getLoadedExtensions()[0]?.version).toBe('1.0.0'); + + // Rewriting the manifest changes neither the extensions dir nor the + // extension dir mtime on every platform, which is why the fingerprint + // covers each manifest itself. The new version is a different length so + // the size differs too — otherwise this would depend on the filesystem's + // mtime granularity. + fs.writeFileSync( + path.join(userExtensionsDir, 'ext-a', EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ name: 'ext-a', version: '10.0.0', mcpServers: {} }), + ); + + expect(await manager.refreshCacheIfSourcesChanged()).toBe(true); + expect(manager.getLoadedExtensions()[0]?.version).toBe('10.0.0'); + }); + + it('shares one refresh between concurrent callers', async () => { + createExtension({ extensionsDir: userExtensionsDir, name: 'ext-a' }); + const manager = createExtensionManager(); + await manager.refreshCache(); + + createExtension({ extensionsDir: userExtensionsDir, name: 'ext-b' }); + const refreshSpy = vi.spyOn(manager, 'refreshCache'); + + const results = await Promise.all([ + manager.refreshCacheIfSourcesChanged(), + manager.refreshCacheIfSourcesChanged(), + manager.refreshCacheIfSourcesChanged(), + ]); + + expect(results).toEqual([true, true, true]); + expect(refreshSpy).toHaveBeenCalledOnce(); + }); + + it('does not mask a change that lands while a refresh is running', async () => { + // The committed baseline is captured before the load, so a write that + // races the refresh leaves the fingerprint stale and is still seen next + // time. Stamping after the load would swallow it until something else + // moved on disk. + createExtension({ extensionsDir: userExtensionsDir, name: 'ext-a' }); + const manager = createExtensionManager(); + await manager.refreshCache(); + expect(manager.getLoadedExtensions()).toHaveLength(1); + + const realLoad = manager['loadExtensionsFromExtensionsDir'].bind(manager); + let raced = false; + vi.spyOn( + manager as unknown as { + loadExtensionsFromExtensionsDir: ( + ...args: unknown[] + ) => Promise; + }, + 'loadExtensionsFromExtensionsDir', + ).mockImplementation(async (...args: unknown[]) => { + const loaded = await ( + realLoad as (...a: unknown[]) => Promise + )(...args); + if (!raced) { + raced = true; + // Lands after this refresh has already read the directory. + createExtension({ extensionsDir: userExtensionsDir, name: 'ext-b' }); + } + return loaded; + }); + + // Triggered by the enablement file moving, so the first refresh does not + // observe ext-b. + fs.writeFileSync( + path.join(userExtensionsDir, 'extension-enablement.json'), + JSON.stringify({ touched: true }), + ); + expect(await manager.refreshCacheIfSourcesChanged()).toBe(true); + expect(manager.getLoadedExtensions()).toHaveLength(1); + + vi.restoreAllMocks(); + + // The racing install is still visible to the next check. + expect(await manager.refreshCacheIfSourcesChanged()).toBe(true); + expect( + manager + .getLoadedExtensions() + .map((e) => e.name) + .sort(), + ).toEqual(['ext-a', 'ext-b']); + }); + }); + describe('loadExtension', () => { it('uses the injected extension store root for discovery', async () => { const customExtensionsDir = path.join(tempHomeDir, 'custom-extensions'); diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index 05ecb4bfbd..31f308b5ab 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -436,6 +436,9 @@ export class ExtensionManager { private readonly networkPolicy?: ExtensionInstallMetadata['networkPolicy']; private readonly preparedMutations = new WeakSet(); private discoverCache: DiscoveredPlugin[] | null = null; + /** See `sourceFingerprint`. `undefined` until the first refresh commits. */ + private lastSourceFingerprint: string | undefined; + private inFlightSourceRevalidation: Promise | undefined; private withNetworkPolicy( installMetadata: ExtensionInstallMetadata | undefined, @@ -1085,6 +1088,11 @@ export class ExtensionManager { names?: string[]; }): Promise { const requestedNames = options?.names?.filter(Boolean) ?? []; + // Captured before the load, not after: an install landing mid-refresh must + // leave the committed fingerprint stale so the next check still sees it. + // Stamping post-load would mask that change until something else moved. + const dirFingerprintBeforeLoad = + requestedNames.length === 0 ? this.extensionDirFingerprint() : undefined; const { value: extensions, snapshot } = await this.extensionStore.readConsistent(async () => { let loaded: Extension[]; @@ -1115,9 +1123,129 @@ export class ExtensionManager { }); this.extensionCache = nextCache; this.applyStoreActivation(snapshot); + // Only a full refresh establishes a baseline. A name-filtered refresh leaves + // the cache partial, so claiming the whole directory is up to date would let + // `refreshCacheIfSourcesChanged` report "unchanged" over a partial set. + if (dirFingerprintBeforeLoad !== undefined) { + this.lastSourceFingerprint = this.sourceFingerprint( + dirFingerprintBeforeLoad, + ); + } return snapshot; } + private static stampPath(target: string): string { + try { + const stats = fs.statSync(target); + return `${stats.mtimeMs}:${stats.size}`; + } catch { + // Absent is a real state and must not collide with any present one — + // otherwise deleting the last extension would look unchanged. + return '-'; + } + } + + /** + * Fingerprints which extension directories exist (install / uninstall) and + * each manifest's mtime and size (in-place edits). + * + * A pure function of on-disk state, deliberately independent of the current + * cache, so the same disk yields the same value before and after a refresh. + * A refresh never writes these paths, which is what makes it safe to commit + * the pre-load value — see `refreshCacheWithSnapshot`. + * + * Deliberately cheap: one `readdir` plus one `stat` per entry, where + * `refreshCache()` parses every manifest and re-lists every extension skill + * directory. That difference is what lets a status read stay self-healing + * without becoming a directory scan. + * + * mtime-and-size is the usual stat-based approximation, so an edit that + * preserves both is not detected. That is acceptable here: this is only the + * out-of-band safety net — mutations made through the daemon invalidate + * explicitly and never rely on it. + */ + private extensionDirFingerprint(): string { + let entries: string[]; + try { + entries = fs.readdirSync(this.configDir); + } catch { + return 'dir:-'; + } + const parts: string[] = []; + for (const entry of entries) { + const stamp = ExtensionManager.stampPath( + path.join(this.configDir, entry, EXTENSIONS_CONFIG_FILENAME), + ); + // Entries with no manifest are not extensions — notably the enablement + // file, which lives in this directory and is created lazily by the store. + // Counting them would make the store's own bookkeeping look like an + // install and cost one spurious refresh. + if (stamp === '-') continue; + parts.push(`ext:${entry}:${stamp}`); + } + // Sorted so directory iteration order cannot make an unchanged set look + // moved. + return parts.sort().join('|'); + } + + /** + * Fingerprints the enablement file and the store's activation state — where + * `enable` / `disable` land. + * + * Unlike the directory part this is stamped *after* a refresh, because a + * refresh writes the store itself. That is safe: store mutations hold the + * store lock, so no external write can interleave with the refresh and be + * masked by the post-load stamp. + */ + private extensionStoreFingerprint(): string { + return [ + `enablement:${ExtensionManager.stampPath(this.configFilePath)}`, + `state:${ExtensionManager.stampPath( + path.join(this.extensionStore.storeDir, 'state.json'), + )}`, + ].join('|'); + } + + private sourceFingerprint(dirFingerprint: string): string { + return `${dirFingerprint}||${this.extensionStoreFingerprint()}`; + } + + /** + * Refreshes the cache only when the on-disk extension sources moved since the + * last refresh. Returns whether a refresh actually ran. + * + * Extension sources have no watcher (skills do — see + * `SkillManager.startWatching`), so read-only consumers that must not scan on + * every call use this to stay eventually consistent with `qwen extensions + * install` / `enable` / `disable` run outside the process. + * + * Concurrent callers share one refresh, so a caller can join a refresh that + * started just before the change it cares about. That is bounded rather than + * lost: the committed baseline is the pre-load fingerprint, so the change is + * still visible to the next call. + */ + async refreshCacheIfSourcesChanged(): Promise { + const inFlight = this.inFlightSourceRevalidation; + if (inFlight) return await inFlight; + const current = this.sourceFingerprint(this.extensionDirFingerprint()); + if (this.lastSourceFingerprint === current) return false; + const revalidation = (async () => { + // `refreshCache` commits the new baseline itself, from its pre-load + // fingerprint. A throw leaves the old baseline in place so the next call + // retries rather than assuming the refresh landed. + await this.refreshCache(); + return true; + })(); + this.inFlightSourceRevalidation = revalidation; + const clear = () => { + if (this.inFlightSourceRevalidation === revalidation) { + this.inFlightSourceRevalidation = undefined; + } + }; + void revalidation.then(clear, clear); + return await revalidation; + } + getLoadedExtensions(): Extension[] { if (!this.extensionCache) { return []; diff --git a/packages/core/src/skills/skill-manager.read-model.test.ts b/packages/core/src/skills/skill-manager.read-model.test.ts new file mode 100644 index 0000000000..7cf5a19d81 --- /dev/null +++ b/packages/core/src/skills/skill-manager.read-model.test.ts @@ -0,0 +1,135 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Regression guard for #8079 — workspace skill status polling repeatedly + * triggered full skill rescans (232,406 `SKILL_LOAD` lines in one daemon + * session). + * + * Deliberately unmocked: the rest of `skill-manager.test.ts` mocks `fs`, which + * can only prove that `refreshCache()` was not *called*. The invariant that + * actually broke is that a status read must not touch the filesystem, so this + * file drives a real temp tree and counts syscalls. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { SkillManager } from './skill-manager.js'; +import type { Config } from '../config/config.js'; + +// Counting wrappers that delegate to the real implementations — ESM namespaces +// are not configurable, so `vi.spyOn` cannot be used on them directly. +const { readFileSpy, readdirSpy } = vi.hoisted(() => ({ + readFileSpy: vi.fn(), + readdirSpy: vi.fn(), +})); + +vi.mock('fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + readFileSpy.mockImplementation(actual.readFile); + readdirSpy.mockImplementation(actual.readdir); + return { + ...actual, + default: actual, + readFile: readFileSpy, + readdir: readdirSpy, + }; +}); + +const fsPromises = await import('fs/promises'); + +const SKILL_COUNT = 6; + +function makeSkillManagerConfig(projectRoot: string): Config { + return { + isSafeMode: () => false, + getBareMode: () => false, + getProjectRoot: () => projectRoot, + getActiveExtensions: () => [], + } as unknown as Config; +} + +describe('workspace skills read model (real filesystem)', () => { + let projectRoot: string; + let manager: SkillManager; + + beforeEach(async () => { + projectRoot = await fsPromises.mkdtemp( + path.join(os.tmpdir(), 'qwen-skill-read-model-'), + ); + const skillsDir = path.join(projectRoot, '.qwen', 'skills'); + for (let i = 0; i < SKILL_COUNT; i++) { + const dir = path.join(skillsDir, `skill-${i}`); + await fsPromises.mkdir(dir, { recursive: true }); + await fsPromises.writeFile( + path.join(dir, 'SKILL.md'), + `---\nname: skill-${i}\ndescription: Skill number ${i}\n---\n\nBody ${i}\n`, + 'utf-8', + ); + } + + manager = new SkillManager(makeSkillManagerConfig(projectRoot)); + // Cleared after the tree is built so fixture setup is not counted. + readFileSpy.mockClear(); + readdirSpy.mockClear(); + }); + + afterEach(async () => { + await fsPromises.rm(projectRoot, { recursive: true, force: true }); + }); + + it('serves repeated cached reads without any filesystem work', async () => { + await manager.refreshCache(); + + // Sanity: the refresh really did the scan and parse we are about to assert + // does not repeat. Without this the test would also pass if the fixture + // silently produced no skills. Counts are scoped to the project level — + // user and bundled levels resolve against the real environment. + const readsAfterRefresh = readFileSpy.mock.calls.length; + const dirReadsAfterRefresh = readdirSpy.mock.calls.length; + expect(manager.getCachedSkills('project')).toHaveLength(SKILL_COUNT); + expect(readsAfterRefresh).toBeGreaterThanOrEqual(SKILL_COUNT); + expect(dirReadsAfterRefresh).toBeGreaterThan(0); + + // The shape of the reported bug: many status reads in a row. + for (let i = 0; i < 50; i++) { + expect(manager.getCachedSkills('project')).toHaveLength(SKILL_COUNT); + } + + expect(readFileSpy.mock.calls.length).toBe(readsAfterRefresh); + expect(readdirSpy.mock.calls.length).toBe(dirReadsAfterRefresh); + }); + + it('reports a cold cache instead of warming it', async () => { + expect(manager.getCachedSkills()).toBeNull(); + // A cold read must stay cold — this is what lets the daemon represent + // "not initialized yet" instead of a status request paying for discovery. + expect(manager.getCachedSkills()).toBeNull(); + + expect(readFileSpy).not.toHaveBeenCalled(); + expect(readdirSpy).not.toHaveBeenCalled(); + }); + + it('still picks up on-disk changes through an explicit refresh', async () => { + await manager.refreshCache(); + expect(manager.getCachedSkills('project')).toHaveLength(SKILL_COUNT); + + const added = path.join(projectRoot, '.qwen', 'skills', 'skill-added'); + await fsPromises.mkdir(added, { recursive: true }); + await fsPromises.writeFile( + path.join(added, 'SKILL.md'), + '---\nname: skill-added\ndescription: Added later\n---\n\nBody\n', + 'utf-8', + ); + + // Cached read stays on the committed snapshot... + expect(manager.getCachedSkills('project')).toHaveLength(SKILL_COUNT); + // ...and the explicit refresh is what publishes the new one. + await manager.refreshCache(); + expect(manager.getCachedSkills('project')).toHaveLength(SKILL_COUNT + 1); + }); +}); diff --git a/packages/core/src/skills/skill-manager.test.ts b/packages/core/src/skills/skill-manager.test.ts index fe781a81f9..f90d3763ef 100644 --- a/packages/core/src/skills/skill-manager.test.ts +++ b/packages/core/src/skills/skill-manager.test.ts @@ -744,6 +744,23 @@ Skill 3 content`); ]); }); + it('reads the committed cache without triggering discovery', async () => { + expect(manager.getCachedSkills()).toBeNull(); + expect(fs.readdir).not.toHaveBeenCalled(); + + await manager.listSkills(); + vi.mocked(fs.readdir).mockClear(); + vi.mocked(fs.readFile).mockClear(); + + expect(manager.getCachedSkills()?.map((skill) => skill.name)).toEqual([ + 'skill1', + 'skill2', + 'skill3', + ]); + expect(fs.readdir).not.toHaveBeenCalled(); + expect(fs.readFile).not.toHaveBeenCalled(); + }); + it('should prioritize project level over user level', async () => { const skills = await manager.listSkills(); const skill1 = skills.find((s) => s.name === 'skill1'); diff --git a/packages/core/src/skills/skill-manager.ts b/packages/core/src/skills/skill-manager.ts index 712c73351b..1e32bfac83 100644 --- a/packages/core/src/skills/skill-manager.ts +++ b/packages/core/src/skills/skill-manager.ts @@ -255,13 +255,6 @@ export class SkillManager { debugLogger.debug( `Listing skills${options.level ? ` at level: ${options.level}` : ''}${options.force ? ' (forced refresh)' : ''}`, ); - const skills: SkillConfig[] = []; - const seenNames = new Set(); - - const levelsToCheck: SkillLevel[] = options.level - ? [options.level] - : ['project', 'user', 'extension', 'bundled']; - // Check if we should use cache or force refresh const shouldUseCache = !options.force && this.skillsCache !== null; @@ -273,6 +266,30 @@ export class SkillManager { debugLogger.debug('Using cached skills'); } + const skills = this.collectCachedSkills(options.level); + debugLogger.info(`Listed ${skills.length} unique skills`); + return skills; + } + + /** + * Returns the currently committed cache without triggering discovery. + * + * Status and diagnostics callers must use this method instead of + * `listSkills()` so a read-only request cannot turn a cold cache into a + * filesystem scan. `null` means no refresh has committed yet. + */ + getCachedSkills(level?: SkillLevel): SkillConfig[] | null { + if (this.skillsCache === null) return null; + return this.collectCachedSkills(level); + } + + private collectCachedSkills(level?: SkillLevel): SkillConfig[] { + const skills: SkillConfig[] = []; + const seenNames = new Set(); + const levelsToCheck: SkillLevel[] = level + ? [level] + : ['project', 'user', 'extension', 'bundled']; + // Collect skills from each level (precedence: project > user > extension > bundled) for (const level of levelsToCheck) { const levelSkills = this.skillsCache?.get(level) || []; @@ -300,8 +317,6 @@ export class SkillManager { // programmatic consumers — notably SkillTool's model-facing // `` description — are not reordered by priority. skills.sort((a, b) => a.name.localeCompare(b.name)); - - debugLogger.info(`Listed ${skills.length} unique skills`); return skills; } From 36a3fb2fa269458ca0c695d98eb7fcf2db7f3bdc Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Thu, 30 Jul 2026 21:06:35 +0800 Subject: [PATCH 10/17] feat(review): statement-level mutation probes in test-efficacy (#8020) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(review): statement-level mutation probes in test-efficacy The revert probe is all-or-nothing: it reverts whole production files, so a suite that gates six of a diff's behaviours goes red and the probe says "gated" — even when the seventh behaviour, a one-line safety statement, has no test at all. Dogfooded on a live PR: deleting a single `reminders.clear()` inside the not-continued branch left the full 471-test suite green, and that line carried the PR's headline safety property (an abandoned task's todos must not bleed into an unrelated new prompt). A human reviewer found it with a hand-rolled mutation probe; the command could not. Add the probe kind the human ran, deterministically. Candidates are added lines from the committed head (never the dirty worktree) whose trimmed statement calls a safety verb — .clear(), .delete(), .reset(), .abort(), .removeListener(), .unref() — or reassigns state to empty ([] / new Map()/Set()), and that are removable as a whole: single complete expression statements, brace-balanced via a string/comment-aware scanner, previous significant line ending ;, { or } (which rejects fluent tails, continuations, and the brace-less-if silent-rebind trap), outside template literals and block comments. Selection is conservative by design: a false negative costs nothing, a false positive burns a suite run. Each mutant (capped at 8, files with collocated new tests first) deletes its one line in the existing probe worktree and re-runs the affected suites through the existing vitest-json classifier: red = killed (the line is guarded), green = SURVIVED — the invariant it enforces ships unprotected — filed as a finding in the unreachable/inert register so the Agent-7 pipeline picks it up without any skill change. Compile/load failure = inconclusive, never a finding. Mutants run only after a cleanly green baseline, inside the command's existing deadline budget (each run must leave room for the revert probe); candidates that no longer fit are counted, not silently dropped. * fix(cli): harden test-efficacy mutant selection text checks (#8020) Mutant selection ran its end-anchored checks on raw trimmed lines, so a trailing comment hid a statement's real end (dropping genuine candidates like `reminders.clear(); // why`) and a safety verb inside a string faked one (a wasted suite run plus a misleading survivor). Route the `SAFETY_VERB_RE`, `endsWith(';')`, and predecessor `/[;{}]$/` checks through a shared `codeOnly()` that strips comments and blanks literal contents first. Also guard the template-state escape skip against a backslash-continued line, mirroring the single/double-quote branch: swallowing that newline dropped a per-line literal flag and shifted every later line's verdict. Adds unit coverage for all three selection fixes and an integration test for the baseline-not-green skip branch. * fix(cli): gitignore fake vitest in test-efficacy integration fixture (#8020) The fake vitest bin was committed by `git add -A` and checked out into the probe worktree as the stale passing copy, so installFailingVitest's overwrite in the main worktree was never seen by npx in the probe tree. The baseline read green and the baseline-not-green skip test failed. * fix(review): make mutation-probe reporting precise (#8020) Address review feedback on the test-efficacy mutation probe: - Count candidates the MAX_MUTANTS cap drops in `skippedForCap` instead of silently losing them, so a capped `survived: 0` cannot read as "every safety statement is covered" (mirrors the existing `skippedForBudget`). - Gate the mutant phase per probe file: run each mutant against the files that are green in the unmutated baseline, so one unrelated quarantined (all-skip) suite — `inconclusive`, not red — no longer disables the whole probe. - Scope the `mutant-survived` finding to the diff's own tests ("confirm an existing test covers it, or add one") rather than asserting the invariant ships unprotected, which an untouched pre-existing test may still cover. * test(review): cover the budget-skip path in mutation-probe integration tests (#8020) * fix(review): harden mutation-probe selection and diff parsing (#8020) * fix(review): handle multi-line class headers in mutation-probe selection (#8020) * fix(review): stop class-body walk at braces before matching class keyword (#8020) * fix(review): whole-file literal scan for mutant selection; pin the untested guard paths The per-line scanner pair (codeOnly + lineStartsInsideLiteral) shared a blind spot: a backtick inside a `${…}` interpolation read as the outer template's closing backtick. That flipped the literal state for every following line and, in the single-line skip, exposed nested-template content as code — so a safety verb inside a string could be selected as a mutant (a false-finding vector) and a class field below a brace-bearing template could slip the class-body rejection. Replace the pair with one whole-file pass that tracks interpolation brace depth: per-line code text with comments stripped and literal contents blanked, plus the same `${}`-aware skip for delimiter scanning. Differential audit over every core+cli source file: zero selection differences on real code; the pathological shapes are pinned by three new tests. Also pin the remaining untested paths from review: the selection-failure catch (discloses and still runs the revert probe), the runOneMutant line-mismatch guard (now exported; inconclusive, file untouched), and the budget-skip stdout disclosure. * fix(review): clamp probe deadlines to the whole-command budget (#8020) * fix(review): harden mutant selection guards and disclosure accounting (#8020) * fix(review): close silent-zero paths in mutant disclosure and harden diff parsing (#8020) * fix(review): drop interpolation quote-skip that mis-parsed regex literals (#8020) * fix(review): track template nesting with a stack; disclose derailed files; harden and pin the remaining probe paths The nested-template fix that landed as a counter cannot represent a nested template INSIDE a nested interpolation: at two levels the deep template's text `}` is charged against the wrong frame, the scan desyncs, and the file either admits template text as a mutant or derail-drops its real candidates. Replace the counter with a stack — one frame per open template, `}` closes only the top interpolation, a backtick closes only the innermost template — and derive the end state from the stack. The two-level trigger is pinned by a test written red-first against the counter. Derailed files are now disclosed, not silently dropped: selectMutants returns them, and the note composer stacks the derail note with the red-baseline note instead of clobbering. The hostile-git-config path gets its missing test (repo diff.srcPrefix/dstPrefix, diff.external, core.quotePath with a non-ASCII path — fails with the pinned flags removed). The budget test drops its Date.now call-count coupling for an injected clock threaded through runTestEfficacy/runProbeSuite. The mutation-phase catch gets an end-to-end test (ENOBUFS mid-phase → all candidates inconclusive, revert probe still runs, report still written). --------- Co-authored-by: verify Co-authored-by: qwen-code-dev-bot Co-authored-by: Qwen Code Bot Co-authored-by: Qwen Code Co-authored-by: Qwen Code Autofix --- .../src/commands/review/agent-prompt.test.ts | 8 + .../cli/src/commands/review/agent-prompt.ts | 21 +- .../review/test-efficacy.integration.test.ts | 826 ++++++++++++++++- .../src/commands/review/test-efficacy.test.ts | 743 +++++++++++++++ .../cli/src/commands/review/test-efficacy.ts | 862 +++++++++++++++++- .../core/src/skills/bundled/review/SKILL.md | 2 +- 6 files changed, 2422 insertions(+), 40 deletions(-) diff --git a/packages/cli/src/commands/review/agent-prompt.test.ts b/packages/cli/src/commands/review/agent-prompt.test.ts index b8c1529342..3e4212a5db 100644 --- a/packages/cli/src/commands/review/agent-prompt.test.ts +++ b/packages/cli/src/commands/review/agent-prompt.test.ts @@ -1864,6 +1864,14 @@ describe('buildRoleBrief — every agent, not just the territory ones', () => { '"${QWEN_CODE_CLI:-qwen}" review test-efficacy /tmp/plan.json', ); expect(p).toContain('--base abc123'); + // All three finding kinds are named, or the agent meets a `mutant-survived` + // it was never told how to file — and the skipped/inconclusive mutants must + // be fenced off from findings the same way the probes' inconclusive is. + expect(p).toContain('`kind: "mutant-survived"`'); + expect(p).toContain('mutants.skippedForBudget'); + expect(p).toContain('mutants.skippedForCap'); + expect(p).toContain('mutants.skippedForBaseline'); + expect(p).toContain('mutants.note'); // No bare executable `qwen` anywhere in this brief. Agent 7 is the one // SUBAGENT that shells out to the review CLI — the one call site neither the // SKILL.md sweep nor check-coverage's stderr hints can reach — and its shell diff --git a/packages/cli/src/commands/review/agent-prompt.ts b/packages/cli/src/commands/review/agent-prompt.ts index 3828a7b49d..4096b16eb9 100644 --- a/packages/cli/src/commands/review/agent-prompt.ts +++ b/packages/cli/src/commands/review/agent-prompt.ts @@ -860,7 +860,9 @@ export function buildRoleBrief( '', '**Then run the test-efficacy probe.** A green suite says the tests pass. It does ' + 'not say they would have failed had the change been wrong, and those are ' + - 'different claims:', + 'different claims. Give this call `timeout: 600000` too — besides the revert ' + + 'probe it runs up to 8 single-statement deletion mutants, each a suite run, and ' + + 'it budgets itself to finish inside that ceiling:', '', '```bash', `"\${QWEN_CODE_CLI:-qwen}" review test-efficacy ${resolve(opts.planPath)} \\`, @@ -872,11 +874,18 @@ export function buildRoleBrief( 'Read its `findings[]`. `kind: "unreachable"` is a test the project\'s test command ' + 'never collects — it did not run here and it does not run in CI. `kind: "inert"` is ' + 'a test that **still passed with the change reverted**: it is green whether or not ' + - 'the feature exists, so it cannot catch a regression in it. Report each as a ' + - '**Suggestion** with `Source: [test]`, saying plainly which behaviour ships ' + - 'unprotected. **`inconclusive` is not a finding** — reverting the source often ' + - "breaks the test's own compile, and that is not the test catching anything. Note it " + - 'and move on.', + 'the feature exists, so it cannot catch a regression in it. `kind: "mutant-survived"` ' + + 'is a single safety statement the diff added (a `.clear()`, an `.abort(…)`, a ' + + 'reset-to-empty) that was **deleted and every affected test stayed green** — no ' + + 'test in the diff fails when it is removed, which the whole-file ' + + "revert cannot see when the file's other, tested behaviours mask it. Report each as a " + + '**Suggestion** with `Source: [test]`, saying plainly which behaviour has no ' + + 'test in this diff that would catch its removal. **`inconclusive` is not a ' + + 'finding** — for probes and mutants alike, ' + + "reverting or mutating the source often breaks the test's own compile, and that is " + + 'not the test catching anything. Mutants counted in `mutants.skippedForBudget`, ' + + '`mutants.skippedForCap`, or `mutants.skippedForBaseline` never ran — not findings ' + + 'either. `mutants.note`, when present, explains why no mutants ran at all. Note them and move on.', ); } } diff --git a/packages/cli/src/commands/review/test-efficacy.integration.test.ts b/packages/cli/src/commands/review/test-efficacy.integration.test.ts index c50196de10..1927d6acd3 100644 --- a/packages/cli/src/commands/review/test-efficacy.integration.test.ts +++ b/packages/cli/src/commands/review/test-efficacy.integration.test.ts @@ -11,7 +11,7 @@ // verdict logic is unit-tested in `classifyProbeRun`; what these lock down is // where the probe runs and what it leaves behind. -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { execFileSync } from 'node:child_process'; import { mkdtempSync, @@ -25,13 +25,14 @@ import { } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { testEfficacyCommand } from './test-efficacy.js'; +import { runOneMutant, testEfficacyCommand } from './test-efficacy.js'; type Handler = (args: { report: string; worktree: string; base: string; out: string; + now?: () => number; }) => Promise; const runHandler = testEfficacyCommand.handler as unknown as Handler; @@ -97,10 +98,62 @@ function scaffoldModifiedPr(): { wt: string; base: string } { return { wt, base }; } +/** + * Swap the fake runner for one that reports every test file as FAILED. Used to + * drive the unmutated baseline red, so the mutant phase must skip wholesale. + */ +function installFailingVitest(): void { + const bin = join(repo, 'node_modules', '.bin', 'vitest'); + writeFileSync( + bin, + `#!/usr/bin/env node +const path = require('path'); +const files = process.argv.slice(2).filter((a) => a.includes('.test.')); +process.stdout.write(JSON.stringify({ + numPassedTests: 0, + numFailedTests: files.length, + testResults: files.map((f) => ({ + name: path.resolve(f), + assertionResults: [{ status: 'failed' }], + })), +})); +`, + ); + chmodSync(bin, 0o755); +} + +/** + * Swap the fake runner for one that reports a file whose path contains "skip" + * as all-skipped (collected, but no assertion executed) and every other file as + * PASSED. Drives the per-file baseline gate: an unrelated all-skip file is + * `inconclusive`, not red, and must not disable the mutant phase. + */ +function installMixedVitest(): void { + const bin = join(repo, 'node_modules', '.bin', 'vitest'); + writeFileSync( + bin, + `#!/usr/bin/env node +const path = require('path'); +const files = process.argv.slice(2).filter((a) => a.includes('.test.')); +process.stdout.write(JSON.stringify({ + testResults: files.map((f) => ({ + name: path.resolve(f), + assertionResults: [{ status: f.includes('skip') ? 'skipped' : 'passed' }], + })), +})); +`, + ); + chmodSync(bin, 0o755); +} + beforeEach(() => { repo = mkdtempSync(join(tmpdir(), 'efficacy-iso-')); outside = mkdtempSync(join(tmpdir(), 'efficacy-outside-')); git(repo, 'init', '-q', '-b', 'main', '.'); + // Keep the fake vitest out of git: `commitAll` runs `git add -A`, and a + // committed bin would be checked out into the probe worktree — the stale + // passing copy, not the file `installFailingVitest` overwrites. + writeFileSync(join(repo, '.gitignore'), 'node_modules\n'); // A fake `vitest` on the up-tree bin path so `npx vitest` in the probe tree // resolves locally — fast, deterministic, no network. It echoes each test @@ -225,6 +278,775 @@ describe('test-efficacy probe isolation (#6832)', () => { expect(existsSync(join(repo, 'wt-probe'))).toBe(false); }); + it('runs a deletion mutant end-to-end and reports the survivor', async () => { + // The dogfood shape at full scale: the PR adds a reset function whose one + // safety statement (`state.clear()`) nothing gates. The fake vitest is + // green no matter what, so the baseline run passes, the mutant run passes + // — a SURVIVOR — and the revert probe still reads the test as inert. Both + // trees end clean: the mutation happened only in the disposable worktree. + write('package.json', '{"private":true,"workspaces":["packages/*"]}\n'); + write( + 'packages/lib/src/f.ts', + 'export const state = new Map();\n' + + 'export function use(k: string) {\n' + + ' return state.get(k);\n' + + '}\n', + ); + const base = commitAll('base'); + const prSource = + 'export const state = new Map();\n' + + 'export function use(k: string) {\n' + + ' return state.get(k);\n' + + '}\n' + + 'export function reset() {\n' + + ' state.clear();\n' + + '}\n'; + write('packages/lib/src/f.ts', prSource); + write( + 'packages/lib/src/f.test.ts', + 'import { reset } from "./f.js"; import { it, expect } from "vitest"; it("t", () => expect(typeof reset).toBe("function"));\n', + ); + commitAll('pr'); + const wt = join(repo, 'wt'); + git(repo, 'worktree', 'add', '-q', '--detach', wt, 'HEAD'); + writeFileSync( + join(repo, 'report.json'), + JSON.stringify({ + files: [ + { path: 'packages/lib/src/f.ts', kind: 'source' }, + { path: 'packages/lib/src/f.test.ts', kind: 'test' }, + ], + }), + ); + + const before = treeState(wt); + await runHandler({ + report: join(repo, 'report.json'), + worktree: wt, + base, + out: join(repo, 'out.json'), + }); + + const out = JSON.parse(readFileSync(join(repo, 'out.json'), 'utf8')); + expect(out.mutants.probed).toEqual([ + { + file: 'packages/lib/src/f.ts', + line: 6, + statement: 'state.clear();', + verdict: 'survived', + detail: expect.stringContaining('still PASSED'), + }, + ]); + expect(out.mutants.survived).toBe(1); + expect(out.mutants.skippedForBudget).toBe(0); + // The survivor is a finding the orchestrator files; the register matches + // the unreachable/inert messages Agent 7's brief already knows how to read. + const survivor = ( + out.findings as Array<{ kind: string; file: string; message: string }> + ).find((f) => f.kind === 'mutant-survived'); + expect(survivor?.file).toBe('packages/lib/src/f.ts'); + expect(survivor?.message).toContain('state.clear();'); + // The mutation never touched the shared tree, and the probe tree is gone. + expect(treeState(wt)).toBe(before); + expect(readFileSync(join(wt, 'packages/lib/src/f.ts'), 'utf8')).toBe( + prSource, + ); + expect(existsSync(join(repo, 'wt-probe'))).toBe(false); + }); + + it('kills a mutant the suite catches — the A/B control for the survivor test', async () => { + // Same source, same statement, same line as the survivor test above. The + // ONLY variable is the fake runner: here it reads the source and fails when + // `state.clear()` is gone — a genuinely gating test. The mutant must be + // KILLED (no finding), proving the verdict tracks the test, not the harness. + write('package.json', '{"private":true,"workspaces":["packages/*"]}\n'); + write( + 'packages/lib/src/f.ts', + 'export const state = new Map();\n' + + 'export function use(k: string) {\n' + + ' return state.get(k);\n' + + '}\n', + ); + const base = commitAll('base'); + write( + 'packages/lib/src/f.ts', + 'export const state = new Map();\n' + + 'export function use(k: string) {\n' + + ' return state.get(k);\n' + + '}\n' + + 'export function reset() {\n' + + ' state.clear();\n' + + '}\n', + ); + write( + 'packages/lib/src/f.test.ts', + 'import { reset } from "./f.js"; import { it, expect } from "vitest"; it("t", () => expect(typeof reset).toBe("function"));\n', + ); + commitAll('pr'); + const wt = join(repo, 'wt'); + git(repo, 'worktree', 'add', '-q', '--detach', wt, 'HEAD'); + writeFileSync( + join(repo, 'report.json'), + JSON.stringify({ + files: [ + { path: 'packages/lib/src/f.ts', kind: 'source' }, + { path: 'packages/lib/src/f.test.ts', kind: 'test' }, + ], + }), + ); + // The fake runner reads the source: green when `state.clear()` is present, + // red when it is gone. The baseline passes; the mutant (statement deleted) + // fails — KILLED. + const bin = join(repo, 'node_modules', '.bin', 'vitest'); + writeFileSync( + bin, + `#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const files = process.argv.slice(2).filter((a) => a.includes('.test.')); +const src = fs.readFileSync(path.join(process.cwd(), 'packages/lib/src/f.ts'), 'utf8'); +const failed = src.includes('state.clear()') ? 0 : 1; +process.stdout.write(JSON.stringify({ + numPassedTests: failed ? 0 : files.length, + numFailedTests: failed ? files.length : 0, + testResults: files.map((f) => ({ + name: path.resolve(f), + assertionResults: [{ status: failed ? 'failed' : 'passed' }], + })), +})); +`, + ); + chmodSync(bin, 0o755); + + const before = treeState(wt); + await runHandler({ + report: join(repo, 'report.json'), + worktree: wt, + base, + out: join(repo, 'out.json'), + }); + + const out = JSON.parse(readFileSync(join(repo, 'out.json'), 'utf8')); + expect(out.mutants.probed).toEqual([ + { + file: 'packages/lib/src/f.ts', + line: 6, + statement: 'state.clear();', + verdict: 'killed', + detail: expect.stringContaining('suite went red'), + }, + ]); + expect(out.mutants.killed).toBe(1); + expect(out.mutants.survived).toBe(0); + // A killed mutant is the GOOD outcome — no finding. + expect( + (out.findings as Array<{ kind: string }>).some( + (f) => f.kind === 'mutant-survived', + ), + ).toBe(false); + expect(treeState(wt)).toBe(before); + expect(existsSync(join(repo, 'wt-probe'))).toBe(false); + }); + + it('skips the mutants wholesale when the unmutated baseline is not green', async () => { + // A mutant is only evidence against a suite that is green WITHOUT it: against + // a baseline that already fails, every mutant would be "killed" by failures + // it did not cause. So when no probe file is green in the unmutated run, the whole + // mutant phase is skipped and the report says so — no probed mutants and no + // survivor finding, even though the diff adds an ungated safety statement. + write('package.json', '{"private":true,"workspaces":["packages/*"]}\n'); + write( + 'packages/lib/src/f.ts', + 'export const state = new Map();\n', + ); + const base = commitAll('base'); + write( + 'packages/lib/src/f.ts', + 'export const state = new Map();\n' + + 'export function reset() {\n' + + ' state.clear();\n' + + '}\n', + ); + // The test FAILS, so the suite is not cleanly green under a real runner + // too — not only under the fake one installed below. Whichever runner the + // probe resolves to, the baseline is red and the mutants must be skipped. + write( + 'packages/lib/src/f.test.ts', + 'import { reset } from "./f.js"; import { it, expect } from "vitest"; it("t", () => { reset(); expect(1).toBe(2); });\n', + ); + commitAll('pr'); + const wt = join(repo, 'wt'); + git(repo, 'worktree', 'add', '-q', '--detach', wt, 'HEAD'); + writeFileSync( + join(repo, 'report.json'), + JSON.stringify({ + files: [ + { path: 'packages/lib/src/f.ts', kind: 'source' }, + { path: 'packages/lib/src/f.test.ts', kind: 'test' }, + ], + }), + ); + // The unmutated suite is NOT green: the fake runner reports a failure. + installFailingVitest(); + + const stdoutChunks: string[] = []; + const stdoutSpy = vi + .spyOn(process.stdout, 'write') + .mockImplementation((chunk) => { + stdoutChunks.push(String(chunk)); + return true; + }); + try { + await runHandler({ + report: join(repo, 'report.json'), + worktree: wt, + base, + out: join(repo, 'out.json'), + }); + } finally { + stdoutSpy.mockRestore(); + } + + const out = JSON.parse(readFileSync(join(repo, 'out.json'), 'utf8')); + expect(out.mutants.probed).toEqual([]); + expect(out.mutants.skippedForBaseline).toBe(1); + expect(out.mutants.note).toContain('no probe file was green'); + expect( + (out.findings as Array<{ kind: string }>).some( + (f) => f.kind === 'mutant-survived', + ), + ).toBe(false); + const stdout = stdoutChunks.join(''); + expect(stdout).toContain( + '1 mutant(s) skipped: no probe file was green in the unmutated baseline', + ); + expect(stdout).toContain('mutants not run: no probe file was green'); + }); + + it('still probes when an UNRELATED probe file is all-skipped (per-file gate)', async () => { + // Finding 2's shape: a quarantined suite that is entirely `it.skip` + // classifies `inconclusive` — not red, not a failure. The old whole-suite + // gate read that as "not cleanly green" and took the ENTIRE mutant phase + // down with it, losing the survivor finding below. The gate is per file: + // the mutant runs against the probe files that ARE green in the baseline, + // so an unrelated all-skip file no longer disables it. + write('package.json', '{"private":true,"workspaces":["packages/*"]}\n'); + write( + 'packages/lib/src/f.ts', + 'export const state = new Map();\n', + ); + const base = commitAll('base'); + write( + 'packages/lib/src/f.ts', + 'export const state = new Map();\n' + + 'export function reset() {\n' + + ' state.clear();\n' + + '}\n', + ); + write( + 'packages/lib/src/f.test.ts', + 'import { reset } from "./f.js"; import { it, expect } from "vitest"; it("t", () => expect(typeof reset).toBe("function"));\n', + ); + // An unrelated suite that collects but runs nothing (all skipped). + write( + 'packages/lib/src/skipped.test.ts', + 'import { it } from "vitest"; it.skip("quarantined", () => {});\n', + ); + commitAll('pr'); + const wt = join(repo, 'wt'); + git(repo, 'worktree', 'add', '-q', '--detach', wt, 'HEAD'); + writeFileSync( + join(repo, 'report.json'), + JSON.stringify({ + files: [ + { path: 'packages/lib/src/f.ts', kind: 'source' }, + { path: 'packages/lib/src/f.test.ts', kind: 'test' }, + { path: 'packages/lib/src/skipped.test.ts', kind: 'test' }, + ], + }), + ); + // Baseline: f.test.ts passes (inert), skipped.test.ts collects but runs + // nothing (inconclusive). The mutant must still run against the green file. + installMixedVitest(); + + await runHandler({ + report: join(repo, 'report.json'), + worktree: wt, + base, + out: join(repo, 'out.json'), + }); + + const out = JSON.parse(readFileSync(join(repo, 'out.json'), 'utf8')); + expect(out.mutants.note).toBeUndefined(); + expect(out.mutants.survived).toBe(1); + expect(out.mutants.probed).toEqual([ + { + file: 'packages/lib/src/f.ts', + line: 3, + statement: 'state.clear();', + verdict: 'survived', + detail: expect.stringContaining('still PASSED'), + }, + ]); + }); + + it('reports mutants skipped for budget when time runs out mid-loop', async () => { + // Three safety-verb candidates, but the budget expires after one: the + // counter, the `skippedForBudget` report field, and the stdout line are + // exercised end-to-end. The injected clock advances 100 s per SUITE RUN + // (the fake runner logs each run; the real budget is 540 s and a real run + // cannot reach it in a test) — a simulated duration, not a count of + // `Date.now()` calls, so the implementation is free to consult the clock + // as often as it likes. The mutant deadline is 240 s (540 − 300 revert + // reservation), the baseline measures 100 s, so `estimatedRunMs` is + // 115 s; after the baseline and one mutant the clock reads 200 s and the + // remaining 40 s cannot fit another run. + write('package.json', '{"private":true,"workspaces":["packages/*"]}\n'); + write( + 'packages/lib/src/f.ts', + 'export let items: string[] = ["a"];\n' + + 'export const state = new Map();\n' + + 'export const cache = new Set();\n', + ); + const base = commitAll('base'); + write( + 'packages/lib/src/f.ts', + 'export let items: string[] = ["a"];\n' + + 'export const state = new Map();\n' + + 'export const cache = new Set();\n' + + 'export function reset() {\n' + + ' items = [];\n' + + ' state.clear();\n' + + ' cache.clear();\n' + + '}\n', + ); + write( + 'packages/lib/src/f.test.ts', + 'import { reset } from "./f.js"; import { it, expect } from "vitest"; it("t", () => expect(typeof reset).toBe("function"));\n', + ); + commitAll('pr'); + const wt = join(repo, 'wt'); + git(repo, 'worktree', 'add', '-q', '--detach', wt, 'HEAD'); + writeFileSync( + join(repo, 'report.json'), + JSON.stringify({ + files: [ + { path: 'packages/lib/src/f.ts', kind: 'source' }, + { path: 'packages/lib/src/f.test.ts', kind: 'test' }, + ], + }), + ); + + // The fake runner appends one line per invocation; the injected clock + // reads the log, so it moves only when a suite actually runs. + const runsLog = join(repo, 'runs.log'); + const bin = join(repo, 'node_modules', '.bin', 'vitest'); + writeFileSync( + bin, + `#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +fs.appendFileSync(${JSON.stringify(runsLog)}, 'run\\n'); +const files = process.argv.slice(2).filter((a) => a.includes('.test.')); +process.stdout.write(JSON.stringify({ + numPassedTests: files.length, + numFailedTests: 0, + testResults: files.map((f) => ({ + name: path.resolve(f), + assertionResults: [{ status: 'passed' }], + })), +})); +`, + ); + chmodSync(bin, 0o755); + const suiteRuns = () => + existsSync(runsLog) + ? readFileSync(runsLog, 'utf8').split('\n').filter(Boolean).length + : 0; + // The skip must also be DISCLOSED on stdout — a capped run that stays + // silent lets `survived: 0` read as "every safety statement is covered". + const stdoutChunks: string[] = []; + const stdoutSpy = vi + .spyOn(process.stdout, 'write') + .mockImplementation((chunk) => { + stdoutChunks.push(String(chunk)); + return true; + }); + try { + await runHandler({ + report: join(repo, 'report.json'), + worktree: wt, + base, + out: join(repo, 'out.json'), + now: () => suiteRuns() * 100_000, + }); + } finally { + stdoutSpy.mockRestore(); + } + + const out = JSON.parse(readFileSync(join(repo, 'out.json'), 'utf8')); + expect(out.mutants.probed.length).toBe(1); + expect(out.mutants.skippedForBudget).toBe(2); + expect(out.mutants.skippedForBaseline).toBe(0); + expect(out.mutants.probed.length + out.mutants.skippedForBudget).toBe(3); + for (const m of out.mutants.probed) { + expect(m.verdict).toBe('survived'); + } + expect(stdoutChunks.join('')).toContain( + '2 mutant(s) skipped: the remaining budget cannot fit another suite run', + ); + }); + + it('reports mutants skipped for cap when candidates exceed MAX_MUTANTS', async () => { + // Nine safety-verb candidates but MAX_MUTANTS is 8: the counter, the + // `skippedForCap` report field, and the stdout line are exercised + // end-to-end, mirroring the budget-skip test above. + write('package.json', '{"private":true,"workspaces":["packages/*"]}\n'); + write( + 'packages/lib/src/f.ts', + 'export const state = new Map();\n', + ); + const base = commitAll('base'); + const stmts = Array.from({ length: 9 }, (_, i) => ` state${i}.clear();`); + write( + 'packages/lib/src/f.ts', + 'export const state = new Map();\n' + + 'export function reset() {\n' + + stmts.join('\n') + + '\n}\n', + ); + write( + 'packages/lib/src/f.test.ts', + 'import { it, expect } from "vitest"; it("t", () => expect(1).toBe(1));\n', + ); + commitAll('pr'); + const wt = join(repo, 'wt'); + git(repo, 'worktree', 'add', '-q', '--detach', wt, 'HEAD'); + writeFileSync( + join(repo, 'report.json'), + JSON.stringify({ + files: [ + { path: 'packages/lib/src/f.ts', kind: 'source' }, + { path: 'packages/lib/src/f.test.ts', kind: 'test' }, + ], + }), + ); + + const stdoutChunks: string[] = []; + const stdoutSpy = vi + .spyOn(process.stdout, 'write') + .mockImplementation((chunk) => { + stdoutChunks.push(String(chunk)); + return true; + }); + try { + await runHandler({ + report: join(repo, 'report.json'), + worktree: wt, + base, + out: join(repo, 'out.json'), + }); + } finally { + stdoutSpy.mockRestore(); + } + + const out = JSON.parse(readFileSync(join(repo, 'out.json'), 'utf8')); + expect(out.mutants.probed.length).toBe(8); + expect(out.mutants.skippedForCap).toBe(1); + expect(out.mutants.skippedForBaseline).toBe(0); + expect(out.mutants.probed.length + out.mutants.skippedForCap).toBe(9); + expect(stdoutChunks.join('')).toContain( + '1 mutant(s) skipped: more candidates than the cap of 8', + ); + }); + + it('marks every candidate inconclusive when the runner dies mid-mutation, and still runs the revert probe', async () => { + // The mutation-phase catch: a runner killed (or failing to spawn) during a + // mutant run is not evidence about any statement. Every candidate that + // never got a verdict — the one being run AND the ones never attempted — + // must come back `inconclusive` with the reason, the revert probe must + // still run, and the report must still be written. The fake runner passes + // the baseline (run 1), floods stdout past spawnSync's 64 MiB maxBuffer on + // run 2 (the first mutant) so the runner spawn itself errors (ENOBUFS), + // and passes the revert probe (run 3). + write('package.json', '{"private":true,"workspaces":["packages/*"]}\n'); + write( + 'packages/lib/src/f.ts', + 'export const state = new Map();\n' + + 'export const cache = new Set();\n', + ); + const base = commitAll('base'); + write( + 'packages/lib/src/f.ts', + 'export const state = new Map();\n' + + 'export const cache = new Set();\n' + + 'export function reset() {\n' + + ' state.clear();\n' + + ' cache.clear();\n' + + '}\n', + ); + write( + 'packages/lib/src/f.test.ts', + 'import { reset } from "./f.js"; import { it, expect } from "vitest"; it("t", () => expect(typeof reset).toBe("function"));\n', + ); + commitAll('pr'); + const wt = join(repo, 'wt'); + git(repo, 'worktree', 'add', '-q', '--detach', wt, 'HEAD'); + writeFileSync( + join(repo, 'report.json'), + JSON.stringify({ + files: [ + { path: 'packages/lib/src/f.ts', kind: 'source' }, + { path: 'packages/lib/src/f.test.ts', kind: 'test' }, + ], + }), + ); + const callsFile = join(repo, 'calls.txt'); + const bin = join(repo, 'node_modules', '.bin', 'vitest'); + writeFileSync( + bin, + `#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +let n = 0; +try { n = parseInt(fs.readFileSync(${JSON.stringify(callsFile)}, 'utf8'), 10) || 0; } catch {} +n += 1; +fs.writeFileSync(${JSON.stringify(callsFile)}, String(n)); +if (n === 2) { + const big = Buffer.alloc(8 * 1024 * 1024, 97); + try { for (let i = 0; i < 10; i++) fs.writeSync(1, big); } catch {} + process.exit(0); +} +const files = process.argv.slice(2).filter((a) => a.includes('.test.')); +process.stdout.write(JSON.stringify({ + numPassedTests: files.length, + numFailedTests: 0, + testResults: files.map((f) => ({ + name: path.resolve(f), + assertionResults: [{ status: 'passed' }], + })), +})); +`, + ); + chmodSync(bin, 0o755); + + await runHandler({ + report: join(repo, 'report.json'), + worktree: wt, + base, + out: join(repo, 'out.json'), + }); + + const out = JSON.parse(readFileSync(join(repo, 'out.json'), 'utf8')); + expect(out.mutants.probed).toHaveLength(2); + for (const m of out.mutants.probed as Array<{ + verdict: string; + detail: string; + }>) { + expect(m.verdict).toBe('inconclusive'); + expect(m.detail).toContain('mutation probe could not run'); + } + expect(out.mutants.probed[0].detail).toContain('ENOBUFS'); + expect(out.mutants.inconclusive).toBe(2); + expect(out.mutants.killed).toBe(0); + expect(out.mutants.survived).toBe(0); + expect( + (out.findings as Array<{ kind: string }>).some( + (f) => f.kind === 'mutant-survived', + ), + ).toBe(false); + // The revert probe still ran: a real verdict from run 3, not a propagated + // mutation failure. + expect(out.probed).toEqual([ + expect.objectContaining({ + file: 'packages/lib/src/f.test.ts', + verdict: 'inert', + }), + ]); + expect(existsSync(join(repo, 'wt-probe'))).toBe(false); + }); + + it('still finds the survivor under hostile user git diff config', async () => { + // A developer's diff.srcPrefix/dstPrefix reshapes the `+++ b/…` headers + // parseAddedLines anchors on, diff.external replaces the unified diff with + // an external command's output (here one that dies outright), and + // core.quotePath octal-escapes every non-ASCII path — each one alone + // would turn selection into a silent zero or a selection failure. The + // invocation pins its own prefixes and disables ext-diff/textconv/quoting, + // so the survivor must still be found, in a non-ASCII path too. + git(repo, 'config', 'diff.srcPrefix', 'left/'); + git(repo, 'config', 'diff.dstPrefix', 'right/'); + git(repo, 'config', 'diff.external', 'false'); + git(repo, 'config', 'core.quotePath', 'true'); + write('package.json', '{"private":true,"workspaces":["packages/*"]}\n'); + write( + 'packages/lib/src/fø.ts', + 'export const state = new Map();\n', + ); + const base = commitAll('base'); + write( + 'packages/lib/src/fø.ts', + 'export const state = new Map();\n' + + 'export function reset() {\n' + + ' state.clear();\n' + + '}\n', + ); + write( + 'packages/lib/src/f.test.ts', + 'import { it, expect } from "vitest"; it("t", () => expect(1).toBe(1));\n', + ); + commitAll('pr'); + const wt = join(repo, 'wt'); + git(repo, 'worktree', 'add', '-q', '--detach', wt, 'HEAD'); + writeFileSync( + join(repo, 'report.json'), + JSON.stringify({ + files: [ + { path: 'packages/lib/src/fø.ts', kind: 'source' }, + { path: 'packages/lib/src/f.test.ts', kind: 'test' }, + ], + }), + ); + + await runHandler({ + report: join(repo, 'report.json'), + worktree: wt, + base, + out: join(repo, 'out.json'), + }); + + const out = JSON.parse(readFileSync(join(repo, 'out.json'), 'utf8')); + expect(out.mutants.note).toBeUndefined(); + expect(out.mutants.survived).toBe(1); + expect(out.mutants.probed).toEqual([ + { + file: 'packages/lib/src/fø.ts', + line: 3, + statement: 'state.clear();', + verdict: 'survived', + detail: expect.stringContaining('still PASSED'), + }, + ]); + }); + + it('discloses the dropped candidates when a file derails the literal scan', async () => { + // A regex literal holding a backtick flips the whole-file scan into + // template state through to EOF, so every candidate in the file — here a + // genuinely ungated `state.clear()` — is dropped as untrustworthy. That + // zero must be DISCLOSED in `mutants.note`, never silent: a report that + // says `survived: 0` without it reads as "every safety statement is + // covered". The revert probe does not depend on selection and still runs. + write('package.json', '{"private":true,"workspaces":["packages/*"]}\n'); + write( + 'packages/lib/src/f.ts', + 'export const state = new Map();\n', + ); + const base = commitAll('base'); + write( + 'packages/lib/src/f.ts', + 'export const state = new Map();\n' + + 'export const TICK_RE = /`/;\n' + + 'export function reset() {\n' + + ' state.clear();\n' + + '}\n', + ); + write( + 'packages/lib/src/f.test.ts', + 'import { reset } from "./f.js"; import { it, expect } from "vitest"; it("t", () => expect(typeof reset).toBe("function"));\n', + ); + commitAll('pr'); + const wt = join(repo, 'wt'); + git(repo, 'worktree', 'add', '-q', '--detach', wt, 'HEAD'); + writeFileSync( + join(repo, 'report.json'), + JSON.stringify({ + files: [ + { path: 'packages/lib/src/f.ts', kind: 'source' }, + { path: 'packages/lib/src/f.test.ts', kind: 'test' }, + ], + }), + ); + + const stdoutChunks: string[] = []; + const stdoutSpy = vi + .spyOn(process.stdout, 'write') + .mockImplementation((chunk) => { + stdoutChunks.push(String(chunk)); + return true; + }); + try { + await runHandler({ + report: join(repo, 'report.json'), + worktree: wt, + base, + out: join(repo, 'out.json'), + }); + } finally { + stdoutSpy.mockRestore(); + } + + const out = JSON.parse(readFileSync(join(repo, 'out.json'), 'utf8')); + expect(out.mutants.probed).toEqual([]); + expect(out.mutants.note).toContain('literal scan derailed'); + expect(out.mutants.note).toContain('packages/lib/src/f.ts'); + expect(stdoutChunks.join('')).toContain('literal scan derailed'); + // The revert probe still produced a real verdict. + expect(out.probed).toEqual([ + expect.objectContaining({ + file: 'packages/lib/src/f.test.ts', + verdict: 'inert', + }), + ]); + }); + + it('discloses a selection failure and still runs the revert probe', async () => { + // Mutant selection captures the diff with `git diff `, and a base + // this repository cannot resolve (a shallow clone's truncated history has + // exactly this shape) makes that capture throw. The catch is load-bearing: + // without it the whole command crashes and the revert probe — which does + // not depend on selection — is lost with it. The failure must be disclosed + // as the mutants note, never as a crash and never as silent zero mutants. + const { wt } = scaffoldModifiedPr(); + + await runHandler({ + report: join(repo, 'report.json'), + worktree: wt, + base: 'no-such-base-rev', + out: join(repo, 'out.json'), + }); + + const out = JSON.parse(readFileSync(join(repo, 'out.json'), 'utf8')); + expect(out.mutants.note).toContain('mutant selection failed'); + expect(out.mutants.probed).toEqual([]); + // The revert probe still produced a real verdict from the fake runner. + expect(out.probed).toEqual([ + expect.objectContaining({ + file: 'packages/lib/src/f.test.ts', + verdict: 'inert', + }), + ]); + }); + + it('never deletes a line that does not hold the selected statement', () => { + // `runOneMutant`'s mismatch guard, pinned directly: selection and the + // probe tree both derive from the same commit, so the command cannot reach + // this branch — but if the guard were dropped, a stale line number would + // delete the WRONG statement and attribute the run's verdict (here the + // fake runner's green — `survived`) to a statement that was never removed. + write('src/x.ts', 'alpha();\nbeta();\n'); + const before = readFileSync(join(repo, 'src/x.ts'), 'utf8'); + + const got = runOneMutant( + repo, + { file: 'src/x.ts', line: 1, statement: 'gone.clear();' }, + ['src/x.test.ts'], + ); + + expect(got.verdict).toBe('inconclusive'); + expect(got.detail).toContain('does not match the selected statement'); + expect(readFileSync(join(repo, 'src/x.ts'), 'utf8')).toBe(before); + }); + it('sweeps a stale REGISTERED probe worktree left by a crashed run', async () => { const { wt, base } = scaffoldModifiedPr(); // A prior probe crashed after `worktree add` but before its cleanup, leaving diff --git a/packages/cli/src/commands/review/test-efficacy.test.ts b/packages/cli/src/commands/review/test-efficacy.test.ts index 6b60de10c1..1d79f799be 100644 --- a/packages/cli/src/commands/review/test-efficacy.test.ts +++ b/packages/cli/src/commands/review/test-efficacy.test.ts @@ -9,9 +9,15 @@ import { isWorkspaceMember, planTestEfficacy, classifyProbeRun, + classifyMutantRun, safeRmWithin, + selectMutants, + parseAddedLines, + hasCollocatedNewTest, + fitsAnotherMutantRun, probeCreateFailureDetail, probeCleanupFailureDetail, + MAX_MUTANTS, } from './test-efficacy.js'; import { mkdtempSync, @@ -405,3 +411,740 @@ describe('classifyProbeRun', () => { expect(got.detail).toContain('none executed'); }); }); + +describe('parseAddedLines', () => { + it('numbers added lines on the NEW side, per post-change path', () => { + const diff = [ + 'diff --git a/src/a.ts b/src/a.ts', + 'index 1111111..2222222 100644', + '--- a/src/a.ts', + '+++ b/src/a.ts', + '@@ -10,0 +11,2 @@ ctx', + '+first added', + '+second added', + '@@ -20 +22,0 @@ ctx', + '-removed only', + 'diff --git a/src/gone.ts b/src/gone.ts', + 'deleted file mode 100644', + '--- a/src/gone.ts', + '+++ /dev/null', + '@@ -1,2 +0,0 @@', + '-x', + '-y', + 'diff --git a/src/b.ts b/src/b.ts', + 'new file mode 100644', + '--- /dev/null', + '+++ b/src/b.ts', + '@@ -0,0 +1 @@', + '+only line', + '', + ].join('\n'); + const got = parseAddedLines(diff); + // The `index`/`new file mode` header lines sit between hunks; counting + // them as context would shift every number below by the header count. + expect(got.get('src/a.ts')).toEqual([11, 12]); + expect(got.get('src/b.ts')).toEqual([1]); + // A deletion has no new side and must contribute nothing. + expect(got.has('src/gone.ts')).toBe(false); + }); + + it('counts context lines, so a default -U3 diff still numbers correctly', () => { + const diff = [ + '--- a/src/a.ts', + '+++ b/src/a.ts', + '@@ -4,3 +4,4 @@', + ' ctx one', + '+added', + ' ctx two', + ' ctx three', + '', + ].join('\n'); + expect(parseAddedLines(diff).get('src/a.ts')).toEqual([5]); + }); + + it('does not count a "\\ No newline" marker as a context line', () => { + const diff = [ + 'diff --git a/src/a.ts b/src/a.ts', + '+++ b/src/a.ts', + '@@ -5,0 +6,2 @@', + '+added line', + '\\ No newline at end of file', + '+second added', + ].join('\n'); + const got = parseAddedLines(diff); + expect(got.get('src/a.ts')).toEqual([6, 7]); + }); + + it('does not read an added `++ x` line as a file header', () => { + // `git diff --unified=0` prefixes each added line with `+`, so a spaced + // pre-increment (`++ count;`) renders as `+++ count;`. Matching `+++ ` + // unconditionally misreads it as a header, drops the line, and attributes + // every later added line in the file to a phantom path. The next file's + // real header must still be recognised once its `diff --git` leaves the + // hunk. + const diff = [ + 'diff --git a/src/a.ts b/src/a.ts', + '--- a/src/a.ts', + '+++ b/src/a.ts', + '@@ -1,0 +2,2 @@ ctx', + '+++ count;', + '+tail.clear();', + 'diff --git a/src/b.ts b/src/b.ts', + '--- a/src/b.ts', + '+++ b/src/b.ts', + '@@ -0,0 +1 @@', + '+only line', + '', + ].join('\n'); + const got = parseAddedLines(diff); + expect(got.get('src/a.ts')).toEqual([2, 3]); + expect(got.get('src/b.ts')).toEqual([1]); + expect(got.has('count;')).toBe(false); + }); +}); + +describe('selectMutants', () => { + const src = (lines: string[]) => lines.join('\n'); + const all = (n: number) => Array.from({ length: n }, (_, i) => i + 1); + + it('selects the dogfood shape: one safety statement inside a guarded branch', () => { + // The finding the revert probe is structurally blind to: the sole + // statement of a not-continued branch. Deleting it leaves `{}` — legal — + // and the file still carries its other, tested behaviour. The comment + // above it must not block the walk back to the `{` that proves the line + // stands alone. + const content = src([ + 'export function onPrompt(continued: boolean) {', + ' if (!continued) {', + " // an abandoned task's todos must not bleed into a new prompt", + ' reminders.clear();', + ' }', + '}', + '', + ]); + const { selected: got } = selectMutants([ + { + file: 'src/todo.ts', + content, + addedLines: [2, 3, 4, 5], + hasNewTests: false, + }, + ]); + expect(got).toEqual([ + { file: 'src/todo.ts', line: 4, statement: 'reminders.clear();' }, + ]); + }); + + it('matches the whole safety-verb set', () => { + const content = src([ + 'cache.delete(key);', + 'state.reset();', + 'ctrl.abort();', + "emitter.removeListener('tick', onTick);", + 'timer.unref();', + 'this.pending = [];', + 'this.timers = new Map();', + 'this.subs = new Map();', + '', + ]); + const { selected: got } = selectMutants([ + { file: 'src/s.ts', content, addedLines: all(8), hasNewTests: false }, + ]); + expect(got.map((c) => c.line)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + }); + + it('matches Set, WeakMap, and WeakSet reassignments', () => { + const content = src([ + 'this.set = new Set();', + 'this.wm = new WeakMap();', + 'this.ws = new WeakSet();', + '', + ]); + const { selected: got } = selectMutants([ + { file: 'src/s.ts', content, addedLines: all(3), hasNewTests: false }, + ]); + expect(got.map((c) => c.line)).toEqual([1, 2, 3]); + }); + + it('skips a modifier-less class field that only looks like an assignment', () => { + // `cache = new Map();` in a class body matches the safety-verb set and + // balances its delimiters, but it is a field DECLARATION: deleting it breaks + // the compile (a wasted run) or, if unused, survives and files a false + // finding. A statement inside a method body is enclosed by the method's + // brace, not the class's, and must still be selected. + const content = src([ + 'class Store {', + ' cache = new Map();', + ' reset() {', + ' this.cache.clear();', + ' }', + '}', + '', + ]); + const { selected: got } = selectMutants([ + { file: 'src/s.ts', content, addedLines: all(6), hasNewTests: false }, + ]); + expect(got).toEqual([ + { file: 'src/s.ts', line: 4, statement: 'this.cache.clear();' }, + ]); + }); + + it('skips a class field when the class header spans multiple lines', () => { + const content = src([ + 'class Store', + ' extends Base', + '{', + ' cache = new Map();', + ' reset() {', + ' this.cache.clear();', + ' }', + '}', + '', + ]); + const { selected: got } = selectMutants([ + { file: 'src/s.ts', content, addedLines: all(8), hasNewTests: false }, + ]); + expect(got).toEqual([ + { file: 'src/s.ts', line: 6, statement: 'this.cache.clear();' }, + ]); + }); + + it('skips a class field when the extends clause has an inline object type', () => { + // `extends Base<{ foo: string }>` has balanced braces on its own line. + // The backward walk must not break there — only a net-unbalanced brace + // (a real block boundary) stops it — or the `class` keyword on the line + // above is never reached and the field is admitted. + const content = src([ + 'class Store', + ' extends Base<{ foo: string }>', + '{', + ' cache = new Map();', + ' reset() {', + ' this.cache.clear();', + ' }', + '}', + '', + ]); + const { selected: got } = selectMutants([ + { file: 'src/s.ts', content, addedLines: all(8), hasNewTests: false }, + ]); + expect(got).toEqual([ + { file: 'src/s.ts', line: 6, statement: 'this.cache.clear();' }, + ]); + }); + + it('selects a method-body statement when the method is the first class member', () => { + // The backward walk from the method's `{` reaches `class Store {` on the + // very first step. The `[;{}]` stop must fire before the `class` match on + // that same line, or the walk overshoots into the class header and rejects + // a statement that is inside the method body, not the class body. + const content = src([ + 'class Store {', + ' reset() {', + ' this.cache.clear();', + ' }', + '}', + '', + ]); + const { selected: got } = selectMutants([ + { file: 'src/s.ts', content, addedLines: all(5), hasNewTests: false }, + ]); + expect(got).toEqual([ + { file: 'src/s.ts', line: 3, statement: 'this.cache.clear();' }, + ]); + }); + + it('skips what it cannot delete whole — declarations, headers, fragments', () => { + // Every line here contains a safety verb; none is a deletable statement. + // False negatives are fine, but each false positive wastes a suite run — + // or worse, `if (stale)` above a call would silently rebind the NEXT + // statement to the `if` when the call is deleted. + const content = src([ + 'const fresh = new Map();', // declaration + 'if (done) pending.delete(id);', // control-flow header on the line + 'register(', // opener … + ' bar.clear(),', // … argument, not `;`-terminated + ');', // … tail + 'chain', // receiver … + ' .clear();', // … fluent tail, starts with `.` + 'const n = base +', // continuation … + ' offsets.delete(k);', // … its tail + 'if (stale)', // brace-less if … + ' cache.clear();', // … its sole statement + 'this.items = [1];', // not reassignment-to-EMPTY + 'this.map = new Map(entries);', // not reassignment-to-empty either + '', + ]); + const { selected: got } = selectMutants([ + { file: 'src/s.ts', content, addedLines: all(13), hasNewTests: false }, + ]); + expect(got).toEqual([]); + }); + + it('rejects a multi-statement line even when a safety verb matches', () => { + // Two statements on one line: deleting the whole line removes BOTH, and + // the extra deletion can MASK a missing test on the safety verb. + const content = src([ + 'export function reset() {', + " this.cache.clear(); this.emit('reset');", + ' live.clear();', + '}', + '', + ]); + const { selected: got } = selectMutants([ + { file: 'src/s.ts', content, addedLines: [2, 3], hasNewTests: false }, + ]); + expect(got).toEqual([ + { file: 'src/s.ts', line: 3, statement: 'live.clear();' }, + ]); + }); + + it('skips safety-verb text inside template literals and comment blocks', () => { + // Deleting a line of string or commented-out code changes no behaviour, so + // its mutant would ALWAYS survive — a guaranteed false finding. + const content = src([ + 'const brief = `', + ' sessions.clear();', + '`;', + '/*', + 'old.clear();', + '*/', + 'live.clear();', + '', + ]); + const { selected: got } = selectMutants([ + { file: 'src/s.ts', content, addedLines: all(7), hasNewTests: false }, + ]); + expect(got).toEqual([ + { file: 'src/s.ts', line: 7, statement: 'live.clear();' }, + ]); + }); + + it('keeps line accounting across a string that swallows its line end', () => { + // A `\`-continued string is legal JS whose literal contains the newline. A + // scanner that consumes that newline drops one per-line flag and every + // later line reads its NEIGHBOUR's literal-state — here that would admit + // line 4, which starts inside a block comment: deleting it removes the + // `*/` and comments out the code below, a mutant nobody asked for. + const content = src([ + "const s = 'weird \\", + "tail';", + '/* block', + 'note */ cache.clear();', + 'after.clear();', + '', + ]); + const { selected: got } = selectMutants([ + { file: 'src/s.ts', content, addedLines: all(5), hasNewTests: false }, + ]); + expect(got).toEqual([ + { file: 'src/s.ts', line: 5, statement: 'after.clear();' }, + ]); + }); + + it('keeps line accounting across a backslash-continued template literal', () => { + // The template-state escape skip must not swallow a `\`-continued line's + // newline: doing so drops a per-line flag and shifts every later verdict + // onto its neighbour — here that would admit line 4, which starts inside a + // block comment, so deleting it removes the `*/` and comments out the code + // below. Mirrors the single-quote case above for the template branch. + const content = src([ + 'const brief = `weird \\', + 'tail`;', + '/* block', + 'note */ cache.clear();', + 'after.clear();', + '', + ]); + const { selected: got } = selectMutants([ + { file: 'src/s.ts', content, addedLines: all(5), hasNewTests: false }, + ]); + expect(got).toEqual([ + { file: 'src/s.ts', line: 5, statement: 'after.clear();' }, + ]); + }); + + it('does not let a nested template inside ${} close the outer literal', () => { + // A backtick inside a `${…}` interpolation opens a NESTED template. + // Reading it as the outer close marks the outer literal's remaining lines + // as code, and the template TEXT `baz.clear();` becomes a candidate whose + // deletion compiles and survives — a false finding filed against string + // content. Real code after the outer literal must still be selected. + const content = src([ + 'const x = `foo ${`bar;', + 'baz.clear();', + '`} qux`;', + 'after.clear();', + '', + ]); + const { selected: got } = selectMutants([ + { file: 'src/s.ts', content, addedLines: all(4), hasNewTests: false }, + ]); + expect(got).toEqual([ + { file: 'src/s.ts', line: 4, statement: 'after.clear();' }, + ]); + }); + + it('does not let a regex literal in an interpolation swallow later code', () => { + // A regex literal is not a string: skipping from the `'` in `/'/g` to a + // matching quote runs past the interpolation's `}` (no closing quote on the + // line), so the scanner never leaves the template, its end state is not + // `code`, and a real safety statement on the next line is silently dropped. + // Not skipping quotes inside an interpolation keeps the brace depth honest; + // the statement must be selected. + const content = src([ + 'const q = `${x.replace(/\'/g, "")}`;', + 'items.clear();', + '', + ]); + const { selected: got } = selectMutants([ + { file: 'src/s.ts', content, addedLines: all(2), hasNewTests: false }, + ]); + expect(got).toEqual([ + { file: 'src/s.ts', line: 2, statement: 'items.clear();' }, + ]); + }); + + it('does not let a } in nested-template text close the outer interpolation', () => { + // A `}` in a nested template's TEXT (not its own interpolation) must not + // decrement the outer interpDepth. Without the nested-template sub-scan, + // the depth drops to 0 and the nested close backtick reads as the outer + // close, admitting the outer literal's remaining text as code. + const content = src([ + 'const x = `a${x + `b } c`}d', + 'items.clear();', + '`;', + 'after.clear();', + '', + ]); + const { selected: got } = selectMutants([ + { file: 'src/s.ts', content, addedLines: all(4), hasNewTests: false }, + ]); + expect(got).toEqual([ + { file: 'src/s.ts', line: 4, statement: 'after.clear();' }, + ]); + }); + + it('keeps outer-template text after a nested template whose text holds a }', () => { + // The #8020 trigger. A `}` in the nested template's TEXT must not read as + // the end of the outer interpolation: with a depth counter it drained the + // depth to zero, the nested close backtick then read as the OUTER close, + // and the template text `sessions.clear();` — a non-executable line — + // became a deletion mutant whose survival was a guaranteed false finding. + const content = src([ + 'const x = `text ${ foo(`nested }`) };', + 'sessions.clear();', + '`;', + 'after.clear();', + '', + ]); + const { selected: got } = selectMutants([ + { file: 'src/s.ts', content, addedLines: all(4), hasNewTests: false }, + ]); + expect(got).toEqual([ + { file: 'src/s.ts', line: 4, statement: 'after.clear();' }, + ]); + }); + + it('tracks a nested template inside a nested interpolation (two levels)', () => { + // Same trigger one level deeper: the deep template's text `}` must only be + // text. A single nesting counter cannot represent this — it mis-assigns + // the `}` to the nested interpolation, reads the rest of the line out of + // phase, and either admits the template text `sessions.clear();` or ends + // the scan derailed and silently drops the REAL candidate on line 4. Only + // a stack of template/interpolation frames gets both lines right. + const content = src([ + 'const x = `text ${ foo(`nested ${ bar(`deep }`) } tail`) };', + 'sessions.clear();', + '`;', + 'after.clear();', + '', + ]); + const { selected: got } = selectMutants([ + { file: 'src/s.ts', content, addedLines: all(4), hasNewTests: false }, + ]); + expect(got).toEqual([ + { file: 'src/s.ts', line: 4, statement: 'after.clear();' }, + ]); + }); + + it('treats a lone ${ left unclosed at EOF as a derailed scan, not code', () => { + // An interpolation that never closes leaves every later line's state + // unknowable. The scan must end non-`code` so the file's candidates are + // dropped (and disclosed), never trusted. + const content = src(['const x = `text ${ foo(', 'sessions.clear();', '']); + const { selected, derailed } = selectMutants([ + { file: 'src/s.ts', content, addedLines: all(2), hasNewTests: false }, + ]); + expect(selected).toEqual([]); + expect(derailed).toEqual(['src/s.ts']); + }); + + it('does not read a single-line nested-template interpolation as code', () => { + // The same nesting on one line: skipping from the outer backtick to the + // NEXT backtick exposes the inner template's content (`key.reset(`) as + // code, so a verb that is actually string content matches and a valid + // template assignment is selected and deleted — a wasted run and a false + // finding. + const content = src([ + 'export function summarize(entries: Entry[]) {', + " summary = `Results: ${entries.map((e) => `key.reset(${e.id})`).join('; ')};`;", + ' live.clear();', + '}', + '', + ]); + const { selected: got } = selectMutants([ + { file: 'src/s.ts', content, addedLines: [2, 3], hasNewTests: false }, + ]); + expect(got).toEqual([ + { file: 'src/s.ts', line: 3, statement: 'live.clear();' }, + ]); + }); + + it('rejects a class field below a template whose text contains a brace', () => { + // The class-body walk reads code lines, not raw text: a multi-line + // template whose CONTENT holds an unmatched `{` (agent briefs embed JSON + // examples) would otherwise read as an opening brace, stop the walk before + // the class header, and admit the field — deleting a declaration, not a + // cleanup. The method-body statement below it must still be selected. + const content = src([ + 'class Store {', + ' brief = `', + ' docs with { brace', + ' `;', + ' cache = new Map();', + ' reset() {', + ' this.cache.clear();', + ' }', + '}', + '', + ]); + const { selected: got } = selectMutants([ + { file: 'src/s.ts', content, addedLines: all(9), hasNewTests: false }, + ]); + expect(got).toEqual([ + { file: 'src/s.ts', line: 7, statement: 'this.cache.clear();' }, + ]); + }); + + it('sees through a trailing comment on the candidate and its predecessor', () => { + // The end-anchored checks run on the code portion only. A trailing comment + // must not hide the candidate's `;` (dropping a genuine reset) nor the + // predecessor's statement end — `reminders.clear(); // why` is exactly the + // dogfood shape this probe was built to catch. + const content = src([ + 'export function reset() {', + ' const x = setup(); // prepare', + ' reminders.clear(); // why', + '}', + '', + ]); + const { selected: got } = selectMutants([ + { file: 'src/s.ts', content, addedLines: [2, 3], hasNewTests: false }, + ]); + expect(got).toEqual([ + { file: 'src/s.ts', line: 3, statement: 'reminders.clear(); // why' }, + ]); + }); + + it('does not select a safety verb that only appears inside a string', () => { + // A verb inside a string is not a statement: deleting the line removes a + // log call, the suite stays green, and a misleading `mutant-survived` + // finding is filed — a false positive that also burns a suite run. + const content = src([ + 'export function report() {', + ' logger.info("sessions.clear() done");', + ' live.clear();', + '}', + '', + ]); + const { selected: got } = selectMutants([ + { file: 'src/s.ts', content, addedLines: [2, 3], hasNewTests: false }, + ]); + expect(got).toEqual([ + { file: 'src/s.ts', line: 3, statement: 'live.clear();' }, + ]); + }); + + it('discards ALL candidates from a file whose scan derails, and names the file', () => { + // A backtick inside a regex literal flips the scanner into template state + // through to EOF. Even the valid candidate before the derailment is + // discarded — the scan is untrustworthy past it, and over-rejecting is the + // cheap error. The file comes back in `derailed` so the caller can + // disclose the dropped candidates instead of reporting a silent zero. A + // clean sibling file's candidates are unaffected. + const content = src([ + 'state.clear();', + 'const re = /`/;', + 'other.clear();', + '', + ]); + const { selected, derailed } = selectMutants([ + { file: 'src/s.ts', content, addedLines: [1, 2, 3], hasNewTests: false }, + { + file: 'src/clean.ts', + content: src(['live.clear();', '']), + addedLines: [1], + hasNewTests: false, + }, + ]); + expect(selected).toEqual([ + { file: 'src/clean.ts', line: 1, statement: 'live.clear();' }, + ]); + expect(derailed).toEqual(['src/s.ts']); + }); + + it('caps at MAX_MUTANTS, preferring files that also have new tests', () => { + const line = (i: number) => `store${i}.clear();`; + const content = src([...all(5).map(line), '']); + const { selected: got, skippedForCap } = selectMutants([ + // Diff order says untested first; the preference must still put every + // candidate from the tested file ahead of it, and the cap then keeps + // the untested file's EARLIEST lines. + { + file: 'src/untested.ts', + content, + addedLines: all(5), + hasNewTests: false, + }, + { file: 'src/tested.ts', content, addedLines: all(5), hasNewTests: true }, + ]); + expect(MAX_MUTANTS).toBe(8); + expect(got).toHaveLength(8); + expect(skippedForCap).toBe(2); + expect(got.slice(0, 5).map((c) => c.file)).toEqual( + Array(5).fill('src/tested.ts'), + ); + expect(got.slice(5).map((c) => [c.file, c.line])).toEqual([ + ['src/untested.ts', 1], + ['src/untested.ts', 2], + ['src/untested.ts', 3], + ]); + }); +}); + +describe('hasCollocatedNewTest', () => { + it('pairs file.ts with its collocated file.test.ts / file.spec.ts', () => { + expect( + hasCollocatedNewTest('packages/cli/src/x.ts', [ + 'packages/cli/src/x.test.ts', + ]), + ).toBe(true); + expect( + hasCollocatedNewTest('packages/cli/src/x.ts', [ + 'packages/cli/src/x.spec.ts', + ]), + ).toBe(true); + expect( + hasCollocatedNewTest('packages/cli/src/Comp.tsx', [ + 'packages/cli/src/Comp.test.tsx', + ]), + ).toBe(true); + }); + + it('does not pair across directories or by basename suffix', () => { + expect( + hasCollocatedNewTest('packages/cli/src/x.ts', [ + 'packages/core/src/x.test.ts', + ]), + ).toBe(false); + // `xy.test.ts` must not satisfy `y.ts` — stem equality, not endsWith. + expect( + hasCollocatedNewTest('packages/cli/src/y.ts', [ + 'packages/cli/src/xy.test.ts', + ]), + ).toBe(false); + }); +}); + +describe('classifyMutantRun', () => { + // Verdicts flow through the SAME per-file classifier the revert probe uses, + // so these fixtures are the vitest-JSON shapes classifyProbeRun already + // understands — what is under test is the mutant-level aggregation. + const perFile = (exit: number, json: unknown, probes: string[]) => + classifyProbeRun(exit, JSON.stringify(json), probes); + + it('SURVIVED when every affected test still passes', () => { + const got = classifyMutantRun( + perFile( + 0, + { + testResults: [ + { name: '/w/a.test.ts', assertionResults: [{ status: 'passed' }] }, + ], + }, + ['a.test.ts'], + ), + ); + expect(got).toBe('survived'); + }); + + it('KILLED when any assertion fails — the deletion was caught', () => { + const got = classifyMutantRun( + perFile( + 1, + { + testResults: [ + { name: '/w/a.test.ts', assertionResults: [{ status: 'passed' }] }, + { name: '/w/b.test.ts', assertionResults: [{ status: 'failed' }] }, + ], + }, + ['a.test.ts', 'b.test.ts'], + ), + ); + expect(got).toBe('killed'); + }); + + it('INCONCLUSIVE when the mutant breaks the compile, never killed', () => { + // The revert probe's trap, inherited: a run that collected nothing is not + // a test catching the deletion. + const got = classifyMutantRun( + perFile(1, { testResults: [] }, ['a.test.ts']), + ); + expect(got).toBe('inconclusive'); + }); + + it('does not let a green sibling upgrade a non-collected file to SURVIVED', () => { + // The file that failed to collect might be the very one that would have + // caught the deletion — "survived" requires every file to have run. + const got = classifyMutantRun( + perFile( + 0, + { + testResults: [ + { name: '/w/a.test.ts', assertionResults: [{ status: 'passed' }] }, + ], + }, + ['a.test.ts', 'b.test.ts'], + ), + ); + expect(got).toBe('inconclusive'); + }); + + it('a kill outranks an inconclusive sibling — red is red', () => { + const got = classifyMutantRun( + perFile( + 1, + { + testResults: [ + { name: '/w/a.test.ts', assertionResults: [{ status: 'failed' }] }, + ], + }, + ['a.test.ts', 'b.test.ts'], + ), + ); + expect(got).toBe('killed'); + }); + + it('an empty run proves nothing', () => { + expect(classifyMutantRun([])).toBe('inconclusive'); + }); +}); + +describe('fitsAnotherMutantRun', () => { + it('requires room for one more mutant run — the revert is reserved by the deadline', () => { + expect(fitsAnotherMutantRun(60_000, 60_000)).toBe(true); + expect(fitsAnotherMutantRun(59_999, 60_000)).toBe(false); + expect(fitsAnotherMutantRun(0, 60_000)).toBe(false); + }); +}); diff --git a/packages/cli/src/commands/review/test-efficacy.ts b/packages/cli/src/commands/review/test-efficacy.ts index 58467f8e1b..df00d1d9ff 100644 --- a/packages/cli/src/commands/review/test-efficacy.ts +++ b/packages/cli/src/commands/review/test-efficacy.ts @@ -33,6 +33,19 @@ // anything, and calling it "gated" would be exactly the false assurance this // command exists to remove. So `gated` requires a real assertion failure, and // everything else that is not a clean pass is `inconclusive`. +// +// The revert probe is also ALL-OR-NOTHING, and a live dogfood found the gap +// that leaves. A PR's file carried six well-tested behaviours and one untested +// safety statement; reverting the whole file went red on the six — "gated" — +// while deleting just the one statement (a `reminders.clear()` in a +// not-continued branch) left the entire 471-test suite green. The PR's headline +// invariant had zero coverage and both probes were structurally blind to it. So +// a third probe runs statement-level deletion MUTANTS over the diff's added +// lines, restricted to a high-precision set of safety verbs. A mutant the suite +// never notices — a SURVIVOR — is a finding: the invariant that statement +// enforces has no test that would fail without it. The third-outcome discipline +// applies here too: a mutant that breaks the compile is `inconclusive`, never +// `killed`. import type { CommandModule } from 'yargs'; import { spawnSync } from 'node:child_process'; @@ -118,6 +131,518 @@ export function planTestEfficacy( }; } +export type MutantVerdict = 'killed' | 'survived' | 'inconclusive'; + +export interface MutantCandidate { + file: string; + /** 1-based line number in the post-change file. */ + line: number; + /** The statement's text, trimmed — quoted back verbatim in the report. */ + statement: string; +} + +export interface MutantResult extends MutantCandidate { + verdict: MutantVerdict; + detail: string; +} + +/** + * At most this many deletion mutants per run. Every mutant is a full vitest run + * over the affected test files, so the cap — not the candidate count — is what + * keeps this command inside its budget on a diff that clears eight Maps. + */ +export const MAX_MUTANTS = 8; + +/** Deadline for one vitest run (baseline, mutant, or revert probe alike). */ +const PROBE_RUN_TIMEOUT_MS = 300_000; + +/** + * Whole-command budget. Agent 7 invokes review commands with the 600s + * (600000ms) tool timeout; staying strictly below it means the budget cutoff in + * the mutant loop — which reports HOW MANY mutants it skipped — fires before + * the harness kills the process and reports nothing at all. + */ +const TOTAL_BUDGET_MS = 540_000; + +/** + * Slack added to the measured baseline duration when pricing a mutant run: a + * killed mutant's run is about as long as a green one, but vitest startup and + * the restore write jitter, and an estimate that runs hot skips a mutant it + * could have fit — cheaper than blowing the deadline on one it could not. + */ +const RUN_ESTIMATE_MARGIN_MS = 15_000; + +/** + * The statements worth mutating: calls that discard, detach or reset state, and + * reassignment to an empty collection. Deliberately high-precision — every + * selected line costs a full suite run, so this matches the safety-verb shapes + * whose deletion is (a) silent at compile time and (b) exactly the kind of + * cleanup a test suite forgets to gate. Matched against the TRIMMED line. + */ +const SAFETY_VERB_RE = + /\.(?:clear|delete|reset|abort|removeListener|unref)\(|=\s*\[\]\s*;$|=\s*new\s+(?:Map|Set|WeakMap|WeakSet)(?:<[^=;]*>)?\(\)\s*;$/; + +/** + * Files a deletion mutant can run in: TS/JS production source (not `.d.ts` — + * declarations never execute). The revert set also carries runtime-loaded prose + * and config (an executable SKILL.md, a schema JSON); deleting a line of prose + * never breaks anything the runner sees, so every such mutant would "survive" + * and file a false finding. + */ +const MUTANT_SOURCE_RE = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs)$/; +const DECLARATION_FILE_RE = /\.d\.[cm]?ts$/; + +/** + * Line starts that are not deletable expression statements: declarations, + * control-flow headers, and clause keywords. Class-member modifiers are in the + * list because a class field (`private timers = new Map();`) looks exactly like + * an assignment statement from one line away. + */ +const NON_STATEMENT_START_RE = + /^(?:const|let|var|function|class|interface|type|enum|import|export|return|throw|yield|if|for|while|switch|do|else|try|catch|finally|case|default|break|continue|async|public|private|protected|readonly|static)\b/; + +/** + * Skip a template literal that opens at `line[start]`. Returns the index of + * its closing backtick, or -1 when it does not close on this line. Tracks + * `${…}` interpolation brace depth so a backtick seen inside an interpolation + * opens a NESTED template and is never mistaken for the outer close — without + * this, everything after the nested backtick (its string content included) + * reads as code. Approximate by construction (a `}` in a nested template's + * text, or in a string inside the interpolation, still miscounts), but the + * approximation only mis-scans shapes the delimiter check then rejects. + */ +function skipTemplateOnLine(line: string, start: number): number { + let depth = 0; + for (let i = start + 1; i < line.length; i++) { + const ch = line[i]; + if (ch === '\\') { + i++; + } else if (depth === 0) { + if (ch === '`') return i; + if (ch === '$' && line[i + 1] === '{') { + depth = 1; + i++; + } + } else if (ch === '{') { + depth++; + } else if (ch === '}') { + depth--; + } + } + return -1; +} + +/** + * Scan one line's code, skipping string literals and comments. Returns `null` + * when the line cannot be judged in isolation — an unterminated string or block + * comment (it continues on another line), or a closer without an opener (the + * line is the tail of a multi-line expression). A regex literal containing a + * quote or bracket can confuse this scanner, but only toward rejection or a + * mutant that fails to compile (`inconclusive`) — never toward a false finding. + */ +function scanLineDelimiters( + line: string, +): { paren: number; bracket: number; brace: number } | null { + let paren = 0; + let bracket = 0; + let brace = 0; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch === '`') { + const close = skipTemplateOnLine(line, i); + if (close < 0) return null; + i = close; + continue; + } + if (ch === '"' || ch === "'") { + i++; + while (i < line.length && line[i] !== ch) { + if (line[i] === '\\') i++; + i++; + } + if (i >= line.length) return null; + continue; + } + if (ch === '/' && line[i + 1] === '/') break; + if (ch === '/' && line[i + 1] === '*') { + const close = line.indexOf('*/', i + 2); + if (close < 0) return null; + i = close + 1; + continue; + } + if (ch === '(') paren++; + else if (ch === ')') paren--; + else if (ch === '[') bracket++; + else if (ch === ']') bracket--; + else if (ch === '{') brace++; + else if (ch === '}') brace--; + if (paren < 0 || bracket < 0 || brace < 0) return null; + } + return { paren, bracket, brace }; +} + +interface FileScan { + /** Per line: does it START inside a template literal or block comment? */ + inLiteral: boolean[]; + /** Per line: its code portion — comments stripped, literal contents blanked + * (delimiters kept), trimmed. */ + codeLines: string[]; + /** The scanner's state at EOF. A non-`code` end means a regex literal or + * similar shape derailed the scan — every later line's `inLiteral` is + * suspect, so the caller discards the file's candidates. */ + endState: 'code' | 'template' | 'comment'; +} + +/** + * One pass over the whole file, feeding every text check mutant selection runs. + * + * `inLiteral`: without it, a safety-verb line inside a multi-line template (an + * agent-brief string, a here-doc in a test) or a commented-out block would be + * "deleted" without changing any behaviour — a guaranteed false survivor. + * Interpolations (`${…}`) are treated as still-template, tracked with a STACK + * of frames — one per open template literal: `${` opens an interpolation on + * the innermost template, a backtick inside an interpolation opens a NESTED + * template, a `}` only closes the interpolation at the top of the stack, and a + * backtick in template text only closes the CURRENT template, never an outer + * one. A depth counter cannot represent this: a `}` in a nested template's + * TEXT drained it to zero, so the nested template's closing backtick read as + * the OUTER close and the outer literal's remaining text was admitted as code + * — still-template can only skip a candidate, never admit one. + * Quotes inside an interpolation are deliberately not skipped: a regex literal + * (`/'/g`) is not a string, and skipping to its matching quote runs past the + * interpolation's own `}`, derailing the scan and dropping every later candidate + * in the file. A `}` in a plain string can still close an interpolation early — + * handling that needs regex-literal awareness — but not skipping is what the + * corpus shows is safe today. + * + * `codeLines`: the selection checks are end-anchored — `endsWith(';')`, the + * `$` alternatives in {@link SAFETY_VERB_RE}, the predecessor `/[;{}]$/` — so + * they must see the real statement end: a trailing comment + * (`reminders.clear(); // why`) otherwise hides it, and a verb inside a string + * (`log("sessions.clear()")`) fakes it. Whole-file state is what lets a line + * that is comment or template CONTENT come out empty — per-line stripping + * cannot know that, and its stray `{`/`}` mislead the class-body walk. A line + * holding an unterminated single/double-quoted string cannot be judged at all + * and is kept verbatim, which only ever preserves the conservative rejection + * the checks already apply. The scan stops such a string BEFORE its newline: + * consuming the `\n` (a `\`-continued line swallows it) would drop one per-line + * entry and shift every later line's verdict onto its neighbour — the template + * escape skip below guards its newline for the same reason. + */ +function scanFileLines(content: string): FileScan { + const inLiteral: boolean[] = []; + const codeLines: string[] = []; + let state: 'code' | 'comment' = 'code'; + // One entry per open template literal, innermost last: -1 while the scan is + // in that template's TEXT, otherwise the brace depth of its open `${…}` + // interpolation. + const templates: number[] = []; + let buf = ''; + let lineStart = 0; + let rawLine = false; + const inTemplateOrComment = () => state !== 'code' || templates.length > 0; + inLiteral.push(inTemplateOrComment()); + const endLine = (i: number) => { + codeLines.push(rawLine ? content.slice(lineStart, i).trim() : buf.trim()); + buf = ''; + rawLine = false; + lineStart = i + 1; + }; + for (let i = 0; i < content.length; i++) { + const ch = content[i]; + if (ch === '\n') { + endLine(i); + inLiteral.push(inTemplateOrComment()); + continue; + } + if (templates.length > 0) { + const top = templates.length - 1; + if (ch === '\\' && content[i + 1] !== '\n') { + i++; + } else if (templates[top] < 0) { + // In the innermost template's text. + if (ch === '`') { + templates.pop(); + if (templates.length === 0) buf += '`'; + } else if (ch === '$' && content[i + 1] === '{') { + templates[top] = 0; + i++; + } + } else if (ch === '`') { + templates.push(-1); + } else if (ch === '{') { + templates[top]++; + } else if (ch === '}') { + if (templates[top] === 0) templates[top] = -1; + else templates[top]--; + } + continue; + } + if (state === 'comment') { + if (ch === '*' && content[i + 1] === '/') { + state = 'code'; + i++; + } + continue; + } + if (ch === '`') { + buf += '`'; + templates.push(-1); + } else if (ch === '/' && content[i + 1] === '*') { + state = 'comment'; + i++; + } else if (ch === '/' && content[i + 1] === '/') { + while (i + 1 < content.length && content[i + 1] !== '\n') i++; + } else if (ch === '"' || ch === "'") { + let k = i + 1; + while (k < content.length && content[k] !== ch && content[k] !== '\n') { + if (content[k] === '\\' && content[k + 1] !== '\n') k++; + k++; + } + if (k < content.length && content[k] === ch) { + buf += ch + ch; + i = k; + } else { + rawLine = true; + i = k < content.length && content[k] === '\n' ? k - 1 : k; + } + } else { + buf += ch; + } + } + endLine(content.length); + return { + inLiteral, + codeLines, + endState: templates.length > 0 ? 'template' : state, + }; +} + +/** + * Does `lines[idx]` sit directly inside a `class` body? A modifier-less class + * field (`cache = new Map();`) reads exactly like a bare assignment statement + * from one line away, yet deleting it removes a DECLARATION, not a cleanup — a + * compile error (`inconclusive`) or, for an unused field, a false `survived` + * that labels a field an added safety statement. Walk backward to the brace + * that opens the immediately enclosing block and report whether it belongs to a + * `class`. A statement in a method body is enclosed by the method's brace, not + * the class's, so it is unaffected. Over-rejecting here is the cheap error. + * Walks the {@link scanFileLines} code lines, never the raw text: a `{` in + * template or comment CONTENT (an agent brief embedding a JSON example) would + * otherwise read as an opening brace, stop the walk early, and admit the field. + */ +function insideClassBody(codeLines: string[], idx: number): boolean { + let depth = 0; + for (let j = idx - 1; j >= 0; j--) { + const code = codeLines[j]; + for (let i = code.length - 1; i >= 0; i--) { + const ch = code[i]; + if (ch === '}') depth++; + else if (ch === '{') { + if (depth === 0) { + if (/\bclass\b/.test(code.slice(0, i))) return true; + for (let k = j - 1; k >= 0; k--) { + const prev = codeLines[k]; + if (prev.includes(';')) break; + const d = scanLineDelimiters(prev); + if (!d || d.brace !== 0) break; + if (/\bclass\b/.test(prev)) return true; + } + return false; + } + depth--; + } + } + } + return false; +} + +/** + * Is `lines[idx]` deletable as one whole statement? Conservative on purpose: a + * false negative costs one unprobed candidate, a false positive costs a full + * suite run on a mutant that cannot compile — or worse, one whose deletion is + * syntactically fine but rebinds the NEXT statement (the sole statement of a + * brace-less `if`). So the line must end in `;`, start like an expression + * statement, balance its own delimiters, and follow a line that clearly ENDED + * something: `;`, `{`, or `}`. Anything else — a trailing `(`, `,`, `=>`, + * `&&`, or the bare `)` that may be an `if (…)` header — is skipped. + */ +function isRemovableStatement( + lines: string[], + codeLines: string[], + idx: number, +): boolean { + const t = (lines[idx] ?? '').trim(); + // End-anchored checks run on the code portion only, so a trailing comment + // (`reminders.clear(); // why`) does not hide the statement's real end. + if (!(codeLines[idx] ?? '').endsWith(';')) return false; + if ((codeLines[idx] ?? '').slice(0, -1).includes(';')) return false; + if (!/^(?:await\s+)?[A-Za-z_$]/.test(t)) return false; + if (NON_STATEMENT_START_RE.test(t)) return false; + if (insideClassBody(codeLines, idx)) return false; + const depth = scanLineDelimiters(t); + if (!depth || depth.paren !== 0 || depth.bracket !== 0 || depth.brace !== 0) { + return false; + } + // The nearest line that holds any CODE at all — a blank line, a comment + // (whether it looks like one or is the content of a block), or template text + // decides nothing about where the previous statement ended. + let j = idx - 1; + while (j >= 0 && codeLines[j] === '') j--; + if (j < 0) return true; + return /[;{}]$/.test(codeLines[j]); +} + +export interface MutantSourceFile { + file: string; + /** Post-change content at the PR head — what the probe tree checks out. */ + content: string; + /** 1-based new-side line numbers the diff ADDED in this file. */ + addedLines: number[]; + /** The diff also adds/changes this file's collocated test. Preference only: + * under the cap these candidates go first — a mutant is most informative + * exactly where the PR claims its new tests cover the new code. */ + hasNewTests: boolean; +} + +/** + * Deterministic mutant selection: among the diff's added lines, the complete + * single-line safety-verb statements, capped at {@link MAX_MUTANTS} — files + * with new tests first, then diff order, then line order. Candidates the cap + * cannot fit are counted in `skippedForCap`, not silently lost — a report that + * omits them lets a capped `survived: 0` read as "every safety statement is + * covered", the same false assurance `skippedForBudget` exists to prevent. A + * file whose scan ends outside code state (a regex literal holding a quote or + * backtick derails it) has ALL its candidates dropped and is returned in + * `derailed` — the caller must disclose that zero for the same reason. + */ +export function selectMutants( + files: MutantSourceFile[], + cap: number = MAX_MUTANTS, +): { selected: MutantCandidate[]; skippedForCap: number; derailed: string[] } { + const preferred: MutantCandidate[] = []; + const rest: MutantCandidate[] = []; + const derailed: string[] = []; + for (const f of files) { + const lines = f.content.split('\n'); + const { inLiteral, codeLines, endState } = scanFileLines(f.content); + if (endState !== 'code') { + derailed.push(f.file); + continue; + } + for (const n of [...f.addedLines].sort((a, b) => a - b)) { + const raw = lines[n - 1]; + if (raw === undefined) continue; + const t = raw.trim(); + if (!SAFETY_VERB_RE.test(codeLines[n - 1] ?? '')) continue; + if (inLiteral[n - 1]) continue; + if (!isRemovableStatement(lines, codeLines, n - 1)) continue; + (f.hasNewTests ? preferred : rest).push({ + file: f.file, + line: n, + statement: t, + }); + } + } + const eligible = [...preferred, ...rest]; + return { + selected: eligible.slice(0, cap), + skippedForCap: Math.max(0, eligible.length - cap), + derailed, + }; +} + +/** + * The new-side line numbers a `--unified=0` diff ADDED, per post-change path. + * Zero context is what the caller asks git for, but context lines are counted + * anyway so a diff captured with the default `-U3` still numbers correctly. + */ +export function parseAddedLines(diffText: string): Map { + const added = new Map(); + let file: string | null = null; + let inHunk = false; + let newLine = 0; + for (const line of diffText.split('\n')) { + if (line.startsWith('diff --git ')) { + // A new file's header block follows; leave the previous file's hunk so + // its `+++ ` header is recognised rather than read as an added line. + inHunk = false; + continue; + } + // `!inHunk`: inside a hunk an added source line that begins with `++ ` + // (spaced pre-increment) renders as `+++ x` and is not a file header. + if (!inHunk && line.startsWith('+++ ')) { + const p = line.slice(4).split('\t')[0]; + file = p === '/dev/null' ? null : p.replace(/^b\//, ''); + continue; + } + const m = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line); + if (m) { + newLine = Number(m[1]); + inHunk = true; + continue; + } + if (!inHunk || !file) continue; + if (line.startsWith('+')) { + const list = added.get(file); + if (list) list.push(newLine); + else added.set(file, [newLine]); + newLine++; + } else if (!line.startsWith('-') && !line.startsWith('\\')) { + newLine++; + } + } + return added; +} + +/** + * Does the diff add or change a test collocated with this production file? + * The repo convention is `file.test.ts` beside `file.ts`. Used only to ORDER + * candidates under the cap, so a miss costs priority, not selection. + */ +export function hasCollocatedNewTest( + file: string, + testPaths: string[], +): boolean { + const stem = file.replace(/\.[^./]+$/, ''); + return testPaths.some((t) => { + const tstem = t.replace(/\.[^./]+$/, ''); + return tstem === `${stem}.test` || tstem === `${stem}.spec`; + }); +} + +/** + * Rule on one mutant from the per-file revert-probe verdicts of its run. + * + * `gated` on any file means an assertion failed with the statement deleted — + * the mutant was caught, which is the good outcome and NOT a finding. But + * `survived` requires every affected test file to have genuinely run and + * passed: a file that collected nothing might be the very one that would have + * caught the deletion, so any `inconclusive` without a kill makes the mutant + * `inconclusive` — the same never-read-an-error-as-a-verdict asymmetry the + * revert probe holds. + */ +export function classifyMutantRun( + perFile: Array<{ verdict: ProbeVerdict }>, +): MutantVerdict { + if (perFile.some((r) => r.verdict === 'gated')) return 'killed'; + if (perFile.length === 0 || perFile.some((r) => r.verdict === 'inconclusive')) + return 'inconclusive'; + return 'survived'; +} + +/** + * Can the remaining budget fit one more mutant? The revert probe's slot is + * reserved by the deadline passed to {@link runProbeSuite}, so this guard + * only prices the mutant's own suite run. + */ +export function fitsAnotherMutantRun( + remainingMs: number, + estimatedRunMs: number, +): boolean { + return remainingMs >= estimatedRunMs; +} + interface VitestAssertion { status?: string; } @@ -235,6 +760,9 @@ interface TestEfficacyArgs { worktree: string; base: string; out: string; + /** Injectable clock, for tests only — the budget math cannot be driven to + * its cutoff in real time. Defaults to `Date.now`. */ + now?: () => number; } function git(cwd: string, ...args: string[]): void { @@ -258,6 +786,25 @@ function gitOut(cwd: string, ...args: string[]): string { return (r.stdout ?? '').trim(); } +/** + * Run git and return stdout VERBATIM, with a large buffer. Mutant selection + * reads blob contents and a whole diff through this: `gitOut`'s trim would + * strip a file's leading blank lines and silently shift every line number, and + * the 1 MiB default buffer would ENOBUFS on a large PR's diff. + */ +function gitCapture(cwd: string, ...args: string[]): string { + const r = spawnSync('git', args, { + cwd, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + }); + if (r.error) throw r.error; + if (r.status !== 0) { + throw new Error(`git ${args.join(' ')} failed: ${r.stderr ?? ''}`); + } + return r.stdout ?? ''; +} + /** * Does this path exist at the given rev? A non-zero exit is a legitimate "no" * (git prints nothing), but a spawn *failure* (`r.error`, e.g. git missing) is @@ -391,7 +938,117 @@ export function probeCleanupFailureDetail( return `could not remove probe worktree ${probeTree}${why ? `: ${why}` : ''}`; } +/** + * One vitest run over the probe files, classified per file. Shared by the + * baseline run, every mutant run, and the revert probe — the same suite, the + * same runner, the same classifier. Throws when the run never produced output + * to classify (spawn failure, or killed by the deadline). + * + * `deadlineAt` clamps the per-run timeout so the baseline + mutants + revert + * cannot together exceed {@link TOTAL_BUDGET_MS}: the baseline and mutant + * runs share a window that reserves the revert probe's full slot, and the + * revert probe gets the remainder of the whole budget. + */ +function runProbeSuite( + probeTree: string, + probes: string[], + deadlineAt?: number, + now: () => number = Date.now, +): { + perFile: Array<{ file: string; verdict: ProbeVerdict; detail: string }>; + ms: number; +} { + const started = now(); + const timeout = + deadlineAt !== undefined + ? Math.max(1, Math.min(PROBE_RUN_TIMEOUT_MS, deadlineAt - started)) + : PROBE_RUN_TIMEOUT_MS; + const r = spawnSync('npx', ['vitest', 'run', '--reporter=json', ...probes], { + cwd: probeTree, + encoding: 'utf8', + timeout, + // Vitest's JSON reporter on a large suite easily exceeds spawnSync's + // 1 MiB default stdout buffer, which returns ENOBUFS and turns every + // probe `inconclusive`. Match the 64 MiB ceiling the gh wrapper uses. + maxBuffer: 64 * 1024 * 1024, + }); + // `r.error` is set — and `r.status` is null — when the process never ran + // (npx missing) or was killed (the timeout above fires SIGTERM). Ignoring + // it reports those as "the runner produced no parseable JSON", which + // blames the runner's output for a run that produced none. + if (r.error) throw r.error; + if (r.signal) { + throw new Error( + `runner killed by ${r.signal}${r.signal === 'SIGTERM' ? ` (probe timed out after ${Math.round(timeout / 1000)}s)` : ''}`, + ); + } + return { + perFile: classifyProbeRun( + r.status ?? 1, + `${r.stdout ?? ''}`, + probes, + `${r.stderr ?? ''}`, + ), + ms: now() - started, + }; +} + +/** + * Delete one statement in the probe tree, run the affected tests, put the file + * back. The restore is a plain content write, not a git call: the original + * bytes are already in hand, and a write cannot be confused by whatever + * checkout state a failed run leaves. A restore failure throws — the caller + * must not keep mutating a tree it cannot prove clean. (Writing through + * `join(probeTree, file)` is symlink-safe here the way `safeRmWithin` has to + * enforce for deletes: the candidate resolved as a blob at the head commit, and + * one git tree cannot hold both `dir` as a symlink and `dir/file` as a blob, so + * in a fresh checkout every ancestor is a real directory.) + * + * Exported for its tests: the never-delete-a-mismatched-line guard cannot be + * reached through the command (selection and the probe tree derive from the + * same commit), so the test pins it directly rather than not at all. + */ +export function runOneMutant( + probeTree: string, + mutant: MutantCandidate, + probes: string[], + deadlineAt?: number, + now: () => number = Date.now, +): MutantResult { + const abs = join(probeTree, mutant.file); + const original = readFileSync(abs, 'utf8'); + const lines = original.split('\n'); + if ((lines[mutant.line - 1] ?? '').trim() !== mutant.statement) { + // The tree does not hold the selected statement at that line. Never delete + // a line that is not the one selected — a wrong-line mutant's verdict would + // be attributed to a statement it never touched. + return { + ...mutant, + verdict: 'inconclusive', + detail: + 'the probe tree does not match the selected statement at this line — nothing was mutated', + }; + } + lines.splice(mutant.line - 1, 1); + try { + writeFileSync(abs, lines.join('\n'), 'utf8'); + const { perFile } = runProbeSuite(probeTree, probes, deadlineAt, now); + const verdict = classifyMutantRun(perFile); + const detail = + verdict === 'killed' + ? 'the suite went red with this statement deleted — a test catches its removal' + : verdict === 'survived' + ? 'every affected test still PASSED with this statement deleted — no test fails when it is removed' + : 'the mutated tree produced no clean verdict (likely a compile or import error) — not evidence either way'; + return { ...mutant, verdict, detail }; + } finally { + writeFileSync(abs, original, 'utf8'); + } +} + async function runTestEfficacy(args: TestEfficacyArgs): Promise { + const now = args.now ?? Date.now; + const startedAt = now(); const { report, worktree, base, out } = args; const plan = JSON.parse(readFileSync(report, 'utf8')) as { files?: FileEntry[]; @@ -428,6 +1085,16 @@ async function runTestEfficacy(args: TestEfficacyArgs): Promise { detail: string; }> = []; let cleanupFailure: string | undefined; + const mutantResults: MutantResult[] = []; + let mutantsSkippedForBudget = 0; + let mutantsSkippedForCap = 0; + let mutantsSkippedForBaseline = 0; + let mutantsNote: string | undefined; + // Notes can stack (a derailed file AND a red baseline); never clobber one + // disclosure with another. + const noteMutants = (note: string) => { + mutantsNote = mutantsNote ? `${mutantsNote}; ${note}` : note; + }; if (probes.length > 0 && revert.length > 0) { // The probe reverts the PR's source to base and runs the tests against it — @@ -447,6 +1114,63 @@ async function runTestEfficacy(args: TestEfficacyArgs): Promise { // repo-root `node_modules` — exactly how the shared review worktree already // runs vitest. const headSha = gitOut(worktree, 'rev-parse', 'HEAD'); + + // Mutant selection, from the COMMITTED head: the diff's added lines come + // from `base..HEAD` and the contents from the head blobs, so the selection + // describes exactly the tree the probe worktree below checks out — never + // whatever uncommitted state the shared worktree happens to hold. + let candidates: MutantCandidate[] = []; + try { + const mutantFiles = revert.filter( + (p) => MUTANT_SOURCE_RE.test(p) && !DECLARATION_FILE_RE.test(p), + ); + if (mutantFiles.length > 0) { + const added = parseAddedLines( + gitCapture( + worktree, + '-c', + 'core.quotePath=false', + 'diff', + '--unified=0', + '--no-color', + '--src-prefix=a/', + '--dst-prefix=b/', + '--no-ext-diff', + '--no-textconv', + base, + headSha, + '--', + ...mutantFiles, + ), + ); + const selection = selectMutants( + mutantFiles + .filter((p) => (added.get(p) ?? []).length > 0) + .map((p) => ({ + file: p, + content: gitCapture(worktree, 'show', `${headSha}:${p}`), + addedLines: added.get(p) ?? [], + hasNewTests: hasCollocatedNewTest(p, probes), + })), + ); + candidates = selection.selected; + mutantsSkippedForCap = selection.skippedForCap; + if (selection.derailed.length > 0) { + noteMutants( + `mutant selection dropped ${selection.derailed.length} file(s) whose literal scan derailed (${selection.derailed.join(', ')}) — a regex literal holding a quote or backtick can do this; their candidates were not probed`, + ); + } + } + } catch (e) { + // Selection is bookkeeping, not evidence: a diff that will not parse or a + // blob that will not read says nothing about any test. Disclose and move + // on — the probes and the unreachable findings do not depend on it. + noteMutants( + `mutant selection failed: ${e instanceof Error ? e.message : String(e)} — no mutants were run`, + ); + candidates = []; + } + const probeTree = probeWorktreePath(worktree); let created = false; let sweep: SweepResult | undefined; @@ -464,6 +1188,72 @@ async function runTestEfficacy(args: TestEfficacyArgs): Promise { for (const file of probes) { results.push({ file, verdict: 'inconclusive' as const, detail }); } + for (const c of candidates) { + mutantResults.push({ ...c, verdict: 'inconclusive' as const, detail }); + } + } + + if (created && candidates.length > 0) { + // The mutation phase runs BEFORE the revert: it needs the probe tree at + // the unmodified PR head, and the revert below rewrites that tree to + // base. The two cannot contaminate each other — every mutated file is in + // the revert set, so the revert's checkout/delete resets it regardless of + // what a failed restore left behind. + try { + // The baseline run does two jobs. A mutant is only evidence against a + // suite that is green WITHOUT it — against a base run that already + // fails, every mutant would be "killed" by failures it did not cause. + // And its measured duration is the unit the budget check prices a + // suite run at. + // The baseline and mutant runs share a window that ends one + // PROBE_RUN_TIMEOUT_MS before the whole budget, reserving the + // revert probe's full slot so the pair can never exceed the + // 600s tool ceiling (540s budget: at most 240s here + 300s revert). + const mutantDeadline = + startedAt + TOTAL_BUDGET_MS - PROBE_RUN_TIMEOUT_MS; + const baseline = runProbeSuite(probeTree, probes, mutantDeadline, now); + // A mutant is only evidence against a probe file that is green WITHOUT + // it: against a file already red the mutant is "killed" by failures it + // did not cause, and a file that collected nothing proves nothing. Gate + // PER FILE, not on the whole suite — one unrelated quarantined (all-skip) + // file is `inconclusive`, not red, and must not take the whole probe down. + // (`inert` here is the baseline's "all passed" — the same verdict the + // revert probe reads as "still passed with the source reverted".) + const greenProbes = baseline.perFile + .filter((r) => r.verdict === 'inert') + .map((r) => r.file); + if (greenProbes.length === 0) { + mutantsSkippedForBaseline = candidates.length; + noteMutants( + 'mutants not run: no probe file was green in the unmutated baseline (every file was red or collected nothing), so a red mutant run would prove nothing', + ); + } else { + const estimatedRunMs = baseline.ms + RUN_ESTIMATE_MARGIN_MS; + for (const c of candidates) { + const remaining = mutantDeadline - now(); + if (!fitsAnotherMutantRun(remaining, estimatedRunMs)) { + mutantsSkippedForBudget = + candidates.length - mutantResults.length; + break; + } + mutantResults.push( + runOneMutant(probeTree, c, greenProbes, mutantDeadline, now), + ); + } + } + } catch (e) { + // The baseline, a mutant run, or a restore failed. Not evidence about + // any statement — mark whatever never got a verdict and keep going, so + // the revert probe below still runs. + const detail = `mutation probe could not run: ${e instanceof Error ? e.message : String(e)}`; + for (const c of candidates.slice(mutantResults.length)) { + mutantResults.push({ + ...c, + verdict: 'inconclusive' as const, + detail, + }); + } + } } if (created) { @@ -484,36 +1274,9 @@ async function runTestEfficacy(args: TestEfficacyArgs): Promise { } for (const p of added) safeRmWithin(probeTree, p); - const r = spawnSync( - 'npx', - ['vitest', 'run', '--reporter=json', ...probes], - { - cwd: probeTree, - encoding: 'utf8', - timeout: 300_000, - // Vitest's JSON reporter on a large suite easily exceeds spawnSync's - // 1 MiB default stdout buffer, which returns ENOBUFS and turns every - // probe `inconclusive`. Match the 64 MiB ceiling the gh wrapper uses. - maxBuffer: 64 * 1024 * 1024, - }, - ); - // `r.error` is set — and `r.status` is null — when the process never ran - // (npx missing) or was killed (the timeout above fires SIGTERM). Ignoring - // it reports those as "the runner produced no parseable JSON", which - // blames the runner's output for a run that produced none. - if (r.error) throw r.error; - if (r.signal) { - throw new Error( - `runner killed by ${r.signal}${r.signal === 'SIGTERM' ? ' (probe timed out after 300s)' : ''}`, - ); - } results.push( - ...classifyProbeRun( - r.status ?? 1, - `${r.stdout ?? ''}`, - probes, - `${r.stderr ?? ''}`, - ), + ...runProbeSuite(probeTree, probes, startedAt + TOTAL_BUDGET_MS, now) + .perFile, ); } catch (e) { // The probe could not be set up or run. That is not evidence about any @@ -569,23 +1332,60 @@ async function runTestEfficacy(args: TestEfficacyArgs): Promise { kind: 'inert' as const, message: `\`${r.file}\`: ${r.detail}. It passes whether or not the change is present, so it cannot catch a regression in it.`, })), + ...mutantResults + .filter((m) => m.verdict === 'survived') + .map((m) => ({ + file: m.file, + kind: 'mutant-survived' as const, + message: `\`${m.file}:${m.line}\`: deleting the added safety statement \`${m.statement}\` leaves every affected test green. No test in this diff fails when it is removed — confirm an existing test covers it, or add one, so a regression that drops or skips this statement is caught.`, + })), ]; + const count = (v: MutantVerdict) => + mutantResults.filter((m) => m.verdict === v).length; const result = { unreachable, probed: results, inconclusive: results.filter((r) => r.verdict === 'inconclusive'), + mutants: { + probed: mutantResults, + killed: count('killed'), + survived: count('survived'), + inconclusive: count('inconclusive'), + skippedForBudget: mutantsSkippedForBudget, + skippedForCap: mutantsSkippedForCap, + skippedForBaseline: mutantsSkippedForBaseline, + ...(mutantsNote ? { note: mutantsNote } : {}), + }, findings, cleanupFailure, }; mkdirSync(dirname(out), { recursive: true }); writeFileSync(out, JSON.stringify(result, null, 2), 'utf8'); writeStdoutLine( - `Wrote test-efficacy report to ${out} (${unreachable.length} unreachable, ${results.length} probed, ${findings.length} finding(s))`, + `Wrote test-efficacy report to ${out} (${unreachable.length} unreachable, ${results.length} probed, ${mutantResults.length} mutant(s), ${findings.length} finding(s))`, ); for (const f of findings) { writeStdoutLine(` [test] ${f.kind}: ${f.file}`); } + if (mutantsSkippedForCap > 0) { + writeStdoutLine( + ` ${mutantsSkippedForCap} mutant(s) skipped: more candidates than the cap of ${MAX_MUTANTS}`, + ); + } + if (mutantsSkippedForBaseline > 0) { + writeStdoutLine( + ` ${mutantsSkippedForBaseline} mutant(s) skipped: no probe file was green in the unmutated baseline`, + ); + } + if (mutantsSkippedForBudget > 0) { + writeStdoutLine( + ` ${mutantsSkippedForBudget} mutant(s) skipped: the remaining budget cannot fit another suite run`, + ); + } + if (mutantsNote) { + writeStdoutLine(` ${mutantsNote}`); + } if (cleanupFailure) { // A leftover probe worktree does not corrupt the shared tree — it is swept // at the start of the next run and by cleanup.ts — so this is a warning, not @@ -597,7 +1397,7 @@ async function runTestEfficacy(args: TestEfficacyArgs): Promise { export const testEfficacyCommand: CommandModule = { command: 'test-efficacy ', describe: - "Check whether the diff's new tests actually gate its new behaviour (unreachable + revert probe)", + "Check whether the diff's new tests actually gate its new behaviour (unreachable + revert probe + statement-deletion mutants)", builder: (yargs) => yargs .positional('report', { diff --git a/packages/core/src/skills/bundled/review/SKILL.md b/packages/core/src/skills/bundled/review/SKILL.md index c7931e9d7f..c479a0821c 100644 --- a/packages/core/src/skills/bundled/review/SKILL.md +++ b/packages/core/src/skills/bundled/review/SKILL.md @@ -444,7 +444,7 @@ An agent that finds nothing must say so **and say what it walked** — `No issue | `4` | **Performance & efficiency.** N+1s, leaks, needless re-renders, bad data structures, bundle size. **Reproduces the PR's claimed numbers** rather than trusting them — confirms a cheap deterministic claim (bundle bytes, tree-shake) or flags an unreproducible/unsubstantiated benchmark as unverified. | | `5` | **Test coverage.** Specific untested paths in the diff, never "coverage is low"; a missing test is a Suggestion. **Mutation-tests the tests the diff adds/changes** — a test that stays green when the code under it is broken is vacuous — a Suggestion, Critical only when it asserts the opposite, was weakened in-diff, or lets a named incorrect behaviour ship (report the behaviour, not the gap). | | `6a` `6b` `6c` | **Undirected audit, three personas** — attacker, 3 AM oncall, six-months-later maintainer. The framings force diverse paths; the union of what they find is the point, so all three run. | -| `7` | **Build & test verification** (needs a local tree). Runs _one_ build and _one_ test command, and the **test-efficacy probe** — which reverts the diff's source, keeps its tests, and reports the ones that pass anyway. Its evidence is the commands it ran. `Source: [build]` / `[test]`, never `[review]`. | +| `7` | **Build & test verification** (needs a local tree). Runs _one_ build and _one_ test command, and the **test-efficacy probe** — which reverts the diff's source, keeps its tests, and reports the ones that pass anyway, and deletes individual added safety statements (mutants) to find the ones no test notices. Its evidence is the commands it ran. `Source: [build]` / `[test]`, never `[review]`. | | `test-matrix` | **Test coverage matrix** (Step 3B). Maps each behavioural change to the test that exercises it — the pairing a territory agent cannot see, because it holds either the implementation or the test, rarely both. | | `invariant-a` `invariant-b` `invariant-c` | **Whole-file invariants** on a `heavy` file, one checklist slice each: (a) mutable fields, timers, collections; (b) retry counters, ignored return values, error taxonomies; (c) config fields, early returns. | From 0a3098a2797bfd3b0db5349a5e9908fc7ff970e6 Mon Sep 17 00:00:00 2001 From: ytahdn <1294726970@qq.com> Date: Thu, 30 Jul 2026 21:45:30 +0800 Subject: [PATCH 11/17] feat(web-shell): add contextual task panels (#7929) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web-shell): add contextual task panels * fix(web-shell): harden contextual task panels * fix(web-shell): preserve side task titles * fix(web-shell): address review feedback on context panels PR (#7929) - Add POST /session/:id/side-task to telemetry route catalog (51 routes) - Increase SDK browser bundle size limit to 184KB - Fix duplicated data-testid="chat-pane" → "chat-pane-container" on container - Gate sourceType behind session_source_metadata capability check - Add removeSession cleanup after killSession in !res.writable path - Add i18n key sideTask.renameFailed for error fallback - Add unit tests for selectVisibleHistoryRecords invariant * fix(cli): update telemetry-catalog route drift guard to 51 routes (#7929) * fix(web-shell): address review feedback round 2 on context panels PR (#7929) - Fix /fork sider discarding createSideTask() return value: show toast when side tasks are unavailable - Fix layout feedback loop: availableWidth no longer depends on environmentPanelVisible since the CSS overlay does not change the chat pane DOM width - Remove dead environmentPanelSuppressed state (never set to true) - Restore setArtifactPanelOpen(false) in closeArtifactPanelTab when the last tab is closed - Extract agentDisplayName(task) to a local variable to avoid triple invocation per render * fix(web-shell): dedupe completed background agents in environment panel (#7929) getEnvironmentAgentTasks correlated a transcript tool card with the live /tasks snapshot only on toolUseId, the notification taskId, and a - derived id. A completed background agent can lose that linkage (its live task carries no usable toolUseId and its daemon id is general-purpose-), so the trailing loop appended the live task as a second entry. Add a conservative content fallback (prompt, or description+subagentType) mirroring the daemon's legacy resolver. * feat(web-shell): support side tasks during active turns * fix(web-shell): deduplicate completed subagents and gate sourceType on capability (#7929) * fix(web-shell): restore background agent reconciliation and fix agent dedupe (#7929) Restore the one-shot subagent reconciliation for inline background Agent tool cards. Persisted notification records do not always retain a toolUseId, so the SSE discrete-notification path alone can leave a card stuck in Running; the documented fallback resolves pending cards through the subagent endpoint after catch-up, reconnect, and terminal notifications. Also stop the loose description content fallback in getEnvironmentAgentTasks from claiming a live task that another transcript tool call already links precisely (by toolUseId, message taskId, or derived id). Two agents sharing a description previously collapsed into one: the fallback stole the linked task, its owner re-matched the same task, and the orphan was dropped. * fix(web-shell): address critical review feedback on context panels (#7929) * fix(web-shell): reconcile side-task state across sessions and listings (#7929) * fix(web-shell): preserve contextual panel fallbacks --------- Co-authored-by: 钉萁 Co-authored-by: qwen-code-dev-bot Co-authored-by: Shaojin Wen Co-authored-by: qwen-code-dev-bot Co-authored-by: qwen-code-ci-bot Co-authored-by: Qwen Code Autofix Co-authored-by: Qwen Code Bot --- docs/design/web-shell-context-panels.md | 112 ++ .../cli/qwen-serve-routes.test.ts | 1 + packages/acp-bridge/src/bridge.test.ts | 98 + packages/acp-bridge/src/bridge.ts | 139 +- packages/acp-bridge/src/bridgeTypes.ts | 23 + packages/acp-bridge/src/status.ts | 1 + .../cli/src/acp-integration/acpAgent.test.ts | 102 ++ packages/cli/src/acp-integration/acpAgent.ts | 72 +- .../session/history-replayer.test.ts | 2 +- packages/cli/src/serve/capabilities.ts | 1 + .../serve/multi-workspace-sessions.test.ts | 6 +- packages/cli/src/serve/routes/session.ts | 65 + packages/cli/src/serve/server.test.ts | 1 + .../serve/server/telemetry-catalog.test.ts | 2 +- .../cli/src/serve/server/telemetry.test.ts | 8 +- packages/cli/src/serve/server/telemetry.ts | 6 + .../serve/virtual-subagent-sessions.test.ts | 40 + .../src/serve/virtual-subagent-sessions.ts | 3 + .../session-transcript-reader.test.ts | 46 +- .../src/services/session-transcript-reader.ts | 21 +- .../core/src/services/sessionService.test.ts | 63 + packages/core/src/services/sessionService.ts | 77 +- packages/core/src/utils/transcript-records.ts | 4 + packages/sdk-typescript/scripts/build.js | 3 +- .../sdk-typescript/src/daemon/DaemonClient.ts | 29 +- packages/sdk-typescript/src/daemon/index.ts | 2 + packages/sdk-typescript/src/daemon/types.ts | 9 + .../test/unit/DaemonClient.test.ts | 32 + packages/web-shell/README.md | 4 +- packages/web-shell/client/App.module.css | 47 +- packages/web-shell/client/App.test.tsx | 1623 ++++++++++++++++- packages/web-shell/client/App.tsx | 1274 ++++++++++++- .../client/components/BranchPickerPopover.tsx | 4 +- .../components/ChatContextHeader.module.css | 55 + .../components/ChatContextHeader.test.tsx | 107 ++ .../client/components/ChatContextHeader.tsx | 59 + .../client/components/ChatPane.module.css | 9 + .../client/components/ChatPane.test.tsx | 104 ++ .../web-shell/client/components/ChatPane.tsx | 336 ++-- .../client/components/MonitorIcon.tsx | 21 + .../web-shell/client/components/SplitView.tsx | 9 + .../artifacts/ArtifactPanel.module.css | 138 +- .../artifacts/ArtifactPanel.test.tsx | 486 ++++- .../components/artifacts/ArtifactPanel.tsx | 497 ++++- .../artifacts/SideTaskPanel.test.tsx | 547 ++++++ .../components/artifacts/SideTaskPanel.tsx | 285 +++ .../SubagentDetail.integration.test.tsx | 148 ++ .../components/artifacts/SubagentDetail.tsx | 70 + .../components/artifacts/TurnOutputs.tsx | 8 +- .../messages/TasksStatusMessage.module.css | 22 + .../messages/TasksStatusMessage.tsx | 162 +- .../panels/EnvironmentPanel.module.css | 265 +++ .../panels/EnvironmentPanel.test.tsx | 532 ++++++ .../components/panels/EnvironmentPanel.tsx | 380 ++++ .../components/sidebar/WebShellSidebar.tsx | 22 + ...WebShellSidebar.workspace-removal.test.tsx | 16 +- .../components/sidebar/WorkspaceSection.tsx | 18 +- .../client/constants/localCommands.test.ts | 7 + .../client/constants/localCommands.ts | 9 +- .../web-shell/client/constants/sessions.ts | 2 + packages/web-shell/client/customization.tsx | 38 +- .../client/hooks/useBackgroundTasks.test.tsx | 76 +- .../client/hooks/useBackgroundTasks.ts | 13 +- packages/web-shell/client/i18n.tsx | 47 + packages/web-shell/client/index.tsx | 6 + packages/web-shell/client/main.tsx | 9 + .../client/utils/sessionErrors.test.ts | 18 + .../web-shell/client/utils/sessionErrors.ts | 6 + .../DaemonSessionProvider.subagent.test.ts | 2 + .../daemon/session/DaemonSessionProvider.tsx | 2 + .../webui/src/daemon/session/actions.test.ts | 10 + packages/webui/src/daemon/session/actions.ts | 14 +- 72 files changed, 8034 insertions(+), 441 deletions(-) create mode 100644 docs/design/web-shell-context-panels.md create mode 100644 packages/web-shell/client/components/ChatContextHeader.module.css create mode 100644 packages/web-shell/client/components/ChatContextHeader.test.tsx create mode 100644 packages/web-shell/client/components/ChatContextHeader.tsx create mode 100644 packages/web-shell/client/components/MonitorIcon.tsx create mode 100644 packages/web-shell/client/components/artifacts/SideTaskPanel.test.tsx create mode 100644 packages/web-shell/client/components/artifacts/SideTaskPanel.tsx create mode 100644 packages/web-shell/client/components/artifacts/SubagentDetail.integration.test.tsx create mode 100644 packages/web-shell/client/components/panels/EnvironmentPanel.module.css create mode 100644 packages/web-shell/client/components/panels/EnvironmentPanel.test.tsx create mode 100644 packages/web-shell/client/components/panels/EnvironmentPanel.tsx create mode 100644 packages/web-shell/client/utils/sessionErrors.test.ts create mode 100644 packages/web-shell/client/utils/sessionErrors.ts diff --git a/docs/design/web-shell-context-panels.md b/docs/design/web-shell-context-panels.md new file mode 100644 index 0000000000..ec4626020a --- /dev/null +++ b/docs/design/web-shell-context-panels.md @@ -0,0 +1,112 @@ +# Web Shell context panels + +## Goal + +Add a persistent header to active chat sessions and move supported workspace +and background-task context into a fixed-width environment panel. Keep the +existing artifact panel as an independent right-side surface. + +## Header + +The active chat header is opt-in so existing integrations without header props +keep their previous layout. Passing `header` enables the default header, whose +content is the current session title. `header.items` controls the title, +environment action, and artifact-panel action independently; an empty items +array hides the complete header. Passing `renderChatHeader` also enables the +header and replaces it completely; the renderer receives the session metadata, +enabled items, controlled panel state, and panel open-change callbacks. The +compact sidebar toggle remains owned by `sidebar` and renders outside the +custom header. While the artifact panel is closed, its toggle is in the chat +header. While it is open, that same toggle moves to the right edge of the +artifact-panel header, leaving the environment action at the right edge of the +chat header adjacent to the panel. + +The artifact-panel action remains available when no tab exists. Opening an +The empty panel shows Review and, when session-source metadata is supported, +side-task history plus New side task. Once a tab is open, the panel header add +menu contains Review and New side task without repeating the side-task history. +Review opens the most recent transcript turn containing reviewable file +changes, is disabled when no such turn exists, and is hidden from the add menu +while a review tab is already open. Closing a populated artifact panel keeps +its tabs so the header action can reopen the existing content. + +`rightPanel.items` independently controls whether Review and Side task appear +on the empty panel page. Both items are enabled by default. + +## Side tasks + +A side task is a distinct daemon thread session in the same workspace as its +parent. It renders the existing interactive chat pane, including the transcript, +composer, approval-mode selector, model selector, streaming state, and +permission handling. Creation uses the dedicated side-task endpoint to snapshot +the main session's complete persisted model context at that moment, then +continues independently. The snapshot is serialized against transcript writes, +so a side task can be created while the parent is responding without observing +a partial JSONL record. Inherited records are not replayed in the side-task +transcript; only messages created inside the side task are shown. + +Side-task sessions record `sourceType: side_task` and the parent session id as +`sourceId`. The Web Shell session catalog filters this source type, so side +tasks do not appear as top-level sessions. With saved side tasks, hovering Side +task on the empty right-panel page opens a menu of those sessions and a New +action. With no saved task, clicking the row creates one directly. Selecting a +saved task restores it as a tab. Closing a tab only detaches its client; the +daemon transcript remains available for later conversation. + +`/btw ` keeps the lightweight, one-shot BTW interaction. +`/btw side ` opens a new side-task draft and sends the question as +its first prompt when the daemon advertises `session_side_task`. Hosts can +trigger the same action through `shellRef.current.createSideTask()`. + +## Environment panel + +The environment panel uses only existing Web Shell capabilities: + +- workspace path; +- Git branch and working-tree summary; +- working-tree diff and commit history entry points; +- configured agents entry point; +- background agent, shell, and monitor task summaries. + +The environment action and environment section remain available throughout an +active chat session. A clean working tree is shown explicitly; agent and +background-task sections appear only when they have content. + +`environmentPanel.items` independently controls the environment, subagent, and +background-task sections. All three sections are enabled by default. + +The local `/fork` command refreshes the session task snapshot as soon as its +background agent launches. Fork agents have no parent transcript tool call, so +their right-panel detail resolves the virtual subagent session by agent task ID +instead of `toolUseId`. + +The local `/tasks` command opens the environment panel and refreshes its task +snapshot instead of opening the legacy task dialog. + +Side-task, subagent, and fork transcripts expose their own file changes and +artifacts through the main right panel. Their source session scopes tab +identities and workspace actions, so opening a nested output creates a separate +tab without replacing the main session's review or artifact tabs. + +It is a fixed-width, non-resizable layout column styled as a floating card with +a border and shadow. At narrower message widths it opens as a dismissible +floating popover instead of consuming chat width. + +The environment panel and artifact panel are independent. At desktop widths the +two may be visible together. When the viewport cannot fit both, the artifact +panel normally takes priority and the environment panel is hidden without +losing its open state. Opening a subagent or background task from the +environment panel keeps that panel visible beside the resulting detail. A +floating environment panel is positioned within the remaining message area and +never overlaps the artifact panel. + +## Responsive behavior + +The environment panel is hidden for split/full-page views. When the message +area cannot keep at least 800 pixels after docking the panel, the panel closes +and can be reopened as a floating popover. An open artifact panel takes +priority when both panels cannot fit, but the environment action remains +available for explicitly reopening the popover. The existing artifact drawer +behavior on narrow screens is unchanged. On desktop, the artifact panel is a +top-level layout column beside the chat shell, so it starts at the top of the +page and the chat header ends at the panel boundary. diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index 95eaf0afec..dd51c44c4c 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -310,6 +310,7 @@ describe('qwen serve — capabilities envelope', () => { 'session_list', 'session_info', 'session_source_metadata', + 'session_side_task', 'session_prompt', 'session_cancel', 'session_events', diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 4cebe090c0..b9bb91e9e1 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -62,6 +62,7 @@ import { TurnBoundaryCompactionEngine } from './compactionEngine.js'; import { CHANNEL_STARTUP_PROFILE_META_KEY, CHANNEL_STARTUP_PROFILE_VERSION, + LOAD_REPLAY_HIDE_INHERITED_META_KEY, } from './bridgeTypes.js'; import { ApprovalMode, @@ -10836,6 +10837,103 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); + it('creates a side task with hidden inherited replay', async () => { + const handle = makeChannel({ + extMethodImpl: async (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.sessionSideTask) { + return { newSessionId: 'side-1', title: 'Side task' }; + } + if (method === SERVE_CONTROL_EXT_METHODS.sessionSource) { + return { persisted: true }; + } + return {}; + }, + resumeSessionImpl: () => ({}), + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + const parent = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + + const sideTask = await bridge.createSideTaskSession(parent.sessionId, { + name: 'Side task', + }); + + expect(sideTask).toMatchObject({ + sessionId: 'side-1', + sourceType: 'side_task', + sourceId: parent.sessionId, + sourcePersisted: true, + parentSessionId: parent.sessionId, + }); + expect(bridge.getSessionSummary(sideTask.sessionId)).toMatchObject({ + sourceType: 'side_task', + sourceId: parent.sessionId, + }); + expect(handle.agent.extMethodCalls).toContainEqual({ + method: SERVE_CONTROL_EXT_METHODS.sessionSource, + params: { + sessionId: sideTask.sessionId, + sourceType: 'side_task', + sourceId: parent.sessionId, + }, + }); + expect(handle.agent.loadSessionCalls[0]?._meta).toMatchObject({ + [LOAD_REPLAY_HIDE_INHERITED_META_KEY]: true, + }); + + await bridge.shutdown(); + }); + + it('creates a side task while the parent prompt is active', async () => { + const promptGate = deferred(); + const handle = makeChannel({ + promptImpl: async () => { + await promptGate.promise; + return { stopReason: 'end_turn' }; + }, + extMethodImpl: async (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.sessionSideTask) { + return { newSessionId: 'side-active', title: 'Side task' }; + } + if (method === SERVE_CONTROL_EXT_METHODS.sessionSource) { + return { persisted: true }; + } + return {}; + }, + resumeSessionImpl: () => ({}), + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + const parent = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + const prompt = bridge.sendPrompt(parent.sessionId, { + sessionId: parent.sessionId, + prompt: [{ type: 'text', text: 'keep working' }], + }); + await vi.waitFor(() => expect(handle.agent.promptCalls).toHaveLength(1)); + + await expect( + bridge.createSideTaskSession(parent.sessionId, { name: 'Side task' }), + ).resolves.toMatchObject({ + sessionId: 'side-active', + parentSessionId: parent.sessionId, + }); + expect(bridge.getSessionSummary(parent.sessionId)).toMatchObject({ + hasActivePrompt: true, + }); + + promptGate.resolve(); + await prompt; + await bridge.shutdown(); + }); + it('carries persisted source metadata into ACP session restore', async () => { for (const action of ['load', 'resume'] as const) { const handle = makeChannel(); diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 8b59d4e45d..0af40b650c 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -103,6 +103,7 @@ import { CHANNEL_STARTUP_PROFILE_VERSION, DAEMON_CHANNEL_DELIVERY_META_KEY, LOAD_REPLAY_BULK_MODE, + LOAD_REPLAY_HIDE_INHERITED_META_KEY, LOAD_REPLAY_META_KEY, LOAD_REPLAY_MODE_META_KEY, LOAD_REPLAY_PAGE_SIZE_META_KEY, @@ -2035,6 +2036,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { interface InFlightRestore { action: 'load' | 'resume'; historyReplay: 'stream' | 'response'; + hideInheritedHistory: boolean; promise: Promise; /** * Synchronous reservation slot for callers that coalesce onto this @@ -4395,6 +4397,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { } const historyReplay = action === 'load' ? (req.historyReplay ?? 'stream') : 'stream'; + const hideInheritedHistory = + action === 'load' && req.hideInheritedHistory === true; const existing = byId.get(req.sessionId); if (existing) { @@ -4456,7 +4460,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // missing snapshot. Same-action coalescing is unaffected. if ( action !== inFlight.action || - historyReplay !== inFlight.historyReplay + historyReplay !== inFlight.historyReplay || + hideInheritedHistory !== inFlight.hideInheritedHistory ) { throw new RestoreInProgressError( req.sessionId, @@ -4588,7 +4593,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // intentionally has no `mcpServers` field for the // same reason. mcpServers: [], - ...(historyReplay === 'response' || req.sourceType + ...(historyReplay === 'response' || + hideInheritedHistory || + req.sourceType ? { _meta: { ...sessionSourceRequestMeta( @@ -4607,6 +4614,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { : {}), } : {}), + ...(hideInheritedHistory + ? { + [LOAD_REPLAY_HIDE_INHERITED_META_KEY]: true, + } + : {}), }, } : {}), @@ -4850,6 +4862,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { inFlightRestores.set(req.sessionId, { action, historyReplay, + hideInheritedHistory, promise, coalesceState, }); @@ -6230,14 +6243,22 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); + const source = parseSessionSource(req.sourceType, req.sourceId); + if ('error' in source) { + throw new InvalidSessionMetadataError('sourceType', source.error); + } + const isSideTask = source.sourceType === 'side_task'; let originatorClientId: string | undefined; if (context?.clientId !== undefined) { originatorClientId = resolveTrustedClientId(entry, context.clientId); } - const branchResult = entry.promptQueue.then(async () => { - if (entry.promptActive) { + const concurrentSideTask = isSideTask && entry.promptActive; + const branchResult = ( + concurrentSideTask ? Promise.resolve() : entry.promptQueue + ).then(async () => { + if (entry.promptActive && !isSideTask) { throw new BranchWhilePromptActiveError(sessionId); } @@ -6262,13 +6283,18 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { try { const ci = await ensureChannel(); const result = (await withTimeout( - ci.connection.extMethod(SERVE_CONTROL_EXT_METHODS.sessionBranch, { - sessionId, - cwd: boundWorkspace, - name: req.name, - }), + ci.connection.extMethod( + isSideTask + ? SERVE_CONTROL_EXT_METHODS.sessionSideTask + : SERVE_CONTROL_EXT_METHODS.sessionBranch, + { + sessionId, + cwd: boundWorkspace, + name: req.name, + }, + ), initTimeoutMs, - 'branchSession', + isSideTask ? 'createSideTaskSession' : 'branchSession', )) as { newSessionId: string; title?: string; displayName?: string }; if (!result || typeof result.newSessionId !== 'string') { @@ -6284,12 +6310,20 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { let restored; try { + const hideInheritedHistory = req.replayInheritedHistory === false; restored = await restoreSession( 'load', { sessionId: result.newSessionId, workspaceCwd: boundWorkspace, clientId: context?.clientId, + ...(hideInheritedHistory + ? { + historyReplay: 'response', + hideInheritedHistory: true, + } + : {}), + ...source, }, { skipFreshSessionAdmission: true, @@ -6315,20 +6349,50 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const newEntry = byId.get(result.newSessionId); if (newEntry) newEntry.displayName = branchDisplayName; + let sourcePersisted: boolean | undefined; + if (newEntry?.sourceType) { + try { + const sourceResult = await withTimeout( + newEntry.connection.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionSource, + { + sessionId: newEntry.sessionId, + sourceType: newEntry.sourceType, + ...(newEntry.sourceId !== undefined + ? { sourceId: newEntry.sourceId } + : {}), + }, + ), + initTimeoutMs, + 'sessionSource', + ); + sourcePersisted = + (sourceResult as { persisted?: boolean } | undefined) + ?.persisted === true; + } catch (error) { + sourcePersisted = false; + writeStderrLine( + `qwen serve: source metadata for branched session ${result.newSessionId} was not persisted: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } - const eventData = { - sourceSessionId: sessionId, - newSessionId: result.newSessionId, - displayName: branchDisplayName, - }; - const branchEnvelope = { - type: 'session_branched' as const, - data: eventData, - ...(originatorClientId ? { originatorClientId } : {}), - }; - // The branch announcement belongs to the new session only. Publishing - // it on the source session would persist in that session's replay ring. - newEntry?.events.publish(branchEnvelope); + if (!isSideTask) { + const eventData = { + sourceSessionId: sessionId, + newSessionId: result.newSessionId, + displayName: branchDisplayName, + }; + const branchEnvelope = { + type: 'session_branched' as const, + data: eventData, + ...(originatorClientId ? { originatorClientId } : {}), + }; + // The branch announcement belongs to the new session only. + newEntry?.events.publish(branchEnvelope); + } return { ...restored, @@ -6337,18 +6401,39 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { sessionId, displayName: entry.displayName ?? sessionId.slice(0, 8), }, + ...(sourcePersisted !== undefined ? { sourcePersisted } : {}), }; } finally { releaseAdmissionOnce(); } }); - entry.promptQueue = branchResult.then( - () => undefined, - () => undefined, - ); + if (!concurrentSideTask) { + entry.promptQueue = branchResult.then( + () => undefined, + () => undefined, + ); + } return branchResult; }, + async createSideTaskSession(sessionId, req, context) { + const result = await this.branchSession( + sessionId, + { + name: req.name, + sourceType: 'side_task', + sourceId: sessionId, + replayInheritedHistory: false, + }, + context, + ); + const { forkedFrom: _forkedFrom, ...sideTask } = result; + return { + ...sideTask, + parentSessionId: sessionId, + }; + }, + async changeSessionCwd( sessionId: string, req: ChangeSessionCwdRequest, diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index e39fd207a5..90e6516d58 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -147,6 +147,8 @@ export interface BridgeRestoreSessionRequest { historyReplay?: 'stream' | 'response'; /** Optional newest persisted-record page requested for response replay. */ historyPageSize?: number; + /** Keep inherited fork records as model context without replaying them. */ + hideInheritedHistory?: boolean; approvalMode?: ApprovalMode; /** * Persisted parent lineage recovered from the transcript by the caller (the @@ -165,6 +167,8 @@ export interface BridgeRestoreSessionRequest { export const LOAD_REPLAY_MODE_META_KEY = 'qwen.session.loadReplayMode'; export const LOAD_REPLAY_META_KEY = 'qwen.session.loadReplay'; export const LOAD_REPLAY_PAGE_SIZE_META_KEY = 'qwen.session.loadReplayPageSize'; +export const LOAD_REPLAY_HIDE_INHERITED_META_KEY = + 'qwen.session.loadReplayHideInherited'; export const LOAD_REPLAY_BULK_MODE = 'bulk'; export const LOAD_REPLAY_VERSION = 1 as const; @@ -284,6 +288,9 @@ export interface BridgeSessionTranscriptPage { export interface BridgeBranchSessionRequest { name?: string; + sourceType?: string; + sourceId?: string; + replayInheritedHistory?: boolean; } export interface BridgeBranchedSession extends BridgeRestoredSession { @@ -291,6 +298,15 @@ export interface BridgeBranchedSession extends BridgeRestoredSession { forkedFrom: { sessionId: string; displayName: string }; } +export interface BridgeSideTaskSessionRequest { + name?: string; +} + +export interface BridgeSideTaskSession extends BridgeRestoredSession { + displayName: string; + parentSessionId: string; +} + export interface BridgeForkAgentResult { sessionId: string; description: string; @@ -875,6 +891,13 @@ export interface AcpSessionBridge { context?: BridgeClientRequestContext, ): Promise; + /** Create a persisted side task with a snapshot of the parent's context. */ + createSideTaskSession( + sessionId: string, + req: BridgeSideTaskSessionRequest, + context?: BridgeClientRequestContext, + ): Promise; + /** * Change the working directory of a live session. The session must be * idle (no active prompt). Chains onto `entry.promptQueue` and updates diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index 1c749585cd..c573dbe3db 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -131,6 +131,7 @@ export const SERVE_CONTROL_EXT_METHODS = { sessionClose: 'qwen/control/session/close', sessionApprovalMode: 'qwen/control/session/approval_mode', sessionBranch: 'qwen/control/session/branch', + sessionSideTask: 'qwen/control/session/side_task', sessionForkAgent: 'qwen/control/session/fork_agent', sessionRecap: 'qwen/control/session/recap', sessionGenerationStart: 'qwen/control/session/generation/start', diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 01de5a5ff5..1c544c09da 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -799,6 +799,7 @@ import { fetchAllowedGitHub, createWorkspaceMcpBudget, deliverClientMcpMessage, + selectVisibleHistoryRecords, } from './acpAgent.js'; import { gzipSync } from 'node:zlib'; import type { Config } from '@qwen-code/qwen-code-core'; @@ -12626,6 +12627,55 @@ describe('QwenAgent extMethod renameSession routing', () => { await agentPromise; }); + it('creates a side task with source metadata and no branch suffix', async () => { + const recording = makeRecordingService(); + const sessionService = { + forkSession: vi.fn().mockResolvedValue(undefined), + renameSession: vi.fn().mockResolvedValue(true), + removeSession: vi.fn().mockResolvedValue(undefined), + }; + const innerConfig = makeLiveSessionInnerConfig(recording); + innerConfig.getSessionService.mockReturnValue( + sessionService as unknown as SessionService, + ); + const { agent, agentPromise } = await bootAgent(innerConfig); + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + const result = await agent.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionSideTask, + { + cwd: '/tmp', + sessionId: liveSessionId, + name: 'Side task', + }, + ); + + expect(sessionService.forkSession).toHaveBeenCalledWith( + liveSessionId, + expect.any(String), + { + source: { + sourceType: 'side_task', + sourceId: liveSessionId, + }, + }, + ); + expect(recording.runWithWriteBarrier).toHaveBeenCalledOnce(); + const newSessionId = sessionService.forkSession.mock.calls[0]?.[1]; + expect(sessionService.renameSession).toHaveBeenCalledWith( + newSessionId, + 'Side task', + 'manual', + ); + expect(result).toMatchObject({ + title: 'Side task', + displayName: 'Side task', + }); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('keeps the live session open when strict session close flush fails', async () => { const recording = makeRecordingService(); recording.flush.mockRejectedValue(new Error('flush failed')); @@ -16898,3 +16948,55 @@ describe('deliverClientMcpMessage — reverse tool channel (#5626)', () => { }); }); }); + +describe('selectVisibleHistoryRecords', () => { + function makeRecord( + overrides: Partial<{ + type: string; + subtype: string; + systemPayload: unknown; + forkedFrom: { sessionId: string; messageUuid: string }; + }> = {}, + ) { + return { + uuid: `uuid-${Math.random().toString(36).slice(2)}`, + parentUuid: null, + sessionId: 'test-session', + timestamp: '2025-01-01T00:00:00Z', + type: 'user', + ...overrides, + } as never; + } + + const sourceBoundary = makeRecord({ + type: 'system', + subtype: 'session_source', + systemPayload: { sourceType: 'side_task', sourceId: 'parent-1' }, + }); + + it('filters records before a side-task source boundary regardless of hideInheritedHistory', () => { + const inherited = makeRecord({ + forkedFrom: { sessionId: 'parent-1', messageUuid: 'm1' }, + }); + const before = makeRecord(); + const after = makeRecord(); + const records = [inherited, before, sourceBoundary, after]; + + const withHide = selectVisibleHistoryRecords(records, true); + const withoutHide = selectVisibleHistoryRecords(records, false); + + expect(withHide).toEqual([sourceBoundary, after]); + expect(withoutHide).toEqual([sourceBoundary, after]); + }); + + it('filters forkedFrom records when hideInheritedHistory is true and no boundary exists', () => { + const inherited = makeRecord({ + forkedFrom: { sessionId: 'parent-1', messageUuid: 'm1' }, + }); + const own = makeRecord(); + const records = [inherited, own]; + + expect(selectVisibleHistoryRecords(records, true)).toEqual([own]); + expect(selectVisibleHistoryRecords(records, false)).toEqual(records); + }); +}); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 25c4ecd9ee..8e9d39d6e3 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -300,6 +300,7 @@ import { CHANNEL_STARTUP_PROFILE_VERSION, CLIENT_MCP_OVER_WS_CONFIG_FLAG, LOAD_REPLAY_BULK_MODE, + LOAD_REPLAY_HIDE_INHERITED_META_KEY, LOAD_REPLAY_META_KEY, LOAD_REPLAY_MODE_META_KEY, LOAD_REPLAY_PAGE_SIZE_META_KEY, @@ -626,6 +627,34 @@ function isBulkLoadReplayRequest(params: LoadSessionRequest): boolean { return meta?.[LOAD_REPLAY_MODE_META_KEY] === LOAD_REPLAY_BULK_MODE; } +function shouldHideInheritedHistory(params: LoadSessionRequest): boolean { + const meta = isObjectRecord(params._meta) ? params._meta : undefined; + return meta?.[LOAD_REPLAY_HIDE_INHERITED_META_KEY] === true; +} + +export function selectVisibleHistoryRecords( + records: ChatRecord[], + hideInheritedHistory: boolean, +): ChatRecord[] { + const sourceBoundary = records.findIndex( + (record) => + record.type === 'system' && + record.subtype === 'session_source' && + isObjectRecord(record.systemPayload) && + record.systemPayload['sourceType'] === 'side_task', + ); + // A persisted side-task source boundary is authoritative for every replay; + // callers cannot opt inherited parent history back into that child session. + if (sourceBoundary >= 0) { + return records + .slice(sourceBoundary) + .filter((record) => record.forkedFrom === undefined); + } + return hideInheritedHistory + ? records.filter((record) => record.forkedFrom === undefined) + : records; +} + function isChannelSessionRequest(params: { _meta?: unknown }): boolean { const meta = isObjectRecord(params._meta) ? params._meta : undefined; const value = meta?.[SESSION_SOURCE_META_KEY]; @@ -4445,12 +4474,19 @@ class QwenAgent implements Agent { : {}), } as LoadSessionResponse; const records = sessionData.conversation.messages; - if (records.length === 0) return response; + const visibleRecords = selectVisibleHistoryRecords( + records, + shouldHideInheritedHistory(params), + ); + if (visibleRecords.length === 0) return response; const bulkReplay = isBulkLoadReplayRequest(params); const replayPage = bulkReplay - ? selectRecentHistoryRecords(records, getLoadReplayPageSize(params)) - : { records, hasMore: false }; + ? selectRecentHistoryRecords( + visibleRecords, + getLoadReplayPageSize(params), + ) + : { records: visibleRecords, hasMore: false }; const replay = await collectHistoryReplayUpdates({ sessionId: params.sessionId, config, @@ -4530,8 +4566,12 @@ class QwenAgent implements Agent { let replayUpdates: SessionUpdate[] = []; if (records) { createdSession.primeTurnFromHistory(records); - const replayPage = selectRecentHistoryRecords( + const visibleRecords = selectVisibleHistoryRecords( records, + shouldHideInheritedHistory(params), + ); + const replayPage = selectRecentHistoryRecords( + visibleRecords, replayPageSize, ); const replayUsage = createReplayCumulativeUsage(); @@ -4579,7 +4619,6 @@ class QwenAgent implements Agent { } }); } - const modesData = this.buildModesData(config); const availableModels = this.buildAvailableModels(config); const configOptions = this.buildConfigOptions(config); @@ -10093,7 +10132,9 @@ class QwenAgent implements Agent { apiKeyEnvKey: cfg?.apiKeyEnvKey ?? null, }; } - case SERVE_CONTROL_EXT_METHODS.sessionBranch: { + case SERVE_CONTROL_EXT_METHODS.sessionBranch: + case SERVE_CONTROL_EXT_METHODS.sessionSideTask: { + const isSideTask = method === SERVE_CONTROL_EXT_METHODS.sessionSideTask; const sessionId = params['sessionId']; if (typeof sessionId !== 'string' || !SESSION_ID_RE.test(sessionId)) { throw RequestError.invalidParams( @@ -10119,7 +10160,20 @@ class QwenAgent implements Agent { const newSessionId = randomUUID(); const sessionService = sourceConfig.getSessionService(); - await sessionService.forkSession(sessionId, newSessionId); + const fork = () => + isSideTask + ? sessionService.forkSession(sessionId, newSessionId, { + source: { + sourceType: 'side_task', + sourceId: sessionId, + }, + }) + : sessionService.forkSession(sessionId, newSessionId); + if (isSideTask && recording) { + await recording.runWithWriteBarrier(fork); + } else { + await fork(); + } let title: string; try { @@ -10138,7 +10192,9 @@ class QwenAgent implements Agent { } } - title = await computeUniqueBranchTitle(baseName, sessionService); + title = isSideTask + ? baseName + : await computeUniqueBranchTitle(baseName, sessionService); const renamed = await sessionService.renameSession( newSessionId, title, diff --git a/packages/cli/src/acp-integration/session/history-replayer.test.ts b/packages/cli/src/acp-integration/session/history-replayer.test.ts index 627592d6d5..1dd8ba19ce 100644 --- a/packages/cli/src/acp-integration/session/history-replayer.test.ts +++ b/packages/cli/src/acp-integration/session/history-replayer.test.ts @@ -1574,4 +1574,4 @@ describe('HistoryReplayer', () => { ); }); }); -}); \ No newline at end of file +}); diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index 177bd5cdcc..805bd530d6 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -49,6 +49,7 @@ export const SERVE_CAPABILITY_REGISTRY = { // must not be polled in a tight loop. session_info: { since: 'v1' }, session_source_metadata: { since: 'v1' }, + session_side_task: { since: 'v1' }, session_prompt: { since: 'v1' }, session_cancel: { since: 'v1' }, session_events: { since: 'v1' }, diff --git a/packages/cli/src/serve/multi-workspace-sessions.test.ts b/packages/cli/src/serve/multi-workspace-sessions.test.ts index 9d40c7caea..b5388db461 100644 --- a/packages/cli/src/serve/multi-workspace-sessions.test.ts +++ b/packages/cli/src/serve/multi-workspace-sessions.test.ts @@ -153,7 +153,7 @@ interface FakeBridge extends AcpSessionBridge { context?: BridgeClientRequestContext; }>; readonly primaryOnlyMutationCalls: Array<{ - route: 'branch' | 'fork' | 'cd'; + route: 'branch' | 'side-task' | 'fork' | 'cd'; sessionId: string; }>; } @@ -622,6 +622,10 @@ function makeBridge( primaryOnlyMutationCalls.push({ route: 'branch', sessionId }); throw new Error('Unexpected branchSession call'); }, + async createSideTaskSession(sessionId: string) { + primaryOnlyMutationCalls.push({ route: 'side-task', sessionId }); + throw new Error('Unexpected createSideTaskSession call'); + }, async launchSessionForkAgent(sessionId: string) { primaryOnlyMutationCalls.push({ route: 'fork', sessionId }); throw new Error('Unexpected launchSessionForkAgent call'); diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index a7fd9e41fa..5f9c9f5499 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -155,6 +155,7 @@ const TRANSCRIPT_CURSOR_TOO_LARGE_REPLAY_ERROR = const CHANNEL_DELIVERY_AUTHORIZATION_GRACE_MS = 60_000; const PRIMARY_ONLY_LIVE_SESSION_ROUTES = [ 'POST /session/:id/branch', + 'POST /session/:id/side-task', 'POST /session/:id/fork', 'POST /session/:id/cd', ] as const; @@ -2256,6 +2257,70 @@ export function registerSessionRoutes( ), ); + app.post( + '/session/:id/side-task', + mutate(), + withPrimaryOnlyMutableSession( + 'POST /session/:id/side-task', + async (req, res, sessionId, runtime) => { + const body = safeBody(req); + let name = + typeof body?.['name'] === 'string' ? body['name'] : undefined; + if (name) { + // eslint-disable-next-line no-control-regex + name = Array.from(name.replace(/[\x00-\x1F\x7F-\x9F]/g, '')) + .slice(0, 200) + .join(''); + } + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + const result = await runtime.bridge.createSideTaskSession( + sessionId, + { name }, + { clientId }, + ); + try { + runtime.generationGuard?.assertOpen(); + } catch (error) { + if (!result.attached) { + const killed = await runtime.bridge + .killSession(result.sessionId, { requireZeroAttaches: true }) + .catch(() => false); + if (killed) { + await new SessionService(runtime.workspaceCwd) + .removeSession(result.sessionId) + .catch(() => {}); + } + } else { + await runtime.bridge + .detachClient(result.sessionId, result.clientId) + .catch(() => {}); + } + throw error; + } + if (!res.writable) { + if (!result.attached) { + runtime.bridge + .killSession(result.sessionId, { requireZeroAttaches: true }) + .then((killed) => { + if (!killed) return undefined; + return new SessionService(runtime.workspaceCwd!).removeSession( + result.sessionId, + ); + }) + .catch(() => {}); + } else { + runtime.bridge + .detachClient(result.sessionId, result.clientId) + .catch(() => {}); + } + return; + } + res.status(201).json(result); + }, + ), + ); + app.post( '/session/:id/fork', mutate(), diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 450706e5b8..56c3f3f5c0 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -334,6 +334,7 @@ const EXPECTED_STAGE1_FEATURES = [ 'session_list', 'session_info', 'session_source_metadata', + 'session_side_task', 'session_prompt', 'session_cancel', 'session_events', diff --git a/packages/cli/src/serve/server/telemetry-catalog.test.ts b/packages/cli/src/serve/server/telemetry-catalog.test.ts index cdd457c646..654b8cb813 100644 --- a/packages/cli/src/serve/server/telemetry-catalog.test.ts +++ b/packages/cli/src/serve/server/telemetry-catalog.test.ts @@ -98,7 +98,7 @@ describe('legacy session telemetry route drift guard', () => { .map(({ method, path }) => `${method} ${path}`) .sort(); - expect(registered).toHaveLength(50); + expect(registered).toHaveLength(51); expect(registered).toEqual(catalog); }); }); diff --git a/packages/cli/src/serve/server/telemetry.test.ts b/packages/cli/src/serve/server/telemetry.test.ts index 8e04d6c7c7..76a4284f61 100644 --- a/packages/cli/src/serve/server/telemetry.test.ts +++ b/packages/cli/src/serve/server/telemetry.test.ts @@ -794,17 +794,17 @@ describe('daemonTelemetryMiddleware — recordRequest seam', () => { }); describe('legacy session telemetry route catalog', () => { - it('contains 50 unique routes with the audited 43/7 attribution split', () => { + it('contains 51 unique routes with the audited 44/7 attribution split', () => { const keys = legacySessionTelemetryRoutes.map( ({ method, path }) => `${method} ${path}`, ); - expect(keys).toHaveLength(50); - expect(new Set(keys).size).toBe(50); + expect(keys).toHaveLength(51); + expect(new Set(keys).size).toBe(51); expect( legacySessionTelemetryRoutes.filter( ({ attribution }) => attribution === 'handler_resolved', ), - ).toHaveLength(43); + ).toHaveLength(44); expect( legacySessionTelemetryRoutes.filter( ({ attribution }) => attribution === 'pre_resolved', diff --git a/packages/cli/src/serve/server/telemetry.ts b/packages/cli/src/serve/server/telemetry.ts index 7e5bdc639c..071b4efef7 100644 --- a/packages/cli/src/serve/server/telemetry.ts +++ b/packages/cli/src/serve/server/telemetry.ts @@ -59,6 +59,12 @@ export const legacySessionTelemetryRoutes = [ attribution: 'handler_resolved', route: 'POST /session/:id/fork', }, + { + method: 'POST', + path: '/session/:id/side-task', + attribution: 'handler_resolved', + route: 'POST /session/:id/side-task', + }, { method: 'POST', path: '/session/:id/cd', diff --git a/packages/cli/src/serve/virtual-subagent-sessions.test.ts b/packages/cli/src/serve/virtual-subagent-sessions.test.ts index 4222c056d2..028f2797b4 100644 --- a/packages/cli/src/serve/virtual-subagent-sessions.test.ts +++ b/packages/cli/src/serve/virtual-subagent-sessions.test.ts @@ -88,6 +88,46 @@ describe('VirtualSubagentSessions', () => { ).toThrow('valid id parts'); }); + it('resolves an out-of-band fork by agent task id', async () => { + const runtime = { + workspaceId: 'workspace-1', + workspaceCwd: '/workspace', + env: { mode: 'parent-process', overlayKeys: [] }, + bridge: { + getSessionTasksStatus: async () => ({ + v: 1 as const, + sessionId: 'parent-session', + now: Date.now(), + tasks: [ + { + kind: 'agent' as const, + id: 'fork-agent-1', + label: 'Review current changes', + description: 'Review current changes', + status: 'running' as const, + startTime: Date.now(), + runtimeMs: 1, + outputFile: '/tmp/fork-agent-1.jsonl', + isBackgrounded: true, + }, + ], + }), + }, + } as unknown as WorkspaceRuntime; + + const resolved = await new VirtualSubagentSessions().resolve( + runtime, + 'parent-session', + 'fork-agent-1', + ); + + expect(resolved).toMatchObject({ + taskId: 'fork-agent-1', + title: 'Review current changes', + status: 'running', + }); + }); + it('resolves, fully loads, and independently streams an agent transcript', async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-subagent-')); tempDirs.push(dir); diff --git a/packages/cli/src/serve/virtual-subagent-sessions.ts b/packages/cli/src/serve/virtual-subagent-sessions.ts index 4b7c4b7cf6..e8cfaddc54 100644 --- a/packages/cli/src/serve/virtual-subagent-sessions.ts +++ b/packages/cli/src/serve/virtual-subagent-sessions.ts @@ -903,6 +903,9 @@ export class VirtualSubagentSessions { runtime, parentSessionId, (candidate) => + // /fork has no parent transcript tool call, so its task ID is the + // stable reference used by Web Shell. + candidate.id === toolCallId || candidate.toolUseId === toolCallId || candidate.id.endsWith(`-${toolCallId}`), ); diff --git a/packages/core/src/services/session-transcript-reader.test.ts b/packages/core/src/services/session-transcript-reader.test.ts index 52e2fedaea..ba71c01e80 100644 --- a/packages/core/src/services/session-transcript-reader.test.ts +++ b/packages/core/src/services/session-transcript-reader.test.ts @@ -494,6 +494,51 @@ describe('SessionTranscriptReader', () => { expect(page.nextCursorState).toBeUndefined(); }); + it('does not page into inherited side-task context', async () => { + const inheritedUser = { + ...record('parent-u1', 'source', 'parent prompt'), + forkedFrom: { + sessionId: 'parent-session', + messageUuid: 'parent-u1', + }, + }; + const inheritedAssistant = { + ...record('parent-a1', 'parent-u1', 'parent answer'), + forkedFrom: { + sessionId: 'parent-session', + messageUuid: 'parent-a1', + }, + }; + const sessionSource = { + ...record('source', null, 'session source'), + type: 'system' as const, + subtype: 'session_source' as const, + systemPayload: { + sourceType: 'side_task', + sourceId: 'parent-session', + }, + }; + await writeRecords([ + sessionSource, + inheritedUser, + inheritedAssistant, + record('side-u1', 'parent-a1', 'side prompt'), + record('side-a1', 'side-u1', 'side answer'), + ]); + + const page = await new SessionTranscriptReader(workspaceDir).readPage( + sessionId, + { direction: 'backward', limit: 100 }, + ); + + expect(page.records.map((item) => item.uuid)).toEqual([ + 'source', + 'side-u1', + 'side-a1', + ]); + expect(page.hasMore).toBe(false); + }); + it('keeps backward pages within a normal user turn boundary', async () => { const toolCall = record('a-tool', 'u1', 'call tool'); const toolResult = { @@ -1178,7 +1223,6 @@ describe('SessionTranscriptReader', () => { }); it('pages backward through records without a normal user turn start', async () => { - await writeRecords([ record('a1', null, 'orphan assistant reply'), record('u1', 'a1', 'second prompt'), diff --git a/packages/core/src/services/session-transcript-reader.ts b/packages/core/src/services/session-transcript-reader.ts index 0fcf41c934..179d258d34 100644 --- a/packages/core/src/services/session-transcript-reader.ts +++ b/packages/core/src/services/session-transcript-reader.ts @@ -122,6 +122,7 @@ interface UuidIndexEntry { parentUuid: string | null; type: ChatRecord['type']; subtype?: TranscriptRecordInput['subtype']; + inherited: boolean; segments: RecordSegment[]; } @@ -837,6 +838,7 @@ async function buildIndex(params: { let sequence = 0; let leafUuid: string | undefined; let startTime: string | undefined; + let sideTaskSourceUuid: string | undefined; await forEachLineInSnapshot( filePath, @@ -850,6 +852,14 @@ async function buildIndex(params: { if (!record || !isTranscriptConversationRecord(record)) { continue; } + if ( + record.type === 'system' && + record.subtype === 'session_source' && + isObjectRecord(record.systemPayload) && + record.systemPayload['sourceType'] === 'side_task' + ) { + sideTaskSourceUuid = record.uuid; + } if (record.timestamp) startTime ??= record.timestamp; leafUuid = record.uuid; const existing = byUuid.get(record.uuid); @@ -869,6 +879,7 @@ async function buildIndex(params: { ...(record.subtype !== undefined ? { subtype: record.subtype } : {}), + inherited: record.forkedFrom !== undefined, segments: [segment], }); } @@ -896,7 +907,15 @@ async function buildIndex(params: { } : undefined; }); - const activeUuids = [...chain.uuids]; + const sourceBoundary = sideTaskSourceUuid + ? chain.uuids.indexOf(sideTaskSourceUuid) + : -1; + const activeUuids = + sourceBoundary >= 0 + ? chain.uuids + .slice(sourceBoundary) + .filter((uuid) => byUuid.get(uuid)?.inherited !== true) + : [...chain.uuids]; const goalStatePositions: number[] = []; for (let position = 0; position < activeUuids.length; position++) { const uuid = activeUuids[position]!; diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index cf6f1cf805..a5024d6f02 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -3093,6 +3093,69 @@ describe('SessionService', () => { expect(srcLines.every((r) => !r.forkedFrom)).toBe(true); }); + it('writes source metadata and drops the inherited title for sourced forks', async () => { + const oldId = '10101010-1010-1010-1010-101010101010'; + const newId = '20202020-2020-2020-2020-202020202020'; + const { file, lines } = seedSession(oldId); + fs.writeFileSync( + file, + [ + ...lines, + { + uuid: 'title-1', + parentUuid: 'u2', + sessionId: oldId, + type: 'system', + subtype: 'custom_title', + timestamp: '2026-04-22T00:00:02.000Z', + cwd, + version: 'test', + systemPayload: { + customTitle: 'Parent title', + titleSource: 'manual', + }, + }, + ] + .map((line) => JSON.stringify(line)) + .join('\n') + '\n', + ); + + const result = await service.forkSession(oldId, newId, { + source: { + sourceType: 'side_task', + sourceId: oldId, + }, + }); + const written = fs + .readFileSync(result.filePath, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + + expect(written[0]).toMatchObject({ + parentUuid: null, + sessionId: newId, + type: 'system', + subtype: 'session_source', + cwd, + version: 'test', + systemPayload: { + sourceType: 'side_task', + sourceId: oldId, + }, + }); + expect(written.some((record) => record.subtype === 'custom_title')).toBe( + false, + ); + expect(written[1]).toMatchObject({ + parentUuid: written[0].uuid, + forkedFrom: { + sessionId: oldId, + messageUuid: 'u1', + }, + }); + }); + it('copies artifact side records from the active branch', async () => { const oldId = '71717171-7171-7171-7171-717171717171'; const newId = '81818181-8181-8181-8181-818181818181'; diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index ca7a7ff38e..e5088cb1cf 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -1666,6 +1666,9 @@ export class SessionService { async forkSession( sourceSessionId: string, newSessionId: string, + options: { + source?: { sourceType: string; sourceId?: string }; + } = {}, ): Promise<{ filePath: string; copiedCount: number }> { if (!SESSION_FILE_PATTERN.test(`${sourceSessionId}.jsonl`)) { throw new Error(`Invalid source sessionId: ${sourceSessionId}`); @@ -1709,7 +1712,8 @@ export class SessionService { !( record.type === 'system' && (record.subtype === 'parent_session' || - record.subtype === 'session_source') + record.subtype === 'session_source' || + (options.source && record.subtype === 'custom_title')) ), ); if (sourceRecords.length === 0) { @@ -1719,32 +1723,53 @@ export class SessionService { // Rebuild the parentUuid chain in active-history order so the fork is a // clean linear descendant. `forkedFrom` captures the origin of each // message. - let prevUuid: string | null = null; + const sourceRecord: ChatRecord | undefined = options.source + ? { + uuid: randomUUID(), + parentUuid: null, + sessionId: newSessionId, + timestamp: new Date().toISOString(), + type: 'system', + subtype: 'session_source', + cwd: this.projectRoot, + version: records[0].version, + systemPayload: { + sourceType: options.source.sourceType, + ...(options.source.sourceId !== undefined + ? { sourceId: options.source.sourceId } + : {}), + }, + } + : undefined; + let prevUuid: string | null = sourceRecord?.uuid ?? null; const remappedArtifactIds = new Map(); - const forked: ChatRecord[] = sourceRecords.map((record) => { - const isArtifactRecord = isSessionArtifactRecord(record); - const systemPayload = remapSystemPayloadForFork( - record, - sourceSessionId, - newSessionId, - remappedArtifactIds, - ); - const next: ChatRecord = { - ...record, - sessionId: newSessionId, - cwd: this.projectRoot, - systemPayload, - parentUuid: isArtifactRecord ? record.parentUuid : prevUuid, - forkedFrom: { - sessionId: sourceSessionId, - messageUuid: record.uuid, - }, - }; - if (!isArtifactRecord) { - prevUuid = record.uuid; - } - return next; - }); + const forked: ChatRecord[] = [ + ...(sourceRecord ? [sourceRecord] : []), + ...sourceRecords.map((record) => { + const isArtifactRecord = isSessionArtifactRecord(record); + const systemPayload = remapSystemPayloadForFork( + record, + sourceSessionId, + newSessionId, + remappedArtifactIds, + ); + const next: ChatRecord = { + ...record, + sessionId: newSessionId, + cwd: this.projectRoot, + systemPayload, + parentUuid: isArtifactRecord ? record.parentUuid : prevUuid, + forkedFrom: { + sessionId: sourceSessionId, + messageUuid: record.uuid, + }, + }; + if (!isArtifactRecord) { + prevUuid = record.uuid; + } + return next; + }), + ]; // File-history snapshots are side-channel system records used by /rewind. // They may not sit on the active message leaf copied above, and copied diff --git a/packages/core/src/utils/transcript-records.ts b/packages/core/src/utils/transcript-records.ts index 95d7be556a..cf4234f199 100644 --- a/packages/core/src/utils/transcript-records.ts +++ b/packages/core/src/utils/transcript-records.ts @@ -35,6 +35,10 @@ export interface TranscriptRecordInput { readonly usageMetadata?: unknown; readonly toolCallResult?: unknown; readonly systemPayload?: unknown; + readonly forkedFrom?: { + readonly sessionId: string; + readonly messageUuid: string; + }; } export interface TranscriptReplayGapInput { diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index 883edf3fd4..362b738aa2 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -77,7 +77,8 @@ const rootDir = join(__dirname, '..'); // DaemonSessionClient (#6930). // Bumped from 177KB to 178KB for workspace file byte-cursor paging after // merging the workspace pairing approval SDK surface. -const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 178 * 1024; +// Bumped from 178KB to 184KB for side-task session APIs and source metadata. +const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 184 * 1024; // The opt-in `daemon/transports` browser bundle legitimately ships the concrete // ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so // it's larger than the default barrel — but still budgeted so a future PR can't diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index a27774c03f..d43b4bee1d 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -33,6 +33,7 @@ import type { DaemonSessionContextUsageStatus, BranchSessionRequest, DaemonBranchedSession, + DaemonSideTaskSession, DaemonForkSessionResult, DaemonRestoredSession, DaemonSession, @@ -41,6 +42,7 @@ import type { DaemonSessionExportResult, DaemonSessionTranscriptPage, DaemonSessionTranscriptPageOptions, + SideTaskSessionRequest, DaemonSubagentSessionResolution, DaemonSessionGroup, DaemonSessionGroupCatalog, @@ -2463,7 +2465,9 @@ export class DaemonClient { { method: 'POST', headers: this.headers({ 'Content-Type': 'application/json' }, clientId), - body: JSON.stringify({ name: req.name }), + body: JSON.stringify({ + ...(req.name !== undefined ? { name: req.name } : {}), + }), }, async (res) => { if (!res.ok) { @@ -2474,6 +2478,29 @@ export class DaemonClient { ); } + async createSideTaskSession( + sessionId: string, + req: SideTaskSessionRequest = {}, + clientId?: string, + ): Promise { + return await this.fetchWithTimeout( + `${this.baseUrl}/session/${urlEncode(sessionId)}/side-task`, + { + method: 'POST', + headers: this.headers({ 'Content-Type': 'application/json' }, clientId), + body: JSON.stringify({ + ...(req.name !== undefined ? { name: req.name } : {}), + }), + }, + async (res) => { + if (!res.ok) { + throw await this.failOnError(res, 'POST /session/:id/side-task'); + } + return (await res.json()) as DaemonSideTaskSession; + }, + ); + } + async forkSession( sessionId: string, req: ForkSessionRequest, diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 4a0be90a01..249b1aa3f7 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -470,8 +470,10 @@ export type { DaemonProtocolVersions, BranchSessionRequest, DaemonBranchedSession, + DaemonSideTaskSession, DaemonForkSessionResult, ForkSessionRequest, + SideTaskSessionRequest, DaemonRestoredSession, DaemonSession, DaemonSessionArchiveState, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 963cde7763..db61813c33 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -838,6 +838,15 @@ export interface DaemonBranchedSession extends DaemonRestoredSession { forkedFrom: { sessionId: string; displayName: string }; } +export interface SideTaskSessionRequest { + name?: string; +} + +export interface DaemonSideTaskSession extends DaemonRestoredSession { + displayName: string; + parentSessionId: string; +} + export interface ForkSessionRequest { directive: string; } diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index 8990ba0cca..4fa87e121f 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -2699,6 +2699,38 @@ describe('DaemonClient', () => { }); }); + describe('createSideTaskSession', () => { + it('uses the dedicated side-task endpoint', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(201, { + sessionId: 'side-1', + workspaceCwd: '/work/a', + attached: false, + state: {}, + displayName: 'Side task', + parentSessionId: 'main-1', + sourceType: 'side_task', + sourceId: 'main-1', + }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await client.createSideTaskSession( + 'main-1', + { + name: 'Side task', + }, + 'side-task-client', + ); + + expect(calls[0]?.url).toBe('http://daemon/session/main-1/side-task'); + expect(calls[0]?.headers['x-qwen-client-id']).toBe('side-task-client'); + expect(JSON.parse(calls[0]!.body!)).toEqual({ + name: 'Side task', + }); + }); + }); + describe('cancel', () => { it('POSTs /cancel and tolerates 204', async () => { const { fetch, calls } = recordingFetch( diff --git a/packages/web-shell/README.md b/packages/web-shell/README.md index f2b3c175b5..53f1468216 100644 --- a/packages/web-shell/README.md +++ b/packages/web-shell/README.md @@ -368,5 +368,7 @@ Chart/Data 控件、无数据提示和错误提示默认跟随 WebShell 语言 | `/init` | ACP 透传 | 分析项目并创建定制的 `QWEN.md`。 | | `/stats` | ACP 透传 | 显示统计信息,包含 `model`、`tools` 子命令。 | | `/summary` | ACP 透传 | 生成当前会话摘要。 | -| `/tasks` | ACP 透传 | 列出后台任务。 | +| `/tasks` | 本地实现 | 打开环境信息面板并刷新后台任务。 | +| `/btw` | 本地实现 + ACP 透传 | daemon 支持侧边任务时新建侧边任务;否则发送一个不影响主对话的侧边问题。 | +| `/fork` | 本地实现 + ACP 透传 | 启动共享当前上下文的后台智能体。 | | `/insight` | ACP 透传 | 查看 insight 相关信息。 | diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css index 5a0edd1b80..a6e9c0ae5e 100644 --- a/packages/web-shell/client/App.module.css +++ b/packages/web-shell/client/App.module.css @@ -59,6 +59,49 @@ overflow: hidden; } +.contextShell { + display: flex; + flex: 1 1 auto; + min-width: 0; + min-height: 0; + flex-direction: column; + overflow: hidden; +} + +.chatHeaderRow { + display: flex; + min-width: 0; + flex: 0 0 auto; + align-items: center; + border-bottom: 1px solid var(--border); + background: var(--background); +} + +.customChatHeader { + min-width: 0; + flex: 1 1 auto; +} + +.contextBody { + position: relative; + display: flex; + flex: 1 1 auto; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.contextBodyWithEnvironmentPanel .chatPane, +.contextBodyWithEnvironmentPanel .content { + overflow: visible; +} + +.contextBodyWithEnvironmentPanel [data-web-shell-message-list] { + box-sizing: border-box; + width: calc(100% + 332px); + padding-right: 356px; +} + .chatPane { flex: 1 1 auto; min-width: 0; @@ -760,10 +803,6 @@ margin-bottom: 8px; } -.chatHeader { - flex-shrink: 0; -} - .customFooter { flex-shrink: 0; } diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 3050186dc0..9ff32908af 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -1,10 +1,11 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, createRef, type CSSProperties } from 'react'; +import { act, createRef, type CSSProperties, type ReactNode } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import type { DaemonInputAnnotation, DaemonSessionMonitorTaskStatus, + DaemonSessionShellTaskStatus, DaemonSettingDescriptor, DaemonWorkspaceGitStatus, } from '@qwen-code/sdk/daemon'; @@ -80,6 +81,8 @@ type ChatEditorTestProps = { gitBranch?: string; gitStatus?: DaemonWorkspaceGitStatus; onOpenGitDiff?: () => void; + visibleToolbarActions?: string[]; + onChatWidthModeChange?: (mode: '1000' | 'wide') => void; }; type AddWorkspaceDialogTestProps = { @@ -164,7 +167,10 @@ const { workspaceProviders: qualifiedWorkspaceProviders, setWorkspaceSetting: qualifiedSetWorkspaceSetting, })), - sessionStatus: vi.fn(() => Promise.resolve({})), + sessionStatus: vi.fn(() => + Promise.resolve({ workspaceCwd: '/tmp/project' }), + ), + listWorkspaceSessions: vi.fn(() => Promise.resolve([])), }; const settingsSetValue = vi.fn().mockResolvedValue(undefined); return { @@ -254,6 +260,7 @@ const { messages: [] as unknown[], chatEditorRenderCount: 0, latestChatEditorProps: null as ChatEditorTestProps | null, + latestStatusBarTasks: null as DaemonSessionMonitorTaskStatus[] | null, latestMessageListProps: null as { failedPromptMessageId?: string; onRetryFailedPrompt?: () => void; @@ -316,6 +323,7 @@ const { vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ DAEMON_APPROVAL_MODES: ['default', 'plan', 'auto-edit', 'auto', 'yolo'], + DaemonSessionProvider: ({ children }: { children: ReactNode }) => children, useActions: () => mockSessionActions, useConnection: () => mockConnection, useDaemonFollowupSuggestion: () => ({ @@ -817,7 +825,15 @@ function mockComponent(path: string, exportName: string): void { }); } -mockComponent('./components/StatusBar', 'StatusBar'); +vi.doMock('./components/StatusBar', async () => { + const React = await import('react'); + return { + StatusBar: (props: { tasks?: DaemonSessionMonitorTaskStatus[] }) => { + testState.latestStatusBarTasks = props.tasks ?? []; + return React.createElement('div'); + }, + }; +}); vi.doMock('./components/StreamingStatus', async () => { const React = await import('react'); return { @@ -858,6 +874,11 @@ vi.doMock('./components/SplitView', async () => { workspaceActions: unknown, ) => void; onRightPanelOpen?: (request: unknown) => void; + onOpenMonitor?: ( + task: DaemonSessionMonitorTaskStatus, + sessionId: string, + sessionActions: typeof mockSessionActions, + ) => void; renderPaneHeaderActions?: (info: { sessionId: string; workspaceCwd?: string; @@ -966,6 +987,32 @@ vi.doMock('./components/SplitView', async () => { }, 'open artifact', ), + React.createElement( + 'button', + { + 'data-testid': 'split-open-monitor', + type: 'button', + onClick: () => + props.onOpenMonitor?.( + { + kind: 'monitor', + id: 'monitor-1', + label: 'monitor-label', + description: 'watch pane logs', + status: 'running', + startTime: 1, + runtimeMs: 10, + command: 'tail -f pane.log', + eventCount: 1, + droppedLines: 0, + toolUseId: 'monitor-call', + }, + 'pane-session', + mockSessionActions, + ), + }, + 'open monitor', + ), React.createElement( 'button', { @@ -1092,6 +1139,12 @@ vi.doMock('./components/messages/TasksStatusMessage', async () => { return React.createElement('div'); }, MonitorTaskDetail: () => React.createElement('div'), + ShellTaskDetail: (props: { task: DaemonSessionShellTaskStatus }) => + React.createElement( + 'div', + null, + `${props.task.command} ${props.task.cwd}`, + ), }; }); vi.doMock('./monitorDetailsContext', async () => { @@ -1113,8 +1166,13 @@ vi.doMock('./monitorDetailsContext', async () => { mockComponent('./components/messages/BtwMessage', 'BtwMessage'); mockComponent('./components/QueuedPromptDisplay', 'QueuedPromptDisplay'); -const { App, getBackgroundTaskActivityKey, mergeMonitorTaskSnapshot } = - await import('./App'); +const { + App, + getTaskActivityKey, + getEnvironmentAgentTasks, + mergeMonitorTaskSnapshot, + mergeSideTaskCatalog, +} = await import('./App'); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } @@ -1122,8 +1180,64 @@ const { App, getBackgroundTaskActivityKey, mergeMonitorTaskSnapshot } = const mounted: Array<{ root: Root; container: HTMLElement }> = []; -describe('background task activity key', () => { - it('includes background shells and monitors but excludes background agents', () => { +describe('mergeSideTaskCatalog', () => { + const listed = (sessionId: string) => ({ sessionId, title: sessionId }); + + it('replaces the catalog when the parent session changes', () => { + const next = mergeSideTaskCatalog( + { parentSessionId: 'parent-a', items: [listed('stale')], loaded: true }, + 'parent-b', + [listed('b1')], + new Set(['stale']), + ); + expect(next).toEqual({ + parentSessionId: 'parent-b', + items: [listed('b1')], + loaded: true, + }); + }); + + it('treats a successful listing as authoritative for confirmed items', () => { + const next = mergeSideTaskCatalog( + { + parentSessionId: 'parent-a', + items: [listed('kept'), listed('deleted-elsewhere')], + loaded: true, + }, + 'parent-a', + [listed('kept')], + new Set(), + ); + expect(next.items.map((item) => item.sessionId)).toEqual(['kept']); + }); + + it('keeps a locally created draft the listing has not echoed yet', () => { + const next = mergeSideTaskCatalog( + { + parentSessionId: 'parent-a', + items: [listed('kept'), listed('draft')], + loaded: true, + }, + 'parent-a', + [listed('kept')], + new Set(['draft']), + ); + expect(next.items.map((item) => item.sessionId)).toEqual(['kept', 'draft']); + }); + + it('does not duplicate a draft once the listing confirms it', () => { + const next = mergeSideTaskCatalog( + { parentSessionId: 'parent-a', items: [listed('draft')], loaded: true }, + 'parent-a', + [listed('draft')], + new Set(['draft']), + ); + expect(next.items.map((item) => item.sessionId)).toEqual(['draft']); + }); +}); + +describe('task activity key', () => { + it('includes background shells in any tool-call state', () => { const messages = [ { id: 'tools', @@ -1139,20 +1253,40 @@ describe('background task activity key', () => { callId: 'agent-call', toolName: 'agent', status: 'pending', - args: { run_in_background: true }, + args: {}, + subTools: [ + { + callId: 'nested-shell', + toolName: 'run_shell_command', + status: 'completed', + args: { is_background: true }, + }, + ], + }, + { + callId: 'foreground-agent', + toolName: 'agent', + status: 'in_progress', + args: { run_in_background: false }, + }, + { + callId: 'completed-shell', + toolName: 'shell', + status: 'completed', + args: { is_background: true }, }, { callId: 'monitor-call', toolName: 'monitor', status: 'completed', - args: {}, + args: { command: 'npm run dev --watch' }, }, ], }, ] satisfies Message[]; - expect(getBackgroundTaskActivityKey(messages)).toBe( - 'shell-call:in_progress|monitor-call:completed', + expect(getTaskActivityKey(messages)).toBe( + 'shell-call:in_progress|agent-call:pending|nested-shell:completed|completed-shell:completed|monitor-call:completed', ); }); @@ -1188,26 +1322,7 @@ describe('background task activity key', () => { expect(mockSessionActions.getTasks).not.toHaveBeenCalled(); }); - it('restarts shared task polling when a monitor opens from the task dialog', async () => { - const task: DaemonSessionMonitorTaskStatus = { - kind: 'monitor', - id: 'monitor-1', - label: 'monitor-label', - description: 'watch server log', - status: 'running', - startTime: 1_000, - runtimeMs: 5_000, - command: 'tail -f server.log', - eventCount: 3, - lastEventTime: 5_000, - droppedLines: 0, - }; - mockSessionActions.getTasks.mockResolvedValue({ - v: 1, - sessionId: 'session-1', - now: 6_000, - tasks: [task], - }); + it('opens environment information for /tasks without a dialog', async () => { const { container } = renderApp(); await flush(); expect(testState.latestBackgroundTasksRefreshTrigger).toBe(0); @@ -1215,14 +1330,14 @@ describe('background task activity key', () => { testState.prompt = '/tasks'; await clickSubmit(container); await flush(); - expect(testState.latestTasksStatusProps?.onOpenMonitor).toBeTypeOf( - 'function', - ); - - act(() => { - testState.latestTasksStatusProps?.onOpenMonitor?.(task); - }); + expect( + container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ), + ).not.toBeNull(); + expect(testState.latestTasksStatusProps).toBeNull(); + expect(mockSessionActions.getTasks).not.toHaveBeenCalled(); expect(testState.latestBackgroundTasksRefreshTrigger).toBe(1); }); @@ -1269,6 +1384,23 @@ describe('background task activity key', () => { container.querySelector('button[title="watch server log"]'), ).not.toBeNull(); expect(testState.latestBackgroundTasksRefreshTrigger).toBe(1); + + act(() => { + container + .querySelector( + 'button[aria-label="Toggle environment information"]', + ) + ?.click(); + }); + + expect( + container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ), + ).not.toBeNull(); + expect( + container.querySelector('button[title="watch server log"]'), + ).not.toBeNull(); }); it('merges a reopened monitor into its existing tab', async () => { @@ -1286,24 +1418,22 @@ describe('background task activity key', () => { lastEventTime: 5_000, droppedLines: 0, }; - mockSessionActions.getTasks.mockResolvedValue({ + mockConnection.capabilities.features = ['session_monitor_tool_correlation']; + mockSessionActions.getTasks.mockResolvedValueOnce({ v: 1, sessionId: 'session-1', now: 6_000, - tasks: [stopped], + tasks: [{ ...stopped, toolUseId: 'monitor-call' }], }); const { container } = renderApp(); await flush(); - testState.prompt = '/tasks'; - await clickSubmit(container); - await flush(); - expect(testState.latestTasksStatusProps?.onOpenMonitor).toBeTypeOf( - 'function', - ); - - act(() => { - testState.latestTasksStatusProps?.onOpenMonitor?.(stopped); + await act(async () => { + await testState.latestMonitorDetailsOnOpen?.({ + callId: 'monitor-call', + toolName: 'monitor', + status: 'completed', + }); }); await flush(); @@ -1325,8 +1455,18 @@ describe('background task activity key', () => { lastEventTime: 5_000, droppedLines: 0, }; - act(() => { - testState.latestTasksStatusProps?.onOpenMonitor?.(running); + mockSessionActions.getTasks.mockResolvedValueOnce({ + v: 1, + sessionId: 'session-1', + now: 7_000, + tasks: [{ ...running, toolUseId: 'monitor-call' }], + }); + await act(async () => { + await testState.latestMonitorDetailsOnOpen?.({ + callId: 'monitor-call', + toolName: 'monitor', + status: 'completed', + }); }); await flush(); @@ -1464,6 +1604,397 @@ describe('background task activity key', () => { }); }); +describe('environment agent tasks', () => { + it('keeps a completed foreground agent from the session transcript', () => { + const messages = [ + { + id: 'tools', + role: 'tool_group', + tools: [ + { + callId: 'agent-call', + toolName: 'agent', + title: 'Agent: Explore code', + status: 'completed', + args: { + description: 'Explore code', + run_in_background: false, + }, + rawOutput: { + type: 'task_execution', + status: 'completed', + subagentColor: 'purple', + }, + }, + ], + }, + ] satisfies Message[]; + + expect(getEnvironmentAgentTasks(messages, [])).toMatchObject([ + { + id: 'agent-call', + label: 'Explore code', + status: 'completed', + color: 'purple', + isBackgrounded: false, + toolUseId: 'agent-call', + }, + ]); + }); + + it('uses the prompt for a generic Agent title and ignores nested tools', () => { + const messages = [ + { + id: 'tools', + role: 'tool_group', + tools: [ + { + callId: 'agent-call', + toolName: 'agent', + title: 'Agent', + status: 'in_progress', + args: { + prompt: '查询杭州明天天气', + run_in_background: true, + }, + subTools: [ + { + callId: 'search-call', + toolName: 'web_search', + status: 'completed', + subContent: 'result', + }, + ], + }, + ], + }, + ] satisfies Message[]; + + expect(getEnvironmentAgentTasks(messages, [])).toMatchObject([ + { + id: 'agent-call', + label: '查询杭州明天天气', + }, + ]); + }); + + it('keeps the transcript color when a live agent task is available', () => { + const messages = [ + { + id: 'tools', + role: 'tool_group', + tools: [ + { + callId: 'agent-call', + toolName: 'agent', + title: 'Agent: Review code', + status: 'in_progress', + args: { subagent_type: 'reviewer' }, + rawOutput: { + type: 'task_execution', + subagentColor: 'purple', + }, + }, + ], + }, + ] satisfies Message[]; + const liveTask = { + kind: 'agent' as const, + id: 'agent-task', + label: 'reviewer: Review code', + description: 'Review code', + subagentType: 'reviewer', + status: 'running' as const, + startTime: 1, + runtimeMs: 1, + isBackgrounded: true, + toolUseId: 'agent-call', + }; + + expect(getEnvironmentAgentTasks(messages, [liveTask])).toMatchObject([ + { + id: 'agent-task', + color: 'purple', + }, + ]); + }); + + it('deduplicates a live agent by the task id recorded in the message stream', () => { + const messages = [ + { + id: 'tools', + role: 'tool_group', + tools: [ + { + callId: 'call-agent-1', + toolName: 'agent', + title: 'Agent: Review code', + status: 'in_progress', + args: { subagent_type: 'shadcn-ux' }, + }, + ], + }, + { + id: 'agent-notification', + role: 'system', + content: 'background agent completed', + variant: 'info', + source: 'background_notification', + data: { + kind: 'agent', + taskId: 'agent-runtime-id', + toolUseId: 'call-agent-1', + status: 'completed', + }, + }, + ] satisfies Message[]; + const liveTask = { + kind: 'agent' as const, + id: 'agent-runtime-id', + label: 'shadcn-ux: Review code', + description: 'Review code', + subagentType: 'shadcn-ux', + status: 'completed' as const, + startTime: 1, + runtimeMs: 1, + isBackgrounded: true, + }; + + expect(getEnvironmentAgentTasks(messages, [liveTask])).toMatchObject([ + { + id: 'agent-runtime-id', + label: 'Review code', + status: 'completed', + }, + ]); + }); + + it('deduplicates a completed background agent whose live task lost its toolUseId', () => { + const messages = [ + { + id: 'tools', + role: 'tool_group', + tools: [ + { + callId: 'call-agent-1', + toolName: 'agent', + title: 'Agent: Review code', + status: 'completed', + args: { + description: 'Review code', + prompt: 'Review the diff for bugs', + subagent_type: 'general-purpose', + run_in_background: true, + }, + rawOutput: { + type: 'task_execution', + status: 'completed', + }, + }, + ], + }, + { + id: 'agent-notification', + role: 'system', + content: 'background agent completed', + variant: 'info', + source: 'background_notification', + data: { + kind: 'agent', + taskId: 'general-purpose-internal-1', + status: 'completed', + }, + }, + ] satisfies Message[]; + const liveTask = { + kind: 'agent' as const, + id: 'general-purpose-internal-1', + label: 'general-purpose: Review code', + description: 'Review code', + prompt: 'Review the diff for bugs', + subagentType: 'general-purpose', + status: 'completed' as const, + startTime: 1, + runtimeMs: 1, + isBackgrounded: true, + }; + + expect(getEnvironmentAgentTasks(messages, [liveTask])).toMatchObject([ + { + id: 'general-purpose-internal-1', + label: 'Review code', + status: 'completed', + }, + ]); + }); + + it('deduplicates a completed background agent with no toolUseId or prompt on the live task', () => { + const messages = [ + { + id: 'tools', + role: 'tool_group', + tools: [ + { + callId: 'call-agent-1', + toolName: 'agent', + title: 'Agent: Fix lint errors', + status: 'completed', + args: { + description: 'Fix lint errors', + prompt: 'Fix all lint errors in src/', + run_in_background: true, + }, + rawOutput: { + type: 'task_execution', + status: 'completed', + }, + }, + ], + }, + { + id: 'agent-notification', + role: 'system', + content: 'background agent completed', + variant: 'info', + source: 'background_notification', + data: { + kind: 'agent', + taskId: 'general-purpose-internal-2', + status: 'completed', + }, + }, + ] satisfies Message[]; + const liveTask = { + kind: 'agent' as const, + id: 'general-purpose-internal-2', + label: 'general-purpose: Fix lint errors', + description: 'Fix lint errors', + status: 'completed' as const, + startTime: 1, + runtimeMs: 1, + isBackgrounded: true, + }; + + expect(getEnvironmentAgentTasks(messages, [liveTask])).toMatchObject([ + { + id: 'general-purpose-internal-2', + label: 'Fix lint errors', + status: 'completed', + }, + ]); + }); + + it('does not collapse two agents that share a description when one is linked precisely', () => { + const messages = [ + { + id: 'tools', + role: 'tool_group', + tools: [ + { + callId: 'call-A', + toolName: 'agent', + title: 'Agent: Review code', + status: 'completed', + args: { description: 'Review code', run_in_background: true }, + rawOutput: { type: 'task_execution', status: 'completed' }, + }, + { + callId: 'call-B', + toolName: 'agent', + title: 'Agent: Review code', + status: 'completed', + args: { description: 'Review code', run_in_background: true }, + rawOutput: { type: 'task_execution', status: 'completed' }, + }, + ], + }, + ] satisfies Message[]; + // The precisely-linked task is listed first so a loose description fallback + // would steal it before reaching the orphaned one. + const linkedTask = { + kind: 'agent' as const, + id: 'task-B', + label: 'Review code', + description: 'Review code', + status: 'completed' as const, + startTime: 1, + runtimeMs: 1, + isBackgrounded: true, + toolUseId: 'call-B', + }; + const orphanTask = { + kind: 'agent' as const, + id: 'task-A', + label: 'Review code', + description: 'Review code', + status: 'completed' as const, + startTime: 1, + runtimeMs: 1, + isBackgrounded: true, + }; + + const result = getEnvironmentAgentTasks(messages, [linkedTask, orphanTask]); + expect(result).toHaveLength(2); + expect(result).toMatchObject([ + { id: 'task-A', description: 'Review code', status: 'completed' }, + { id: 'task-B', description: 'Review code', status: 'completed' }, + ]); + }); + + it('lists two precisely-linked agents that share a description once each', () => { + const messages = [ + { + id: 'tools', + role: 'tool_group', + tools: [ + { + callId: 'call-A', + toolName: 'agent', + title: 'Agent: Review code', + status: 'completed', + args: { description: 'Review code', run_in_background: true }, + rawOutput: { type: 'task_execution', status: 'completed' }, + }, + { + callId: 'call-B', + toolName: 'agent', + title: 'Agent: Review code', + status: 'completed', + args: { description: 'Review code', run_in_background: true }, + rawOutput: { type: 'task_execution', status: 'completed' }, + }, + ], + }, + ] satisfies Message[]; + const taskA = { + kind: 'agent' as const, + id: 'task-A', + label: 'Review code', + description: 'Review code', + status: 'completed' as const, + startTime: 1, + runtimeMs: 1, + isBackgrounded: true, + toolUseId: 'call-A', + }; + const taskB = { + kind: 'agent' as const, + id: 'task-B', + label: 'Review code', + description: 'Review code', + status: 'completed' as const, + startTime: 1, + runtimeMs: 1, + isBackgrounded: true, + toolUseId: 'call-B', + }; + + const result = getEnvironmentAgentTasks(messages, [taskA, taskB]); + expect(result).toHaveLength(2); + expect(result).toMatchObject([{ id: 'task-A' }, { id: 'task-B' }]); + }); +}); + function renderApp(props: React.ComponentProps = {}): { container: HTMLElement; rerender: (nextProps?: React.ComponentProps) => void; @@ -1474,7 +2005,9 @@ function renderApp(props: React.ComponentProps = {}): { const root = createRoot(container); const doRender = (nextProps: React.ComponentProps = props) => { act(() => { - root.render(); + root.render( + , + ); }); }; doRender(props); @@ -1554,6 +2087,7 @@ beforeEach(() => { // Split persistence uses sessionStorage; clear it so one test's split doesn't // auto-restore into the next test's App mount. sessionStorage.clear(); + localStorage.removeItem('qwen-code-web-shell-chat-width'); Object.defineProperty(window, 'matchMedia', { configurable: true, // Query-aware: report a large screen (min-width matches) so the Session @@ -1604,6 +2138,12 @@ beforeEach(() => { }), })); mockWorkspace.client.workspaceById.mockClear(); + mockWorkspace.client.sessionStatus.mockReset(); + mockWorkspace.client.sessionStatus.mockResolvedValue({ + workspaceCwd: '/tmp/project', + }); + mockWorkspace.client.listWorkspaceSessions.mockReset(); + mockWorkspace.client.listWorkspaceSessions.mockResolvedValue([]); testState.prompt = 'hello'; testState.inputAnnotations = undefined; testState.promptImages = undefined; @@ -1612,6 +2152,7 @@ beforeEach(() => { testState.messages = []; testState.chatEditorRenderCount = 0; testState.latestChatEditorProps = null; + testState.latestStatusBarTasks = null; testState.latestMessageListProps = null; testState.latestAddWorkspaceDialogProps = null; testState.latestToolApprovalKeyboardActive = null; @@ -3578,6 +4119,858 @@ describe('App session callbacks', () => { ).not.toBeNull(); }); + it('waits for session loading to finish before requesting status', async () => { + mockConnection.loadingTranscript = true; + const { rerender } = renderApp(); + await flush(); + + expect(mockWorkspace.client.sessionStatus).not.toHaveBeenCalled(); + + mockConnection.loadingTranscript = false; + rerender(); + + await vi.waitFor(() => { + expect(mockWorkspace.client.sessionStatus).toHaveBeenCalledWith( + 'session-1', + ); + }); + }); + + it('uses the session catalog title when the connection has no display name', async () => { + mockConnection.displayName = undefined; + mockWorkspace.client.listWorkspaceSessions.mockResolvedValue([ + { + sessionId: 'session-1', + workspaceCwd: '/tmp/project', + displayName: 'Real session title', + }, + ]); + + const { container } = renderApp(); + + await vi.waitFor(() => { + expect( + container.querySelector('[data-testid="chat-context-header"]') + ?.textContent, + ).toContain('Real session title'); + }); + }); + + it('keeps the persistent chat header opt-in for existing integrations', () => { + const { container } = renderApp({ header: undefined }); + + expect( + container.querySelector('[data-testid="chat-context-header"]'), + ).toBeNull(); + }); + + it('lets a custom renderer replace the complete persistent chat header', () => { + mockConnection.gitBranch = 'main'; + mockConnection.gitStatus = { + v: 2, + workspaceCwd: '/tmp/project', + branch: 'main', + unstaged: 1, + }; + const renderChatHeader = vi.fn(() => ( +
Custom session header
+ )); + const { container } = renderApp({ + header: undefined, + renderChatHeader, + }); + + expect( + container.querySelector('[data-testid="chat-context-header"]'), + ).toBeNull(); + expect( + container.querySelector('[data-testid="custom-chat-header"]') + ?.textContent, + ).toContain('Custom session header'); + expect(renderChatHeader).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'session-1', + sessionName: 'Session One', + workspaceCwd: '/tmp/project', + items: ['title', 'environment', 'rightPanel'], + environmentPanelOpen: false, + rightPanelOpen: false, + onEnvironmentPanelOpenChange: expect.any(Function), + onRightPanelOpenChange: expect.any(Function), + }), + ); + expect(testState.latestChatEditorProps?.visibleToolbarActions).toContain( + 'gitBranch', + ); + }); + + it('keeps legacy task status for a custom header without explicit header configuration', () => { + const monitor: DaemonSessionMonitorTaskStatus = { + kind: 'monitor', + id: 'monitor-1', + label: 'Watch server', + description: 'Watch server', + status: 'running', + startTime: 1, + runtimeMs: 10, + }; + testState.backgroundTasks = [monitor]; + + renderApp({ + header: undefined, + renderChatHeader: () =>
Custom session header
, + }); + + expect(testState.latestStatusBarTasks).toEqual([monitor]); + }); + + it('controls the built-in chat header actions through header items', () => { + const { container } = renderApp({ + header: { items: ['environment'] }, + }); + + expect( + container.querySelector( + 'button[aria-label="Toggle environment information"]', + ), + ).not.toBeNull(); + expect( + container.querySelector('button[aria-label="Toggle right panel"]'), + ).toBeNull(); + expect( + container.querySelector('[data-testid="chat-context-header"]') + ?.textContent, + ).not.toContain('Session One'); + }); + + it('hides the complete chat header when header items are empty', () => { + const { container } = renderApp({ header: { items: [] } }); + + expect( + container.querySelector('[data-testid="chat-context-header"]'), + ).toBeNull(); + }); + + it('opens environment information without restoring composer Git information', () => { + mockConnection.gitBranch = 'main'; + mockConnection.gitStatus = { + v: 2, + workspaceCwd: '/tmp/project', + branch: 'main', + unstaged: 1, + }; + const { container } = renderApp(); + const rightPanelButton = container.querySelector( + 'button[aria-label="Toggle right panel"]', + ); + + expect(rightPanelButton).not.toBeNull(); + act(() => { + container + .querySelector( + 'button[aria-label="Toggle environment information"]', + ) + ?.click(); + }); + + expect( + container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ), + ).not.toBeNull(); + expect( + testState.latestChatEditorProps?.visibleToolbarActions, + ).not.toContain('gitBranch'); + }); + + it('keeps the right-panel action visible and opens a review-only empty state', () => { + const { container } = renderApp(); + const rightPanelButton = container.querySelector( + 'button[aria-label="Toggle right panel"]', + ); + + expect(rightPanelButton).not.toBeNull(); + act(() => rightPanelButton?.click()); + + const emptyActions = container.querySelector( + '[data-testid="right-panel-empty-actions"]', + ); + const actions = Array.from( + emptyActions?.querySelectorAll('button') ?? [], + ); + expect(actions).toHaveLength(1); + expect(actions[0]?.textContent).toContain('Review'); + expect(actions[0]?.disabled).toBe(true); + expect( + container.querySelector('button[aria-label="Add panel"]'), + ).toBeNull(); + + const header = container.querySelector( + '[data-testid="chat-context-header"]', + ); + expect( + header?.querySelector('button[aria-label="Toggle right panel"]'), + ).toBeNull(); + expect( + container + .querySelector('aside[aria-label="Right panel"]') + ?.querySelector('button[aria-label="Toggle right panel"]'), + ).not.toBeNull(); + const artifactDock = + container.querySelector('[role="separator"]')?.parentElement; + expect(artifactDock?.parentElement).toBe( + header?.parentElement?.parentElement?.parentElement, + ); + expect(header?.parentElement?.contains(artifactDock ?? null)).toBe(false); + }); + + it('opens the latest reviewable turn from the empty right panel', () => { + testState.messages = [ + { + id: 'user-1', + role: 'user', + content: 'write the first file', + }, + { + id: 'tools-1', + role: 'tool_group', + tools: [ + { + callId: 'write-1', + toolName: 'write_file', + status: 'completed', + args: { + file_path: 'src/first.ts', + content: 'export const first = true;\n', + }, + }, + ], + }, + { + id: 'user-2', + role: 'user', + content: 'write the latest file', + }, + { + id: 'tools-2', + role: 'tool_group', + tools: [ + { + callId: 'write-2', + toolName: 'write_file', + status: 'completed', + args: { + file_path: 'src/latest.ts', + content: 'export const latest = true;\n', + }, + }, + ], + }, + ]; + const { container } = renderApp(); + + act(() => { + container + .querySelector( + 'button[aria-label="Toggle right panel"]', + ) + ?.click(); + }); + const review = Array.from( + container.querySelectorAll( + '[data-testid="right-panel-empty-actions"] button', + ), + ).find((button) => button.textContent?.startsWith('Review')); + expect(review?.disabled).toBe(false); + + act(() => review?.click()); + + expect(container.querySelector('button[title="Review"]')).not.toBeNull(); + expect(container.textContent).toContain('latest.ts'); + expect(container.textContent).not.toContain('first.ts'); + }); + + it('floats environment information in ultrawide mode', () => { + mockConnection.gitBranch = 'main'; + mockConnection.gitStatus = { + v: 2, + workspaceCwd: '/tmp/project', + branch: 'main', + unstaged: 1, + }; + const { container } = renderApp(); + const environmentButton = container.querySelector( + 'button[aria-label="Toggle environment information"]', + ); + + act(() => { + testState.latestChatEditorProps?.onChatWidthModeChange?.('wide'); + environmentButton?.click(); + }); + + const environmentPanel = container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ); + expect(environmentPanel?.getAttribute('data-floating')).toBe('true'); + expect( + environmentPanel?.parentElement?.contains( + container.querySelector('[data-testid="chat-pane-container"]'), + ), + ).toBe(true); + }); + + it('closes environment information at the dock breakpoint and reopens it floating', async () => { + let availableMessageWidth = 1200; + const resizeCallbacks = new Set(); + const originalResizeObserver = globalThis.ResizeObserver; + globalThis.ResizeObserver = class { + constructor(private readonly callback: ResizeObserverCallback) { + resizeCallbacks.add(callback); + } + observe() {} + unobserve() {} + disconnect() { + resizeCallbacks.delete(this.callback); + } + } as typeof ResizeObserver; + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + function () { + if (this.dataset['testid'] !== 'context-body') return new DOMRect(); + return new DOMRect(0, 0, availableMessageWidth, 600); + }, + ); + testState.messages = [ + { + id: 'tools', + role: 'tool_group', + tools: [ + { + callId: 'agent-call', + toolName: 'agent', + title: 'Inspect repository', + status: 'completed', + args: { subagent_type: 'Explore' }, + }, + ], + }, + ]; + const { container } = renderApp(); + const environmentButton = container.querySelector( + 'button[aria-label="Toggle environment information"]', + ); + + act(() => environmentButton?.click()); + expect( + container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ), + ).not.toBeNull(); + + await act(async () => { + availableMessageWidth = 932; + resizeCallbacks.forEach((callback) => callback([], {} as ResizeObserver)); + await Promise.resolve(); + }); + expect( + container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ), + ).toBeNull(); + + act(() => { + container + .querySelector( + 'button[aria-label="Toggle environment information"]', + ) + ?.click(); + }); + expect( + container + .querySelector('[data-testid="environment-panel"]:not([hidden])') + ?.getAttribute('data-floating'), + ).toBe('true'); + globalThis.ResizeObserver = originalResizeObserver; + }); + + it('opens environment information floating beside an open right panel', async () => { + const resizeCallbacks = new Set(); + const originalResizeObserver = globalThis.ResizeObserver; + globalThis.ResizeObserver = class { + constructor(private readonly callback: ResizeObserverCallback) { + resizeCallbacks.add(callback); + } + observe() {} + unobserve() {} + disconnect() { + resizeCallbacks.delete(this.callback); + } + } as typeof ResizeObserver; + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + function () { + if (this.dataset['testid'] !== 'context-body') return new DOMRect(); + return new DOMRect(0, 0, 1_000, 600); + }, + ); + const { container } = renderApp(); + + await act(async () => { + resizeCallbacks.forEach((callback) => callback([], {} as ResizeObserver)); + await Promise.resolve(); + }); + act(() => { + container + .querySelector( + 'button[aria-label="Toggle right panel"]', + ) + ?.click(); + }); + act(() => { + container + .querySelector( + 'button[aria-label="Toggle environment information"]', + ) + ?.click(); + }); + + const environmentPanel = container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ); + expect(environmentPanel?.getAttribute('data-floating')).toBe('true'); + globalThis.ResizeObserver = originalResizeObserver; + }); + + it('keeps the environment action visible without dynamic activity', () => { + const { container } = renderApp(); + + expect( + container.querySelector( + 'button[aria-label="Toggle environment information"]', + ), + ).not.toBeNull(); + }); + + it('keeps the environment action visible for a clean working tree', () => { + mockConnection.gitBranch = 'main'; + mockConnection.gitStatus = { + v: 2, + workspaceCwd: '/tmp/project', + branch: 'main', + staged: 0, + unstaged: 0, + untracked: 0, + conflicted: 0, + }; + const { container } = renderApp(); + + expect( + container.querySelector( + 'button[aria-label="Toggle environment information"]', + ), + ).not.toBeNull(); + }); + + it('shows the environment action for a background task in the transcript', () => { + testState.messages = [ + { + id: 'tools', + role: 'tool_group', + tools: [ + { + callId: 'background-shell', + toolName: 'shell', + status: 'completed', + args: { + command: 'npm run dev', + is_background: true, + }, + }, + ], + }, + ]; + const { container } = renderApp(); + + expect( + container.querySelector( + 'button[aria-label="Toggle environment information"]', + ), + ).not.toBeNull(); + }); + + it('opens an environment monitor in the right panel', () => { + const monitor: DaemonSessionMonitorTaskStatus = { + kind: 'monitor', + id: 'monitor-1', + label: 'monitor-label', + description: 'watch server log', + status: 'running', + startTime: 1_000, + runtimeMs: 5_000, + command: 'tail -f server.log', + eventCount: 3, + lastEventTime: 5_000, + droppedLines: 0, + }; + testState.backgroundTasks = [monitor]; + const { container } = renderApp(); + + act(() => { + testState.latestChatEditorProps?.onChatWidthModeChange?.('wide'); + }); + act(() => { + container + .querySelector( + 'button[aria-label="Toggle environment information"]', + ) + ?.click(); + }); + const backgroundTasksButton = Array.from( + container.querySelectorAll('button[aria-expanded]'), + ).find((button) => button.textContent?.includes('Background tasks')); + act(() => backgroundTasksButton?.click()); + const monitorButton = Array.from( + container.querySelectorAll( + '[data-testid="environment-panel"] ul button', + ), + ).find((button) => button.textContent?.includes('watch server log')); + + act(() => monitorButton?.click()); + + expect( + container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ), + ).not.toBeNull(); + expect( + container.querySelector('button[title="watch server log"]'), + ).not.toBeNull(); + expect(testState.latestBackgroundTasksRefreshTrigger).toBe(1); + }); + + it('opens an environment shell task in the right panel', () => { + const shell: DaemonSessionShellTaskStatus = { + kind: 'shell', + id: 'shell-1', + label: 'Development server', + description: 'Run the development server', + status: 'running', + startTime: 1_000, + runtimeMs: 5_000, + command: 'npm run dev', + cwd: '/tmp/project', + pid: 42, + }; + testState.backgroundTasks = [shell]; + const { container } = renderApp(); + + act(() => { + testState.latestChatEditorProps?.onChatWidthModeChange?.('wide'); + }); + act(() => { + container + .querySelector( + 'button[aria-label="Toggle environment information"]', + ) + ?.click(); + }); + const backgroundTasksButton = Array.from( + container.querySelectorAll('button[aria-expanded]'), + ).find((button) => button.textContent?.includes('Background tasks')); + act(() => backgroundTasksButton?.click()); + const shellButton = Array.from( + container.querySelectorAll( + '[data-testid="environment-panel"] ul button', + ), + ).find((button) => button.textContent?.includes('npm run dev')); + + act(() => shellButton?.click()); + + expect( + container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ), + ).not.toBeNull(); + expect( + container.querySelector('button[title="npm run dev"]'), + ).not.toBeNull(); + expect(container.textContent).toContain('/tmp/project'); + expect(testState.latestBackgroundTasksRefreshTrigger).toBe(1); + }); + + it('closes environment information when the active session changes', () => { + mockConnection.gitBranch = 'main'; + mockConnection.gitStatus = { + v: 2, + workspaceCwd: '/tmp/project', + branch: 'main', + unstaged: 1, + }; + const { container, rerender } = renderApp(); + act(() => { + container + .querySelector( + 'button[aria-label="Toggle environment information"]', + ) + ?.click(); + }); + expect( + container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ), + ).not.toBeNull(); + + mockConnection.sessionId = 'session-2'; + rerender(); + + expect( + container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ), + ).toBeNull(); + }); + + it('keeps environment information open with its subagent panel', async () => { + let availableContextWidth = 1_200; + const resizeCallbacks = new Set(); + const originalResizeObserver = globalThis.ResizeObserver; + globalThis.ResizeObserver = class { + constructor(private readonly callback: ResizeObserverCallback) { + resizeCallbacks.add(callback); + } + observe() {} + unobserve() {} + disconnect() { + resizeCallbacks.delete(this.callback); + } + } as typeof ResizeObserver; + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + function () { + if (this.dataset['testid'] !== 'context-body') return new DOMRect(); + return new DOMRect(0, 0, availableContextWidth, 600); + }, + ); + testState.messages = [ + { + id: 'tools', + role: 'tool_group', + tools: [ + { + callId: 'agent-call', + toolName: 'agent', + title: 'Inspect repository', + status: 'completed', + args: { subagent_type: 'Explore' }, + rawOutput: { + type: 'task_execution', + status: 'completed', + subagentName: 'Explore', + }, + }, + ], + }, + ]; + const { container } = renderApp(); + await act(async () => { + resizeCallbacks.forEach((callback) => callback([], {} as ResizeObserver)); + await Promise.resolve(); + }); + + act(() => { + container + .querySelector( + 'button[aria-label="Toggle environment information"]', + ) + ?.click(); + }); + const subagentsButton = Array.from( + container.querySelectorAll('button[aria-expanded]'), + ).find((button) => button.textContent?.includes('Subagents')); + act(() => subagentsButton?.click()); + + const environmentButton = container.querySelector( + 'button[aria-label="Toggle environment information"]', + ); + act(() => environmentButton?.click()); + expect( + container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ), + ).toBeNull(); + act(() => environmentButton?.click()); + expect( + Array.from( + container.querySelectorAll( + '[data-testid="environment-panel"]:not([hidden]) button[aria-expanded="true"]', + ), + ).some((button) => button.textContent?.includes('Subagents')), + ).toBe(true); + + const agentButton = Array.from( + container.querySelectorAll( + '[data-testid="environment-panel"]:not([hidden]) ul button', + ), + ).find((button) => button.textContent?.includes('Inspect repository')); + act(() => agentButton?.click()); + await act(async () => { + availableContextWidth = 900; + resizeCallbacks.forEach((callback) => callback([], {} as ResizeObserver)); + await Promise.resolve(); + }); + + expect( + container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ), + ).not.toBeNull(); + expect( + container + .querySelector('[data-testid="environment-panel"]:not([hidden])') + ?.getAttribute('data-floating'), + ).toBe('true'); + + act(() => { + container + .querySelector( + 'button[aria-label="Toggle right panel"]', + ) + ?.click(); + }); + + expect( + container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ), + ).not.toBeNull(); + + act(() => { + testState.latestChatEditorProps?.onChatWidthModeChange?.('wide'); + }); + expect( + container + .querySelector('[data-testid="environment-panel"]:not([hidden])') + ?.getAttribute('data-floating'), + ).toBe('true'); + + act(() => { + container + .querySelector( + 'button[aria-label="Toggle right panel"]', + ) + ?.click(); + }); + expect( + container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ), + ).toBeNull(); + const environmentToggle = container.querySelector( + 'button[aria-label="Toggle environment information"]', + ); + expect(environmentToggle).not.toBeNull(); + + act(() => environmentToggle?.click()); + expect( + container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ), + ).not.toBeNull(); + globalThis.ResizeObserver = originalResizeObserver; + }); + + it('opens an out-of-band fork task in the right panel', () => { + testState.backgroundTasks = [ + { + kind: 'agent', + id: 'fork-agent-1', + label: 'Review current changes', + description: 'Review current changes', + status: 'running', + startTime: 1, + runtimeMs: 10, + isBackgrounded: true, + }, + ]; + const { container } = renderApp(); + + act(() => { + container + .querySelector( + 'button[aria-label="Toggle environment information"]', + ) + ?.click(); + }); + const subagentsButton = Array.from( + container.querySelectorAll('button[aria-expanded]'), + ).find((button) => button.textContent?.includes('Subagents')); + act(() => subagentsButton?.click()); + const forkButton = Array.from( + container.querySelectorAll( + '[data-testid="environment-panel"] ul button', + ), + ).find((button) => button.textContent?.includes('Review current changes')); + + expect(forkButton?.disabled).toBe(false); + act(() => forkButton?.click()); + + expect( + container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ), + ).not.toBeNull(); + expect( + container.querySelector('button[title="Agent: Review current changes"]'), + ).not.toBeNull(); + }); + + it('updates the header when session metadata supplies a generated title', () => { + mockConnection.displayName = undefined; + const { container, rerender } = renderApp(); + expect( + container.querySelector('[data-testid="chat-context-header"]') + ?.textContent, + ).toContain('New session'); + + mockConnection.displayName = 'Investigate task failures'; + rerender(); + + expect( + container.querySelector('[data-testid="chat-context-header"]') + ?.textContent, + ).toContain('Investigate task failures'); + }); + + it('refreshes the generated title after the first turn completes', async () => { + mockConnection.displayName = undefined; + const { container, rerender } = renderApp(); + await vi.waitFor(() => { + expect(mockWorkspace.client.listWorkspaceSessions).toHaveBeenCalled(); + }); + mockWorkspace.client.listWorkspaceSessions.mockResolvedValue([ + { + sessionId: 'session-1', + workspaceCwd: '/tmp/project', + displayName: 'Generated session title', + }, + ]); + vi.useFakeTimers(); + + act(() => { + testState.streamingState = 'responding'; + rerender(); + }); + act(() => { + testState.streamingState = 'idle'; + rerender(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + + expect( + container.querySelector('[data-testid="chat-context-header"]') + ?.textContent, + ).toContain('Generated session title'); + }); + it('submits through a disconnected session when prompt SSE restart is enabled', async () => { mockConnection.status = 'disconnected'; renderApp({ restartSseOnPrompt: true }); @@ -4730,6 +6123,10 @@ describe('App session callbacks', () => { await flush(); await flush(); + expect(testState.latestChatEditorProps?.visibleToolbarActions).toContain( + 'gitBranch', + ); + // Fast GET applied the branch-only last-known status. await vi.waitFor(() => { expect(testState.latestChatEditorProps?.gitStatus).toEqual({ @@ -6531,6 +7928,88 @@ describe('App session callbacks', () => { expect(editorClear).not.toHaveBeenCalled(); }); + it('refreshes background tasks after /fork launches', async () => { + mockSessionActions.forkSession.mockResolvedValue({ + sessionId: 'session-1', + description: 'Review current changes', + launched: true, + }); + const { container } = renderApp(); + await flush(); + + testState.prompt = '/fork Review current changes'; + await clickSubmit(container); + await flush(); + + expect(mockSessionActions.forkSession).toHaveBeenCalledWith( + 'Review current changes', + ); + expect(testState.latestBackgroundTasksRefreshTrigger).toBe(1); + }); + + it('keeps /btw as a lightweight side question when side tasks are available', async () => { + mockConnection.capabilities.features = ['session_side_task']; + const { container } = renderApp(); + await flush(); + + testState.prompt = '/btw explain the current implementation'; + await clickSubmit(container); + await flush(); + + expect(mockSessionActions.forkSession).not.toHaveBeenCalled(); + expect(mockSessionActions.btwSession).toHaveBeenCalledWith( + 'explain the current implementation', + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(container.querySelector('button[title="Side task"]')).toBeNull(); + }); + + it('opens a new side task for /btw side when the capability is available', async () => { + mockConnection.capabilities.features = ['session_side_task']; + const { container } = renderApp(); + await flush(); + + testState.prompt = '/btw side explain the current implementation'; + await clickSubmit(container); + await flush(); + + expect(mockSessionActions.forkSession).not.toHaveBeenCalled(); + expect(mockSessionActions.btwSession).not.toHaveBeenCalled(); + expect(container.querySelector('button[title="Side task"]')).not.toBeNull(); + }); + + it('keeps /btw side as a lightweight question without the capability', async () => { + const { container } = renderApp(); + await flush(); + + testState.prompt = '/btw side explain the current implementation'; + await clickSubmit(container); + await flush(); + + expect(mockSessionActions.btwSession).toHaveBeenCalledWith( + 'side explain the current implementation', + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(container.querySelector('button[title="Side task"]')).toBeNull(); + }); + + it('passes a directive to /fork as a regular background-agent directive', async () => { + mockSessionActions.forkSession.mockResolvedValue({ + sessionId: 'session-1', + description: 'delegate', + launched: true, + }); + const { container } = renderApp(); + await flush(); + + testState.prompt = '/fork delegate'; + await clickSubmit(container); + await flush(); + + expect(mockSessionActions.forkSession).toHaveBeenCalledWith('delegate'); + expect(container.querySelector('button[title="Side task"]')).toBeNull(); + }); + it('notifies the host before forwarding a slash command', async () => { const onSlashCommand = vi.fn(); const { container } = renderApp({ onSlashCommand }); @@ -7641,6 +9120,21 @@ describe('App session callbacks', () => { expect(shellRef.current).toBeNull(); }); + it('creates a side task from the external shell ref', async () => { + mockConnection.capabilities.features = ['session_side_task']; + const shellRef = createRef(); + const { container } = renderApp({ shellRef }); + await flush(); + + let created = false; + act(() => { + created = shellRef.current?.createSideTask() ?? false; + }); + + expect(created).toBe(true); + expect(container.querySelector('button[title="Side task"]')).not.toBeNull(); + }); + it('opens the Session Overview from the external shell ref like the sidebar button', async () => { let shellApi: WebShellApi | null = null; const { container } = renderApp({ @@ -8106,6 +9600,31 @@ describe('App session callbacks', () => { expect(document.body.textContent).toContain('Artifact not found.'); }); + it('opens a split pane monitor in the right panel', async () => { + const { container } = renderApp(); + await flush(); + + await act(async () => { + container + .querySelector('[data-testid="open-split-view"]') + ?.click(); + await Promise.resolve(); + }); + await act(async () => { + container + .querySelector('[data-testid="split-open-monitor"]') + ?.click(); + await Promise.resolve(); + }); + + expect( + document.body.querySelector('button[title="watch pane logs"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).not.toBeNull(); + }); + it('clears split pane artifact snapshots when switching sessions', async () => { const { container, rerender } = renderApp(); await flush(); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 04fea56b69..1aa4ec6dd2 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -25,6 +25,7 @@ import { useWorkspace, useWorkspaceActions, useWorkspaceEventSignals, + type DaemonSessionActions, type DaemonWorkspaceActions, type DaemonSessionNotice, type DaemonStreamingState, @@ -32,8 +33,10 @@ import { import { DaemonHttpError, isDaemonTurnError } from '@qwen-code/sdk/daemon'; import type { DaemonInputAnnotation, + DaemonSessionAgentTaskStatus, DaemonTranscriptBlock, DaemonSessionMonitorTaskStatus, + DaemonSessionShellTaskStatus, DaemonSessionTaskStatus, DaemonSessionArtifact, DaemonWorkspaceCapability, @@ -42,8 +45,11 @@ import type { import { type SessionGitIntent } from './components/GitModePopover'; import { + SESSION_LIST_PAGE_SIZE, SESSION_MONITOR_TOOL_CORRELATION_FEATURE, + SESSION_SIDE_TASK_FEATURE, SESSION_TRANSCRIPT_PAGINATION_FEATURE, + WEB_SHELL_SIDE_TASK_SOURCE_TYPE, } from './constants/sessions'; import { extractPendingPermission } from './adapters/transcriptAdapter'; import { MessageList, type MessageListHandle } from './components/MessageList'; @@ -76,6 +82,11 @@ import { type WebShellToast, } from './components/ToastHost'; import { TodoPanel } from './components/panels/TodoPanel'; +import { + EnvironmentPanel, + type EnvironmentAgentTask, +} from './components/panels/EnvironmentPanel'; +import { ChatContextHeader } from './components/ChatContextHeader'; import { WelcomeHeader } from './components/WelcomeHeader'; import { ApprovalModeDialog } from './components/dialogs/ApprovalModeDialog'; import { ResumeDialog } from './components/dialogs/ResumeDialog'; @@ -98,6 +109,7 @@ import type { PaneHeaderActionsRenderer } from './components/ChatPane'; import { ArtifactPanel, type ArtifactPanelTab, + type SideTaskListItem, } from './components/artifacts/ArtifactPanel'; import { Drawer, DrawerContent, DrawerTitle } from './components/ui/drawer'; import type { @@ -170,6 +182,7 @@ import { import { mergeCommands } from './hooks/daemonSessionMappers'; import { useAnimationFrameTranscriptBlocks } from './hooks/useAnimationFrameTranscriptBlocks'; import { useBackgroundTasks } from './hooks/useBackgroundTasks'; +import { isSessionDisconnectedError } from './utils/sessionErrors'; import { useMessagesFromBlocks } from './hooks/useMessages'; import { useSessionArtifacts } from './hooks/useSessionArtifacts'; import { useShallowMemo, useStableArray } from './hooks/useShallowMemo'; @@ -238,6 +251,7 @@ import { type ComposerPlaceholderState, } from './utils/composerInputState'; import type { ACPToolCall, Message, PermissionRequest } from './adapters/types'; +import { isBackgroundSubAgentToolCall } from './adapters/toolClassification'; import { computeTodoDetails, computeTodoTimeline, @@ -274,6 +288,12 @@ import { type ComposerHeaderRenderer, type ComposerFooterRenderer, type ChatHeaderRenderer, + type WebShellChatHeaderItem, + type WebShellChatHeaderOptions, + type WebShellRightPanelItem, + type WebShellRightPanelOptions, + type WebShellEnvironmentPanelItem, + type WebShellEnvironmentPanelOptions, type FooterRenderer, type LoadingPhrasesResolver, type MarkdownTableMode, @@ -335,9 +355,22 @@ function TodoContextsProvider({ const MODES_CYCLE = DAEMON_APPROVAL_MODES; const MAX_TOASTS = 4; -const DEFAULT_REVIEW_PANEL_WIDTH = 760; +const DEFAULT_REVIEW_PANEL_WIDTH = 500; const MIN_ARTIFACT_PANEL_WIDTH = 320; const MIN_CHAT_PANE_WIDTH_WITH_ARTIFACT_PANEL = 500; +const MIN_DOCKED_MESSAGE_AREA_WIDTH = 800; +const DOCKED_ENVIRONMENT_PANEL_WIDTH = 332; +const DEFAULT_COMPOSER_TOOLBAR_ACTIONS = [ + 'approvalMode', + 'model', + 'widthMode', + 'voice', + 'workspace', +] as const satisfies readonly ComposerToolbarAction[]; +const DEFAULT_EMPTY_COMPOSER_TOOLBAR_ACTIONS = [ + ...DEFAULT_COMPOSER_TOOLBAR_ACTIONS, + 'gitBranch', +] as const satisfies readonly ComposerToolbarAction[]; const MAX_ARTIFACT_PANEL_SESSION_STATES = 20; interface ArtifactPanelSessionState { open: boolean; @@ -521,6 +554,8 @@ export interface WebShellApi { openSessionDrawer: () => void; /** Start a new session using the same lifecycle as the built-in New Chat action. */ createNewSession: () => Promise; + /** Open the right panel with a new side-task draft. */ + createSideTask: () => boolean; } export type WebShellComposerPlaceholderState = ComposerPlaceholderState; @@ -569,6 +604,12 @@ export interface WebShellProps { chatMaxWidth?: number; /** Optional workspace sidebar. Disabled by default. */ sidebar?: boolean | WebShellSidebarOptions; + /** Persistent chat header options. */ + header?: WebShellChatHeaderOptions; + /** Right extension panel options. */ + rightPanel?: WebShellRightPanelOptions; + /** Environment information panel options. */ + environmentPanel?: WebShellEnvironmentPanelOptions; /** Session ids to control the split view; an empty array closes it. */ splitSessionIds?: readonly string[]; /** Called when the split pane list changes from inside WebShell. */ @@ -660,8 +701,8 @@ export interface WebShellProps { /** Custom renderer shown directly below the chat composer input. */ renderComposerFooter?: ComposerFooterRenderer; /** - * Custom renderer shown at the top of the chat view, above the message list. - * Only rendered when a session is active (not in the welcome/empty state). + * Replaces the complete persistent chat header. Only rendered when a + * session is active (not in the welcome/empty state). */ renderChatHeader?: ChatHeaderRenderer; /** Custom component for the footer area below the Editor. Replaces the built-in StatusBar. */ @@ -744,6 +785,17 @@ const emptyComposerApi: WebShellComposerApi = { const EMPTY_BOTTOM_STATUS_ITEMS: readonly WebShellBottomStatusItem[] = []; const DEFAULT_CHAT_MAX_WIDTH = 1000; +const DEFAULT_CHAT_HEADER_ITEMS: readonly WebShellChatHeaderItem[] = [ + 'title', + 'environment', + 'rightPanel', +]; +const DEFAULT_RIGHT_PANEL_ITEMS: readonly WebShellRightPanelItem[] = [ + 'review', + 'sideTask', +]; +const DEFAULT_ENVIRONMENT_PANEL_ITEMS: readonly WebShellEnvironmentPanelItem[] = + ['environment', 'subagents', 'backgroundTasks']; const BOTTOM_PANEL_GAP_PX = 6; const BOTTOM_PANEL_FALLBACK_INSET_PX = 40; type ChatWidthMode = `${typeof DEFAULT_CHAT_MAX_WIDTH}` | 'wide'; @@ -988,9 +1040,10 @@ function parseRenameArgument( return { type: 'manual', displayName: trimmed }; } -function isBackgroundShellToolCall(tool: ACPToolCall): boolean { - if (tool.args?.is_background !== true) return false; +function isBackgroundTaskToolCall(tool: ACPToolCall): boolean { const name = tool.toolName.toLowerCase(); + if (name === 'monitor') return true; + if (tool.args?.is_background !== true) return false; return ( name === 'shell' || name === 'bash' || @@ -999,20 +1052,22 @@ function isBackgroundShellToolCall(tool: ACPToolCall): boolean { ); } -export function getBackgroundTaskActivityKey( - messages: readonly Message[], -): string { +export function getTaskActivityKey(messages: readonly Message[]): string { const parts: string[] = []; - for (const message of messages) { - if (message.role !== 'tool_group') continue; - for (const tool of message.tools) { + const visit = (tools: readonly ACPToolCall[]) => { + for (const tool of tools) { if ( - isBackgroundShellToolCall(tool) || - tool.toolName.toLowerCase() === 'monitor' + isBackgroundTaskToolCall(tool) || + isBackgroundSubAgentToolCall(tool) ) { parts.push(`${tool.callId}:${tool.status}`); } + if (tool.subTools) visit(tool.subTools); } + }; + for (const message of messages) { + if (message.role !== 'tool_group') continue; + visit(message.tools); } return parts.join('|'); } @@ -1026,6 +1081,309 @@ export function mergeMonitorTaskSnapshot( : next; } +function mergeShellTaskSnapshot( + current: DaemonSessionShellTaskStatus, + next: DaemonSessionShellTaskStatus, +): DaemonSessionShellTaskStatus { + return current.status !== 'running' && next.status === 'running' + ? current + : next; +} + +interface SideTaskCatalogState { + parentSessionId?: string; + items: SideTaskListItem[]; + loaded: boolean; +} + +// Merge a fresh side-task listing into the cached catalog. The listing is +// authoritative: a cached item survives only while it is still listed or is a +// locally created draft the daemon has not echoed back yet (optimisticIds). +// Without the optimistic guard, a task deleted or archived on another client +// would be re-added from the cache forever. +export function mergeSideTaskCatalog( + catalog: SideTaskCatalogState, + parentSessionId: string, + listedItems: SideTaskListItem[], + optimisticIds: ReadonlySet, +): SideTaskCatalogState { + if (catalog.parentSessionId !== parentSessionId) { + return { parentSessionId, items: listedItems, loaded: true }; + } + const listedIds = new Set(listedItems.map((item) => item.sessionId)); + return { + parentSessionId, + loaded: true, + items: [ + ...listedItems, + ...catalog.items.filter( + (item) => + !listedIds.has(item.sessionId) && optimisticIds.has(item.sessionId), + ), + ], + }; +} + +function agentStatusFromTool( + tool: ACPToolCall, +): DaemonSessionAgentTaskStatus['status'] { + if (tool.status === 'pending' || tool.status === 'in_progress') { + return 'running'; + } + if (tool.status === 'failed') return 'failed'; + const rawOutput = isRecord(tool.rawOutput) ? tool.rawOutput : undefined; + if (rawOutput?.['status'] === 'cancelled') return 'cancelled'; + return rawOutput?.['status'] === 'failed' ? 'failed' : 'completed'; +} + +function agentTaskAsToolCall(task: DaemonSessionAgentTaskStatus): ACPToolCall { + const status = + task.status === 'running' || task.status === 'paused' + ? 'in_progress' + : task.status === 'failed' + ? 'failed' + : 'completed'; + return { + callId: task.id, + toolName: 'agent', + title: `Agent: ${task.label}`, + status, + args: { + description: task.description, + ...(task.prompt ? { prompt: task.prompt } : {}), + ...(task.subagentType ? { subagent_type: task.subagentType } : {}), + run_in_background: task.isBackgrounded, + }, + rawOutput: { + type: 'task_execution', + subagentName: task.subagentType, + status: task.status, + }, + startTime: task.startTime, + ...(task.endTime !== undefined ? { endTime: task.endTime } : {}), + }; +} + +function isEnvironmentAgentToolCall(tool: ACPToolCall): boolean { + const name = tool.toolName.toLowerCase(); + if (name === 'agent' || name === 'task') return true; + if (typeof tool.args?.subagent_type === 'string') return true; + return ( + isRecord(tool.rawOutput) && tool.rawOutput['type'] === 'task_execution' + ); +} + +function derivedTaskIdForTool(tool: ACPToolCall): string | undefined { + const rawOutput = isRecord(tool.rawOutput) ? tool.rawOutput : undefined; + const subagentName = + typeof rawOutput?.['subagentName'] === 'string' + ? rawOutput['subagentName'] + : undefined; + const subagentType = + typeof tool.args?.subagent_type === 'string' + ? tool.args.subagent_type + : undefined; + return subagentName + ? `${subagentName}-${tool.callId}` + : subagentType + ? `${subagentType}-${tool.callId}` + : undefined; +} + +export function getEnvironmentAgentTasks( + messages: readonly Message[], + sessionTasks: readonly DaemonSessionTaskStatus[], +): EnvironmentAgentTask[] { + const liveAgents = sessionTasks.filter( + (task): task is DaemonSessionAgentTaskStatus => task.kind === 'agent', + ); + const taskIdsByToolUseId = new Map(); + for (const message of messages) { + if (message.role !== 'system' || !isRecord(message.data)) continue; + const taskId = message.data['taskId']; + const toolUseId = message.data['toolUseId']; + if (typeof taskId === 'string' && typeof toolUseId === 'string') { + taskIdsByToolUseId.set(toolUseId, taskId); + } + } + + // A live task already linked precisely (by toolUseId, message taskId, or + // derived id) to some transcript tool call must never be claimed by the loose + // content fallback: two agents sharing a description would otherwise collapse + // into one (the fallback steals the linked task, its owner re-matches it, and + // the orphan is dropped). + const envToolCallIds = new Set(); + const preciselyClaimedTaskIds = new Set(taskIdsByToolUseId.values()); + const collectPreciseLinks = (tools: readonly ACPToolCall[]) => { + for (const tool of tools) { + if ( + isEnvironmentAgentToolCall(tool) && + !envToolCallIds.has(tool.callId) + ) { + envToolCallIds.add(tool.callId); + const derivedTaskId = derivedTaskIdForTool(tool); + if (derivedTaskId) preciselyClaimedTaskIds.add(derivedTaskId); + } + if (tool.subTools) collectPreciseLinks(tool.subTools); + } + }; + for (const message of messages) { + if (message.role === 'tool_group') collectPreciseLinks(message.tools); + } + const isPreciselyClaimed = (task: DaemonSessionAgentTaskStatus): boolean => + (task.toolUseId != null && envToolCallIds.has(task.toolUseId)) || + preciselyClaimedTaskIds.has(task.id); + + const agents: EnvironmentAgentTask[] = []; + const seenTaskIds = new Set(); + const seenToolCallIds = new Set(); + const visit = (tools: readonly ACPToolCall[]) => { + for (const tool of tools) { + if ( + isEnvironmentAgentToolCall(tool) && + !seenToolCallIds.has(tool.callId) + ) { + seenToolCallIds.add(tool.callId); + const rawOutput = isRecord(tool.rawOutput) ? tool.rawOutput : undefined; + const color = + typeof rawOutput?.['subagentColor'] === 'string' + ? rawOutput['subagentColor'] + : undefined; + const description = + typeof tool.args?.description === 'string' + ? tool.args.description + : undefined; + const prompt = + typeof tool.args?.prompt === 'string' ? tool.args.prompt : undefined; + const subagentType = + typeof tool.args?.subagent_type === 'string' + ? tool.args.subagent_type + : undefined; + const subagentName = + typeof rawOutput?.['subagentName'] === 'string' + ? rawOutput['subagentName'] + : undefined; + const taskId = taskIdsByToolUseId.get(tool.callId); + const derivedTaskId = derivedTaskIdForTool(tool); + // Completed background agents can lose their toolUseId / derived-id + // linkage (e.g. across a daemon reload); fall back to content matching, + // the same signal the daemon's legacy resolver uses. + const matchesLiveTaskContent = ( + task: DaemonSessionAgentTaskStatus, + ): boolean => { + if (prompt && task.prompt === prompt) return true; + if ( + description && + task.description === description && + subagentType && + task.subagentType === subagentType + ) { + return true; + } + return !!description && task.description === description; + }; + const liveTask = liveAgents.find( + (task) => + task.toolUseId === tool.callId || + task.id === taskId || + task.id === derivedTaskId || + (!seenTaskIds.has(task.id) && + !isPreciselyClaimed(task) && + matchesLiveTaskContent(task)), + ); + const title = tool.title?.replace(/^Agent:\s*/i, '').trim(); + const meaningfulTitle = + title && title.toLowerCase() !== 'agent' ? title : undefined; + const label = + meaningfulTitle ?? + description ?? + prompt ?? + subagentName ?? + subagentType ?? + ''; + const taskDescription = + description ?? prompt ?? subagentName ?? subagentType ?? ''; + const startTime = tool.startTime ?? 0; + + agents.push( + liveTask + ? { + ...liveTask, + label, + description: taskDescription || liveTask.description, + ...(subagentType ? { subagentType } : {}), + ...(color ? { color } : {}), + } + : { + kind: 'agent', + id: taskId ?? derivedTaskId ?? tool.callId, + label, + description: taskDescription, + status: agentStatusFromTool(tool), + startTime, + ...(tool.endTime !== undefined + ? { endTime: tool.endTime } + : {}), + runtimeMs: Math.max( + 0, + (tool.endTime ?? tool.startTime ?? startTime) - startTime, + ), + ...(subagentType ? { subagentType } : {}), + ...(color ? { color } : {}), + isBackgrounded: isBackgroundSubAgentToolCall(tool), + toolUseId: tool.callId, + }, + ); + if (liveTask) seenTaskIds.add(liveTask.id); + } + if (tool.subTools) visit(tool.subTools); + } + }; + + for (const message of messages) { + if (message.role === 'tool_group') visit(message.tools); + } + for (const task of liveAgents) { + if ( + seenTaskIds.has(task.id) || + (task.toolUseId && seenToolCallIds.has(task.toolUseId)) + ) { + continue; + } + const alreadyListed = agents.some( + (a) => + (a.toolUseId != null && a.toolUseId === task.toolUseId) || + (a.description !== '' && a.description === task.description), + ); + if (alreadyListed) continue; + agents.push(task); + } + return agents; +} + +function findToolCall( + messages: readonly Message[], + callId: string, +): ACPToolCall | undefined { + const findNested = ( + tools: readonly ACPToolCall[], + ): ACPToolCall | undefined => { + for (const tool of tools) { + if (tool.callId === callId) return tool; + const nested = tool.subTools ? findNested(tool.subTools) : undefined; + if (nested) return nested; + } + return undefined; + }; + + for (const message of messages) { + if (message.role !== 'tool_group') continue; + const tool = findNested(message.tools); + if (tool) return tool; + } + return undefined; +} + function mapToWebShellTaskInfo( task: DaemonSessionTaskStatus, ): WebShellTaskInfo { @@ -1180,6 +1538,9 @@ export function App({ bottomStatusItems, chatMaxWidth, sidebar, + header, + rightPanel, + environmentPanel, splitSessionIds: externalSplitSessionIds, onSplitSessionIdsChange, renderPaneHeaderActions, @@ -1225,6 +1586,28 @@ export function App({ () => resolveSidebarOptions(sidebar), [sidebar], ); + const chatHeaderItems = header?.items ?? DEFAULT_CHAT_HEADER_ITEMS; + const chatHeaderEnabled = + chatHeaderItems.length > 0 && Boolean(header || renderChatHeader); + const titleHeaderItemVisible = chatHeaderItems.includes('title'); + const environmentHeaderItemVisible = chatHeaderItems.includes('environment'); + const rightPanelHeaderItemVisible = chatHeaderItems.includes('rightPanel'); + const rightPanelItems = rightPanel?.items ?? DEFAULT_RIGHT_PANEL_ITEMS; + const environmentPanelItems = + environmentPanel?.items ?? DEFAULT_ENVIRONMENT_PANEL_ITEMS; + // The environment panel is only reachable through the chat header toggle, + // so its sections replace the composer git entry / footer task pills only + // when that header is actually enabled. Embeddings that omit the header keep + // the legacy entries. + const environmentPanelReachable = + chatHeaderEnabled && + environmentHeaderItemVisible && + (!renderChatHeader || Boolean(header)); + const environmentGitReplacementEnabled = + environmentPanelReachable && environmentPanelItems.includes('environment'); + const environmentTasksReplacementEnabled = + environmentPanelReachable && + environmentPanelItems.includes('backgroundTasks'); const [sidebarCollapsed, setSidebarCollapsed] = useState(() => readSidebarCollapsed(sidebarOptions.defaultCollapsed), ); @@ -1566,6 +1949,9 @@ export function App({ const [sessionBranch, setSessionBranch] = useState< { name: string; baseBranch: string } | undefined >(undefined); + const [sessionStatusDisplayName, setSessionStatusDisplayName] = useState< + string | undefined + >(undefined); // Tracks the session id from the latest effect run. In-flight fetches // compare their captured sid against this ref on resolve: a match means // the response is still relevant and may set OR clear the worktree state; @@ -1579,10 +1965,24 @@ export function App({ // discard the one response we actually need. useEffect(() => { const sid = connection.sessionId; + const previousSid = worktreeSessionIdRef.current; worktreeSessionIdRef.current = sid; if (!sid) { setSessionWorktree(undefined); setSessionBranch(undefined); + setSessionStatusDisplayName(undefined); + return; + } + if (previousSid !== sid) { + setSessionWorktree(undefined); + setSessionBranch(undefined); + setSessionStatusDisplayName(undefined); + } + if ( + connection.status !== 'connected' || + connection.loadingTranscript || + connection.catchingUp + ) { return; } workspace.client @@ -1591,15 +1991,35 @@ export function App({ if (worktreeSessionIdRef.current === sid) { setSessionWorktree(summary.worktree); setSessionBranch(summary.branch); + setSessionStatusDisplayName(summary.displayName); } + return workspace.client + .listWorkspaceSessions(summary.workspaceCwd, { pageSize: 200 }) + .then((sessions) => { + if (worktreeSessionIdRef.current !== sid) return; + const listedSession = sessions.find( + (session) => session.sessionId === sid, + ); + setSessionStatusDisplayName( + listedSession?.displayName ?? summary.displayName, + ); + }) + .catch(() => undefined); }) .catch(() => { if (worktreeSessionIdRef.current === sid) { setSessionWorktree(undefined); setSessionBranch(undefined); + setSessionStatusDisplayName(undefined); } }); - }, [connection.sessionId, workspace.client]); + }, [ + connection.catchingUp, + connection.loadingTranscript, + connection.sessionId, + connection.status, + workspace.client, + ]); // Active workspace: the connected session's workspace, else the workspace // picked for the next session (locked / selected / primary). Computed once // and shared by the git-status effect and the Changes-dialog entry point so @@ -1749,6 +2169,8 @@ export function App({ const nextBtwMessageIdRef = useRef(1); const btwAbortControllerRef = useRef(null); const chatPaneRef = useRef(null); + const contextBodyRef = useRef(null); + const [contextBodyWidth, setContextBodyWidth] = useState(null); const currentSessionIdRef = useRef(connection.sessionId); const lastNotifiedSessionIdRef = useRef(undefined); const lastNotifiedWorkspaceIdRef = useRef(undefined); @@ -1808,6 +2230,8 @@ export function App({ const [artifactPanelTabs, setArtifactPanelTabs] = useState< ArtifactPanelTab[] >([]); + const artifactPanelTabsRef = useRef(artifactPanelTabs); + artifactPanelTabsRef.current = artifactPanelTabs; useEffect(() => { if (artifactPanelExtraArtifacts.length === 0 || artifacts.length === 0) { return; @@ -1925,6 +2349,13 @@ export function App({ ), [displayMessages, artifactsByTurn, connection.workspaceCwd], ); + const latestReviewChanges = useMemo(() => { + let latest: readonly TurnOutputFileChange[] = []; + for (const changes of fileChangesByTurn.values()) { + if (changes.length > 0) latest = changes; + } + return latest; + }, [fileChangesByTurn]); const scheduledTasksByTurn = useMemo( () => getScheduledTasksByTurn(displayMessages), [displayMessages], @@ -1934,6 +2365,12 @@ export function App({ [messageTurnOutputs], ); const [artifactPanelOpen, setArtifactPanelOpen] = useState(false); + const [environmentPanelOpen, setEnvironmentPanelOpen] = useState(false); + const preserveEnvironmentPanelOnArtifactOpenRef = useRef(false); + useLayoutEffect(() => { + preserveEnvironmentPanelOnArtifactOpenRef.current = false; + setEnvironmentPanelOpen(false); + }, [connection.sessionId]); const artifactPanelOpenRef = useRef(artifactPanelOpen); artifactPanelOpenRef.current = artifactPanelOpen; const [activeArtifactPanelTabId, setActiveArtifactPanelTabId] = useState< @@ -2016,6 +2453,256 @@ export function App({ setPaneArtifactSnapshots(new Map()); setArtifactPanelWidth(savedState.width); }, [connection.sessionId]); + const sideTasksAvailable = + Boolean(connection.sessionId && connection.workspaceCwd) && + connection.capabilities?.features.includes(SESSION_SIDE_TASK_FEATURE) === + true; + const [sideTaskCatalog, setSideTaskCatalog] = useState({ + items: [], + loaded: false, + }); + const optimisticSideTaskIdsRef = useRef(new Set()); + const visibleSideTasks = + sideTaskCatalog.parentSessionId === connection.sessionId + ? sideTaskCatalog.items + : []; + const sideTasksLoading = + visibleSideTasks.length === 0 && + (sideTaskCatalog.parentSessionId !== connection.sessionId || + !sideTaskCatalog.loaded); + const nextSideTaskTabIdRef = useRef(0); + const createSideTask = useCallback( + (initialPrompt?: string) => { + const parentSessionId = connection.sessionId; + if (!parentSessionId || !sideTasksAvailable) return false; + const tab: ArtifactPanelTab = { + id: `side-task:draft:${Date.now()}:${++nextSideTaskTabIdRef.current}`, + kind: 'side_task', + title: t('sideTask.title'), + parentSessionId, + workspaceCwd: connection.workspaceCwd, + nameFromFirstPrompt: true, + ...(initialPrompt?.trim() + ? { initialPrompt: initialPrompt.trim() } + : {}), + }; + setArtifactPanelTabs((tabs) => [...tabs, tab]); + setActiveArtifactPanelTabId(tab.id); + setArtifactPanelOpen(true); + return true; + }, + [connection.sessionId, connection.workspaceCwd, sideTasksAvailable, t], + ); + const createEmptySideTask = useCallback(() => { + if (createSideTask()) return; + pushToast('error', t('sideTask.createFailed')); + }, [createSideTask, pushToast, t]); + const createSideTaskSession = useCallback( + async (_tabId: string, parentSessionId: string, title: string) => { + const parentClientId = + connection.sessionId === parentSessionId + ? connection.clientId + : undefined; + const session = await workspace.client.createSideTaskSession( + parentSessionId, + { + name: title, + }, + parentClientId, + ); + await workspace.client + .detachSession(session.sessionId, session.clientId) + .catch(() => undefined); + return { + sessionId: session.sessionId, + displayName: session.displayName, + }; + }, + [connection.clientId, connection.sessionId, workspace.client], + ); + const handleSideTaskCreated = useCallback( + (tabId: string, sessionId: string) => { + let createdTab = artifactPanelTabsRef.current.find( + (candidate) => candidate.id === tabId, + ); + setArtifactPanelTabs((tabs) => + tabs.map((tab) => + tab.id === tabId && tab.kind === 'side_task' + ? { ...tab, sessionId } + : tab, + ), + ); + if (!createdTab) { + // Creation can resolve after we navigate away from the parent session; + // the draft tab then lives in a saved per-session bucket rather than the + // live tabs, so write the sessionId there too or reopening the parent + // creates a duplicate side task. + for (const state of artifactPanelStateBySessionRef.current.values()) { + const candidate = state.tabs.find( + (bucketTab) => bucketTab.id === tabId, + ); + if (!candidate) continue; + createdTab = candidate; + state.tabs = state.tabs.map((bucketTab) => + bucketTab.id === tabId && bucketTab.kind === 'side_task' + ? { ...bucketTab, sessionId } + : bucketTab, + ); + break; + } + } + const sideTaskTab = + createdTab?.kind === 'side_task' ? createdTab : undefined; + if (!sideTaskTab) return; + optimisticSideTaskIdsRef.current.add(sessionId); + setSideTaskCatalog((catalog) => { + if (catalog.parentSessionId !== sideTaskTab.parentSessionId) { + return catalog; + } + if (catalog.items.some((item) => item.sessionId === sessionId)) { + return catalog; + } + return { + ...catalog, + items: [ + ...catalog.items, + { + sessionId, + title: sideTaskTab.title, + workspaceCwd: sideTaskTab.workspaceCwd, + updatedAt: new Date().toISOString(), + }, + ], + }; + }); + }, + [], + ); + const handleSideTaskTitleChange = useCallback( + (tabId: string, title: string, fromFirstPrompt = false) => { + const sideTaskTab = artifactPanelTabsRef.current.find( + (tab) => tab.id === tabId && tab.kind === 'side_task', + ); + const sessionId = + sideTaskTab?.kind === 'side_task' ? sideTaskTab.sessionId : undefined; + setArtifactPanelTabs((tabs) => + tabs.map((tab) => { + if (tab.id !== tabId || tab.kind !== 'side_task') return tab; + if (!fromFirstPrompt && tab.title === title) return tab; + return { + ...tab, + title, + ...(fromFirstPrompt + ? { + nameFromFirstPrompt: false, + initialPrompt: undefined, + } + : {}), + }; + }), + ); + if (sessionId) { + setSideTaskCatalog((catalog) => ({ + ...catalog, + items: catalog.items.map((item) => + item.sessionId === sessionId ? { ...item, title } : item, + ), + })); + } + }, + [], + ); + const openSideTask = useCallback( + (sideTask: SideTaskListItem) => { + const parentSessionId = connection.sessionId; + if (!parentSessionId) return; + const tab: ArtifactPanelTab = { + id: `side-task:${sideTask.sessionId}`, + kind: 'side_task', + title: sideTask.title, + sessionId: sideTask.sessionId, + parentSessionId, + workspaceCwd: sideTask.workspaceCwd ?? connection.workspaceCwd, + }; + setArtifactPanelTabs((tabs) => + tabs.some( + (item) => + item.kind === 'side_task' && item.sessionId === sideTask.sessionId, + ) + ? tabs + : [...tabs, tab], + ); + const existingTab = artifactPanelTabsRef.current.find( + (item) => + item.kind === 'side_task' && item.sessionId === sideTask.sessionId, + ); + setActiveArtifactPanelTabId(existingTab?.id ?? tab.id); + setArtifactPanelOpen(true); + }, + [connection.sessionId, connection.workspaceCwd], + ); + useEffect(() => { + const parentSessionId = connection.sessionId; + const workspaceCwd = connection.workspaceCwd; + if (!sideTasksAvailable || !parentSessionId || !workspaceCwd) { + setSideTaskCatalog({ items: [], loaded: false }); + return; + } + if (!artifactPanelOpen) return; + setSideTaskCatalog((catalog) => + catalog.parentSessionId === parentSessionId + ? { ...catalog, loaded: false } + : { parentSessionId, items: [], loaded: false }, + ); + let cancelled = false; + void workspace.client + .listWorkspaceSessions(workspaceCwd, { + pageSize: SESSION_LIST_PAGE_SIZE, + archiveState: 'active', + sourceType: WEB_SHELL_SIDE_TASK_SOURCE_TYPE, + sourceId: parentSessionId, + }) + .then((sessions) => { + if (cancelled) return; + const listedItems = sessions.map((session) => ({ + sessionId: session.sessionId, + title: + session.displayName?.trim() || + `${t('sideTask.title')} ${session.sessionId.slice(0, 8)}`, + workspaceCwd: session.workspaceCwd || workspaceCwd, + updatedAt: session.updatedAt || session.createdAt, + })); + for (const item of listedItems) { + optimisticSideTaskIdsRef.current.delete(item.sessionId); + } + setSideTaskCatalog((catalog) => + mergeSideTaskCatalog( + catalog, + parentSessionId, + listedItems, + optimisticSideTaskIdsRef.current, + ), + ); + }) + .catch(() => { + if (cancelled) return; + setSideTaskCatalog((catalog) => + catalog.parentSessionId === parentSessionId + ? { ...catalog, loaded: true } + : catalog, + ); + }); + return () => { + cancelled = true; + }; + }, [ + connection.sessionId, + connection.workspaceCwd, + artifactPanelOpen, + sideTasksAvailable, + t, + workspace.client, + ]); const getMaxArtifactPanelWidth = useCallback(() => { const chatPaneWidth = chatPaneRef.current?.getBoundingClientRect().width; if (!chatPaneWidth) { @@ -2077,11 +2764,14 @@ export function App({ selectedPath?: string, workspaceActions?: DaemonWorkspaceActions, reviewWorkspaceCwd?: string, + tabId = 'review', ) => { const reviewTab: ArtifactPanelTab = { - id: 'review', + id: tabId, kind: 'review', title: t('turnOutputs.review'), + changes, + ...(selectedPath ? { selectedPath } : {}), ...(workspaceActions ? { workspaceActions } : {}), ...(reviewWorkspaceCwd ? { workspaceCwd: reviewWorkspaceCwd } : {}), }; @@ -2100,13 +2790,20 @@ export function App({ }, [getDefaultReviewPanelWidth, t], ); + const openLatestReviewPanel = useCallback(() => { + if (latestReviewChanges.length === 0) return; + openReviewPanel(latestReviewChanges); + }, [latestReviewChanges, openReviewPanel]); const openScheduledTaskPanel = useCallback( ( task: TurnOutputScheduledTask, tabWorkspaceActions?: ReturnType, + sourceSessionId?: string, ) => { const tab: ArtifactPanelTab = { - id: `scheduled-task:${task.toolCallId}`, + id: sourceSessionId + ? `scheduled-task:${sourceSessionId}:${task.toolCallId}` + : `scheduled-task:${task.toolCallId}`, kind: 'scheduled_task', title: t('scheduledTasks.title'), task, @@ -2128,12 +2825,22 @@ export function App({ [getDefaultReviewPanelWidth, t], ); const openMonitorPanel = useCallback( - (task: DaemonSessionMonitorTaskStatus) => { + ( + task: DaemonSessionMonitorTaskStatus, + sourceSessionId?: string, + sourceSessionActions?: DaemonSessionActions, + ) => { const tab: ArtifactPanelTab = { - id: `monitor:${task.id}`, + id: sourceSessionId + ? `monitor:${sourceSessionId}:${task.id}` + : `monitor:${task.id}`, kind: 'monitor', title: task.description, task, + ...(sourceSessionId ? { sessionId: sourceSessionId } : {}), + ...(sourceSessionActions + ? { sessionActions: sourceSessionActions } + : {}), }; setArtifactPanelTabs((tabs) => tabs.some((item) => item.id === tab.id) @@ -2156,6 +2863,45 @@ export function App({ }, [getDefaultReviewPanelWidth], ); + const openShellPanel = useCallback( + ( + task: DaemonSessionShellTaskStatus, + sourceSessionId?: string, + sourceSessionActions?: DaemonSessionActions, + ) => { + const tab: ArtifactPanelTab = { + id: sourceSessionId + ? `shell:${sourceSessionId}:${task.id}` + : `shell:${task.id}`, + kind: 'shell', + title: task.command, + task, + ...(sourceSessionId ? { sessionId: sourceSessionId } : {}), + ...(sourceSessionActions + ? { sessionActions: sourceSessionActions } + : {}), + }; + setArtifactPanelTabs((tabs) => + tabs.some((item) => item.id === tab.id) + ? tabs.map((item) => { + if (item.id !== tab.id || item.kind !== 'shell') return item; + const mergedTask = mergeShellTaskSnapshot(item.task, task); + return { + ...tab, + title: mergedTask.command, + task: mergedTask, + }; + }) + : [...tabs, tab], + ); + setActiveArtifactPanelTabId(tab.id); + setArtifactPanelWidth((width) => + artifactPanelOpenRef.current ? width : getDefaultReviewPanelWidth(), + ); + setArtifactPanelOpen(true); + }, + [getDefaultReviewPanelWidth], + ); const openSubagentPanelForSession = useCallback( (tool: ACPToolCall, sessionId: string, workspaceCwd?: string) => { const rawOutput = @@ -2206,6 +2952,28 @@ export function App({ openSubagentPanelForSession, ], ); + const openEnvironmentAgent = useCallback( + (task: DaemonSessionAgentTaskStatus) => { + if (!connection.sessionId) return; + if (!artifactPanelOpenRef.current) { + preserveEnvironmentPanelOnArtifactOpenRef.current = true; + } + const tool = task.toolUseId + ? findToolCall(messages, task.toolUseId) + : undefined; + openSubagentPanelForSession( + tool ?? agentTaskAsToolCall(task), + connection.sessionId, + connection.workspaceCwd, + ); + }, + [ + connection.sessionId, + connection.workspaceCwd, + messages, + openSubagentPanelForSession, + ], + ); const handleTurnOutputOpen = useCallback( (request: TurnOutputOpenRequest) => { if (onRightPanelOpen) { @@ -2218,11 +2986,18 @@ export function App({ request.selectedPath, request.workspaceActions, request.workspaceCwd, + request.sourceSessionId + ? `review:${request.sourceSessionId}:${request.turnId}` + : undefined, ); return; } if (request.kind === 'scheduled_task') { - openScheduledTaskPanel(request.task, request.workspaceActions); + openScheduledTaskPanel( + request.task, + request.workspaceActions, + request.sourceSessionId, + ); return; } if (request.kind === 'subagent') { @@ -2233,7 +3008,7 @@ export function App({ ); return; } - if (!request.workspaceActions) { + if (!request.workspaceActions || request.sourceSessionId) { setArtifactPanelExtraArtifacts((current) => { const index = current.findIndex( (artifact) => artifact.id === request.artifact.id, @@ -2245,7 +3020,9 @@ export function App({ }); } const tab: ArtifactPanelTab = { - id: request.id, + id: request.sourceSessionId + ? `${request.sourceSessionId}:${request.id}` + : request.id, kind: 'artifact', title: request.title, artifactId: request.artifactId, @@ -2303,12 +3080,9 @@ export function App({ ); const closeArtifactPanel = useCallback(() => { setArtifactPanelOpen(false); - setArtifactPanelTabs([]); - setActiveArtifactPanelTabId(null); - setReviewChanges([]); - setSelectedReviewPath(null); - setArtifactPanelExtraArtifacts([]); - setPaneArtifactSnapshots(new Map()); + setSideTaskCatalog((catalog) => + catalog.items.length === 0 ? { ...catalog, loaded: false } : catalog, + ); }, []); useLayoutEffect(() => { if (!artifactPanelOpen) return; @@ -2555,18 +3329,26 @@ export function App({ }) as CSSProperties, [bottomPanelHeight, bottomPanelInset], ); - const backgroundTaskActivityKey = useMemo( - () => getBackgroundTaskActivityKey(messages), + const taskActivityKey = useMemo( + () => getTaskActivityKey(messages), [messages], ); const [backgroundTasksRefreshTrigger, setBackgroundTasksRefreshTrigger] = useState(0); - const backgroundTasks = useBackgroundTasks( + const sessionTasks = useBackgroundTasks( connection.sessionId, - backgroundTaskActivityKey, + taskActivityKey, connection.status === 'connected', backgroundTasksRefreshTrigger, ); + const environmentAgentTasks = useMemo( + () => getEnvironmentAgentTasks(messages, sessionTasks), + [messages, sessionTasks], + ); + const backgroundTasks = useMemo( + () => sessionTasks.filter((task) => task.kind !== 'agent'), + [sessionTasks], + ); const monitorDetailsSessionIdRef = useRef(connection.sessionId); monitorDetailsSessionIdRef.current = connection.sessionId; const openMonitorPanelFromTool = useCallback( @@ -2605,7 +3387,12 @@ export function App({ setArtifactPanelTabs((tabs) => { let changed = false; const next = tabs.map((tab) => { - if (tab.kind !== 'monitor') return tab; + if ( + tab.kind !== 'monitor' || + (tab.sessionId && tab.sessionId !== connection.sessionId) + ) { + return tab; + } const task = monitors.get(tab.task.id); if (!task || task === tab.task) return tab; const mergedTask = mergeMonitorTaskSnapshot(tab.task, task); @@ -2619,7 +3406,39 @@ export function App({ }); return changed ? next : tabs; }); - }, [backgroundTasks]); + }, [backgroundTasks, connection.sessionId]); + useEffect(() => { + const shellTasks = new Map( + backgroundTasks + .filter( + (task): task is DaemonSessionShellTaskStatus => task.kind === 'shell', + ) + .map((task) => [task.id, task]), + ); + if (shellTasks.size === 0) return; + setArtifactPanelTabs((tabs) => { + let changed = false; + const next = tabs.map((tab) => { + if ( + tab.kind !== 'shell' || + (tab.sessionId && tab.sessionId !== connection.sessionId) + ) { + return tab; + } + const task = shellTasks.get(tab.task.id); + if (!task || task === tab.task) return tab; + const mergedTask = mergeShellTaskSnapshot(tab.task, task); + if (mergedTask === tab.task) return tab; + changed = true; + return { + ...tab, + title: mergedTask.command, + task: mergedTask, + }; + }); + return changed ? next : tabs; + }); + }, [backgroundTasks, connection.sessionId]); const footerTasks = useMemo( () => (renderFooter ? backgroundTasks.map(mapToWebShellTaskInfo) : []), [backgroundTasks, renderFooter], @@ -3277,6 +4096,14 @@ export function App({ }, [openMonitorPanel], ); + const handleOpenShellDetails = useCallback( + (task: DaemonSessionShellTaskStatus) => { + setTasksDialogMessage(null); + setBackgroundTasksRefreshTrigger((value) => value + 1); + openShellPanel(task); + }, + [openShellPanel], + ); const [selectedTheme, setSelectedTheme] = useState( providedTheme ?? WebShellThemeId.Dark, ); @@ -3289,12 +4116,38 @@ export function App({ }, []); const connectionRef = useRef(connection); connectionRef.current = connection; + const refreshActiveSessionDisplayName = useCallback(async () => { + const activeConnection = connectionRef.current; + if (!activeConnection.sessionId || !activeConnection.workspaceCwd) return; + try { + const sessions = await workspace.client.listWorkspaceSessions( + activeConnection.workspaceCwd, + { pageSize: 200 }, + ); + if ( + connectionRef.current.sessionId !== activeConnection.sessionId || + connectionRef.current.displayName + ) { + return; + } + const displayName = sessions.find( + (session) => session.sessionId === activeConnection.sessionId, + )?.displayName; + if (displayName?.trim()) setSessionStatusDisplayName(displayName); + } catch { + // The live session_metadata_updated event remains the primary path. + } + }, [workspace.client]); + const refreshActiveSessionDisplayNameRef = useRef( + refreshActiveSessionDisplayName, + ); + refreshActiveSessionDisplayNameRef.current = refreshActiveSessionDisplayName; const requireActiveSessionForLocalCommand = useCallback((): boolean => { if (connectionRef.current.sessionId) return true; pushToast('info', t('localCommand.noSession')); return false; }, [pushToast, t]); - const sessionDisplayName = connection.displayName; + const sessionDisplayName = connection.displayName ?? sessionStatusDisplayName; const [currentMode, setCurrentMode] = useState('default'); const currentModeRef = useRef(currentMode); currentModeRef.current = currentMode; @@ -3459,14 +4312,18 @@ export function App({ } delayedReloadTimerRef.current = setTimeout(() => { setSessionListReloadToken((n) => n + 1); + void refreshActiveSessionDisplayNameRef.current(); }, 2000); }, []); const dispatchSessionChange = useCallback( (event: SessionChangeEvent) => { onSessionChange?.(event); setSessionListReloadToken((n) => n + 1); + if (event.type === 'turn_complete') { + scheduleDelayedSessionListReload(); + } }, - [onSessionChange], + [onSessionChange, scheduleDelayedSessionListReload], ); // Ref-stable handle so that useCallback hooks (sendPrompt, enqueuePrompt, // turn_complete effect) don't need dispatchSessionChange in their dep arrays. @@ -5475,10 +6332,12 @@ export function App({ }, openSessionDrawer, createNewSession: () => createNewSession(), + createSideTask, }), [ closeMobileDrawer, createNewSession, + createSideTask, openPanel, openSessionDrawer, requestOpenSplitView, @@ -5728,9 +6587,32 @@ export function App({ setTasksDialogMessage({ snapshot }); }) .catch((error: unknown) => { + if (isSessionDisconnectedError(error)) return; reportError(error, 'Failed to load tasks'); }); }, [reportError, requireActiveSessionForLocalCommand, sessionActions]); + const openEnvironmentTasksPanel = useCallback(() => { + if (!requireActiveSessionForLocalCommand()) return; + setEnvironmentPanelOpen(true); + setBackgroundTasksRefreshTrigger((value) => value + 1); + }, [requireActiveSessionForLocalCommand]); + const openEnvironmentTask = useCallback( + (task: DaemonSessionTaskStatus) => { + if (task.kind === 'monitor' || task.kind === 'shell') { + if (!artifactPanelOpenRef.current) { + preserveEnvironmentPanelOnArtifactOpenRef.current = true; + } + if (task.kind === 'monitor') { + handleOpenMonitorDetails(task); + } else { + handleOpenShellDetails(task); + } + return; + } + openTasksPanel(); + }, + [handleOpenMonitorDetails, handleOpenShellDetails, openTasksPanel], + ); const dispatchGoalSet = useCallback( (condition: string, setAt: number) => { @@ -6017,7 +6899,7 @@ export function App({ return true; } if (cmd === 'tasks') { - openTasksPanel(); + openEnvironmentTasksPanel(); return true; } if (cmd === 'goal') { @@ -6165,6 +7047,7 @@ export function App({ pushToast('warning', t('fork.notStarted')); return; } + setBackgroundTasksRefreshTrigger((value) => value + 1); pushToast( 'success', t('fork.started', { name: result.description }), @@ -6608,7 +7491,20 @@ export function App({ return true; } if (cmd === 'btw') { - runVisibleBtw(text.slice(match[0].length)); + const rawQuestion = text.slice(match[0].length).trim(); + const sideTaskMatch = /^side(?:\s+|$)/i.exec(rawQuestion); + if (sideTasksAvailable && sideTaskMatch) { + const question = rawQuestion + .slice(sideTaskMatch[0].length) + .trim(); + if (!question) { + pushToast('error', t('btw.side.empty')); + return true; + } + createSideTask(question); + return true; + } + runVisibleBtw(rawQuestion); return true; } if (cmd === 'stats') { @@ -6854,7 +7750,9 @@ export function App({ handleSetMode, handleLanguageChange, blockLocalCommandDuringTurn, - openTasksPanel, + createSideTask, + sideTasksAvailable, + openEnvironmentTasksPanel, hiddenCommands, pushToast, reportError, @@ -7436,7 +8334,7 @@ export function App({ mergeCommands( retainedCommands, refreshedSkillCommands, - getLocalCommands(t), + getLocalCommands(t, { sideTaskAvailable: sideTasksAvailable }), ), t, ) @@ -7458,6 +8356,7 @@ export function App({ hiddenCommands, loadedSkills, loadedSkillsReady, + sideTasksAvailable, t, ]); @@ -7516,6 +8415,99 @@ export function App({ const effectiveChatWidthMode: ChatWidthMode = isChatEmptyState ? getDefaultChatWidthMode() : chatWidthMode; + const activeGitBranch = sessionWorktree + ? (selectedWorkspaceGitStatus?.branch ?? sessionWorktree.branch) + : sessionBranch + ? (selectedWorkspaceGitStatus?.branch ?? sessionBranch.name) + : connection.sessionId + ? connection.gitBranch + : (selectedWorkspaceGitStatus?.branch ?? undefined); + const environmentPanelCanDock = + contextBodyWidth === null || + contextBodyWidth >= + MIN_DOCKED_MESSAGE_AREA_WIDTH + DOCKED_ENVIRONMENT_PANEL_WIDTH; + const environmentPanelFits = + chatWidthMode !== 'wide' && environmentPanelCanDock; + const environmentPanelVisible = + environmentPanelOpen && + !isChatEmptyState && + !activePanel && + mainView === 'chat'; + const handleEnvironmentPanelOpenChange = useCallback((open: boolean) => { + if (!open) { + preserveEnvironmentPanelOnArtifactOpenRef.current = false; + setEnvironmentPanelOpen(false); + return; + } + setEnvironmentPanelOpen(true); + }, []); + const dismissEnvironmentPanel = useCallback(() => { + preserveEnvironmentPanelOnArtifactOpenRef.current = false; + setEnvironmentPanelOpen(false); + }, []); + const handleRightPanelOpenChange = useCallback( + (open: boolean) => { + if (open) { + setArtifactPanelOpen(true); + } else { + closeArtifactPanel(); + } + }, + [closeArtifactPanel], + ); + useLayoutEffect(() => { + const body = contextBodyRef.current; + if (!body) return; + const updateWidth = () => { + const availableWidth = body.getBoundingClientRect().width; + if (availableWidth <= 0) return; + setContextBodyWidth((current) => + current === availableWidth ? current : availableWidth, + ); + }; + const handleWindowResize = () => { + preserveEnvironmentPanelOnArtifactOpenRef.current = false; + updateWidth(); + }; + updateWidth(); + window.addEventListener('resize', handleWindowResize); + const observer = new ResizeObserver(updateWidth); + observer.observe(body); + return () => { + window.removeEventListener('resize', handleWindowResize); + observer.disconnect(); + }; + }, []); + const previousEnvironmentCanDockRef = useRef(environmentPanelCanDock); + useLayoutEffect(() => { + const crossedDockBreakpoint = + previousEnvironmentCanDockRef.current && !environmentPanelCanDock; + previousEnvironmentCanDockRef.current = environmentPanelCanDock; + if ( + crossedDockBreakpoint && + !preserveEnvironmentPanelOnArtifactOpenRef.current + ) { + setEnvironmentPanelOpen(false); + } + }, [environmentPanelCanDock]); + const previousArtifactPanelOpenForEnvironmentRef = useRef(artifactPanelOpen); + useLayoutEffect(() => { + const artifactPanelJustOpened = + !previousArtifactPanelOpenForEnvironmentRef.current && artifactPanelOpen; + previousArtifactPanelOpenForEnvironmentRef.current = artifactPanelOpen; + if (!artifactPanelOpen) { + preserveEnvironmentPanelOnArtifactOpenRef.current = false; + return; + } + if (!artifactPanelJustOpened) return; + const preserveEnvironmentPanel = + preserveEnvironmentPanelOnArtifactOpenRef.current; + if (!preserveEnvironmentPanel && !environmentPanelFits) { + setEnvironmentPanelOpen(false); + } + }, [artifactPanelOpen, environmentPanelFits]); + const environmentPanelMounted = + !isChatEmptyState && !activePanel && mainView === 'chat'; const chatWidthToggleMin = getChatMaxWidth(chatMaxWidth); const appClassName = [ @@ -8102,8 +9094,93 @@ export function App({ /> )} +
+ {chatHeaderEnabled && + !isChatEmptyState && + !activePanel && + mainView === 'chat' && ( +
+ {sidebarOptions.enabled && + sidebarOptions.showCompactToggle && ( + + )} + {renderChatHeader ? ( +
+ {renderChatHeader({ + sessionId: connection.sessionId, + sessionName: sessionDisplayName, + workspaceCwd: connection.workspaceCwd, + items: chatHeaderItems, + environmentPanelOpen: environmentPanelVisible, + rightPanelOpen: artifactPanelOpen, + onEnvironmentPanelOpenChange: + handleEnvironmentPanelOpenChange, + onRightPanelOpenChange: handleRightPanelOpenChange, + })} +
+ ) : ( + + handleEnvironmentPanelOpenChange( + !environmentPanelVisible, + ) + } + onToggleRightPanel={() => + handleRightPanelOpenChange(!artifactPanelOpen) + } + /> + )} +
+ )} +
{sidebarOptions.enabled && sidebarOptions.showCompactToggle && + (!chatHeaderEnabled || isChatEmptyState) && !activePanel && mainView === 'chat' && (
+ {environmentPanelMounted && ( +