diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index d6211713e8..6f1a2054cb 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 @@ -1689,6 +1700,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 +2259,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 @@ -2974,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(">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..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 @@ -1578,24 +1587,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/docs/developers/tools/task.md b/docs/developers/tools/task.md index 57509ecbeb..6ca6975d80 100644 --- a/docs/developers/tools/task.md +++ b/docs/developers/tools/task.md @@ -14,6 +14,7 @@ Use `agent` to launch a specialized subagent to handle complex, multi-step tasks - `prompt` (string, required): The detailed task prompt for the subagent to execute. Should contain comprehensive instructions for autonomous execution. - `subagent_type` (string, optional): The type of specialized agent to use for this task. Defaults to `general-purpose` if omitted. - `fork_turns` (string, optional): Only valid with `subagent_type="fork"`. Omit it or use `all` for the full parent conversation, or use a positive integer string such as `"3"` for the most recent three real user turns. Tool responses and pure system reminders do not count as turns. +- `fork_tools` (array of strings, optional): Only valid with `subagent_type="fork"`. Restricts execution to exact canonical tool names or MCP server patterns while keeping the fork's current model-visible tool declarations unchanged for prompt-cache sharing. Entries cannot have surrounding whitespace; wildcards are limited to `mcp__*` or a trailing MCP tool-prefix pattern such as `mcp__github__read_*`. Omit it for unrestricted execution; use an empty array to reject every tool call. - `run_in_background` (boolean, optional): Defaults to `true` for top-level regular agents. Set to `false` to wait for a regular agent's result inline. Headless forks always run in the background. Nested agents run in the foreground unless `run_in_background` is explicitly `true`, which is rejected because nested agents cannot receive background completion notifications. Caller-owned `working_dir` launches run in the foreground and reject explicit or configured background execution. - `isolation` (string, optional): Set to `"worktree"` to run an explicitly named, non-fork agent in an isolated git worktree that Qwen Code creates and manages. - `working_dir` (string, optional): Pin an explicitly named, non-fork agent to an existing registered git worktree inside the current repository. The caller owns the worktree lifecycle, so this mode runs in the foreground. If both `working_dir` and `isolation` are provided, `working_dir` takes precedence. @@ -34,6 +35,7 @@ Usage: ``` agent(description="Brief task description", prompt="Detailed task instructions for the subagent", subagent_type="agent_name") agent(description="Brief task description", prompt="Detailed task instructions for the fork", subagent_type="fork", fork_turns="3") +agent(description="Read-only investigation", prompt="Inspect the implementation", subagent_type="fork", fork_tools=["read_file", "grep_search", "mcp__github"]) ``` Set `run_in_background=false` when the current turn must use the subagent result before continuing. @@ -144,6 +146,7 @@ Don't use the Agent tool for: ## Important Notes - **Independent context**: Regular subagents start without parent conversation history. Forks inherit the full conversation by default and accept `fork_turns` when a bounded recent window is sufficient. +- **Fork execution restrictions**: `fork_tools` narrows which already-declared tools a fork may execute. Disallowed calls return an error before scheduling or approval; the same declaration list remains model-visible for cache sharing. This is a per-call restriction chosen by the caller, not an administrator-enforced sandbox. - **Completion delivery**: Background results arrive through completion notifications in a later turn. Do not assume a result before the notification arrives. - **Continuation**: Use `list_agents` and `send_message` for related follow-up work instead of launching a duplicate agent. Continuation depends on compatible retained state and may be unavailable. - **Comprehensive prompts**: Your initial prompt should contain all necessary context and instructions for autonomous execution. A regular subagent does not see the parent conversation. 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/docs/users/features/sub-agents.md b/docs/users/features/sub-agents.md index a73f72f574..7e2c99b6a1 100644 --- a/docs/users/features/sub-agents.md +++ b/docs/users/features/sub-agents.md @@ -25,14 +25,28 @@ Only `subagent_type: "fork"` accepts `fork_turns`: Tool responses and pure system reminders do not count as user turns. Regular named subagents and agent-team teammates do not accept `fork_turns`; they keep their separate conversation context. +## Restricting Fork Tool Execution with `fork_tools` + +Only `subagent_type: "fork"` accepts `fork_tools`. The array may contain exact canonical tool names, such as `read_file` and `grep_search`, or MCP server patterns such as `mcp__github`. The fork still receives the same model-visible tool declarations as an unrestricted fork, preserving its prompt-cache prefix, but its task prompt identifies the restriction and a call not matched by `fork_tools` is rejected before scheduling or approval. + +- Omitting `fork_tools` preserves unrestricted fork execution. +- An empty array rejects every tool call. +- `*` is not accepted; omit `fork_tools` when unrestricted execution is intended. +- Tool names cannot have surrounding whitespace. Wildcards are accepted only as `mcp__*` or as a trailing MCP tool-prefix pattern such as `mcp__github__read_*`. +- `mcp__*` intentionally allows every MCP tool while still denying unlisted built-in tools. +- Shell command argument patterns are not supported. Listing `run_shell_command` allows that tool to proceed through its normal permission checks but does not pre-approve any command. + +This is a per-invocation restriction supplied by the caller. It narrows a child fork's capabilities but is not an administrator-enforced security sandbox because the caller can omit or expand the list. + ### How Fork Differs from Named Subagents -| | Named Subagent | Fork Subagent | -| ------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------- | -| Context | Starts fresh with no parent conversation history | Inherits all parent history by default; `fork_turns` can select a bounded recent window | -| System prompt | Uses its own configured prompt | Uses parent's exact system prompt (for cache sharing) | -| Execution | Background by default; supports an explicit foreground opt-out | Always detached; parent continues immediately | -| Use case | Specialized tasks (testing, docs) | Parallel tasks that need the current context | +| | Named Subagent | Fork Subagent | +| ------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| Context | Starts fresh with no parent conversation history | Inherits all parent history by default; `fork_turns` can select a bounded recent window | +| System prompt | Uses its own configured prompt | Uses parent's exact system prompt (for cache sharing) | +| Tools | Configured declaration set | Keeps the parent-derived declaration set; `fork_tools` can independently narrow execution without changing that set | +| Execution | Background by default; supports an explicit foreground opt-out | Always detached; parent continues immediately | +| Use case | Specialized tasks (testing, docs) | Parallel tasks that need the current context | ### When Fork is Used @@ -46,9 +60,9 @@ The AI automatically uses fork when it needs to: All forks share the parent's exact API request prefix (system prompt, tools, conversation history), enabling DashScope prompt cache hits. When 3 forks run in parallel, the shared prefix is cached once and reused — saving 80%+ token costs compared to independent subagents. -### Recursive Fork Prevention +### Recursive Delegation Prevention -Fork children cannot create further forks. This is enforced at runtime — if a fork attempts to spawn another fork, it receives an error instructing it to execute tasks directly. +Fork children cannot spawn any further sub-agent. This is enforced at runtime — if a fork calls the Agent tool, it receives an error instructing it to execute tasks directly. ### Current Limitation diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index 2ab3f55d2d..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', @@ -357,6 +358,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/integration-tests/sdk-typescript/permission-control.test.ts b/integration-tests/sdk-typescript/permission-control.test.ts index 4f06f2bfb8..cf822733df 100644 --- a/integration-tests/sdk-typescript/permission-control.test.ts +++ b/integration-tests/sdk-typescript/permission-control.test.ts @@ -34,7 +34,7 @@ import { createResultWaiter, } from './test-helper.js'; -const TEST_TIMEOUT = process.env['CI'] ? 60000 : 30000; +const TEST_TIMEOUT = 60000; const SHARED_TEST_OPTIONS = createSharedTestOptions(); /** @@ -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(); } } @@ -460,7 +460,7 @@ describe('Permission Control (E2E)', () => { new Promise((_, reject) => setTimeout( () => reject(new Error('Timeout waiting for first response')), - 10000, + TEST_TIMEOUT, ), ), ]); @@ -476,7 +476,7 @@ describe('Permission Control (E2E)', () => { new Promise((_, reject) => setTimeout( () => reject(new Error('Timeout waiting for second response')), - 10000, + TEST_TIMEOUT, ), ), ]); @@ -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..6ee1234f35 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, @@ -18,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. @@ -139,8 +140,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?.(); diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 279b0b82dc..9f9d764551 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -62,6 +62,8 @@ import { TurnBoundaryCompactionEngine } from './compactionEngine.js'; import { CHANNEL_STARTUP_PROFILE_META_KEY, CHANNEL_STARTUP_PROFILE_VERSION, + WORKTREE_MCP_DEFER_META_KEY, + LOAD_REPLAY_HIDE_INHERITED_META_KEY, } from './bridgeTypes.js'; import { ApprovalMode, @@ -1047,6 +1049,24 @@ describe('createAcpSessionBridge', () => { }); }); + it('marks worktree session creation to defer MCP discovery', async () => { + const handle = makeChannel(); + const bridge = makeBridge({ + sessionScope: 'thread', + channelFactory: async () => handle.channel, + }); + + await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + worktree: { slug: 'task-a', path: WS_B, branch: 'worktree-task-a' }, + }); + + expect(handle.agent.newSessionCalls[0]?._meta).toMatchObject({ + [WORKTREE_MCP_DEFER_META_KEY]: true, + }); + await bridge.shutdown(); + }); + it('does not fail initialization when span enrichment throws', async () => { const handle = makeChannel({ initializeImpl: async () => ({ @@ -2040,14 +2060,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 {}; @@ -2082,6 +2104,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 }, @@ -10849,6 +10878,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(); @@ -11541,6 +11667,15 @@ describe('createAcpSessionBridge', () => { aborted: false, }); expect(shellSpy).toHaveBeenCalledTimes(1); + expect(shellSpy).toHaveBeenCalledWith( + 'echo hello', + WS_A, + expect.any(Function), + expect.any(AbortSignal), + false, + { terminalWidth: 120, terminalHeight: 40 }, + { streamStdout: true }, + ); const it = events[Symbol.asyncIterator](); const first = await it.next(); expect(first.value?.type).toBe('user_shell_command'); @@ -11550,6 +11685,224 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); shellSpy.mockRestore(); }); + + it('executes direct shell in each session effective cwd', async () => { + const shellSpy = mockShellExecute(); + const handle = makeChannel({ + extMethodImpl: async (method, params) => { + if (method === SERVE_CONTROL_EXT_METHODS.sessionCd) { + return { + previousCwd: WS_A, + newCwd: (params as { path: string }).path, + warnings: [], + }; + } + return {}; + }, + }); + const bridge = makeBridge({ + sessionShellCommandEnabled: true, + channelFactory: async () => handle.channel, + }); + const firstSession = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + const secondSession = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + + await Promise.all([ + bridge.changeSessionCwd(firstSession.sessionId, { path: WS_A }), + bridge.changeSessionCwd(secondSession.sessionId, { path: WS_B }), + ]); + await Promise.all([ + bridge.executeShellCommand( + firstSession.sessionId, + 'echo first', + undefined, + { clientId: firstSession.clientId }, + ), + bridge.executeShellCommand( + secondSession.sessionId, + 'echo second', + undefined, + { clientId: secondSession.clientId }, + ), + ]); + + expect(shellSpy).toHaveBeenCalledTimes(2); + expect( + shellSpy.mock.calls.map(([command, cwd]) => [command, cwd]), + ).toEqual( + expect.arrayContaining([ + ['echo first', WS_A], + ['echo second', WS_B], + ]), + ); + + await bridge.shutdown(); + shellSpy.mockRestore(); + }); + + it('waits for a pending cwd change before executing direct shell', async () => { + const shellSpy = mockShellExecute(); + const cdResult = deferred<{ + previousCwd: string; + newCwd: string; + warnings: string[]; + }>(); + const handle = makeChannel({ + extMethodImpl: async (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.sessionCd) { + return cdResult.promise; + } + return {}; + }, + }); + const bridge = makeBridge({ + sessionShellCommandEnabled: true, + channelFactory: async () => handle.channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + const cd = bridge.changeSessionCwd(session.sessionId, { path: WS_B }); + await vi.waitFor(() => + expect(handle.agent.extMethodCalls).toContainEqual({ + method: SERVE_CONTROL_EXT_METHODS.sessionCd, + params: { + sessionId: session.sessionId, + path: WS_B, + }, + }), + ); + const shell = bridge.executeShellCommand( + session.sessionId, + 'echo after-cd', + undefined, + { clientId: session.clientId }, + ); + + await Promise.resolve(); + expect(shellSpy).not.toHaveBeenCalled(); + cdResult.resolve({ previousCwd: WS_A, newCwd: WS_B, warnings: [] }); + await Promise.all([cd, shell]); + + expect(shellSpy.mock.calls[0]?.[1]).toBe(WS_B); + + await bridge.shutdown(); + shellSpy.mockRestore(); + }); + + it('executes direct shell in previous cwd when a pending cd fails', async () => { + const shellSpy = mockShellExecute(); + const cdResult = deferred<{ + previousCwd: string; + newCwd: string; + warnings: string[]; + }>(); + const handle = makeChannel({ + extMethodImpl: async (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.sessionCd) { + return cdResult.promise; + } + return {}; + }, + }); + const bridge = makeBridge({ + sessionShellCommandEnabled: true, + channelFactory: async () => handle.channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + const cd = bridge.changeSessionCwd(session.sessionId, { path: WS_B }); + await vi.waitFor(() => + expect(handle.agent.extMethodCalls).toContainEqual({ + method: SERVE_CONTROL_EXT_METHODS.sessionCd, + params: { + sessionId: session.sessionId, + path: WS_B, + }, + }), + ); + const shell = bridge.executeShellCommand( + session.sessionId, + 'echo after-failed-cd', + undefined, + { clientId: session.clientId }, + ); + + await Promise.resolve(); + expect(shellSpy).not.toHaveBeenCalled(); + cdResult.reject(new Error('cd failed')); + await expect(cd).rejects.toThrow(); + await shell; + + expect(shellSpy.mock.calls[0]?.[1]).toBe(WS_A); + + await bridge.shutdown(); + shellSpy.mockRestore(); + }); + + it('returns an aborted direct shell without waiting for a hung cwd change', async () => { + const shellSpy = mockShellExecute(); + const cdResult = deferred<{ + previousCwd: string; + newCwd: string; + warnings: string[]; + }>(); + const handle = makeChannel({ + extMethodImpl: async (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.sessionCd) { + return cdResult.promise; + } + return {}; + }, + }); + const bridge = makeBridge({ + sessionShellCommandEnabled: true, + channelFactory: async () => handle.channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + const cd = bridge.changeSessionCwd(session.sessionId, { path: WS_B }); + await vi.waitFor(() => + expect(handle.agent.extMethodCalls).toContainEqual({ + method: SERVE_CONTROL_EXT_METHODS.sessionCd, + params: { + sessionId: session.sessionId, + path: WS_B, + }, + }), + ); + + const abort = new AbortController(); + const shell = bridge.executeShellCommand( + session.sessionId, + 'echo aborted-cd', + abort.signal, + { clientId: session.clientId }, + ); + + await Promise.resolve(); + expect(shellSpy).not.toHaveBeenCalled(); + abort.abort(); + + // The cd extMethod never settles, yet the aborted command must return + // promptly instead of parking on the cwd queue forever. + await expect(shell).resolves.toEqual({ + exitCode: null, + output: '', + aborted: true, + }); + expect(shellSpy).not.toHaveBeenCalled(); + + cdResult.resolve({ previousCwd: WS_A, newCwd: WS_B, warnings: [] }); + await cd; + await bridge.shutdown(); + shellSpy.mockRestore(); + }); }); describe('setSessionApprovalMode (#4175 Wave 4 PR 17)', () => { diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 8c62aebd9e..410a7d507e 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, @@ -110,6 +111,7 @@ import { PROMPT_CANCEL_METHOD, REQUESTED_SESSION_ID_META_KEY, TODO_STOP_GUARD_QUEUE_RELEASE_METHOD, + WORKTREE_MCP_DEFER_META_KEY, } from './bridgeTypes.js'; import { getChannelStartupProfileAttributes } from './channel-startup-profile.js'; import type { @@ -471,6 +473,7 @@ interface ChannelInfo { interface SessionEntry { sessionId: string; workspaceCwd: string; + effectiveCwd: string; createdAt: string; displayName?: string; /** Id of the session that spawned this one (via `create_sub_session`). @@ -494,6 +497,8 @@ interface SessionEntry { recordingDegraded: boolean; /** Set synchronously while agent-owned state and its writer lease close. */ closing: boolean; + /** Tail of cwd changes that direct shell commands must not overtake. */ + cwdChangeQueue: Promise; /** * Tail of the per-session prompt queue. Each new prompt chains off the * resolved (or rejected) state of this promise so prompts run one at a @@ -1914,7 +1919,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; @@ -2032,6 +2041,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 @@ -2647,25 +2657,33 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { async () => { // This legacy-named helper sanitizes and injects trace metadata // for any ACP request, not only prompts. + const request = telemetry.injectPromptContext({ + cwd: boundWorkspace, + mcpServers: [], + ...(requestedSessionId || sourceType + ? { + _meta: { + ...sessionSourceRequestMeta(sourceType, sourceId), + ...(requestedSessionId + ? { + [REQUESTED_SESSION_ID_META_KEY]: requestedSessionId, + } + : {}), + }, + } + : {}), + }); const response = await withTimeout( ci.connection.newSession( - telemetry.injectPromptContext({ - cwd: boundWorkspace, - mcpServers: [], - ...(requestedSessionId || sourceType - ? { - _meta: { - ...sessionSourceRequestMeta(sourceType, sourceId), - ...(requestedSessionId - ? { - [REQUESTED_SESSION_ID_META_KEY]: - requestedSessionId, - } - : {}), - }, - } - : {}), - }), + worktree + ? { + ...request, + _meta: { + ...(isRecord(request._meta) ? request._meta : {}), + [WORKTREE_MCP_DEFER_META_KEY]: true, + }, + } + : request, ), initTimeoutMs, 'newSession', @@ -3889,6 +3907,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const entry: SessionEntry = { sessionId, workspaceCwd, + effectiveCwd: workspaceCwd, createdAt: new Date().toISOString(), ...(options.parentSessionId ? { parentSessionId: options.parentSessionId } @@ -3907,6 +3926,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { }), recordingDegraded: false, closing: false, + cwdChangeQueue: Promise.resolve(), promptQueue: Promise.resolve(), pendingPromptCount: 0, pendingPromptList: [], @@ -4401,6 +4421,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) { @@ -4462,7 +4484,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, @@ -4594,7 +4617,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( @@ -4613,6 +4638,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { : {}), } : {}), + ...(hideInheritedHistory + ? { + [LOAD_REPLAY_HIDE_INHERITED_META_KEY]: true, + } + : {}), }, } : {}), @@ -4856,6 +4886,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { inFlightRestores.set(req.sessionId, { action, historyReplay, + hideInheritedHistory, promise, coalesceState, }); @@ -6237,14 +6268,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); } @@ -6269,13 +6308,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') { @@ -6291,12 +6335,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, @@ -6322,20 +6374,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, @@ -6344,18 +6426,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, @@ -6405,6 +6508,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // State update inside the queue lambda — always executes when // the extMethod settles, regardless of caller timeout. + entry.effectiveCwd = extResult.newCwd; if (extResult.previousCwd !== extResult.newCwd) { entry.events.publish({ type: 'session_cwd_changed', @@ -6426,6 +6530,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { () => undefined, () => undefined, ); + entry.cwdChangeQueue = cdPromise.then( + () => undefined, + () => undefined, + ); // Timeout is caller-facing only: surfaces a deadline exceeded error // to the HTTP client without advancing the queue prematurely. @@ -7115,52 +7223,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, @@ -7784,7 +7940,27 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { return { exitCode: null, output: '', aborted: true }; } - const cwd = entry.workspaceCwd; + // Race the cwd queue against the caller's abort signal so a shell + // command cannot park forever on a changeSessionCwd extMethod that + // never settles (agent crash / deadlock / partitioned ACP channel). + let abortResolve: (() => void) | undefined; + const onAbort = () => abortResolve?.(); + try { + await Promise.race([ + entry.cwdChangeQueue, + new Promise((resolve) => { + abortResolve = resolve; + if (signal?.aborted) return resolve(); + signal?.addEventListener('abort', onAbort, { once: true }); + }), + ]); + } finally { + signal?.removeEventListener('abort', onAbort); + } + if (signal?.aborted) { + return { exitCode: null, output: '', aborted: true }; + } + const cwd = entry.effectiveCwd; entry.events.publish({ type: 'user_shell_command', diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 4d433a9ec3..a040ec0109 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -155,6 +155,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 @@ -173,6 +175,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; @@ -181,6 +185,7 @@ export const REQUESTED_SESSION_ID_META_KEY = 'qwen-code/sessionId'; export const CHANNEL_STARTUP_PROFILE_META_KEY = 'qwen.daemon.channelStartupProfile'; export const CHANNEL_STARTUP_PROFILE_VERSION = 1 as const; +export const WORKTREE_MCP_DEFER_META_KEY = 'qwen.session.deferMcpDiscovery'; export interface ChannelStartupProfileV1 { v: typeof CHANNEL_STARTUP_PROFILE_VERSION; @@ -294,6 +299,9 @@ export interface BridgeSessionTranscriptPage { export interface BridgeBranchSessionRequest { name?: string; + sourceType?: string; + sourceId?: string; + replayInheritedHistory?: boolean; } export interface BridgeBranchedSession extends BridgeRestoredSession { @@ -301,6 +309,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; @@ -885,6 +902,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 851cf1cd6a..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', @@ -449,8 +450,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/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/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index e0904d6f48..e5540afaae 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'; @@ -868,6 +869,7 @@ import { CHANNEL_STARTUP_PROFILE_VERSION, PROMPT_CANCEL_METHOD, TODO_STOP_GUARD_QUEUE_RELEASE_METHOD, + WORKTREE_MCP_DEFER_META_KEY, } from '@qwen-code/acp-bridge/bridgeTypes'; import { initializeAcpStartupProfiler, @@ -3752,6 +3754,24 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('defers MCP discovery for a worktree session until relocation', async () => { + const innerConfig = await setupSessionMocks('worktree-mcp-session'); + const { agent, agentPromise } = await bootAcpAgent(); + + await agent.newSession({ + cwd: '/tmp', + mcpServers: [], + _meta: { [WORKTREE_MCP_DEFER_META_KEY]: true }, + }); + + expect(innerConfig.initialize).toHaveBeenCalledWith( + expect.objectContaining({ skipMcpDiscovery: true }), + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('serializes a working-directory change and hard-suspends Todo Stop Guard', async () => { const sessionId = '11111111-1111-1111-1111-111111111111'; const targetDir = await fs.mkdtemp( @@ -3797,6 +3817,45 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('reports an MCP refresh warning after changing the working directory', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const targetDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-mcp-refresh-cwd-'), + ); + const canonicalTargetDir = await fs.realpath(targetDir); + const innerConfig = await setupSessionMocks(sessionId); + Object.assign(innerConfig, { + getTargetDir: vi.fn().mockReturnValue('/tmp'), + isRestrictiveSandbox: vi.fn().mockReturnValue(false), + relocateWorkingDirectory: vi.fn().mockResolvedValue({ + mcpRefreshError: new Error('MCP failed'), + }), + }); + Object.assign(innerConfig.getGeminiClient(), { + addWorkingDirectoryChangedContext: vi.fn().mockResolvedValue(undefined), + }); + const { agent, agentPromise } = await bootAcpAgent(); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + + try { + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionCd, { + sessionId, + path: targetDir, + }), + ).resolves.toEqual({ + previousCwd: '/tmp', + newCwd: canonicalTargetDir, + warnings: ['MCP refresh failed: MCP failed'], + }); + } finally { + await fs.rm(targetDir, { recursive: true, force: true }); + } + + mockConnectionState.resolve(); + await agentPromise; + }); + it('rechecks a no-op working-directory change after a concurrent relocation', async () => { const sessionId = '11111111-1111-1111-1111-111111111111'; const oldDir = await fs.mkdtemp( @@ -4269,7 +4328,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { ? MCPServerStatus.DISCONNECTED : MCPServerStatus.CONNECTED, ); - const listSkills = vi.fn().mockResolvedValue([ + const cachedSkills = [ { name: 'review', description: 'Review code', @@ -4316,13 +4375,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: { @@ -4354,8 +4420,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([ { @@ -4463,6 +4529,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, {}, @@ -4629,6 +4699,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'); @@ -4670,6 +4747,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, @@ -11904,6 +12166,42 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('keeps ACP stdio MCP cwd implicit so session relocation can rebind it', async () => { + await setupSessionMocks('session-stdio-cwd'); + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ + cwd: '/tmp', + mcpServers: [ + { + name: 'local', + command: 'node', + args: ['server.js'], + env: [], + } as unknown as McpServer, + ], + }); + + const sessionMcpServers = vi.mocked(loadCliConfig).mock.calls[0]?.[6]; + const localConfig = sessionMcpServers?.['local'] as unknown as { + _args: unknown[]; + }; + expect(localConfig._args).toEqual(['node', ['server.js'], {}]); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('passes undefined (not []) as the extension override to loadCliConfig', async () => { await setupSessionMocks('session-ext-override'); @@ -12442,6 +12740,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')); @@ -15716,6 +16063,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), @@ -16180,6 +16532,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) @@ -16191,6 +16545,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(), @@ -16214,14 +16569,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', ); @@ -16230,7 +16595,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), @@ -16240,6 +16707,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'), @@ -16271,7 +16752,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, ); @@ -16283,21 +16764,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]!, ); @@ -16547,3 +17061,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 e97bc4a9f8..c7d90534ef 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, @@ -307,6 +308,7 @@ import { PROMPT_CANCEL_METHOD, REQUESTED_SESSION_ID_META_KEY, TODO_STOP_GUARD_QUEUE_RELEASE_METHOD, + WORKTREE_MCP_DEFER_META_KEY, type ClientMcpOverWsRuntimeConfig, type BridgeLoadReplayEnvelope, } from '@qwen-code/acp-bridge/bridgeTypes'; @@ -627,12 +629,45 @@ 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]; return isObjectRecord(value) && value['sourceType'] === 'channel'; } +function shouldDeferMcpDiscovery(params: { _meta?: unknown }): boolean { + const meta = isObjectRecord(params._meta) ? params._meta : undefined; + return meta?.[WORKTREE_MCP_DEFER_META_KEY] === true; +} + function getLoadReplayPageSize(params: LoadSessionRequest): number | undefined { const meta = isObjectRecord(params._meta) ? params._meta : undefined; const value = meta?.[LOAD_REPLAY_PAGE_SIZE_META_KEY]; @@ -3329,6 +3364,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 @@ -3519,6 +3555,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([ @@ -4365,6 +4436,10 @@ class QwenAgent implements Agent { settings, isChannelSession, requestedSessionId, + undefined, + shouldDeferMcpDiscovery(params) + ? { skipMcpDiscovery: true } + : undefined, ), ); let session: Session; @@ -4420,12 +4495,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, @@ -4505,8 +4587,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(); @@ -4554,7 +4640,6 @@ class QwenAgent implements Agent { } }); } - const modesData = this.buildModesData(config); const availableModels = this.buildAvailableModels(config); const configOptions = this.buildConfigOptions(config); @@ -5843,26 +5928,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(); @@ -5873,17 +6001,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) => [ @@ -8951,6 +9068,15 @@ class QwenAgent implements Agent { }`, ); } + if (relocation.mcpRefreshError) { + warnings.push( + `MCP refresh failed: ${ + relocation.mcpRefreshError instanceof Error + ? relocation.mcpRefreshError.message + : String(relocation.mcpRefreshError) + }`, + ); + } try { await config @@ -9659,6 +9785,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(); @@ -9672,6 +9808,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 = @@ -10020,7 +10162,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( @@ -10046,7 +10190,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 { @@ -10065,7 +10222,9 @@ class QwenAgent implements Agent { } } - title = await computeUniqueBranchTitle(baseName, sessionService); + title = isSideTask + ? baseName + : await computeUniqueBranchTitle(baseName, sessionService); const renamed = await sessionService.renameSession( newSessionId, title, @@ -10504,10 +10663,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') { @@ -10524,6 +10735,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: @@ -10710,7 +10928,6 @@ class QwenAgent implements Agent { stdioServer.command, stdioServer.args, env, - cwd, ); continue; } diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index aefd1b475b..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); @@ -13749,6 +13773,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..4ca6da3c54 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) }, + ]; } } @@ -6167,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; @@ -6178,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(); @@ -6193,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/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/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/cli/src/serve/acp-http/dispatch-error.test.ts b/packages/cli/src/serve/acp-http/dispatch-error.test.ts new file mode 100644 index 0000000000..ed0d4a1f7f --- /dev/null +++ b/packages/cli/src/serve/acp-http/dispatch-error.test.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { DaemonDrainingError } from '../server/session-archive.js'; +import { toRpcError } from './dispatch.js'; +import { RPC } from './json-rpc.js'; + +describe('toRpcError', () => { + it('maps sealed maintenance to a JSON-RPC server error', () => { + expect(toRpcError(new DaemonDrainingError())).toEqual({ + code: RPC.INTERNAL_ERROR, + message: + 'The daemon is draining and no longer accepts session maintenance.', + data: { errorKind: 'daemon_draining' }, + }); + }); +}); diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 6abac99af8..f0400ef94d 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -10,6 +10,7 @@ import { BTW_MAX_INPUT_LENGTH, createDebugLogger, GROUP_COLOR_OPTIONS, + Storage, SessionService, SessionOrganizationError, SESSION_WRITER_RPC_CODES, @@ -60,6 +61,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 { @@ -102,7 +104,9 @@ import { createSessionOrganizationService } from '../session-organization-helper import { archiveDaemonSessions, assertSessionLoadable, + deleteDaemonSessionIfOrphan, deleteDaemonSessions, + DaemonDrainingError, logSessionArchiveWarning, SessionArchiveCoordinator, unarchiveDaemonSessions, @@ -560,11 +564,18 @@ function pickSessionArtifactInput( * the operator-facing message is not a cross-tenant leak), and anything * unrecognized collapses to a generic INTERNAL_ERROR string. */ -function toRpcError(err: unknown): { +export function toRpcError(err: unknown): { code: number; message: string; data?: Record; } { + if (err instanceof DaemonDrainingError) { + return { + code: RPC.INTERNAL_ERROR, + message: err.message, + data: { errorKind: 'daemon_draining' }, + }; + } const writerError = sessionWriterRpcError(err); if (writerError) return writerError; if (err instanceof AcpParamError || err instanceof InvalidCursorError) { @@ -807,6 +818,7 @@ export class AcpDispatcher { private readonly captureGenerationAssertion: () => | (() => void) | undefined = () => undefined, + private readonly sessionRuntimeBaseDir: string = Storage.getRuntimeBaseDir(), ) { this.agentManager = createDaemonSubagentManager(boundWorkspace); } @@ -815,20 +827,21 @@ export class AcpDispatcher { sessionId: string, removePersistedSession = false, ): void { - void this.bridge - .killSession(sessionId, { requireZeroAttaches: true }) - .then(async (killed) => { - if (killed && removePersistedSession) { - await new SessionService(this.boundWorkspace).removeSession( - sessionId, - ); - } - }) - .catch((err) => - writeStderrLine( - `qwen serve: /acp orphan killSession(${logSafe(sessionId)}) failed: ${logSafe(errMsg(err))}`, - ), - ); + const cleanup = removePersistedSession + ? deleteDaemonSessionIfOrphan({ + sessionId, + service: new SessionService(this.boundWorkspace, { + runtimeBaseDir: this.sessionRuntimeBaseDir, + }), + bridge: this.bridge, + coordinator: this.archiveCoordinator, + }) + : this.bridge.killSession(sessionId, { requireZeroAttaches: true }); + void cleanup.catch((err) => + writeStderrLine( + `qwen serve: /acp orphan killSession(${logSafe(sessionId)}) failed: ${logSafe(errMsg(err))}`, + ), + ); } /** @@ -1162,6 +1175,18 @@ export class AcpDispatcher { msg: JsonRpcInbound, sessionHeader?: string, reqLoopback?: boolean, + ): Promise { + return Storage.runWithResolvedRuntimeBaseDir( + this.sessionRuntimeBaseDir, + () => this.handleInRuntime(conn, msg, sessionHeader, reqLoopback), + ); + } + + private async handleInRuntime( + conn: AcpConnection, + msg: JsonRpcInbound, + sessionHeader?: string, + reqLoopback?: boolean, ): Promise { // Loopback is evaluated PER REQUEST (the permission-vote POST may arrive // from a different peer than `initialize`), falling back to the @@ -3334,8 +3359,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/index.ts b/packages/cli/src/serve/acp-http/index.ts index a087c34970..5b1a841dcb 100644 --- a/packages/cli/src/serve/acp-http/index.ts +++ b/packages/cli/src/serve/acp-http/index.ts @@ -10,7 +10,10 @@ import type { Duplex } from 'node:stream'; import type { Application, Request, Response } from 'express'; import { WebSocketServer, type WebSocket } from 'ws'; import type { HttpAcpBridge } from '@qwen-code/acp-bridge/bridgeTypes'; -import { RUNTIME_MCP_IF_ABSENT_CONFIG_FLAG } from '@qwen-code/qwen-code-core'; +import { + RUNTIME_MCP_IF_ABSENT_CONFIG_FLAG, + Storage, +} from '@qwen-code/qwen-code-core'; import { writeStderrLine } from '../../utils/stdioHelpers.js'; import type { DaemonWorkspaceService } from '../workspace-service/types.js'; import type { WorkspaceFileSystemFactory } from '../fs/index.js'; @@ -792,6 +795,8 @@ export function mountAcpHttp( const guard = opts.workspaceRegistry?.primaryEntry.current?.guard; return guard ? () => guard.assertOpen() : undefined; }, + opts.workspaceRegistry?.primary.sessionRuntimeBaseDir ?? + Storage.getRuntimeBaseDir(), ); dispatcherRef.current = dispatcher; @@ -1271,6 +1276,7 @@ export function mountAcpHttp( const guard = rt.generationGuard; return guard ? () => guard.assertOpen() : undefined; }, + rt.sessionRuntimeBaseDir, ); secondaryDispatcherRef.current = secondaryDispatcher; return { diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index a7dc629e17..d7773cbb51 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, @@ -173,6 +174,7 @@ class FakeBridge { gate: Promise | undefined; /** `attached` value loadSession returns (false = spawned-from-disk). */ loadAttached = true; + spawnSessionId = 'sess-1'; spawnClientId: string | undefined = 'client-1'; loadRequests: Array<{ sessionId: string; @@ -190,7 +192,7 @@ class FakeBridge { this.lastSpawnScope = req?.sessionScope; if (this.gate) await this.gate; return { - sessionId: 'sess-1', + sessionId: this.spawnSessionId, workspaceCwd: '/ws', attached: false, clientId: this.spawnClientId, @@ -838,8 +840,13 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { let base: string; let bridge: FakeBridge; let acpHandle: AcpHttpHandle | undefined; + let previousRuntimeDir: string | undefined; + let runtimeDir: string; beforeEach(async () => { + previousRuntimeDir = process.env['QWEN_RUNTIME_DIR']; + runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-acp-archive-')); + process.env['QWEN_RUNTIME_DIR'] = runtimeDir; stdioMocks.writeStderrLine.mockClear(); setupGithubMocks.setupGithub.mockReset(); setupGithubMocks.setupGithub.mockResolvedValue({ @@ -901,6 +908,12 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { // `server.close()` doesn't hang on them. server.closeAllConnections?.(); await new Promise((r) => server.close(() => r())); + if (previousRuntimeDir === undefined) { + delete process.env['QWEN_RUNTIME_DIR']; + } else { + process.env['QWEN_RUNTIME_DIR'] = previousRuntimeDir; + } + await fs.rm(runtimeDir, { recursive: true, force: true }); }); async function restartServer(opts: { @@ -922,6 +935,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { ? createSingleWorkspaceRegistry({ workspaceId: 'primary', workspaceCwd: boundWorkspace, + sessionRuntimeBaseDir: Storage.getRuntimeBaseDir(), primary: true, trusted: opts.primaryTrusted ?? true, env: { mode: 'parent-process', overlayKeys: [] }, @@ -1018,21 +1032,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { async function withRuntimeDir( fn: (runtimeDir: string) => Promise, ): Promise { - const previousRuntimeDir = process.env['QWEN_RUNTIME_DIR']; - const runtimeDir = await fs.mkdtemp( - path.join(os.tmpdir(), 'qwen-acp-archive-'), - ); - process.env['QWEN_RUNTIME_DIR'] = runtimeDir; - try { - return await fn(runtimeDir); - } finally { - if (previousRuntimeDir === undefined) { - delete process.env['QWEN_RUNTIME_DIR']; - } else { - process.env['QWEN_RUNTIME_DIR'] = previousRuntimeDir; - } - await fs.rm(runtimeDir, { recursive: true, force: true }); - } + return fn(runtimeDir); } async function writeStoredSession( @@ -3737,13 +3737,8 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { it.each(['session/load', 'session/resume'])( '%s rejects archived sessions', async (method) => { - const previousRuntimeDir = process.env['QWEN_RUNTIME_DIR']; - const runtimeDir = await fs.mkdtemp( - path.join(os.tmpdir(), 'qwen-acp-archive-'), - ); - process.env['QWEN_RUNTIME_DIR'] = runtimeDir; const sessionId = '550e8400-e29b-41d4-a716-446655440123'; - try { + await withRuntimeDir(async () => { const chatsDir = path.join( new Storage('/ws').getProjectDir(), 'chats', @@ -3782,14 +3777,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { expect(frame.id).toBe(211); expect(frame.error.code).toBe(-32603); expect(frame.error.data?.errorKind).toBe('session_archived'); - } finally { - if (previousRuntimeDir === undefined) { - delete process.env['QWEN_RUNTIME_DIR']; - } else { - process.env['QWEN_RUNTIME_DIR'] = previousRuntimeDir; - } - await fs.rm(runtimeDir, { recursive: true, force: true }); - } + }); }, ); @@ -3860,7 +3848,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }); }); - it('session/load holds archive gate while restore is in flight', async () => { + it('session/load reports an archive conflict while restore is in flight', async () => { await withRuntimeDir(async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440124'; await writeStoredSession(sessionId); @@ -3903,9 +3891,14 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }); expect(await reader.next()).toMatchObject({ id: 213, - error: { - code: -32603, - data: { errorKind: 'session_archiving', sessionId }, + result: { + archived: [], + errors: [ + { + sessionId, + error: expect.stringContaining('is being archived or unarchived'), + }, + ], }, }); @@ -3973,7 +3966,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }, ); - it('session/prompt holds archive gate while prompt is in flight', async () => { + it('session/prompt reports an archive conflict while prompt is in flight', async () => { await withRuntimeDir(async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440127'; await writeStoredSession(sessionId); @@ -4023,9 +4016,14 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }); expect(await connReader.next()).toMatchObject({ id: 219, - error: { - code: -32603, - data: { errorKind: 'session_archiving', sessionId }, + result: { + archived: [], + errors: [ + { + sessionId, + error: expect.stringContaining('is being archived or unarchived'), + }, + ], }, }); expect(bridge.closedSessions).toEqual([]); @@ -4880,6 +4878,9 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }); it('session/new orphan: DELETE before spawn resolves removes the persisted session', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440126'; + bridge.spawnSessionId = sessionId; + await writeStoredSession(sessionId); const removeSession = vi .spyOn(SessionService.prototype, 'removeSession') .mockResolvedValue(true); @@ -4899,8 +4900,8 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }); release(); // spawn resolves AFTER destroy await new Promise((r) => setTimeout(r, 40)); - expect(bridge.killed).toContain('sess-1'); - expect(removeSession).toHaveBeenCalledWith('sess-1'); + expect(bridge.killed).toContain(sessionId); + expect(removeSession).toHaveBeenCalledWith(sessionId); removeSession.mockRestore(); }); @@ -6606,7 +6607,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }); }); - it('_qwen/session/artifacts/add holds the archive gate while mutating', async () => { + it('_qwen/session/artifacts/add reports an archive conflict while mutating', async () => { await withRuntimeDir(async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440131'; await writeStoredSession(sessionId); @@ -6656,9 +6657,16 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }); expect(await reader.next()).toMatchObject({ id: 61, - error: { - code: -32603, - data: { errorKind: 'session_archiving', sessionId }, + result: { + archived: [], + errors: [ + { + sessionId, + error: expect.stringContaining( + 'is being archived or unarchived', + ), + }, + ], }, }); @@ -6671,7 +6679,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }); }); - it('_qwen/session/artifacts/remove holds the archive gate while mutating', async () => { + it('_qwen/session/artifacts/remove reports an archive conflict while mutating', async () => { await withRuntimeDir(async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440132'; await writeStoredSession(sessionId); @@ -6729,9 +6737,16 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }); expect(await reader.next()).toMatchObject({ id: 63, - error: { - code: -32603, - data: { errorKind: 'session_archiving', sessionId }, + result: { + archived: [], + errors: [ + { + sessionId, + error: expect.stringContaining( + 'is being archived or unarchived', + ), + }, + ], }, }); @@ -7553,46 +7568,47 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { it('_qwen/sessions/delete sanitizes stderr remove errors', async () => { const lineSep = '\u2028'; const bidiOverride = '\u202e'; - const sessionId = `sess${lineSep}FAKE\r\x1b[31m`; + const sessionId = '550e8400-e29b-41d4-a716-446655440127'; const removeError = `remove\nFAILED\r\x1b[31m${lineSep}${bidiOverride}`; - const removeSessionSpy = vi - .spyOn(SessionService.prototype, 'removeSession') - .mockRejectedValueOnce(new Error(removeError)); + await withRuntimeDir(async () => { + await writeStoredSession(sessionId); + const removeSessionSpy = vi + .spyOn(SessionService.prototype, 'removeSession') + .mockRejectedValueOnce(new Error(removeError)); - try { - const connId = await initialize(); - const streamRes = openStream(connId); - await new Promise((r) => setTimeout(r, 30)); - await post(connId, { - jsonrpc: '2.0', - id: 68, - method: '_qwen/sessions/delete', - params: { sessionIds: [sessionId] }, - }); - const frames = await takeFrames(await streamRes, 1); - expect(frames[0]).toMatchObject({ - result: { - removed: [], - notFound: [], - errors: [{ sessionId, error: removeError }], - }, - }); - expect(removeSessionSpy).toHaveBeenCalledWith(sessionId); + try { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 68, + method: '_qwen/sessions/delete', + params: { sessionIds: [sessionId] }, + }); + const frames = await takeFrames(await streamRes, 1); + expect(frames[0]).toMatchObject({ + result: { + removed: [], + notFound: [], + errors: [{ sessionId, error: removeError }], + }, + }); + expect(removeSessionSpy).toHaveBeenCalledWith(sessionId); - const deleteLog = stdioMocks.writeStderrLine.mock.calls - .map(([line]) => line) - .find((line) => line.includes('sessions/delete')); - expect(deleteLog).toContain( - 'removeSession(sess FAK) failed: remove FAILED [31m', - ); - expect(deleteLog).not.toContain('\n'); - expect(deleteLog).not.toContain('\r'); - expect(deleteLog).not.toContain('\x1b'); - expect(deleteLog).not.toContain(lineSep); - expect(deleteLog).not.toContain(bidiOverride); - } finally { - removeSessionSpy.mockRestore(); - } + const deleteLog = stdioMocks.writeStderrLine.mock.calls + .map(([line]) => line) + .find((line) => line.includes('sessions/delete')); + expect(deleteLog).toContain('remove FAILED [31m'); + expect(deleteLog).not.toContain('\n'); + expect(deleteLog).not.toContain('\r'); + expect(deleteLog).not.toContain('\x1b'); + expect(deleteLog).not.toContain(lineSep); + expect(deleteLog).not.toContain(bidiOverride); + } finally { + removeSessionSpy.mockRestore(); + } + }); }); it('_qwen/sessions/delete deletes available ids when another id is loading', async () => { @@ -7659,7 +7675,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }); }); - it('_qwen/sessions/delete does not make missing archive ids wait on live close', async () => { + it('_qwen/sessions/archive returns session_archiving while delete owns the gate', async () => { const sessionId = 'delete-archive-race'; let firstCloseStarted!: () => void; let releaseFirstClose!: () => void; @@ -7720,7 +7736,12 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }), expect.objectContaining({ id: 70, - result: expect.objectContaining({ notFound: [sessionId] }), + error: expect.objectContaining({ + data: { + errorKind: 'session_archiving', + sessionId, + }, + }), }), ]), ); @@ -8141,6 +8162,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 +8216,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/acp-http/workspace-qualified-acp.test.ts b/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts index 435be76c2e..860646e5fc 100644 --- a/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts +++ b/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts @@ -78,6 +78,7 @@ function makeRuntime(input: { return { workspaceId: input.id, workspaceCwd: input.cwd, + sessionRuntimeBaseDir: Storage.getRuntimeBaseDir(), primary: input.primary, trusted: input.trusted, env: input.env ?? PARENT_ENV, @@ -115,24 +116,6 @@ async function writeStoredSession(sessionId: string, cwd: string) { ); } -async function withRuntimeDir(fn: () => Promise): Promise { - const previousRuntimeDir = process.env['QWEN_RUNTIME_DIR']; - const runtimeDir = await fsp.mkdtemp( - path.join(os.tmpdir(), 'qwen-workspace-qualified-acp-'), - ); - process.env['QWEN_RUNTIME_DIR'] = runtimeDir; - try { - return await fn(); - } finally { - if (previousRuntimeDir === undefined) { - delete process.env['QWEN_RUNTIME_DIR']; - } else { - process.env['QWEN_RUNTIME_DIR'] = previousRuntimeDir; - } - await fsp.rm(runtimeDir, { recursive: true, force: true }); - } -} - describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => { let server: Server; let base: string; @@ -146,8 +129,15 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => { let workspaceRegistry: ReturnType; let secondaryRuntime: WorkspaceRuntime; let workspaceVoiceConnection: ReturnType; + let runtimeDir: string; + let previousRuntimeDir: string | undefined; beforeEach(async () => { + previousRuntimeDir = process.env['QWEN_RUNTIME_DIR']; + runtimeDir = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-workspace-qualified-acp-'), + ); + process.env['QWEN_RUNTIME_DIR'] = runtimeDir; setupGithubMock.mockReset(); setupGithubMock.mockImplementation(async ({ cwd }: { cwd: string }) => ({ kind: 'github_setup', @@ -247,6 +237,12 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => { deviceFlowRegistry?.dispose(); server.closeAllConnections?.(); await new Promise((r) => server.close(() => r())); + if (previousRuntimeDir === undefined) { + delete process.env['QWEN_RUNTIME_DIR']; + } else { + process.env['QWEN_RUNTIME_DIR'] = previousRuntimeDir; + } + await fsp.rm(runtimeDir, { recursive: true, force: true }); }); async function postInitialize(pathname: string): Promise { @@ -571,45 +567,43 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => { }); it('updates persisted organization in the selected workspace only', async () => { - await withRuntimeDir(async () => { - const sessionId = '550e8400-e29b-41d4-a716-446655440180'; - await writeStoredSession(sessionId, '/ws-b'); + const sessionId = '550e8400-e29b-41d4-a716-446655440180'; + await writeStoredSession(sessionId, '/ws-b'); - const response = await sendWsRequest('/workspaces/secondary-id/acp', { - jsonrpc: '2.0', - id: 2, - method: '_qwen/session/update_organization', - params: { sessionId, isPinned: true }, - }); - - expect(response['result']).toMatchObject({ sessionId, isPinned: true }); - const listed = await sendWsRequest('/workspaces/secondary-id/acp', { - jsonrpc: '2.0', - id: 3, - method: 'session/list', - params: { view: 'organized', group: 'pinned' }, - }); - expect(listed['result']).toMatchObject({ - sessions: [expect.objectContaining({ sessionId, isPinned: true })], - }); - - const legacy = await sendWsRequest('/acp', { - jsonrpc: '2.0', - id: 4, - method: '_qwen/session/update_organization', - params: { sessionId, isPinned: false }, - }); - expect(legacy['error']).toMatchObject({ code: -32602 }); - - const secondarySnapshot = - await createSessionOrganizationService('/ws-b').readSnapshot(); - const primarySnapshot = - await createSessionOrganizationService('/ws').readSnapshot(); - expect(secondarySnapshot.sessions.get(sessionId)).toMatchObject({ - isPinned: true, - }); - expect(primarySnapshot.sessions.has(sessionId)).toBe(false); + const response = await sendWsRequest('/workspaces/secondary-id/acp', { + jsonrpc: '2.0', + id: 2, + method: '_qwen/session/update_organization', + params: { sessionId, isPinned: true }, }); + + expect(response['result']).toMatchObject({ sessionId, isPinned: true }); + const listed = await sendWsRequest('/workspaces/secondary-id/acp', { + jsonrpc: '2.0', + id: 3, + method: 'session/list', + params: { view: 'organized', group: 'pinned' }, + }); + expect(listed['result']).toMatchObject({ + sessions: [expect.objectContaining({ sessionId, isPinned: true })], + }); + + const legacy = await sendWsRequest('/acp', { + jsonrpc: '2.0', + id: 4, + method: '_qwen/session/update_organization', + params: { sessionId, isPinned: false }, + }); + expect(legacy['error']).toMatchObject({ code: -32602 }); + + const secondarySnapshot = + await createSessionOrganizationService('/ws-b').readSnapshot(); + const primarySnapshot = + await createSessionOrganizationService('/ws').readSnapshot(); + expect(secondarySnapshot.sessions.get(sessionId)).toMatchObject({ + isPinned: true, + }); + expect(primarySnapshot.sessions.has(sessionId)).toBe(false); }); it('rejects an untrusted workspace with 403 untrusted_workspace', async () => { 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..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' }, @@ -135,6 +136,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/multi-workspace-sessions.test.ts b/packages/cli/src/serve/multi-workspace-sessions.test.ts index 9d40c7caea..706c37d462 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'); @@ -699,9 +703,12 @@ function makeRuntime(input: { primary: boolean; trusted: boolean; bridge: AcpSessionBridge; + sessionRuntimeBaseDir?: string; }): WorkspaceRuntime { return { ...input, + sessionRuntimeBaseDir: + input.sessionRuntimeBaseDir ?? Storage.getRuntimeBaseDir(), env: { mode: 'parent-process', overlayKeys: [] }, workspaceService: {} as DaemonWorkspaceService, routeFileSystemFactory: { @@ -743,6 +750,8 @@ function makeHarness(opts?: { secondaryRewindImpl?: AcpSessionBridge['rewindSession']; secondaryShellImpl?: AcpSessionBridge['executeShellCommand']; serveOptions?: Partial; + primaryRuntimeBaseDir?: string; + secondaryRuntimeBaseDir?: string; }) { const primaryBridge = makeBridge( PRIMARY_CWD, @@ -771,6 +780,9 @@ function makeHarness(opts?: { primary: true, trusted: opts?.primaryTrusted ?? true, bridge: primaryBridge, + ...(opts?.primaryRuntimeBaseDir + ? { sessionRuntimeBaseDir: opts.primaryRuntimeBaseDir } + : {}), }), makeRuntime({ workspaceId: 'secondary-id', @@ -779,6 +791,9 @@ function makeHarness(opts?: { primary: false, trusted: opts?.secondaryTrusted ?? true, bridge: secondaryBridge, + ...(opts?.secondaryRuntimeBaseDir + ? { sessionRuntimeBaseDir: opts.secondaryRuntimeBaseDir } + : {}), }), ]); const app = createServeApp( @@ -3627,7 +3642,7 @@ describe('multi-workspace session dispatch', () => { }); }); - it('keeps archive and delete blocked while a workspace export is in flight', async () => { + it('reports archive and delete conflicts while a workspace export is in flight', async () => { await withRuntimeDir(async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440283'; await writeStoredSession({ @@ -3668,10 +3683,15 @@ describe('multi-workspace session dispatch', () => { .post('/workspaces/secondary-id/sessions/archive') .set('Host', host()) .send({ sessionIds: [sessionId] }); - expect(archive.status).toBe(409); + expect(archive.status).toBe(200); expect(archive.body).toMatchObject({ - code: 'session_archiving', - sessionId, + archived: [], + errors: [ + { + sessionId, + error: expect.stringContaining('is being archived or unarchived'), + }, + ], }); const remove = await request(app) @@ -3880,7 +3900,7 @@ describe('multi-workspace session dispatch', () => { }); }); - it('keeps unarchive and delete blocked while archived export is in flight', async () => { + it('reports unarchive and delete conflicts while archived export is in flight', async () => { await withRuntimeDir(async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440289'; await writeStoredSession({ @@ -3922,8 +3942,16 @@ describe('multi-workspace session dispatch', () => { .post('/workspaces/secondary-id/sessions/unarchive') .set('Host', host()) .send({ sessionIds: [sessionId] }); - expect(unarchive.status).toBe(409); - expect(unarchive.body.code).toBe('session_archiving'); + expect(unarchive.status).toBe(200); + expect(unarchive.body).toMatchObject({ + unarchived: [], + errors: [ + { + sessionId, + error: expect.stringContaining('is being archived or unarchived'), + }, + ], + }); const remove = await request(app) .post('/workspaces/secondary-id/sessions/delete') @@ -4600,6 +4628,75 @@ describe('multi-workspace session dispatch', () => { }); }); + it('keeps secondary maintenance inside its fixed runtime root', async () => { + await withRuntimeDir(async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440123'; + const runtimeRoot = Storage.getRuntimeBaseDir(); + const primaryRuntimeBaseDir = path.join(runtimeRoot, 'primary-runtime'); + const secondaryRuntimeBaseDir = path.join( + runtimeRoot, + 'secondary-runtime', + ); + await Storage.runWithResolvedRuntimeBaseDir(primaryRuntimeBaseDir, () => + writeStoredSession({ + sessionId, + cwd: PRIMARY_CWD, + timestamp: '2026-07-08T00:14:00.000Z', + prompt: 'primary fixed-root target', + mtime: new Date('2026-07-08T00:14:00.000Z'), + }), + ); + await Storage.runWithResolvedRuntimeBaseDir(secondaryRuntimeBaseDir, () => + writeStoredSession({ + sessionId, + cwd: SECONDARY_CWD, + timestamp: '2026-07-08T00:15:00.000Z', + prompt: 'secondary fixed-root target', + mtime: new Date('2026-07-08T00:15:00.000Z'), + }), + ); + const primaryService = new SessionService(PRIMARY_CWD, { + runtimeBaseDir: primaryRuntimeBaseDir, + }); + const primaryLease = await primaryService.acquireSessionWriterLease( + sessionId, + { + processKind: 'daemon', + reclaimPolicy: 'never', + }, + ); + + try { + const { app } = makeHarness({ + primaryRuntimeBaseDir, + secondaryRuntimeBaseDir, + primarySummaries: [], + secondarySummaries: [], + }); + const archived = await request(app) + .post('/workspaces/secondary-id/sessions/archive') + .set('Host', host()) + .send({ sessionIds: [sessionId] }) + .expect(200); + + expect(archived.body).toMatchObject({ + archived: [sessionId], + errors: [], + }); + await expect( + primaryService.getSessionLocation(sessionId), + ).resolves.toBe('active'); + await expect( + new SessionService(SECONDARY_CWD, { + runtimeBaseDir: secondaryRuntimeBaseDir, + }).getSessionLocation(sessionId), + ).resolves.toBe('archived'); + } finally { + await primaryLease.release(); + } + }); + }); + it('routes plural session group CRUD to the selected workspace', async () => { await withRuntimeDir(async () => { const { app } = makeHarness(); diff --git a/packages/cli/src/serve/routes/scheduled-tasks.test.ts b/packages/cli/src/serve/routes/scheduled-tasks.test.ts index fda56ab0b7..e5adae3e26 100644 --- a/packages/cli/src/serve/routes/scheduled-tasks.test.ts +++ b/packages/cli/src/serve/routes/scheduled-tasks.test.ts @@ -96,6 +96,7 @@ interface Harness { scratch: string; workspace: string; bridge: StubBridge; + cleanupSession: ReturnType; channelDeliveryAuthorizations: ChannelDeliveryAuthorizationStore; } @@ -119,11 +120,20 @@ async function makeHarness( ({ workspaceId: 'primary', workspaceCwd: workspace, + sessionRuntimeBaseDir: scratch, primary: true, trusted: runtimeTrusted, bridge, generationGuard, }) as unknown as WorkspaceRuntime; + const cleanupSession = vi.fn( + async (_runtime: WorkspaceRuntime, sessionId: string) => { + await bridge.closeSession(sessionId); + await new SessionService(workspace, { + runtimeBaseDir: scratch, + }).removeSession(sessionId); + }, + ); const app = express(); app.use(express.json()); registerScheduledTasksRoutes(app, { @@ -133,13 +143,14 @@ async function makeHarness( safeBody, bridge, channelDeliveryAuthorizations, - ...(getRuntime ? { getRuntime } : {}), + ...(getRuntime ? { getRuntime, cleanupSession } : {}), }); return { app, scratch, workspace, bridge, + cleanupSession, channelDeliveryAuthorizations, }; } @@ -226,6 +237,10 @@ describe('scheduled-tasks routes', () => { expect(res.status).toBe(503); expect(res.body.code).toBe('workspace_runtime_unavailable'); + expect(h.cleanupSession).toHaveBeenCalledWith( + expect.objectContaining({ workspaceCwd: h.workspace }), + 'sess-1', + ); expect(h.bridge.closed).toEqual(['sess-1']); await expect( fsp.readFile(getCronFilePath(h.workspace), 'utf8'), @@ -1778,6 +1793,7 @@ describe('scheduledTaskSessionName', () => { interface QualifiedRuntime { workspaceId: string; workspaceCwd: string; + sessionRuntimeBaseDir: string; trusted: boolean; bridge: StubBridge; } @@ -1846,6 +1862,7 @@ async function makeQualifiedHarness(): Promise { return { workspaceId: `id-${name}`, workspaceCwd, + sessionRuntimeBaseDir: path.join(scratch, `runtime-${name}`), trusted, bridge: makeStubBridge(), }; @@ -1866,6 +1883,7 @@ async function makeQualifiedHarness(): Promise { mutate: () => (_req, _res, next) => next(), safeBody, bridge: primary.bridge, + getRuntime: () => primary as unknown as WorkspaceRuntime, }); registerWorkspaceQualifiedScheduledTasksRoutes(app, { workspaceRegistry: makeStubRegistry(runtimes), @@ -1888,6 +1906,10 @@ describe('workspace-qualified scheduled-tasks routes', () => { }); const qualified = (id: string) => `/workspaces/${id}/scheduled-tasks`; + const cronFilePath = (runtime: QualifiedRuntime) => + Storage.runWithResolvedRuntimeBaseDir(runtime.sessionRuntimeBaseDir, () => + getCronFilePath(runtime.workspaceCwd), + ); it('creates a task in the targeted workspace, isolated from the primary', async () => { const res = await request(h.app) @@ -1913,12 +1935,15 @@ describe('workspace-qualified scheduled-tasks routes', () => { .post(qualified(h.secondary.workspaceId)) .send({ cron: '0 9 * * *', prompt: 'p' }); const onDisk = JSON.parse( - await fsp.readFile(getCronFilePath(h.secondary.workspaceCwd), 'utf-8'), + await fsp.readFile(cronFilePath(h.secondary), 'utf-8'), ); expect(onDisk).toHaveLength(1); - // The primary's file was never created. + // Neither the primary runtime nor the process-global fallback was touched. await expect( - fsp.readFile(getCronFilePath(h.primary.workspaceCwd), 'utf-8'), + fsp.readFile(cronFilePath(h.primary), 'utf-8'), + ).rejects.toThrow(); + await expect( + fsp.readFile(getCronFilePath(h.secondary.workspaceCwd), 'utf-8'), ).rejects.toThrow(); }); diff --git a/packages/cli/src/serve/routes/scheduled-tasks.ts b/packages/cli/src/serve/routes/scheduled-tasks.ts index c44755a071..38380f9d36 100644 --- a/packages/cli/src/serve/routes/scheduled-tasks.ts +++ b/packages/cli/src/serve/routes/scheduled-tasks.ts @@ -42,6 +42,7 @@ import { nextFireTime, nextDurableFireMs, SessionService, + Storage, stripTerminalControlSequences, MAX_JOBS, type CronTaskDelivery, @@ -136,7 +137,9 @@ export function scheduledTaskSessionName(label: string): string { */ interface ScheduledTaskTarget { workspaceCwd: string; + runtimeBaseDir?: string; bridge?: ScheduledTasksSessionBridge; + cleanupSession?: (sessionId: string) => Promise; assertGenerationOpen?: () => void; } @@ -154,14 +157,16 @@ function requireOpenGeneration( } async function rollbackCronMutation( - workspaceCwd: string, + target: ScheduledTaskTarget, before: DurableCronTask[] | undefined, after: DurableCronTask[] | undefined, route: string, ): Promise { if (!before || !after) return; - await updateCronTasks(workspaceCwd, (tasks) => - isDeepStrictEqual(tasks, after) ? before : tasks, + await runWithScheduledTaskTarget(target, () => + updateCronTasks(target.workspaceCwd, (tasks) => + isDeepStrictEqual(tasks, after) ? before : tasks, + ), ).catch((error) => { writeStderrLine( `qwen serve: ${route} failed to roll back a stale task mutation: ${error instanceof Error ? error.message : String(error)}`, @@ -169,6 +174,22 @@ async function rollbackCronMutation( }); } +async function teardownBoundSession( + target: ScheduledTaskTarget, + sessionId: string, +): Promise { + if (target.cleanupSession) { + await target.cleanupSession(sessionId).catch(() => {}); + } else if (target.bridge) { + await target.bridge.closeSession(sessionId).catch(() => {}); + await new SessionService(target.workspaceCwd, { + runtimeBaseDir: target.runtimeBaseDir, + }) + .removeSession(sessionId) + .catch(() => {}); + } +} + /** * Resolves the target workspace for one request. Returns null when it can't be * resolved (unknown or untrusted `:workspace`), in which case the resolver has @@ -201,6 +222,10 @@ interface RegisterScheduledTasksRoutesDeps { bridge?: ScheduledTasksSessionBridge; channelDeliveryAuthorizations?: ChannelDeliveryAuthorizationStore; getRuntime?: () => WorkspaceRuntime | undefined; + cleanupSession?: ( + runtime: WorkspaceRuntime, + sessionId: string, + ) => Promise; } interface RegisterWorkspaceQualifiedScheduledTasksRoutesDeps { @@ -217,6 +242,20 @@ interface RegisterWorkspaceQualifiedScheduledTasksRoutesDeps { * revives. Off → tasks are created unbound (shared-owner firing). */ manageScheduledTaskSessions: boolean; + cleanupSession?: ( + runtime: WorkspaceRuntime, + sessionId: string, + ) => Promise; +} + +function runWithScheduledTaskTarget( + target: ScheduledTaskTarget, + fn: () => T, +): T { + if (target.runtimeBaseDir === undefined) { + return fn(); + } + return Storage.runWithResolvedRuntimeBaseDir(target.runtimeBaseDir, fn); } /** On-the-wire task shape — normalizes the optional on-disk fields so the @@ -340,7 +379,9 @@ function registerScheduledTaskCrudRoutes( if (!target) return; if (!requireOpenGeneration(target, res)) return; try { - const tasks = await readCronTasks(target.workspaceCwd); + const tasks = await runWithScheduledTaskTarget(target, () => + readCronTasks(target.workspaceCwd), + ); if (!requireOpenGeneration(target, res)) return; res.status(200).json({ v: 1, tasks: tasks.map(toView) }); } catch (err) { @@ -465,7 +506,13 @@ function registerScheduledTaskCrudRoutes( // an orphan with no owning task. Best-effort — the write-lock cap check // below stays authoritative for the concurrent-create race. try { - if ((await readCronTasks(workspaceCwd)).length >= MAX_SCHEDULED_TASKS) { + if ( + ( + await runWithScheduledTaskTarget(target, () => + readCronTasks(workspaceCwd), + ) + ).length >= MAX_SCHEDULED_TASKS + ) { res.status(409).json({ error: `Maximum number of scheduled tasks (${MAX_SCHEDULED_TASKS}) reached`, code: 'max_tasks_reached', @@ -485,10 +532,7 @@ function registerScheduledTaskCrudRoutes( }); boundSessionId = session.sessionId; if (!requireOpenGeneration(target, res)) { - await bridge.closeSession(boundSessionId).catch(() => {}); - await new SessionService(workspaceCwd) - .removeSession(boundSessionId) - .catch(() => {}); + await teardownBoundSession(target, boundSessionId); return; } // Name the session after the task so it's recognizable in the session @@ -536,11 +580,8 @@ function registerScheduledTaskCrudRoutes( // which passes the pre-check but loses the authoritative write) would leave // a named "⏰ …" session in the list with no owning task. const rollbackSession = async () => { - if (boundSessionId !== undefined && bridge) { - await bridge.closeSession(boundSessionId).catch(() => {}); - await new SessionService(workspaceCwd) - .removeSession(boundSessionId) - .catch(() => {}); + if (boundSessionId !== undefined) { + await teardownBoundSession(target, boundSessionId); } }; @@ -548,21 +589,23 @@ function registerScheduledTaskCrudRoutes( let rollbackBefore: DurableCronTask[] | undefined; let rollbackAfter: DurableCronTask[] | undefined; try { - await updateCronTasks( - workspaceCwd, - (tasks) => { - // Cap check under the write lock so two concurrent creates can't both - // slip past a stale count. Returning the input unchanged is a no-op - // (no write), which the flag below turns into a 409. - if (tasks.length >= MAX_SCHEDULED_TASKS) { - overCap = true; - return tasks; - } - rollbackBefore = tasks; - rollbackAfter = [...tasks, task]; - return rollbackAfter; - }, - { assertCanCommit: target.assertGenerationOpen }, + await runWithScheduledTaskTarget(target, () => + updateCronTasks( + workspaceCwd, + (tasks) => { + // Cap check under the write lock so two concurrent creates can't both + // slip past a stale count. Returning the input unchanged is a no-op + // (no write), which the flag below turns into a 409. + if (tasks.length >= MAX_SCHEDULED_TASKS) { + overCap = true; + return tasks; + } + rollbackBefore = tasks; + rollbackAfter = [...tasks, task]; + return rollbackAfter; + }, + { assertCanCommit: target.assertGenerationOpen }, + ), ); } catch (err) { await rollbackSession(); @@ -581,7 +624,7 @@ function registerScheduledTaskCrudRoutes( target.assertGenerationOpen?.(); } catch (error) { await rollbackCronMutation( - workspaceCwd, + target, rollbackBefore, rollbackAfter, `POST ${base}`, @@ -727,90 +770,92 @@ function registerScheduledTaskCrudRoutes( let rollbackBefore: DurableCronTask[] | undefined; let rollbackAfter: DurableCronTask[] | undefined; try { - await updateCronTasks( - workspaceCwd, - (tasks) => { - const idx = tasks.findIndex((t) => t.id === id); - if (idx === -1) return tasks; // not found → no write - found = true; - const current = tasks[idx]!; - // A legacy guarded task (isolated + precondition, both removed) can't be - // enabled: `toView` reports it disabled, so the only PATCH the Web Shell - // sends for it is the Enable toggle — which would 200 here and then read - // back disabled again, an Enable control that can never succeed with no - // error explaining why. Reject the enable with the recreate remediation - // instead of acknowledging an update that changes nothing runnable. - if (patch.enabled === true && taskHasLegacyCondition(current)) { - blockedLegacy = true; - return tasks; // no write - } - // A task disabled BY archiving its session (`disabledByArchive`) can't - // be re-enabled through this generic PATCH: its bound session is still - // archived and can't fire, so flipping `enabled: true` here would show - // an enabled task with a countdown that never runs. The task/session - // lifecycle must stay coupled — the caller has to unarchive the session - // (which clears the marker and reloads it). Reject and leave the file - // untouched. - if (patch.enabled === true && current.disabledByArchive === true) { - blockedByArchive = true; - return tasks; // no write - } - const next: DurableCronTask = { ...current, ...patch }; - // `name: null/""` clears the field rather than storing an empty name, - // so toView reports it as unnamed and isValidTask never sees a "". - if (clearName) delete next.name; - if (clearDelivery) delete next.delivery; - // Re-seat the task's schedule anchor to "now" whenever an edit would - // otherwise let the scheduler retroactively fire an already-past slot. - const justReEnabled = - current.enabled === false && patch.enabled === true; - // Compare the EFFECTIVE schedule, not the raw string: a cosmetic edit - // (`0 9 * * *` → `00 9 * * *`, whitespace) must not re-seat the anchor - // and drop a legitimately-pending catch-up fire. - const cronChanged = - patch.cron !== undefined && - canonicalCron(patch.cron) !== canonicalCron(current.cron); - const becameRecurring = - patch.recurring === true && current.recurring !== true; - const becameOneShot = - patch.recurring === false && current.recurring !== false; - // Re-seated REGARDLESS of enabled: a schedule edit made while the task - // is paused must not leave a stale anchor that fires retroactively when - // it's later re-enabled in a SEPARATE request (the re-enable patch has no - // schedule change of its own to trigger the re-seat). Re-seating a paused - // task's anchor is harmless — it doesn't fire until enabled. - { - const now = Date.now(); - const minute = now - (now % 60_000); - if ( - next.recurring && - (justReEnabled || cronChanged || becameRecurring) - ) { - // A recurring task's anchor is lastFiredAt: resume from now so a - // re-enable / cron edit / one-shot→recurring flip doesn't retroactively - // fire a past slot (matters most for a bound task, whose catch-up runs - // on every file-watch reload). - next.lastFiredAt = minute; - } else if ( - !next.recurring && - (justReEnabled || cronChanged || becameOneShot) - ) { - // A one-shot's anchor is createdAt. Re-seat it on a schedule change - // (cron edit, or recurring→one-shot) OR a re-enable so the task fires - // at its NEXT occurrence — otherwise the scheduler reads its original - // long-past slot as a MISSED one-shot and fires + permanently deletes - // it. A one-shot disabled past its slot then re-enabled would - // otherwise be silently destroyed on the next reload. - next.createdAt = now; - next.lastFiredAt = minute; + await runWithScheduledTaskTarget(target, () => + updateCronTasks( + workspaceCwd, + (tasks) => { + const idx = tasks.findIndex((t) => t.id === id); + if (idx === -1) return tasks; // not found → no write + found = true; + const current = tasks[idx]!; + // A legacy guarded task (isolated + precondition, both removed) can't be + // enabled: `toView` reports it disabled, so the only PATCH the Web Shell + // sends for it is the Enable toggle — which would 200 here and then read + // back disabled again, an Enable control that can never succeed with no + // error explaining why. Reject the enable with the recreate remediation + // instead of acknowledging an update that changes nothing runnable. + if (patch.enabled === true && taskHasLegacyCondition(current)) { + blockedLegacy = true; + return tasks; // no write } - } - updated = next; - rollbackBefore = tasks; - rollbackAfter = tasks.map((t, i) => (i === idx ? next : t)); - return rollbackAfter; - }, - { assertCanCommit: target.assertGenerationOpen }, + // A task disabled BY archiving its session (`disabledByArchive`) can't + // be re-enabled through this generic PATCH: its bound session is still + // archived and can't fire, so flipping `enabled: true` here would show + // an enabled task with a countdown that never runs. The task/session + // lifecycle must stay coupled — the caller has to unarchive the session + // (which clears the marker and reloads it). Reject and leave the file + // untouched. + if (patch.enabled === true && current.disabledByArchive === true) { + blockedByArchive = true; + return tasks; // no write + } + const next: DurableCronTask = { ...current, ...patch }; + // `name: null/""` clears the field rather than storing an empty name, + // so toView reports it as unnamed and isValidTask never sees a "". + if (clearName) delete next.name; + if (clearDelivery) delete next.delivery; + // Re-seat the task's schedule anchor to "now" whenever an edit would + // otherwise let the scheduler retroactively fire an already-past slot. + const justReEnabled = + current.enabled === false && patch.enabled === true; + // Compare the EFFECTIVE schedule, not the raw string: a cosmetic edit + // (`0 9 * * *` → `00 9 * * *`, whitespace) must not re-seat the anchor + // and drop a legitimately-pending catch-up fire. + const cronChanged = + patch.cron !== undefined && + canonicalCron(patch.cron) !== canonicalCron(current.cron); + const becameRecurring = + patch.recurring === true && current.recurring !== true; + const becameOneShot = + patch.recurring === false && current.recurring !== false; + // Re-seated REGARDLESS of enabled: a schedule edit made while the task + // is paused must not leave a stale anchor that fires retroactively when + // it's later re-enabled in a SEPARATE request (the re-enable patch has no + // schedule change of its own to trigger the re-seat). Re-seating a paused + // task's anchor is harmless — it doesn't fire until enabled. + { + const now = Date.now(); + const minute = now - (now % 60_000); + if ( + next.recurring && + (justReEnabled || cronChanged || becameRecurring) + ) { + // A recurring task's anchor is lastFiredAt: resume from now so a + // re-enable / cron edit / one-shot→recurring flip doesn't retroactively + // fire a past slot (matters most for a bound task, whose catch-up runs + // on every file-watch reload). + next.lastFiredAt = minute; + } else if ( + !next.recurring && + (justReEnabled || cronChanged || becameOneShot) + ) { + // A one-shot's anchor is createdAt. Re-seat it on a schedule change + // (cron edit, or recurring→one-shot) OR a re-enable so the task fires + // at its NEXT occurrence — otherwise the scheduler reads its original + // long-past slot as a MISSED one-shot and fires + permanently deletes + // it. A one-shot disabled past its slot then re-enabled would + // otherwise be silently destroyed on the next reload. + next.createdAt = now; + next.lastFiredAt = minute; + } + } + updated = next; + rollbackBefore = tasks; + rollbackAfter = tasks.map((t, i) => (i === idx ? next : t)); + return rollbackAfter; + }, + { assertCanCommit: target.assertGenerationOpen }, + ), ); } catch (err) { if (sendGenerationClosedError(res, err)) return; @@ -828,7 +873,7 @@ function registerScheduledTaskCrudRoutes( target.assertGenerationOpen?.(); } catch (error) { await rollbackCronMutation( - workspaceCwd, + target, rollbackBefore, rollbackAfter, `PATCH ${base}/${id}`, @@ -917,21 +962,23 @@ function registerScheduledTaskCrudRoutes( let rollbackBefore: DurableCronTask[] | undefined; let rollbackAfter: DurableCronTask[] | undefined; try { - await updateCronTasks( - workspaceCwd, - (tasks) => { - const idx = tasks.findIndex((t) => t.id === id); - if (idx === -1) return tasks; // not found → no write - const match = tasks[idx]!.sessionId; - if (typeof match === 'string' && match.length > 0) { - boundSessionId = match; - } - removed = true; - rollbackBefore = tasks; - rollbackAfter = tasks.filter((_, i) => i !== idx); - return rollbackAfter; - }, - { assertCanCommit: target.assertGenerationOpen }, + await runWithScheduledTaskTarget(target, () => + updateCronTasks( + workspaceCwd, + (tasks) => { + const idx = tasks.findIndex((t) => t.id === id); + if (idx === -1) return tasks; // not found → no write + const match = tasks[idx]!.sessionId; + if (typeof match === 'string' && match.length > 0) { + boundSessionId = match; + } + removed = true; + rollbackBefore = tasks; + rollbackAfter = tasks.filter((_, i) => i !== idx); + return rollbackAfter; + }, + { assertCanCommit: target.assertGenerationOpen }, + ), ); } catch (err) { if (sendGenerationClosedError(res, err)) return; @@ -949,7 +996,7 @@ function registerScheduledTaskCrudRoutes( target.assertGenerationOpen?.(); } catch (error) { await rollbackCronMutation( - workspaceCwd, + target, rollbackBefore, rollbackAfter, `DELETE ${base}/${id}`, @@ -1007,54 +1054,56 @@ function registerScheduledTaskCrudRoutes( let rollbackBefore: DurableCronTask[] | undefined; let rollbackAfter: DurableCronTask[] | undefined; try { - await updateCronTasks( - workspaceCwd, - (tasks) => { - const idx = tasks.findIndex((t) => t.id === id); - if (idx === -1) return tasks; // not found → no write - found = true; - const current = tasks[idx]!; - // A legacy guarded task (isolated + precondition, both removed) must not - // run from ANY path. The scheduler already skips it and the list view - // reports it disabled; reject a direct `/run` too — its on-disk - // `enabled` may still be true, so the disabled check below is not enough. - // Executing it here would run the prompt with its safety gate ignored, - // which is exactly what the removal must never allow. - if (taskHasLegacyCondition(current)) { - blockedLegacy = true; - return tasks; // no write - } - // A disabled task must not record a manual run: it's paused (and if it - // was disabled by archiving its session, that session can't even fire), - // so stamping lastFiredAt + a 'manual' entry would write a phantom "ran" - // record. Mirrors the PATCH route's refusal to re-enable such tasks and - // the UI, where onRunPrompt already rejects before recording. - if (current.enabled === false) { - blockedDisabled = true; - return tasks; // no write - } - const next: DurableCronTask = { - ...current, - lastFiredAt: now, - runs: appendCronRun(current.runs, { - at: now, - kind: 'manual', - ...(current.sessionId ? { sessionId: current.sessionId } : {}), - }), - }; - updated = next; - // A one-shot's manual run IS its single fire — remove it from the store - // so the scheduler doesn't ALSO fire it at its original scheduled time - // (its slot is still in the future, so stamping lastFiredAt=now wouldn't - // stop that fire). The response still returns the recorded run. - rollbackBefore = tasks; - const nextTasks = !current.recurring - ? tasks.filter((_, i) => i !== idx) - : tasks.map((t, i) => (i === idx ? next : t)); - rollbackAfter = nextTasks; - return nextTasks; - }, - { assertCanCommit: target.assertGenerationOpen }, + await runWithScheduledTaskTarget(target, () => + updateCronTasks( + workspaceCwd, + (tasks) => { + const idx = tasks.findIndex((t) => t.id === id); + if (idx === -1) return tasks; // not found → no write + found = true; + const current = tasks[idx]!; + // A legacy guarded task (isolated + precondition, both removed) must not + // run from ANY path. The scheduler already skips it and the list view + // reports it disabled; reject a direct `/run` too — its on-disk + // `enabled` may still be true, so the disabled check below is not enough. + // Executing it here would run the prompt with its safety gate ignored, + // which is exactly what the removal must never allow. + if (taskHasLegacyCondition(current)) { + blockedLegacy = true; + return tasks; // no write + } + // A disabled task must not record a manual run: it's paused (and if it + // was disabled by archiving its session, that session can't even fire), + // so stamping lastFiredAt + a 'manual' entry would write a phantom "ran" + // record. Mirrors the PATCH route's refusal to re-enable such tasks and + // the UI, where onRunPrompt already rejects before recording. + if (current.enabled === false) { + blockedDisabled = true; + return tasks; // no write + } + const next: DurableCronTask = { + ...current, + lastFiredAt: now, + runs: appendCronRun(current.runs, { + at: now, + kind: 'manual', + ...(current.sessionId ? { sessionId: current.sessionId } : {}), + }), + }; + updated = next; + // A one-shot's manual run IS its single fire — remove it from the store + // so the scheduler doesn't ALSO fire it at its original scheduled time + // (its slot is still in the future, so stamping lastFiredAt=now wouldn't + // stop that fire). The response still returns the recorded run. + rollbackBefore = tasks; + const nextTasks = !current.recurring + ? tasks.filter((_, i) => i !== idx) + : tasks.map((t, i) => (i === idx ? next : t)); + rollbackAfter = nextTasks; + return nextTasks; + }, + { assertCanCommit: target.assertGenerationOpen }, + ), ); } catch (err) { if (sendGenerationClosedError(res, err)) return; @@ -1072,7 +1121,7 @@ function registerScheduledTaskCrudRoutes( target.assertGenerationOpen?.(); } catch (error) { await rollbackCronMutation( - workspaceCwd, + target, rollbackBefore, rollbackAfter, `POST ${base}/${id}/run`, @@ -1141,6 +1190,17 @@ export function registerScheduledTasksRoutes( if (runtime && !requireTrustedWorkspaceRuntime(runtime, res)) return null; return { workspaceCwd: boundWorkspace, + ...(runtime + ? { + runtimeBaseDir: runtime.sessionRuntimeBaseDir, + ...(deps.cleanupSession + ? { + cleanupSession: (sessionId: string) => + deps.cleanupSession!(runtime, sessionId), + } + : {}), + } + : {}), bridge: runtime?.bridge ?? bridge, ...(runtime?.generationGuard ? { @@ -1173,6 +1233,7 @@ export function registerWorkspaceQualifiedScheduledTasksRoutes( safeBody, manageScheduledTaskSessions, channelDeliveryAuthorizations, + cleanupSession, } = deps; registerScheduledTaskCrudRoutes(app, { prefix: '/workspaces/:workspace', @@ -1186,6 +1247,13 @@ export function registerWorkspaceQualifiedScheduledTasksRoutes( if (!requireTrustedWorkspaceRuntime(runtime, res)) return null; return { workspaceCwd: runtime.workspaceCwd, + runtimeBaseDir: runtime.sessionRuntimeBaseDir, + ...(cleanupSession + ? { + cleanupSession: (sessionId: string) => + cleanupSession(runtime, sessionId), + } + : {}), // Mirror the primary surface: only bind a session when management is on, // so a bound task always has something to keep it resident + rehydrate it. bridge: manageScheduledTaskSessions ? runtime.bridge : undefined, diff --git a/packages/cli/src/serve/routes/session-telemetry.test.ts b/packages/cli/src/serve/routes/session-telemetry.test.ts index a8f94e5695..6f024a27db 100644 --- a/packages/cli/src/serve/routes/session-telemetry.test.ts +++ b/packages/cli/src/serve/routes/session-telemetry.test.ts @@ -53,6 +53,7 @@ function runtime(opts: { }): WorkspaceRuntime { return { ...opts, + sessionRuntimeBaseDir: path.join(opts.workspaceCwd, '.runtime'), trusted: opts.trusted !== false, } as WorkspaceRuntime; } @@ -220,6 +221,7 @@ describe('special session resolver telemetry publication', () => { expect(archiveMocks.assertSessionLoadable).toHaveBeenCalledWith( secondaryCwd, 'secondary-session', + path.join(secondaryCwd, '.runtime'), ); expect(telemetryMocks.setDaemonTelemetryWorkspace).toHaveBeenCalledTimes(1); expect(telemetryMocks.setDaemonTelemetryWorkspace).toHaveBeenCalledWith( @@ -230,8 +232,15 @@ describe('special session resolver telemetry publication', () => { it('publishes the sole active transcript runtime after storage lookup', async () => { archiveMocks.assertSessionLoadable.mockImplementation( - async (workspaceCwd: string) => - workspaceCwd === secondaryCwd ? 'active' : undefined, + async ( + workspaceCwd: string, + _sessionId: string, + runtimeBaseDir: string, + ) => + runtimeBaseDir === path.join(secondaryCwd, '.runtime') && + workspaceCwd === secondaryCwd + ? 'active' + : undefined, ); const primary = runtime({ workspaceId: 'primary', @@ -255,10 +264,12 @@ describe('special session resolver telemetry publication', () => { expect(archiveMocks.assertSessionLoadable).toHaveBeenCalledWith( primaryCwd, 'stored-secondary', + path.join(primaryCwd, '.runtime'), ); expect(archiveMocks.assertSessionLoadable).toHaveBeenCalledWith( secondaryCwd, 'stored-secondary', + path.join(secondaryCwd, '.runtime'), ); expect(telemetryMocks.setDaemonTelemetryWorkspace).toHaveBeenCalledTimes(1); expect(telemetryMocks.setDaemonTelemetryWorkspace).toHaveBeenCalledWith( diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 3162d1dc27..a615859a33 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -12,7 +12,6 @@ import { BTW_MAX_INPUT_LENGTH, GROUP_COLOR_OPTIONS, GitWorktreeService, - SessionService, SessionOrganizationError, SESSION_TRANSCRIPT_MAX_LIMIT, SESSION_TRANSCRIPT_MAX_PAGE_BYTES, @@ -71,6 +70,7 @@ import { archiveDaemonSessions, assertSessionArchived, assertSessionLoadable, + deleteDaemonSessionIfOrphan, deleteDaemonSessions, logSessionArchiveWarning, type SessionArchiveCoordinator, @@ -107,6 +107,7 @@ import { type VirtualSubagentSessions, } from '../virtual-subagent-sessions.js'; import { + resolveWorkspaceEntryFromParam, resolveWorkspaceRuntimeFromParam, sendUntrustedWorkspaceResponse, sendWorkspaceRuntimeUnavailable, @@ -115,6 +116,10 @@ import type { WorkspaceRegistry, WorkspaceRuntime, } from '../workspace-registry.js'; +import { + createWorkspaceRuntimeSessionService, + runWithWorkspaceRuntimeStorage, +} from '../workspace-runtime-storage.js'; import type { ChannelDeliveryAuthorizationStore } from '../channel-delivery-authorization.js'; // `HEAD` is the most prominent ref name git rejects as a branch name. @@ -162,6 +167,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; @@ -184,9 +190,10 @@ function runWorkspaceInspectionWithLogPolicy( runtime: WorkspaceRuntime, read: () => Promise, ): Promise { + const readInRuntime = () => runWithWorkspaceRuntimeStorage(runtime, read); return isReadOnlyWorkspaceInspection(runtime) - ? runWithoutDebugLogSession(read) - : read(); + ? runWithoutDebugLogSession(readInRuntime) + : readInRuntime(); } function requireSessionArtifactClientId( @@ -653,9 +660,35 @@ export function registerSessionRoutes( return runtime; }; - const hasActivePersistedSessions = async (workspaceCwd: string) => { + const resolveLegacyPrimaryRuntimeFromParam = ( + req: Request, + res: Response, + ): WorkspaceRuntime | null => { + const entry = resolveWorkspaceEntryFromParam( + workspaceRegistry, + req, + res, + 'id', + ); + if (!entry) return null; + if (!entry.primary) { + sendWorkspaceMismatch(res, entry.workspaceCwd); + return null; + } + const runtime = + entry.state === 'active' ? entry.current?.runtime : undefined; + if (!runtime) { + sendWorkspaceRuntimeUnavailable(res, entry); + return null; + } + return runtime; + }; + + const hasActivePersistedSessions = async (runtime: WorkspaceRuntime) => { try { - const page = await new SessionService(workspaceCwd).listSessions({ + const page = await createWorkspaceRuntimeSessionService( + runtime, + ).listSessions({ archiveState: 'active', size: 1, }); @@ -704,7 +737,7 @@ export function registerSessionRoutes( res: Response, target: { route: string; - workspaceCwd: string; + runtime: WorkspaceRuntime; workspaceQualified?: boolean; archiveState?: SessionArchiveState; }, @@ -723,22 +756,29 @@ export function registerSessionRoutes( return; } try { - const result = await archiveCoordinator.runSharedMany( - [sessionId], - async () => { + const result = await archiveCoordinator.runSharedMany([sessionId], () => + runWithWorkspaceRuntimeStorage(target.runtime, async () => { if (target.archiveState === 'archived') { - await assertSessionArchived(target.workspaceCwd, sessionId); + await assertSessionArchived( + target.runtime.workspaceCwd, + sessionId, + target.runtime.sessionRuntimeBaseDir, + ); } else { - await assertSessionLoadable(target.workspaceCwd, sessionId); + await assertSessionLoadable( + target.runtime.workspaceCwd, + sessionId, + target.runtime.sessionRuntimeBaseDir, + ); } return exportSessionTranscript({ - workspaceCwd: target.workspaceCwd, + workspaceCwd: target.runtime.workspaceCwd, sessionId, format, archiveState: target.archiveState, config: { getChannel: () => 'daemon' }, }); - }, + }), ); const filename = result.filename.replace(/["\\\r\n]/g, '_'); res @@ -761,7 +801,7 @@ export function registerSessionRoutes( route: target.route, sessionId, ...(target.workspaceQualified - ? { workspaceCwd: target.workspaceCwd } + ? { workspaceCwd: target.runtime.workspaceCwd } : {}), }); } @@ -1040,6 +1080,7 @@ export function registerSessionRoutes( const location = await assertSessionLoadable( runtime.workspaceCwd, sessionId, + runtime.sessionRuntimeBaseDir, ); return location === 'active'; }; @@ -1174,25 +1215,6 @@ export function registerSessionRoutes( error: e.error instanceof Error ? e.error.message : String(e.error), })); - const resolveWorkspaceParam = ( - req: Request, - res: Response, - ): string | null => { - const workspaceCwd = req.params['id'] ?? ''; - if (!path.isAbsolute(workspaceCwd)) { - res - .status(400) - .json({ error: '`:id` must decode to an absolute workspace path' }); - return null; - } - const key = canonicalizeWorkspace(workspaceCwd); - if (key !== boundWorkspace) { - sendWorkspaceMismatch(res, key); - return null; - } - return key; - }; - const withPrimaryOnlyMutableSession = ( route: string, handler: ( @@ -1630,13 +1652,15 @@ export function registerSessionRoutes( } catch (error) { if (!session.attached) { try { - const killed = await runtime.bridge.killSession(session.sessionId, { - requireZeroAttaches: true, - }); - if (killed) { - await new SessionService(runtime.workspaceCwd).removeSession( - session.sessionId, - ); + const removed = await runWithWorkspaceRuntimeStorage(runtime, () => + deleteDaemonSessionIfOrphan({ + sessionId: session.sessionId, + service: createWorkspaceRuntimeSessionService(runtime), + bridge: runtime.bridge, + coordinator: archiveCoordinator, + }), + ); + if (removed) { if (worktreeMeta) { await new GitWorktreeService(workspaceCwd) .removeUserWorktree(worktreeMeta.slug, { deleteBranch: true }) @@ -1699,13 +1723,15 @@ export function registerSessionRoutes( // skip the kill. Without the flag, that second client's // session would die mid-prompt. try { - const killed = await runtime.bridge.killSession(session.sessionId, { - requireZeroAttaches: true, - }); - if (killed) { - await new SessionService(runtime.workspaceCwd).removeSession( - session.sessionId, - ); + const removed = await runWithWorkspaceRuntimeStorage(runtime, () => + deleteDaemonSessionIfOrphan({ + sessionId: session.sessionId, + service: createWorkspaceRuntimeSessionService(runtime), + bridge: runtime.bridge, + coordinator: archiveCoordinator, + }), + ); + if (removed) { // Clean up the worktree if one was created for this session. if (worktreeMeta) { await new GitWorktreeService(workspaceCwd) @@ -1798,9 +1824,9 @@ export function registerSessionRoutes( // Write the worktree sidecar so the session list can restore // worktree metadata after a daemon restart. await writeWorktreeSession( - new SessionService(workspaceCwd).getWorktreeSessionPath( - session.sessionId, - ), + createWorkspaceRuntimeSessionService( + runtime, + ).getWorktreeSessionPath(session.sessionId), { slug: worktreeMeta.slug, worktreePath: worktreeMeta.path, @@ -1822,14 +1848,14 @@ export function registerSessionRoutes( error: cdErr instanceof Error ? cdErr.message : String(cdErr), }); } - const killed = await runtime.bridge - .killSession(session.sessionId, { requireZeroAttaches: true }) - .catch(() => false); - if (killed) { - await new SessionService(workspaceCwd) - .removeSession(session.sessionId) - .catch(() => {}); - } + await runWithWorkspaceRuntimeStorage(runtime, () => + deleteDaemonSessionIfOrphan({ + sessionId: session.sessionId, + service: createWorkspaceRuntimeSessionService(runtime), + bridge: runtime.bridge, + coordinator: archiveCoordinator, + }), + ).catch(() => false); // cd failed so the session never entered the worktree — the // worktree is unused regardless of whether the session was // killed or another client keeps it alive in the main checkout. @@ -1975,13 +2001,18 @@ export function registerSessionRoutes( const session = await archiveCoordinator.runSharedMany( [sessionId], async () => { - await assertSessionLoadable(workspaceCwd, sessionId); + await assertSessionLoadable( + workspaceCwd, + sessionId, + runtime.sessionRuntimeBaseDir, + ); // Recover the persisted parent lineage so the restored live entry // reports it (the bridge otherwise creates the entry without it, and // status calls would show a restored sub-session as top-level). - const metadata = await new SessionService( - workspaceCwd, - ).readCreationMetadata(sessionId); + const metadata = + await createWorkspaceRuntimeSessionService( + runtime, + ).readCreationMetadata(sessionId); runtime.generationGuard?.assertOpen(); return action === 'load' ? await runtime.bridge.loadSession({ @@ -2056,7 +2087,9 @@ export function registerSessionRoutes( // the main workspace (pre-existing shape, low frequency). if (!session.worktree) { const sidecar = await readWorktreeSession( - new SessionService(workspaceCwd).getWorktreeSessionPath(sessionId), + createWorkspaceRuntimeSessionService( + runtime, + ).getWorktreeSessionPath(sessionId), ).catch(() => null); if (sidecar) { // Defense-in-depth: resolve symlinks on both the target and @@ -2293,6 +2326,73 @@ export function registerSessionRoutes( { name }, { clientId }, ); + try { + runtime.generationGuard?.assertOpen(); + } catch (error) { + if (!result.attached) { + await runWithWorkspaceRuntimeStorage(runtime, () => + deleteDaemonSessionIfOrphan({ + sessionId: result.sessionId, + service: createWorkspaceRuntimeSessionService(runtime), + bridge: runtime.bridge, + coordinator: archiveCoordinator, + }), + ).catch(() => false); + } else { + await runtime.bridge + .detachClient(result.sessionId, result.clientId) + .catch(() => {}); + } + throw error; + } + if (!res.writable) { + if (!result.attached) { + void runWithWorkspaceRuntimeStorage(runtime, () => + deleteDaemonSessionIfOrphan({ + sessionId: result.sessionId, + service: createWorkspaceRuntimeSessionService(runtime), + bridge: runtime.bridge, + coordinator: archiveCoordinator, + }), + ).catch(() => { + // Best-effort cleanup; channel.exited will eventually reap. + }); + } else { + runtime.bridge + .detachClient(result.sessionId, result.clientId) + .catch(() => { + // Best-effort cleanup; channel.exited will eventually reap. + }); + } + return; + } + res.status(201).json(result); + }, + ), + ); + + 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) { @@ -2301,7 +2401,7 @@ export function registerSessionRoutes( .killSession(result.sessionId, { requireZeroAttaches: true }) .catch(() => false); if (killed) { - await new SessionService(runtime.workspaceCwd) + await createWorkspaceRuntimeSessionService(runtime) .removeSession(result.sessionId) .catch(() => {}); } @@ -2316,15 +2416,17 @@ export function registerSessionRoutes( if (!result.attached) { runtime.bridge .killSession(result.sessionId, { requireZeroAttaches: true }) - .catch(() => { - // Best-effort cleanup; channel.exited will eventually reap. - }); + .then((killed) => { + if (!killed) return undefined; + return createWorkspaceRuntimeSessionService( + runtime, + ).removeSession(result.sessionId); + }) + .catch(() => {}); } else { runtime.bridge .detachClient(result.sessionId, result.clientId) - .catch(() => { - // Best-effort cleanup; channel.exited will eventually reap. - }); + .catch(() => {}); } return; } @@ -2425,7 +2527,7 @@ export function registerSessionRoutes( app.get('/session/:id/export', async (req, res) => { await handleSessionExport(req, res, { route: 'GET /session/:id/export', - workspaceCwd: boundWorkspace, + runtime: workspaceRegistry.primary, }); }); @@ -2435,7 +2537,7 @@ export function registerSessionRoutes( if (!runtime) return; await handleSessionExport(req, res, { route, - workspaceCwd: runtime.workspaceCwd, + runtime, workspaceQualified: true, }); }); @@ -2448,7 +2550,7 @@ export function registerSessionRoutes( if (!runtime) return; await handleSessionExport(req, res, { route, - workspaceCwd: runtime.workspaceCwd, + runtime, workspaceQualified: true, archiveState: 'archived', }); @@ -2549,76 +2651,82 @@ export function registerSessionRoutes( try { const result = await runWithoutDebugLogSession(() => - archiveCoordinator.runSharedMany([sessionId], async () => { - const service = new SessionService(runtime.workspaceCwd); - if (cursor === undefined) { - await assertSessionLoadable(runtime.workspaceCwd, sessionId); - } - const codec = getTranscriptCursorCodec(runtime); - const reader = new SessionTranscriptReader( - runtime.workspaceCwd, - codec, - ); - let page; - try { - page = await reader.readPage(sessionId, { - ...(limit !== undefined ? { limit } : {}), - ...(cursor !== undefined ? { cursor } : {}), - ...(beforeRecordId !== undefined ? { beforeRecordId } : {}), - maxBytes: SESSION_TRANSCRIPT_MAX_PAGE_BYTES, - }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error; + archiveCoordinator.runSharedMany([sessionId], () => + runWithWorkspaceRuntimeStorage(runtime, async () => { + const service = createWorkspaceRuntimeSessionService(runtime); + if (cursor === undefined) { + await assertSessionLoadable( + runtime.workspaceCwd, + sessionId, + runtime.sessionRuntimeBaseDir, + ); } - if (cursor !== undefined) { + const codec = getTranscriptCursorCodec(runtime); + const reader = new SessionTranscriptReader( + runtime.workspaceCwd, + codec, + ); + let page; + try { + page = await reader.readPage(sessionId, { + ...(limit !== undefined ? { limit } : {}), + ...(cursor !== undefined ? { cursor } : {}), + ...(beforeRecordId !== undefined ? { beforeRecordId } : {}), + maxBytes: SESSION_TRANSCRIPT_MAX_PAGE_BYTES, + }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + if (cursor !== undefined) { + throw new SessionTranscriptSnapshotUnavailableError(sessionId); + } + const location = await service.getSessionLocation(sessionId); + if (location === 'archived') { + throw new SessionArchivedError(sessionId); + } + if (location === 'conflict') { + throw new SessionConflictError(sessionId); + } + throw new SessionNotFoundError(sessionId); + } + if (page.records.some((record) => record.sessionId !== sessionId)) { throw new SessionTranscriptSnapshotUnavailableError(sessionId); } - const location = await service.getSessionLocation(sessionId); - if (location === 'archived') { - throw new SessionArchivedError(sessionId); - } - if (location === 'conflict') { - throw new SessionConflictError(sessionId); - } - throw new SessionNotFoundError(sessionId); - } - if (page.records.some((record) => record.sessionId !== sessionId)) { - throw new SessionTranscriptSnapshotUnavailableError(sessionId); - } - const replay = await replayTranscriptRecordPage({ - sessionId, - page, - encodeCursor: (state) => codec.encode(state), - }); - const cursorTooLarge = - replay.nextCursor !== undefined && - Buffer.byteLength(replay.nextCursor) > - WORKSPACE_TRANSCRIPT_CURSOR_MAX_BYTES; - return { - v: 1 as const, - sessionId, - events: replay.updates.map((update) => ({ + const replay = await replayTranscriptRecordPage({ + sessionId, + page, + encodeCursor: (state) => codec.encode(state), + }); + const cursorTooLarge = + replay.nextCursor !== undefined && + Buffer.byteLength(replay.nextCursor) > + WORKSPACE_TRANSCRIPT_CURSOR_MAX_BYTES; + return { v: 1 as const, - type: 'session_update' as const, - data: update, - })), - ...(replay.nextCursor && !cursorTooLarge - ? { nextCursor: replay.nextCursor } - : {}), - hasMore: cursorTooLarge ? false : replay.hasMore, - startTime: replay.startTime, - lastUpdated: replay.lastUpdated, - ...(replay.partial || cursorTooLarge - ? { - partial: true as const, - replayError: cursorTooLarge - ? TRANSCRIPT_CURSOR_TOO_LARGE_REPLAY_ERROR - : replay.replayError, - } - : {}), - }; - }), + sessionId, + events: replay.updates.map((update) => ({ + v: 1 as const, + type: 'session_update' as const, + data: update, + })), + ...(replay.nextCursor && !cursorTooLarge + ? { nextCursor: replay.nextCursor } + : {}), + hasMore: cursorTooLarge ? false : replay.hasMore, + startTime: replay.startTime, + lastUpdated: replay.lastUpdated, + ...(replay.partial || cursorTooLarge + ? { + partial: true as const, + replayError: cursorTooLarge + ? TRANSCRIPT_CURSOR_TOO_LARGE_REPLAY_ERROR + : replay.replayError, + } + : {}), + }; + }), + ), ); const serialized = serializeWorkspaceTranscriptResponse( result, @@ -3373,18 +3481,21 @@ export function registerSessionRoutes( const uniqueIds = parseSessionIdsBody(req, res); if (uniqueIds === undefined) return; try { - const service = new SessionService(boundWorkspace); - const result = await deleteDaemonSessions({ - sessionIds: uniqueIds, - service, - bridge, - coordinator: archiveCoordinator, - onError: ({ phase, sessionId, error }) => { - writeStderrLine( - `qwen serve: ${phase}Session failed for ${safeLogValue(sessionId)}: ${safeLogValue(error)}`, - ); - }, - }); + const runtime = workspaceRegistry.primary; + const service = createWorkspaceRuntimeSessionService(runtime); + const result = await runWithWorkspaceRuntimeStorage(runtime, () => + deleteDaemonSessions({ + sessionIds: uniqueIds, + service, + bridge, + coordinator: archiveCoordinator, + onError: ({ phase, sessionId, error }) => { + writeStderrLine( + `qwen serve: ${phase}Session failed for ${safeLogValue(sessionId)}: ${safeLogValue(error)}`, + ); + }, + }), + ); for (const removedId of result.removed) { clearBranchSessionEntry(removedId); } @@ -3398,17 +3509,20 @@ export function registerSessionRoutes( const uniqueIds = parseSessionIdsBody(req, res); if (uniqueIds === undefined) return; - const service = new SessionService(boundWorkspace, { + const runtime = workspaceRegistry.primary; + const service = createWorkspaceRuntimeSessionService(runtime, { onWarning: logSessionArchiveWarning, }); try { - const result = await archiveDaemonSessions({ - sessionIds: uniqueIds, - service, - bridge, - coordinator: archiveCoordinator, - }); + const result = await runWithWorkspaceRuntimeStorage(runtime, () => + archiveDaemonSessions({ + sessionIds: uniqueIds, + service, + bridge, + coordinator: archiveCoordinator, + }), + ); res.status(200).json({ archived: result.archived, alreadyArchived: result.alreadyArchived, @@ -3424,16 +3538,19 @@ export function registerSessionRoutes( const uniqueIds = parseSessionIdsBody(req, res); if (uniqueIds === undefined) return; - const service = new SessionService(boundWorkspace, { + const runtime = workspaceRegistry.primary; + const service = createWorkspaceRuntimeSessionService(runtime, { onWarning: logSessionArchiveWarning, }); try { - const result = await unarchiveDaemonSessions({ - sessionIds: uniqueIds, - service, - coordinator: archiveCoordinator, - }); + const result = await runWithWorkspaceRuntimeStorage(runtime, () => + unarchiveDaemonSessions({ + sessionIds: uniqueIds, + service, + coordinator: archiveCoordinator, + }), + ); res.status(200).json({ unarchived: result.unarchived, alreadyActive: result.alreadyActive, @@ -3457,18 +3574,20 @@ export function registerSessionRoutes( const uniqueIds = parseSessionIdsBody(req, res); if (uniqueIds === undefined) return; try { - const service = new SessionService(runtime.workspaceCwd); - const result = await deleteDaemonSessions({ - sessionIds: uniqueIds, - service, - bridge: runtime.bridge, - coordinator: archiveCoordinator, - onError: ({ phase, sessionId, error }) => { - writeStderrLine( - `qwen serve: ${phase}Session failed for ${safeLogValue(sessionId)}: ${safeLogValue(error)}`, - ); - }, - }); + const service = createWorkspaceRuntimeSessionService(runtime); + const result = await runWithWorkspaceRuntimeStorage(runtime, () => + deleteDaemonSessions({ + sessionIds: uniqueIds, + service, + bridge: runtime.bridge, + coordinator: archiveCoordinator, + onError: ({ phase, sessionId, error }) => { + writeStderrLine( + `qwen serve: ${phase}Session failed for ${safeLogValue(sessionId)}: ${safeLogValue(error)}`, + ); + }, + }), + ); for (const removedId of result.removed) { clearBranchSessionEntry(removedId); } @@ -3488,16 +3607,18 @@ export function registerSessionRoutes( if (!runtime) return; const uniqueIds = parseSessionIdsBody(req, res); if (uniqueIds === undefined) return; - const service = new SessionService(runtime.workspaceCwd, { + const service = createWorkspaceRuntimeSessionService(runtime, { onWarning: logSessionArchiveWarning, }); try { - const result = await archiveDaemonSessions({ - sessionIds: uniqueIds, - service, - bridge: runtime.bridge, - coordinator: archiveCoordinator, - }); + const result = await runWithWorkspaceRuntimeStorage(runtime, () => + archiveDaemonSessions({ + sessionIds: uniqueIds, + service, + bridge: runtime.bridge, + coordinator: archiveCoordinator, + }), + ); res.status(200).json({ archived: result.archived, alreadyArchived: result.alreadyArchived, @@ -3519,15 +3640,17 @@ export function registerSessionRoutes( if (!runtime) return; const uniqueIds = parseSessionIdsBody(req, res); if (uniqueIds === undefined) return; - const service = new SessionService(runtime.workspaceCwd, { + const service = createWorkspaceRuntimeSessionService(runtime, { onWarning: logSessionArchiveWarning, }); try { - const result = await unarchiveDaemonSessions({ - sessionIds: uniqueIds, - service, - coordinator: archiveCoordinator, - }); + const result = await runWithWorkspaceRuntimeStorage(runtime, () => + unarchiveDaemonSessions({ + sessionIds: uniqueIds, + service, + coordinator: archiveCoordinator, + }), + ); res.status(200).json({ unarchived: result.unarchived, alreadyActive: result.alreadyActive, @@ -3576,8 +3699,7 @@ export function registerSessionRoutes( ); type SessionOrganizationTarget = { - workspaceCwd: string; - bridge: AcpSessionBridge; + runtime: WorkspaceRuntime; route: string; }; @@ -3589,92 +3711,98 @@ export function registerSessionRoutes( const sessionId = requireSessionId(req, res); if (sessionId === null) return; try { - await archiveCoordinator.runSharedMany([sessionId], async () => { - // Organization is workspace-scoped sidecar state, not live-session - // metadata. It intentionally applies to persisted and archived sessions. - const sessionService = new SessionService(target.workspaceCwd); - let exists = await sessionService.sessionExistsInAnyState(sessionId); - if (!exists) { - try { - const summary = target.bridge.getSessionSummary(sessionId); - exists = summary.workspaceCwd === target.workspaceCwd; - } catch { - exists = false; + await archiveCoordinator.runSharedMany([sessionId], () => + runWithWorkspaceRuntimeStorage(target.runtime, async () => { + // Organization is workspace-scoped sidecar state, not live-session + // metadata. It intentionally applies to persisted and archived sessions. + const sessionService = createWorkspaceRuntimeSessionService( + target.runtime, + ); + let exists = await sessionService.sessionExistsInAnyState(sessionId); + if (!exists) { + try { + const summary = + target.runtime.bridge.getSessionSummary(sessionId); + exists = summary.workspaceCwd === target.runtime.workspaceCwd; + } catch { + exists = false; + } + } + if (!exists) { + res.status(404).json({ + error: `No session with id "${sessionId}"`, + sessionId, + }); + return; } - } - if (!exists) { - res.status(404).json({ - error: `No session with id "${sessionId}"`, - sessionId, - }); - return; - } - const body = safeBody(req); - const rawIsPinned = body['isPinned']; - if (rawIsPinned !== undefined && typeof rawIsPinned !== 'boolean') { - res.status(400).json({ - error: '`isPinned` must be a boolean', - code: 'invalid_session_organization', - field: 'isPinned', - }); - return; - } - const rawGroupId = body['groupId']; - if ( - rawGroupId !== undefined && - rawGroupId !== null && - typeof rawGroupId !== 'string' - ) { - res.status(400).json({ - error: '`groupId` must be a string or null', - code: 'invalid_session_organization', - field: 'groupId', - }); - return; - } - const rawColor = body['color']; - if ( - rawColor !== undefined && - rawColor !== null && - (typeof rawColor !== 'string' || - !GROUP_COLOR_OPTIONS.includes(rawColor as SessionGroupPresetColor)) - ) { - res.status(400).json({ - error: '`color` must be a supported color or null', - code: 'invalid_session_organization', - field: 'color', - }); - return; - } + const body = safeBody(req); + const rawIsPinned = body['isPinned']; + if (rawIsPinned !== undefined && typeof rawIsPinned !== 'boolean') { + res.status(400).json({ + error: '`isPinned` must be a boolean', + code: 'invalid_session_organization', + field: 'isPinned', + }); + return; + } + const rawGroupId = body['groupId']; + if ( + rawGroupId !== undefined && + rawGroupId !== null && + typeof rawGroupId !== 'string' + ) { + res.status(400).json({ + error: '`groupId` must be a string or null', + code: 'invalid_session_organization', + field: 'groupId', + }); + return; + } + const rawColor = body['color']; + if ( + rawColor !== undefined && + rawColor !== null && + (typeof rawColor !== 'string' || + !GROUP_COLOR_OPTIONS.includes( + rawColor as SessionGroupPresetColor, + )) + ) { + res.status(400).json({ + error: '`color` must be a supported color or null', + code: 'invalid_session_organization', + field: 'color', + }); + return; + } - const organization = await createSessionOrganizationService( - target.workspaceCwd, - ).updateSessionOrganization(sessionId, { - ...(rawIsPinned !== undefined ? { isPinned: rawIsPinned } : {}), - ...(rawGroupId !== undefined - ? { groupId: rawGroupId as string | null } - : {}), - ...(rawColor !== undefined - ? { color: rawColor as SessionGroupPresetColor | null } - : {}), - }); - res.status(200).json({ sessionId, ...organization }); - }); + const organization = await createSessionOrganizationService( + target.runtime.workspaceCwd, + ).updateSessionOrganization(sessionId, { + ...(rawIsPinned !== undefined ? { isPinned: rawIsPinned } : {}), + ...(rawGroupId !== undefined + ? { groupId: rawGroupId as string | null } + : {}), + ...(rawColor !== undefined + ? { color: rawColor as SessionGroupPresetColor | null } + : {}), + }); + res.status(200).json({ sessionId, ...organization }); + }), + ); } catch (err) { if (sendSessionOrganizationError(res, err)) return; sendBridgeError(res, err, { route: target.route, sessionId, - workspaceCwd: target.workspaceCwd, + workspaceCwd: target.runtime.workspaceCwd, }); } }; app.patch('/session/:id/organization', mutate(), async (req, res) => { await handleSessionOrganizationUpdate(req, res, { - workspaceCwd: boundWorkspace, - bridge, + runtime: workspaceRegistry.primary, route: 'PATCH /session/:id/organization', }); }); @@ -3687,8 +3815,7 @@ export function registerSessionRoutes( const runtime = requireTrustedRuntimeForWorkspaceRoute(req, res, route); if (!runtime) return; await handleSessionOrganizationUpdate(req, res, { - workspaceCwd: runtime.workspaceCwd, - bridge: runtime.bridge, + runtime, route, }); }, @@ -3715,14 +3842,16 @@ export function registerSessionRoutes( }); app.post('/workspace/:id/session-groups', mutate(), async (req, res) => { - const key = resolveWorkspaceParam(req, res); - if (key === null) return; + const runtime = resolveLegacyPrimaryRuntimeFromParam(req, res); + if (runtime === null) return; const body = safeBody(req); try { - const group = await createSessionOrganizationService(key).createGroup({ - name: body['name'] as string, - color: body['color'] as SessionGroupColor, - }); + const group = await runWithWorkspaceRuntimeStorage(runtime, () => + createSessionOrganizationService(runtime.workspaceCwd).createGroup({ + name: body['name'] as string, + color: body['color'] as SessionGroupColor, + }), + ); res.status(201).json({ group }); } catch (err) { if (sendSessionOrganizationError(res, err)) return; @@ -3736,23 +3865,25 @@ export function registerSessionRoutes( '/workspace/:id/session-groups/:groupId', mutate(), async (req, res) => { - const key = resolveWorkspaceParam(req, res); - if (key === null) return; + const runtime = resolveLegacyPrimaryRuntimeFromParam(req, res); + if (runtime === null) return; const body = safeBody(req); try { - const group = await createSessionOrganizationService(key).updateGroup( - req.params['groupId'] ?? '', - { - ...(Object.prototype.hasOwnProperty.call(body, 'name') - ? { name: body['name'] as string } - : {}), - ...(Object.prototype.hasOwnProperty.call(body, 'color') - ? { color: body['color'] as SessionGroupColor } - : {}), - ...(Object.prototype.hasOwnProperty.call(body, 'order') - ? { order: body['order'] as number } - : {}), - }, + const group = await runWithWorkspaceRuntimeStorage(runtime, () => + createSessionOrganizationService(runtime.workspaceCwd).updateGroup( + req.params['groupId'] ?? '', + { + ...(Object.prototype.hasOwnProperty.call(body, 'name') + ? { name: body['name'] as string } + : {}), + ...(Object.prototype.hasOwnProperty.call(body, 'color') + ? { color: body['color'] as SessionGroupColor } + : {}), + ...(Object.prototype.hasOwnProperty.call(body, 'order') + ? { order: body['order'] as number } + : {}), + }, + ), ); res.status(200).json({ group }); } catch (err) { @@ -3768,11 +3899,13 @@ export function registerSessionRoutes( '/workspace/:id/session-groups/:groupId', mutate(), async (req, res) => { - const key = resolveWorkspaceParam(req, res); - if (key === null) return; + const runtime = resolveLegacyPrimaryRuntimeFromParam(req, res); + if (runtime === null) return; try { - const deleted = await createSessionOrganizationService(key).deleteGroup( - req.params['groupId'] ?? '', + const deleted = await runWithWorkspaceRuntimeStorage(runtime, () => + createSessionOrganizationService(runtime.workspaceCwd).deleteGroup( + req.params['groupId'] ?? '', + ), ); res.status(200).json({ deleted }); } catch (err) { @@ -3810,12 +3943,12 @@ export function registerSessionRoutes( if (!runtime) return; const body = safeBody(req); try { - const group = await createSessionOrganizationService( - runtime.workspaceCwd, - ).createGroup({ - name: body['name'] as string, - color: body['color'] as SessionGroupColor, - }); + const group = await runWithWorkspaceRuntimeStorage(runtime, () => + createSessionOrganizationService(runtime.workspaceCwd).createGroup({ + name: body['name'] as string, + color: body['color'] as SessionGroupColor, + }), + ); res.status(201).json({ group }); } catch (err) { if (sendSessionOrganizationError(res, err)) return; @@ -3833,19 +3966,22 @@ export function registerSessionRoutes( if (!runtime) return; const body = safeBody(req); try { - const group = await createSessionOrganizationService( - runtime.workspaceCwd, - ).updateGroup(req.params['groupId'] ?? '', { - ...(Object.prototype.hasOwnProperty.call(body, 'name') - ? { name: body['name'] as string } - : {}), - ...(Object.prototype.hasOwnProperty.call(body, 'color') - ? { color: body['color'] as SessionGroupColor } - : {}), - ...(Object.prototype.hasOwnProperty.call(body, 'order') - ? { order: body['order'] as number } - : {}), - }); + const group = await runWithWorkspaceRuntimeStorage(runtime, () => + createSessionOrganizationService(runtime.workspaceCwd).updateGroup( + req.params['groupId'] ?? '', + { + ...(Object.prototype.hasOwnProperty.call(body, 'name') + ? { name: body['name'] as string } + : {}), + ...(Object.prototype.hasOwnProperty.call(body, 'color') + ? { color: body['color'] as SessionGroupColor } + : {}), + ...(Object.prototype.hasOwnProperty.call(body, 'order') + ? { order: body['order'] as number } + : {}), + }, + ), + ); res.status(200).json({ group }); } catch (err) { if (sendSessionOrganizationError(res, err)) return; @@ -3862,9 +3998,11 @@ export function registerSessionRoutes( const runtime = requireTrustedRuntimeForWorkspaceRoute(req, res, route); if (!runtime) return; try { - const deleted = await createSessionOrganizationService( - runtime.workspaceCwd, - ).deleteGroup(req.params['groupId'] ?? ''); + const deleted = await runWithWorkspaceRuntimeStorage(runtime, () => + createSessionOrganizationService(runtime.workspaceCwd).deleteGroup( + req.params['groupId'] ?? '', + ), + ); res.status(200).json({ deleted }); } catch (err) { if (sendSessionOrganizationError(res, err)) return; @@ -3988,7 +4126,7 @@ export function registerSessionRoutes( parsedSource.sourceType !== undefined || (cursor !== undefined && cursor !== '' ? isNumericSessionCursor(cursor) - : await hasActivePersistedSessions(key)); + : await hasActivePersistedSessions(runtime)); // The live path only reads cursor/size; persisted-only options // (organized view or archived state) would be silently dropped there. // usePersisted already routes those to the persisted path — assert it so 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/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/routes/workspace-qualified-extensions.test.ts b/packages/cli/src/serve/routes/workspace-qualified-extensions.test.ts index 93613670fe..e6aa108834 100644 --- a/packages/cli/src/serve/routes/workspace-qualified-extensions.test.ts +++ b/packages/cli/src/serve/routes/workspace-qualified-extensions.test.ts @@ -96,6 +96,7 @@ function makeRuntime( return { workspaceId: opts.workspaceId, workspaceCwd, + sessionRuntimeBaseDir: path.join(workspaceCwd, '.runtime'), primary: opts.primary, trusted: opts.trusted, env: { mode: 'parent-process', overlayKeys: [] }, diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index a5f1c869c8..f089d8bf22 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -3060,6 +3060,8 @@ describe('runQwenServe runtime startup failures', () => { tmpDir = fs.realpathSync( fs.mkdtempSync(path.join(os.tmpdir(), 'qws-runtime-env-reload-')), ); + const originalRuntimeDir = process.env['QWEN_RUNTIME_DIR']; + delete process.env['QWEN_RUNTIME_DIR']; const originalBase = process.env['QWEN_TEST_BOOT_BASE']; const originalLeak = process.env['QWEN_TEST_RELOAD_LEAK']; const originalRemoved = process.env['QWEN_TEST_REMOVED_FROM_DOTENV']; @@ -3076,6 +3078,11 @@ describe('runQwenServe runtime startup failures', () => { () => ({ merged: { + advanced: { + runtimeOutputDir: runtimeMounted + ? '.runtime-reloaded' + : '.runtime-boot', + }, env: { QWEN_TEST_RUNTIME_VALUE: runtimeMounted ? 'reloaded' : 'boot', }, @@ -3110,11 +3117,15 @@ describe('runQwenServe runtime startup failures', () => { effectiveEnv?: NodeJS.ProcessEnv; } | undefined; + let primaryRuntime: + | import('./workspace-registry.js').WorkspaceRuntime + | undefined; vi.spyOn(serverModule, 'createServeApp').mockImplementation( (_opts, _getPort, deps) => { runtimeMounted = true; workspace = deps?.workspace as typeof workspace; primaryRuntimeEnv = deps?.primaryRuntimeEnv as typeof primaryRuntimeEnv; + primaryRuntime = deps?.workspaceRegistry?.primary; return express(); }, ); @@ -3142,6 +3153,9 @@ describe('runQwenServe runtime startup failures', () => { expect(primaryRuntimeEnv?.effectiveEnv).toBeDefined(); const capturedRuntimeEnv = primaryRuntimeEnv!.effectiveEnv!; expect(capturedRuntimeEnv['QWEN_TEST_RUNTIME_VALUE']).toBe('boot'); + const pinnedRuntimeBaseDir = path.join(tmpDir, '.runtime-boot'); + expect(primaryRuntime?.sessionRuntimeBaseDir).toBe(pinnedRuntimeBaseDir); + expect(capturedRuntimeEnv['QWEN_RUNTIME_DIR']).toBe(pinnedRuntimeBaseDir); await workspace!.reload({ route: 'POST /workspace/reload', @@ -3156,6 +3170,8 @@ describe('runQwenServe runtime startup failures', () => { expect(capturedRuntimeEnv['QWEN_TEST_RUNTIME_VALUE']).toBe('reloaded'); expect(capturedRuntimeEnv['QWEN_TEST_REMOVED_FROM_DOTENV']).toBe('stale'); expect(capturedRuntimeEnv['QWEN_TEST_RELOAD_LEAK']).toBeUndefined(); + expect(primaryRuntime?.sessionRuntimeBaseDir).toBe(pinnedRuntimeBaseDir); + expect(capturedRuntimeEnv['QWEN_RUNTIME_DIR']).toBe(pinnedRuntimeBaseDir); } finally { if (originalBase === undefined) { delete process.env['QWEN_TEST_BOOT_BASE']; @@ -3172,6 +3188,11 @@ describe('runQwenServe runtime startup failures', () => { } else { process.env['QWEN_TEST_REMOVED_FROM_DOTENV'] = originalRemoved; } + if (originalRuntimeDir === undefined) { + delete process.env['QWEN_RUNTIME_DIR']; + } else { + process.env['QWEN_RUNTIME_DIR'] = originalRuntimeDir; + } await handle.close(); } }); @@ -3305,6 +3326,8 @@ describe('runQwenServe runtime startup failures', () => { ); const primary = path.join(tmpDir, 'primary'); const secondary = path.join(tmpDir, 'secondary'); + const originalRuntimeDir = process.env['QWEN_RUNTIME_DIR']; + delete process.env['QWEN_RUNTIME_DIR']; fs.mkdirSync(primary); fs.mkdirSync(secondary); vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockResolvedValue({ @@ -3318,6 +3341,13 @@ describe('runQwenServe runtime startup failures', () => { const isSecondary = workspace === secondary; return { merged: { + advanced: { + runtimeOutputDir: isSecondary + ? runtimeMounted + ? '.secondary-runtime-reloaded' + : '.secondary-runtime-boot' + : '.primary-runtime', + }, env: { [isSecondary ? 'QWEN_TEST_SECONDARY_ENV' @@ -3381,6 +3411,14 @@ describe('runQwenServe runtime startup failures', () => { const envFilePaths = env.envFilePaths; const envFileReadFailures = env.envFileReadFailures; expect(env.effectiveEnv?.['QWEN_TEST_SECONDARY_ENV']).toBe('boot'); + const pinnedRuntimeBaseDir = path.join( + secondary, + '.secondary-runtime-boot', + ); + expect(secondaryRuntime!.sessionRuntimeBaseDir).toBe( + pinnedRuntimeBaseDir, + ); + expect(env.effectiveEnv?.['QWEN_RUNTIME_DIR']).toBe(pinnedRuntimeBaseDir); await secondaryRuntime!.workspaceService.reload({ route: 'POST /workspace/reload', @@ -3391,8 +3429,17 @@ describe('runQwenServe runtime startup failures', () => { expect(env.envFilePaths).toBe(envFilePaths); expect(env.envFileReadFailures).toBe(envFileReadFailures); expect(env.effectiveEnv?.['QWEN_TEST_SECONDARY_ENV']).toBe('reloaded'); + expect(secondaryRuntime!.sessionRuntimeBaseDir).toBe( + pinnedRuntimeBaseDir, + ); + expect(env.effectiveEnv?.['QWEN_RUNTIME_DIR']).toBe(pinnedRuntimeBaseDir); } finally { await handle.close(); + if (originalRuntimeDir === undefined) { + delete process.env['QWEN_RUNTIME_DIR']; + } else { + process.env['QWEN_RUNTIME_DIR'] = originalRuntimeDir; + } } }); @@ -5296,6 +5343,54 @@ describe('runQwenServe runtime startup failures', () => { ).toBeLessThan(vi.mocked(bridge.shutdown).mock.invocationCallOrder[0]!); }); + it('seals and drains admitted session maintenance before bridge shutdown', async () => { + tmpDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-maintenance-drain-')), + ); + vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockResolvedValue({ + enabled: false, + sensitiveSpanAttributeMaxLength: 1024 * 1024, + }); + const bridge = makeRuntimeBridge(); + vi.spyOn(acpBridge, 'createAcpSessionBridge').mockReturnValue( + bridge as ReturnType, + ); + let finishMaintenance!: () => void; + const maintenanceGate = new Promise((resolve) => { + finishMaintenance = resolve; + }); + const sealMaintenanceAndWait = vi.fn(() => maintenanceGate); + vi.spyOn(serverModule, 'createServeApp').mockImplementation(() => { + const runtimeApp = express(); + runtimeApp.locals['sessionArchiveCoordinator'] = { + sealMaintenanceAndWait, + }; + return runtimeApp; + }); + + const handle = await runQwenServe( + { + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: tmpDir, + maxSessions: 1, + serveWebShell: false, + }, + { resolveOnListen: true }, + ); + await handle.runtimeReady; + + const close = handle.close(); + expect(sealMaintenanceAndWait).toHaveBeenCalledOnce(); + await Promise.resolve(); + expect(bridge.shutdown).not.toHaveBeenCalled(); + + finishMaintenance(); + await close; + expect(bridge.shutdown).toHaveBeenCalledOnce(); + }); + it('does not cancel deferred runtime once startup is already running', async () => { tmpDir = fs.realpathSync( fs.mkdtempSync(path.join(os.tmpdir(), 'qws-health-close-running-')), diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index a17b02086a..e48116a252 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -3108,6 +3108,47 @@ async function runQwenServeImpl( envFileReadFailed: false, envFileReadFailures: Object.freeze([]), }; + const resolveSessionRuntimeBaseDir = ( + workspace: string, + settings: ReturnType | undefined, + effectiveEnv: Readonly, + ): string => { + const resolveConfiguredPath = ( + configuredPath: string, + relativeTo: string, + ): string => { + const expanded = + configuredPath === '~' + ? os.homedir() + : configuredPath.startsWith('~/') || + configuredPath.startsWith('~\\') + ? path.join( + os.homedir(), + ...configuredPath + .slice(2) + .split(/[/\\]+/) + .filter(Boolean), + ) + : configuredPath; + return path.resolve(relativeTo, expanded); + }; + const runtimeDir = effectiveEnv['QWEN_RUNTIME_DIR']; + if (runtimeDir) { + return resolveConfiguredPath(runtimeDir, process.cwd()); + } + const settingsDir = settings?.merged.advanced?.runtimeOutputDir; + if (settingsDir) { + return resolveConfiguredPath(settingsDir, workspace); + } + const qwenHome = effectiveEnv['QWEN_HOME']; + if (qwenHome) { + return resolveConfiguredPath(qwenHome, process.cwd()); + } + const homeDir = os.homedir(); + return homeDir + ? path.join(homeDir, '.qwen') + : path.join(os.tmpdir(), '.qwen'); + }; const logRuntimeEnvFileReadFailures = ( workspace: string, snapshot: { @@ -3126,8 +3167,14 @@ async function runQwenServeImpl( }); }; logRuntimeEnvFileReadFailures(boundWorkspace, runtimeEnvSnapshot); + const primarySessionRuntimeBaseDir = resolveSessionRuntimeBaseDir( + boundWorkspace, + runtimeBootSettings, + runtimeEnvSnapshot.effectiveEnv, + ); const runtimeEffectiveEnv: NodeJS.ProcessEnv = { ...runtimeEnvSnapshot.effectiveEnv, + QWEN_RUNTIME_DIR: primarySessionRuntimeBaseDir, }; const replaceRuntimeEffectiveEnv = ( nextEnv: Readonly, @@ -3136,6 +3183,7 @@ async function runQwenServeImpl( delete runtimeEffectiveEnv[key]; } Object.assign(runtimeEffectiveEnv, nextEnv); + runtimeEffectiveEnv['QWEN_RUNTIME_DIR'] = primarySessionRuntimeBaseDir; }; const primaryRuntimeEnv: { mode: 'runtime-overlay'; @@ -3814,6 +3862,7 @@ async function runQwenServeImpl( { workspaceId: daemonWorkspaceHash, workspaceCwd: boundWorkspace, + sessionRuntimeBaseDir: primarySessionRuntimeBaseDir, ...(workspaceInputs[0]?.displayName ? { displayName: workspaceInputs[0].displayName } : {}), @@ -3846,6 +3895,7 @@ async function runQwenServeImpl( fallbackReason?: string; }; effectiveEnv: NodeJS.ProcessEnv; + sessionRuntimeBaseDir: string; replace: (nextEnv: Readonly) => void; } => { const snapshot = settings @@ -3863,7 +3913,15 @@ async function runQwenServeImpl( envFileReadFailures: Object.freeze([]), }; logRuntimeEnvFileReadFailures(workspace, snapshot); - const effectiveEnv: NodeJS.ProcessEnv = { ...snapshot.effectiveEnv }; + const sessionRuntimeBaseDir = resolveSessionRuntimeBaseDir( + workspace, + settings, + snapshot.effectiveEnv, + ); + const effectiveEnv: NodeJS.ProcessEnv = { + ...snapshot.effectiveEnv, + QWEN_RUNTIME_DIR: sessionRuntimeBaseDir, + }; const metadata: { mode: 'runtime-overlay'; overlayKeys: string[]; @@ -3883,11 +3941,13 @@ async function runQwenServeImpl( return { metadata, effectiveEnv, + sessionRuntimeBaseDir, replace(nextEnv) { for (const key of Object.keys(effectiveEnv)) { delete effectiveEnv[key]; } Object.assign(effectiveEnv, nextEnv); + effectiveEnv['QWEN_RUNTIME_DIR'] = sessionRuntimeBaseDir; }, }; }; @@ -4177,6 +4237,7 @@ async function runQwenServeImpl( const secondaryRuntime: WorkspaceRuntime = { workspaceId: secondaryWorkspaceHash, workspaceCwd: workspaceInput.cwd, + sessionRuntimeBaseDir: secondaryEnv.sessionRuntimeBaseDir, ...(workspaceInput.displayName ? { displayName: workspaceInput.displayName } : {}), @@ -4711,6 +4772,7 @@ async function runQwenServeImpl( const wsRuntime: WorkspaceRuntime = { workspaceId: wsHash, workspaceCwd: cwd, + sessionRuntimeBaseDir: wsEnv.sessionRuntimeBaseDir, ...(buildOptions?.displayName !== undefined ? { displayName: buildOptions.displayName } : {}), @@ -6345,11 +6407,17 @@ async function runQwenServeImpl( const initiallyMountedManagement = initiallyMountedApp?.locals?.[ 'workspaceManagementHandle' ] as { sealAndWait?: () => Promise } | undefined; + const initiallyMountedSessionMaintenance = initiallyMountedApp + ?.locals?.['sessionArchiveCoordinator'] as + | { sealMaintenanceAndWait?: () => Promise } + | undefined; // Calling an async function runs through its first await // synchronously. Seal an already-mounted runtime before close() // yields so no management request can enter the shutdown window. const initialManagementWait = initiallyMountedManagement?.sealAndWait?.(); + const initialSessionMaintenanceWait = + initiallyMountedSessionMaintenance?.sealMaintenanceAndWait?.(); let processRegistryShutdown: Promise | undefined; const startProcessRegistryShutdown = () => { processRegistryShutdown ??= managedProcessRegistry @@ -6492,10 +6560,19 @@ async function runQwenServeImpl( const workspaceManagementHandle = appForCleanup?.locals?.[ 'workspaceManagementHandle' ] as { sealAndWait?: () => Promise } | undefined; + const sessionMaintenance = appForCleanup?.locals?.[ + 'sessionArchiveCoordinator' + ] as + | { sealMaintenanceAndWait?: () => Promise } + | undefined; await initialManagementWait; if (workspaceManagementHandle !== initiallyMountedManagement) { await workspaceManagementHandle?.sealAndWait?.(); } + await initialSessionMaintenanceWait; + if (sessionMaintenance !== initiallyMountedSessionMaintenance) { + await sessionMaintenance?.sealMaintenanceAndWait?.(); + } stopTrustPolicyMonitor(appForCleanup); const waitForTrustPolicyIdle = appForCleanup?.locals?.[ 'waitForTrustPolicyIdle' diff --git a/packages/cli/src/serve/scheduled-task-keepalive.test.ts b/packages/cli/src/serve/scheduled-task-keepalive.test.ts index 640acf9e5f..a4a9971b75 100644 --- a/packages/cli/src/serve/scheduled-task-keepalive.test.ts +++ b/packages/cli/src/serve/scheduled-task-keepalive.test.ts @@ -777,6 +777,46 @@ describe('scheduled-task keepalive', () => { releaseSpawn?.(); }); + it('waits for close before deleting a late spawned transcript', async () => { + await updateCronTasks(workspace, () => [ + task({ id: 'hung', prompt: 'will resolve late' }), + ]); + let resolveSpawn!: (value: { sessionId: string }) => void; + let finishClose!: () => void; + const closeGate = new Promise((resolve) => { + finishClose = resolve; + }); + const closeSession = vi.fn(() => closeGate); + const removeSpy = vi + .spyOn(SessionService.prototype, 'removeSession') + .mockResolvedValue(true); + const ka = startScheduledTaskKeepalive({ + bridge: { + ...bridge, + spawnOrAttach: () => + new Promise<{ sessionId: string }>((resolve) => { + resolveSpawn = resolve; + }), + closeSession, + }, + boundWorkspace: workspace, + intervalMs: 50, + spawnTimeoutMs: 5, + }); + + await ka.tick(); + resolveSpawn({ sessionId: 'late-sess' }); + await vi.waitFor(() => + expect(closeSession).toHaveBeenCalledWith('late-sess'), + ); + expect(removeSpy).not.toHaveBeenCalled(); + + finishClose(); + await vi.waitFor(() => expect(removeSpy).toHaveBeenCalledWith('late-sess')); + ka.stop(); + removeSpy.mockRestore(); + }); + it('rehydration onTasksRead populates the authorization store for delivery-enabled tasks', async () => { const authorizations = new ChannelDeliveryAuthorizationStore(); await updateCronTasks(workspace, () => [ diff --git a/packages/cli/src/serve/scheduled-task-keepalive.ts b/packages/cli/src/serve/scheduled-task-keepalive.ts index 347629a235..9ed5a35201 100644 --- a/packages/cli/src/serve/scheduled-task-keepalive.ts +++ b/packages/cli/src/serve/scheduled-task-keepalive.ts @@ -36,6 +36,7 @@ import { getCronFilePath, createDebugLogger, SessionService, + Storage, taskHasLegacyCondition, type DurableCronTask, } from '@qwen-code/qwen-code-core'; @@ -126,6 +127,7 @@ async function bindAndNameSessions( renamed: Set, spawnTimeoutMs: number, binding: Set, + cleanupSession: (sessionId: string) => Promise, ): Promise { const unbound = tasks.filter( (t) => @@ -158,17 +160,14 @@ async function bindAndNameSessions( // binding guard on TRUE settlement so retries are possible. let timedOut = false; rawSpawn - .then(({ sessionId }) => { + .then(async ({ sessionId }) => { if (timedOut) { log.debug( 'keepalive: late spawn resolved, cleaning up', task.id, sessionId, ); - bridge.closeSession(sessionId).catch(() => {}); - new SessionService(boundWorkspace) - .removeSession(sessionId) - .catch(() => {}); + await cleanupSession(sessionId).catch(() => {}); } }) .catch(() => {}) @@ -226,10 +225,7 @@ async function bindAndNameSessions( } catch (err) { log.debug('keepalive: failed to bind task', task.id, err); if (spawnedSessionId !== undefined) { - await bridge.closeSession(spawnedSessionId).catch(() => {}); - await new SessionService(boundWorkspace) - .removeSession(spawnedSessionId) - .catch(() => {}); + await cleanupSession(spawnedSessionId).catch(() => {}); } } } @@ -257,6 +253,8 @@ export interface ScheduledTaskKeepalive { export interface StartScheduledTaskKeepaliveOptions { bridge: KeepaliveBridge; boundWorkspace: string; + runtimeBaseDir?: string; + cleanupSession?: (sessionId: string) => Promise; /** How often to heartbeat; must be comfortably under the reaper timeout. */ intervalMs: number; /** Per-session revive timeout; defaults to KEEPALIVE_REVIVE_TIMEOUT_MS. */ @@ -272,6 +270,14 @@ export function startScheduledTaskKeepalive( const { bridge, boundWorkspace, intervalMs } = opts; const reviveTimeoutMs = opts.reviveTimeoutMs ?? KEEPALIVE_REVIVE_TIMEOUT_MS; const spawnTimeoutMs = opts.spawnTimeoutMs ?? KEEPALIVE_SPAWN_TIMEOUT_MS; + const cleanupSession = + opts.cleanupSession ?? + (async (sessionId: string) => { + await bridge.closeSession(sessionId); + await new SessionService(boundWorkspace, { + runtimeBaseDir: opts.runtimeBaseDir, + }).removeSession(sessionId); + }); // Per-session revive state: `nextAttemptAt` gates retries after failures so a // permanently-gone session isn't reloaded every interval; cleared on success. @@ -294,7 +300,7 @@ export function startScheduledTaskKeepalive( // so updateSessionMetadata isn't called every tick. const renamed = new Set(); - const tick = async (): Promise => { + const tickInRuntime = async (): Promise => { let tasks; try { tasks = await readCronTasks(boundWorkspace); @@ -388,8 +394,16 @@ export function startScheduledTaskKeepalive( renamed, spawnTimeoutMs, binding, + cleanupSession, ); }; + const tick = (): Promise => + opts.runtimeBaseDir === undefined + ? tickInRuntime() + : Storage.runWithResolvedRuntimeBaseDir( + opts.runtimeBaseDir, + tickInRuntime, + ); // In-flight guard: a pass can outlast the interval (each revive awaits up to // the revive timeout), so skip a tick while the previous is still running — @@ -410,7 +424,12 @@ export function startScheduledTaskKeepalive( // dedicated session immediately, not after the next interval. Same // directory-watch + debounce pattern the scheduler uses. let bindDebounce: ReturnType | undefined; - const cronFilePath = getCronFilePath(boundWorkspace); + const cronFilePath = + opts.runtimeBaseDir === undefined + ? getCronFilePath(boundWorkspace) + : Storage.runWithResolvedRuntimeBaseDir(opts.runtimeBaseDir, () => + getCronFilePath(boundWorkspace), + ); const cronDir = path.dirname(cronFilePath); const cronFileName = path.basename(cronFilePath); let fileWatcher: ReturnType | undefined; diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 28938ae337..74978b313d 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', @@ -393,6 +394,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). @@ -2178,12 +2180,15 @@ function makeWorkspaceRuntimeForTest(input: { workspaceCwd: string; primary: boolean; bridge: AcpSessionBridge; + sessionRuntimeBaseDir?: string; trusted?: boolean; generationGuard?: WorkspaceGenerationGuard; }): WorkspaceRuntime { return { workspaceId: input.workspaceId, workspaceCwd: input.workspaceCwd, + sessionRuntimeBaseDir: + input.sessionRuntimeBaseDir ?? Storage.getRuntimeBaseDir(), primary: input.primary, trusted: input.trusted ?? true, env: { mode: 'parent-process', overlayKeys: [] }, @@ -11935,6 +11940,55 @@ describe('createServeApp', () => { ]); }); + it('rejects singular session-group mutations when the selected runtime is unavailable', async () => { + const runtime = makeWorkspaceRuntimeForTest({ + workspaceId: 'primary-id', + workspaceCwd: WS_BOUND, + primary: true, + bridge: fakeBridge(), + }); + const workspaceRegistry = createWorkspaceRegistry([runtime]); + const app = createServeApp(baseOpts, undefined, { + workspaceRegistry, + }); + workspaceRegistry.beginReplacement( + workspaceRegistry.primaryEntry, + 'policy-2', + ); + workspaceRegistry.blockReplacement( + workspaceRegistry.primaryEntry, + 'runtime build failed', + ); + + const responses = await Promise.all([ + request(app) + .post(`/workspace/${encodeURIComponent(WS_BOUND)}/session-groups`) + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ name: 'Frontend', color: 'blue' }), + request(app) + .patch( + `/workspace/${encodeURIComponent( + WS_BOUND, + )}/session-groups/missing-group`, + ) + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ name: 'Frontend' }), + request(app) + .delete( + `/workspace/${encodeURIComponent( + WS_BOUND, + )}/session-groups/missing-group`, + ) + .set('Host', `127.0.0.1:${baseOpts.port}`), + ]); + + for (const response of responses) { + expect(response.status).toBe(503); + expect(response.headers['retry-after']).toBe('1'); + expect(response.body.code).toBe('workspace_runtime_unavailable'); + } + }); + it('returns session organization errors for invalid REST inputs', async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440000'; await writeStoredSession({ @@ -14346,12 +14400,34 @@ describe('createServeApp', () => { ])( '%s the persisted branch when generation cleanup kills=%s', async (_label, killed, expectedRemovals) => { + const runtimeDir = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-branch-cleanup-'), + ); + const staleBranchId = '550e8400-e29b-41d4-a716-446655440125'; + const chatsDir = path.join( + new Storage(WS_BOUND, runtimeDir).getProjectDir(), + 'chats', + ); + await fsp.mkdir(chatsDir, { recursive: true }); + await fsp.writeFile( + path.join(chatsDir, `${staleBranchId}.jsonl`), + `${JSON.stringify({ + uuid: `${staleBranchId}-user-1`, + parentUuid: null, + sessionId: staleBranchId, + timestamp: '2026-07-29T00:00:00.000Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'hello' }] }, + cwd: WS_BOUND, + })}\n`, + 'utf8', + ); const generationGuard = createWorkspaceGenerationGuard(); const bridge = fakeBridge(); bridge.branchSession = vi.fn(async (sessionId) => { generationGuard.close(); return { - sessionId: 'stale-branch', + sessionId: staleBranchId, workspaceCwd: WS_BOUND, attached: false, clientId: 'stale-client', @@ -14369,6 +14445,7 @@ describe('createServeApp', () => { const runtime = makeWorkspaceRuntimeForTest({ workspaceId: 'branch-primary', workspaceCwd: WS_BOUND, + sessionRuntimeBaseDir: runtimeDir, primary: true, bridge, generationGuard, @@ -14387,16 +14464,17 @@ describe('createServeApp', () => { expect(res.status).toBe(503); expect(res.body.code).toBe('workspace_runtime_unavailable'); - expect(killSpy).toHaveBeenCalledWith('stale-branch', { + expect(killSpy).toHaveBeenCalledWith(staleBranchId, { requireZeroAttaches: true, }); expect(removeSpy).toHaveBeenCalledTimes(expectedRemovals); if (killed) { - expect(removeSpy).toHaveBeenCalledWith('stale-branch'); + expect(removeSpy).toHaveBeenCalledWith(staleBranchId); } } finally { killSpy.mockRestore(); removeSpy.mockRestore(); + await fsp.rm(runtimeDir, { recursive: true, force: true }); } }, ); @@ -17825,7 +17903,7 @@ describe('createServeApp', () => { }); }); - it('keeps archive blocked while a legacy export is in flight', async () => { + it('reports an archive conflict while a legacy export is in flight', async () => { const sid = '55555555-bbbb-cccc-dddd-eeeeeeeeeeef'; await writeExportSession(sid); let loadStarted!: () => void; @@ -17859,10 +17937,15 @@ describe('createServeApp', () => { .post('/sessions/archive') .set('Host', `127.0.0.1:${baseOpts.port}`) .send({ sessionIds: [sid] }); - expect(archive.status).toBe(409); + expect(archive.status).toBe(200); expect(archive.body).toMatchObject({ - code: 'session_archiving', - sessionId: sid, + archived: [], + errors: [ + { + sessionId: sid, + error: expect.stringContaining('is being archived or unarchived'), + }, + ], }); releaseLoad(); @@ -18973,6 +19056,8 @@ describe('createServeApp', () => { }); it('returns per-id errors when removeSession throws unexpectedly', async () => { + const sessionId = 'aaaa0000-bbbb-cccc-dddd-eeeeeeeeeeee'; + await writeSession(sessionId); const spy = vi .spyOn(SessionService.prototype, 'removeSession') .mockRejectedValueOnce(new Error('disk on fire')); @@ -18984,11 +19069,11 @@ describe('createServeApp', () => { const res = await request(app) .post('/sessions/delete') .set('Host', `127.0.0.1:${baseOpts.port}`) - .send({ sessionIds: ['aaaa0000-bbbb-cccc-dddd-eeeeeeeeeeee'] }); + .send({ sessionIds: [sessionId] }); expect(res.status).toBe(200); expect(res.body.errors).toEqual([ { - sessionId: 'aaaa0000-bbbb-cccc-dddd-eeeeeeeeeeee', + sessionId, error: 'disk on fire', }, ]); @@ -19174,7 +19259,7 @@ describe('createServeApp', () => { ).rejects.toThrow(); }); - it('does not close a live session when no active JSONL exists', async () => { + it('returns notFound after closing a live session with no active JSONL', async () => { const sid = '22222222-bbbb-cccc-dddd-eeeeeeeeeeee'; const bridge = fakeBridge(); const app = createArchiveApp(bridge); @@ -19191,7 +19276,13 @@ describe('createServeApp', () => { notFound: [sid], errors: [], }); - expect(bridge.closeCalls).toHaveLength(0); + expect(bridge.closeCalls).toEqual([ + { + sessionId: sid, + clientId: undefined, + closeOpts: { requireAgentClose: true }, + }, + ]); }); it('unarchives by moving JSONL back into active chats', async () => { @@ -19383,7 +19474,7 @@ describe('createServeApp', () => { expect(archiveRes.body.archived).toEqual([sid]); }); - it('returns session_archiving for archive while load is in flight', async () => { + it('reports an archive conflict while load is in flight', async () => { const sid = '55555555-bbbb-cccc-dddd-eeeeeeeeeeee'; await writeSession(sid); let loadStarted!: () => void; @@ -19421,12 +19512,16 @@ describe('createServeApp', () => { .post('/sessions/archive') .set('Host', `127.0.0.1:${baseOpts.port}`) .send({ sessionIds: [sid] }); - expect(archiveRes.status).toBe(409); + expect(archiveRes.status).toBe(200); expect(archiveRes.body).toMatchObject({ - code: 'session_archiving', - sessionId: sid, + archived: [], + errors: [ + { + sessionId: sid, + error: expect.stringContaining('is being archived or unarchived'), + }, + ], }); - expect(archiveRes.body.error).toContain('being archived or unarchived'); releaseLoad(); const loadRes = await loadPromise; diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index f835dc990f..2d9170135d 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -9,6 +9,7 @@ import type { Application } from 'express'; import type { DaemonStatusProvider } from '@qwen-code/acp-bridge'; import { hashDaemonWorkspace, + Storage, type DurableCronTask, } from '@qwen-code/qwen-code-core'; import type { DaemonLogger } from './daemon-logger.js'; @@ -171,7 +172,10 @@ import { } from './server/error-handlers.js'; import { installRateLimiter } from './server/rate-limiter-setup.js'; import { createServeFeatures } from './server/serve-features.js'; -import { SessionArchiveCoordinator } from './server/session-archive.js'; +import { + deleteDaemonSessionIfOrphan, + SessionArchiveCoordinator, +} from './server/session-archive.js'; import { installSelfOriginStripMiddleware } from './server/self-origin.js'; import { createSingleWorkspaceRegistry, @@ -181,6 +185,10 @@ import { type WorkspaceRuntime, type WorkspaceRuntimeEnvMetadata, } from './workspace-registry.js'; +import { + createWorkspaceRuntimeSessionService, + runWithWorkspaceRuntimeStorage, +} from './workspace-runtime-storage.js'; import { isScratchRootCompatible, type ManagedScratchRoot, @@ -935,6 +943,21 @@ export function createServeApp( defaultBridgeForAdmission = bridge; } const archiveCoordinator = new SessionArchiveCoordinator(); + ( + app.locals as { + sessionArchiveCoordinator?: SessionArchiveCoordinator; + } + ).sessionArchiveCoordinator = archiveCoordinator; + + const cleanupSession = (runtime: WorkspaceRuntime, sessionId: string) => + runWithWorkspaceRuntimeStorage(runtime, () => + deleteDaemonSessionIfOrphan({ + sessionId, + service: createWorkspaceRuntimeSessionService(runtime), + bridge: runtime.bridge, + coordinator: archiveCoordinator, + }), + ); installSelfOriginStripMiddleware(app, getPort); @@ -1038,6 +1061,7 @@ export function createServeApp( { workspaceId: hashDaemonWorkspace(boundWorkspace), workspaceCwd: boundWorkspace, + sessionRuntimeBaseDir: Storage.getRuntimeBaseDir(), primary: true, trusted: deps.primaryWorkspaceTrusted ?? false, env: primaryRuntimeEnvMetadata ?? { @@ -1852,6 +1876,7 @@ export function createServeApp( workspaceRegistry.primaryEntry.state === 'active' ? workspaceRegistry.primaryEntry.current?.runtime : undefined, + cleanupSession, channelDeliveryAuthorizations: deps.channelDeliveryAuthorizations, }); @@ -1875,6 +1900,7 @@ export function createServeApp( safeBody, manageScheduledTaskSessions: deps.manageScheduledTaskSessions === true, channelDeliveryAuthorizations: deps.channelDeliveryAuthorizations, + cleanupSession, }); // Read-only token-usage dashboard (Daemon Status "统计" tab). Aggregate local @@ -1918,28 +1944,27 @@ export function createServeApp( // restart (a bound task fires only in its own session, which nothing else // reloads). Fire-and-forget so it never delays the server coming up; a // no-op when there are no bound tasks. Deliberately not awaited. - const rehydrateWorkspace = ( - taskBridge: AcpSessionBridge, - workspaceCwd: string, - ) => { - void rehydrateScheduledTaskSessions({ - bridge: taskBridge, - boundWorkspace: workspaceCwd, - onTasksRead: (tasks) => - registerScheduledTaskAuthorizations(workspaceCwd, tasks), - onError: (sessionId, err) => { - process.stderr.write( - `qwen serve: failed to rehydrate scheduled-task session ${sessionId}: ${ - err instanceof Error ? err.message : String(err) - }\n`, - ); - }, - // Outer catch is defense-in-depth: rehydrateScheduledTaskSessions already - // catches readCronTasks failures and per-session load errors internally - // (returning { loaded, failed }), so this only guards an unexpected throw - // from the function entry itself. Log rather than swallow it — a silent - // failure here leaves every bound task dormant with no diagnostic. - }).catch((err) => { + const rehydrateWorkspace = (runtime: WorkspaceRuntime) => { + void runWithWorkspaceRuntimeStorage(runtime, () => + rehydrateScheduledTaskSessions({ + bridge: runtime.bridge, + boundWorkspace: runtime.workspaceCwd, + onTasksRead: (tasks) => + registerScheduledTaskAuthorizations(runtime.workspaceCwd, tasks), + onError: (sessionId, err) => { + process.stderr.write( + `qwen serve: failed to rehydrate scheduled-task session ${sessionId}: ${ + err instanceof Error ? err.message : String(err) + }\n`, + ); + }, + // Outer catch is defense-in-depth: rehydrateScheduledTaskSessions already + // catches readCronTasks failures and per-session load errors internally + // (returning { loaded, failed }), so this only guards an unexpected throw + // from the function entry itself. Log rather than swallow it — a silent + // failure here leaves every bound task dormant with no diagnostic. + }), + ).catch((err) => { process.stderr.write( `qwen serve: unexpected scheduled-task rehydration failure: ${ err instanceof Error ? err.message : String(err) @@ -1961,10 +1986,12 @@ export function createServeApp( bridge: runtime.bridge, boundWorkspace: runtime.workspaceCwd, intervalMs: keepaliveIntervalMs, + runtimeBaseDir: runtime.sessionRuntimeBaseDir, + cleanupSession: (sessionId) => cleanupSession(runtime, sessionId), onTasksRead: (tasks) => registerScheduledTaskAuthorizations(runtime.workspaceCwd, tasks), }); - rehydrateWorkspace(runtime.bridge, runtime.workspaceCwd); + rehydrateWorkspace(runtime); keepaliveStops.set(runtime.workspaceCwd, keepalive.stop); }; for (const runtime of workspaceRegistry.list()) { diff --git a/packages/cli/src/serve/server/error-response.test.ts b/packages/cli/src/serve/server/error-response.test.ts index 1ed6fdacf1..9dadb02ec0 100644 --- a/packages/cli/src/serve/server/error-response.test.ts +++ b/packages/cli/src/serve/server/error-response.test.ts @@ -13,6 +13,7 @@ import { SessionWriterUnavailableError, } from '@qwen-code/qwen-code-core'; import { sendBridgeError } from './error-response.js'; +import { DaemonDrainingError } from './session-archive.js'; function responseMock(): { response: Response; @@ -28,6 +29,20 @@ function responseMock(): { } describe('sendBridgeError session writer errors', () => { + it('maps sealed session maintenance to daemon_draining', () => { + const { response, status, json } = responseMock(); + + sendBridgeError(response, new DaemonDrainingError()); + + expect(status).toHaveBeenCalledWith(503); + expect(json).toHaveBeenCalledWith({ + error: + 'The daemon is draining and no longer accepts session maintenance.', + code: 'daemon_draining', + errorKind: 'daemon_draining', + }); + }); + it.each([ { error: new SessionWriterConflictError(), diff --git a/packages/cli/src/serve/server/error-response.ts b/packages/cli/src/serve/server/error-response.ts index 71a2a00074..70b5cedfd6 100644 --- a/packages/cli/src/serve/server/error-response.ts +++ b/packages/cli/src/serve/server/error-response.ts @@ -56,6 +56,7 @@ import { WorkspaceSkillNotToggleableError, } from '../workspace-service/types.js'; import { sendGenerationClosedError } from '../workspace-route-runtime.js'; +import { DaemonDrainingError } from './session-archive.js'; export type BridgeErrorContext = { route?: string; @@ -169,6 +170,14 @@ export function sendBridgeError( ctx?: BridgeErrorContext, daemonLog?: DaemonLogger, ): void { + if (err instanceof DaemonDrainingError) { + res.status(503).json({ + error: err.message, + code: err.code, + errorKind: err.code, + }); + return; + } if (sendGenerationClosedError(res, err)) return; if (err instanceof SessionWriterError) { res.status(err.httpStatus).json({ diff --git a/packages/cli/src/serve/server/session-archive.test.ts b/packages/cli/src/serve/server/session-archive.test.ts index 9e1153dc08..beaa275f2e 100644 --- a/packages/cli/src/serve/server/session-archive.test.ts +++ b/packages/cli/src/serve/server/session-archive.test.ts @@ -10,7 +10,11 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SessionService, + SessionWriterConflictError, + SessionWriterLostError, + type SessionWriterLease, Storage, + getCronFilePath, readCronTasks, updateCronTasks, } from '@qwen-code/qwen-code-core'; @@ -25,9 +29,11 @@ import { archiveDaemonSessions, assertSessionArchived, assertSessionLoadable, + deleteDaemonSessionIfOrphan, deleteDaemonSessions, SessionArchiveCoordinator, unarchiveDaemonSessions, + DaemonDrainingError, } from './session-archive.js'; describe('assertSessionLoadable', () => { @@ -188,6 +194,47 @@ describe('SessionArchiveCoordinator', () => { coordinator.runExclusiveMany([sessionId], async () => 'ok'), ).resolves.toBe('ok'); }); + + it('seals new maintenance and waits only for admitted exclusive work', async () => { + const coordinator = new SessionArchiveCoordinator(); + let finish!: () => void; + const gate = new Promise((resolve) => { + finish = resolve; + }); + const maintenance = coordinator.runExclusiveMany(['session-a'], () => gate); + const drain = coordinator.sealMaintenanceAndWait(); + + await expect( + coordinator.runExclusiveMany(['session-b'], async () => undefined), + ).rejects.toMatchObject({ code: 'daemon_draining' }); + let drained = false; + void drain.then(() => { + drained = true; + }); + await Promise.resolve(); + expect(drained).toBe(false); + + finish(); + await maintenance; + await drain; + expect(drained).toBe(true); + }); + + it('does not wait for shared transcript reads when sealed', async () => { + const coordinator = new SessionArchiveCoordinator(); + let finish!: () => void; + const shared = coordinator.runSharedMany( + ['session-a'], + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + + await expect(coordinator.sealMaintenanceAndWait()).resolves.toBeUndefined(); + finish(); + await shared; + }); }); describe('archiveDaemonSessions', () => { @@ -273,30 +320,354 @@ describe('archiveDaemonSessions', () => { expect(byId['other']!.enabled).toBeUndefined(); // unrelated — untouched }); - it('does not lock ids that are already archived or missing', async () => { + it('does not acquire writer leases for ids already archived or missing', async () => { const archivedId = '550e8400-e29b-41d4-a716-446655440003'; const missingId = '550e8400-e29b-41d4-a716-446655440004'; writeSessionFile(workspaceDir, archivedId, 'archived'); const service = new SessionService(workspaceDir); const closeSession = vi.fn().mockResolvedValue(undefined); - const coordinator = new SessionArchiveCoordinator(); + const acquire = vi.spyOn(service, 'acquireSessionWriterLease'); - await coordinator.runSharedMany([archivedId, missingId], async () => { - const result = await archiveDaemonSessions({ - sessionIds: [archivedId, missingId], - service, - bridge: { closeSession }, - coordinator, - }); - - expect(result).toEqual({ - archived: [], - alreadyArchived: [archivedId], - notFound: [missingId], - errors: [], - }); + const result = await archiveDaemonSessions({ + sessionIds: [archivedId, missingId], + service, + bridge: { closeSession }, + coordinator: new SessionArchiveCoordinator(), }); - expect(closeSession).not.toHaveBeenCalled(); + + expect(result).toEqual({ + archived: [], + alreadyArchived: [archivedId], + notFound: [missingId], + errors: [], + }); + expect(acquire).not.toHaveBeenCalled(); + expect(closeSession).toHaveBeenCalledTimes(2); + }); + + it('does not archive while another writer holds the lease', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440005'; + writeSessionFile(workspaceDir, sessionId, 'active'); + const service = new SessionService(workspaceDir); + const lease = await service.acquireSessionWriterLease(sessionId, { + processKind: 'daemon', + reclaimPolicy: 'never', + }); + + const blocked = await archiveDaemonSessions({ + sessionIds: [sessionId], + service, + bridge: { closeSession: vi.fn().mockResolvedValue(undefined) }, + coordinator: new SessionArchiveCoordinator(), + }); + expect(blocked.archived).toEqual([]); + expect(blocked.errors[0]?.error).toBeInstanceOf(SessionWriterConflictError); + expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe( + true, + ); + + await lease.release(); + const retried = await archiveDaemonSessions({ + sessionIds: [sessionId], + service, + bridge: { closeSession: vi.fn().mockResolvedValue(undefined) }, + coordinator: new SessionArchiveCoordinator(), + }); + expect(retried.archived).toEqual([sessionId]); + }); + + it('keeps independent batch sessions moving when one writer conflicts', async () => { + const blockedId = '550e8400-e29b-41d4-a716-446655440008'; + const availableId = '550e8400-e29b-41d4-a716-446655440009'; + writeSessionFile(workspaceDir, blockedId, 'active'); + writeSessionFile(workspaceDir, availableId, 'active'); + const service = new SessionService(workspaceDir); + const lease = await service.acquireSessionWriterLease(blockedId, { + processKind: 'daemon', + reclaimPolicy: 'never', + }); + + const result = await archiveDaemonSessions({ + sessionIds: [blockedId, availableId], + service, + bridge: { closeSession: vi.fn().mockResolvedValue(undefined) }, + coordinator: new SessionArchiveCoordinator(), + }); + + expect(result.archived).toEqual([availableId]); + expect(result.errors[0]?.sessionId).toBe(blockedId); + expect(result.errors[0]?.error).toBeInstanceOf(SessionWriterConflictError); + await lease.release(); + }); + + it('reports a gate race per session after another batch item was archived', async () => { + const archivedId = '550e8400-e29b-41d4-a716-446655440023'; + const blockedId = '550e8400-e29b-41d4-a716-446655440024'; + writeSessionFile(workspaceDir, archivedId, 'active'); + writeSessionFile(workspaceDir, blockedId, 'active'); + const coordinator = new SessionArchiveCoordinator(); + let releaseBlocked!: () => void; + const blocked = new Promise((resolve) => { + releaseBlocked = resolve; + }); + let competingMaintenance: Promise | undefined; + + const result = await archiveDaemonSessions({ + sessionIds: [archivedId, blockedId], + service: new SessionService(workspaceDir), + bridge: { + closeSession: vi.fn(async (sessionId) => { + if (sessionId === archivedId) { + competingMaintenance = coordinator.runExclusiveMany( + [blockedId], + () => blocked, + ); + } + }), + }, + coordinator, + }); + + try { + expect(result.archived).toEqual([archivedId]); + expect(result.errors).toEqual([ + { + sessionId: blockedId, + error: expect.any(SessionArchivingError), + }, + ]); + } finally { + releaseBlocked(); + await competingMaintenance; + } + }); + + it('keeps independent batch sessions moving when one classification fails', async () => { + const failedId = '550e8400-e29b-41d4-a716-446655440019'; + const availableId = '550e8400-e29b-41d4-a716-446655440020'; + writeSessionFile(workspaceDir, availableId, 'active'); + const service = new SessionService(workspaceDir); + const getLocation = service.getSessionLocation.bind(service); + const failure = new Error('classification failed'); + vi.spyOn(service, 'getSessionLocation').mockImplementation((sessionId) => + sessionId === failedId ? Promise.reject(failure) : getLocation(sessionId), + ); + + const result = await archiveDaemonSessions({ + sessionIds: [failedId, availableId], + service, + bridge: { closeSession: vi.fn().mockResolvedValue(undefined) }, + coordinator: new SessionArchiveCoordinator(), + }); + + expect(result.archived).toEqual([availableId]); + expect(result.errors).toEqual([{ sessionId: failedId, error: failure }]); + }); + + it('does not acquire a lease or mutate when closing the owner fails', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440017'; + writeSessionFile(workspaceDir, sessionId, 'active'); + const service = new SessionService(workspaceDir); + const acquire = vi.spyOn(service, 'acquireSessionWriterLease'); + const closeError = new Error('agent flush failed'); + + const result = await archiveDaemonSessions({ + sessionIds: [sessionId], + service, + bridge: { closeSession: vi.fn().mockRejectedValue(closeError) }, + coordinator: new SessionArchiveCoordinator(), + }); + + expect(result.archived).toEqual([]); + expect(result.errors).toEqual([{ sessionId, error: closeError }]); + expect(acquire).not.toHaveBeenCalled(); + expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe( + true, + ); + }); + + it('uses the classification made after acquiring the lease', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440010'; + writeSessionFile(workspaceDir, sessionId, 'active'); + const service = new SessionService(workspaceDir); + const originalGetLocation = service.getSessionLocation.bind(service); + let classifications = 0; + vi.spyOn(service, 'getSessionLocation').mockImplementation(async (id) => { + classifications++; + if (classifications === 2) { + fs.mkdirSync(path.dirname(sessionPath(workspaceDir, id, 'archived')), { + recursive: true, + }); + fs.renameSync( + sessionPath(workspaceDir, id, 'active'), + sessionPath(workspaceDir, id, 'archived'), + ); + } + return originalGetLocation(id); + }); + + const result = await archiveDaemonSessions({ + sessionIds: [sessionId], + service, + bridge: { closeSession: vi.fn().mockResolvedValue(undefined) }, + coordinator: new SessionArchiveCoordinator(), + }); + + expect(result).toEqual({ + archived: [], + alreadyArchived: [sessionId], + notFound: [], + errors: [], + }); + const reacquired = await service.acquireSessionWriterLease(sessionId, { + processKind: 'daemon', + reclaimPolicy: 'never', + }); + await reacquired.release(); + }); + + it('does not lock an active/archive conflict', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440016'; + writeSessionFile(workspaceDir, sessionId, 'active'); + writeSessionFile(workspaceDir, sessionId, 'archived'); + const service = new SessionService(workspaceDir); + const acquire = vi.spyOn(service, 'acquireSessionWriterLease'); + + const result = await archiveDaemonSessions({ + sessionIds: [sessionId], + service, + bridge: { closeSession: vi.fn().mockResolvedValue(undefined) }, + coordinator: new SessionArchiveCoordinator(), + }); + + expect(result.archived).toEqual([]); + expect(result.errors).toHaveLength(1); + expect(acquire).not.toHaveBeenCalled(); + }); + + it('does not report success after release fails but reconciles the task to the applied archive', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440006'; + writeSessionFile(workspaceDir, sessionId, 'active'); + await updateCronTasks(workspaceDir, () => [ + { + id: 'bound', + cron: '0 9 * * *', + prompt: 'p', + recurring: true, + createdAt: 1_700_000_000_000, + lastFiredAt: null, + sessionId, + }, + ]); + const service = new SessionService(workspaceDir); + const release = vi.fn(async () => { + expect((await readCronTasks(workspaceDir))[0]?.enabled).toBe(false); + throw new SessionWriterLostError(); + }); + vi.spyOn(service, 'acquireSessionWriterLease').mockResolvedValue({ + assertOwnedAndUnchanged: vi.fn().mockResolvedValue(undefined), + release, + } as unknown as SessionWriterLease); + + const result = await archiveDaemonSessions({ + sessionIds: [sessionId], + service, + bridge: { closeSession: vi.fn().mockResolvedValue(undefined) }, + coordinator: new SessionArchiveCoordinator(), + }); + + expect(result.archived).toEqual([]); + expect(result.errors[0]?.error).toBeInstanceOf(SessionWriterLostError); + expect( + fs.existsSync(sessionPath(workspaceDir, sessionId, 'archived')), + ).toBe(true); + expect((await readCronTasks(workspaceDir))[0]?.enabled).toBe(false); + expect(release).toHaveBeenCalledOnce(); + }); + + it('releases the lease when scheduled-task reconciliation fails', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440018'; + writeSessionFile(workspaceDir, sessionId, 'active'); + fs.mkdirSync(getCronFilePath(workspaceDir), { recursive: true }); + const service = new SessionService(workspaceDir); + + const result = await archiveDaemonSessions({ + sessionIds: [sessionId], + service, + bridge: { closeSession: vi.fn().mockResolvedValue(undefined) }, + coordinator: new SessionArchiveCoordinator(), + }); + + expect(result.archived).toEqual([sessionId]); + const reacquired = await service.acquireSessionWriterLease(sessionId, { + processKind: 'daemon', + reclaimPolicy: 'never', + }); + await reacquired.release(); + }); + + it('checks only the selected runtime root for transcripts and locks', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440007'; + const primaryRuntime = path.join(runtimeDir, 'primary'); + const secondaryRuntime = path.join(runtimeDir, 'secondary'); + writeSessionFile( + workspaceDir, + sessionId, + 'active', + workspaceDir, + secondaryRuntime, + ); + const primaryService = new SessionService(workspaceDir, { + runtimeBaseDir: primaryRuntime, + }); + const primaryLease = await primaryService.acquireSessionWriterLease( + sessionId, + { + processKind: 'daemon', + reclaimPolicy: 'never', + }, + ); + + const result = await archiveDaemonSessions({ + sessionIds: [sessionId], + service: new SessionService(workspaceDir, { + runtimeBaseDir: secondaryRuntime, + }), + bridge: { closeSession: vi.fn().mockResolvedValue(undefined) }, + coordinator: new SessionArchiveCoordinator(), + }); + + expect(result.archived).toEqual([sessionId]); + expect( + fs.existsSync( + sessionPath(workspaceDir, sessionId, 'archived', secondaryRuntime), + ), + ).toBe(true); + expect( + fs.existsSync( + sessionPath(workspaceDir, sessionId, 'active', primaryRuntime), + ), + ).toBe(false); + await primaryLease.release(); + }); + + it('rejects with DaemonDrainingError after the coordinator is sealed', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440080'; + writeSessionFile(workspaceDir, sessionId, 'active'); + const coordinator = new SessionArchiveCoordinator(); + await coordinator.sealMaintenanceAndWait(); + + await expect( + archiveDaemonSessions({ + sessionIds: [sessionId], + service: new SessionService(workspaceDir), + bridge: { closeSession: vi.fn().mockResolvedValue(undefined) }, + coordinator, + }), + ).rejects.toThrow(DaemonDrainingError); + expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe( + true, + ); }); }); @@ -324,22 +695,19 @@ describe('unarchiveDaemonSessions', () => { writeSessionFile(workspaceDir, archivedId, 'archived'); writeSessionFile(workspaceDir, activeId, 'active'); const service = new SessionService(workspaceDir); - const coordinator = new SessionArchiveCoordinator(); - - await coordinator.runSharedMany([activeId, missingId], async () => { - const result = await unarchiveDaemonSessions({ - sessionIds: [archivedId, activeId, missingId, archivedId], - service, - coordinator, - }); - - expect(result).toEqual({ - unarchived: [archivedId], - alreadyActive: [activeId], - notFound: [missingId], - errors: [], - }); + const acquire = vi.spyOn(service, 'acquireSessionWriterLease'); + const result = await unarchiveDaemonSessions({ + sessionIds: [archivedId, activeId, missingId, archivedId], + service, + coordinator: new SessionArchiveCoordinator(), }); + expect(result).toEqual({ + unarchived: [archivedId], + alreadyActive: [activeId], + notFound: [missingId], + errors: [], + }); + expect(acquire).toHaveBeenCalledTimes(1); expect(fs.existsSync(sessionPath(workspaceDir, archivedId, 'active'))).toBe( true, ); @@ -348,6 +716,29 @@ describe('unarchiveDaemonSessions', () => { ).toBe(false); }); + it('does not unarchive while another writer holds the lease', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440015'; + writeSessionFile(workspaceDir, sessionId, 'archived'); + const service = new SessionService(workspaceDir); + const lease = await service.acquireSessionWriterLease(sessionId, { + processKind: 'daemon', + reclaimPolicy: 'never', + }); + + const result = await unarchiveDaemonSessions({ + sessionIds: [sessionId], + service, + coordinator: new SessionArchiveCoordinator(), + }); + expect(result.unarchived).toEqual([]); + expect(result.errors[0]?.error).toBeInstanceOf(SessionWriterConflictError); + expect( + fs.existsSync(sessionPath(workspaceDir, sessionId, 'archived')), + ).toBe(true); + + await lease.release(); + }); + it('reports a single error per archived id when unarchive batch fails', async () => { const archivedId = '550e8400-e29b-41d4-a716-446655440014'; writeSessionFile(workspaceDir, archivedId, 'archived'); @@ -367,6 +758,75 @@ describe('unarchiveDaemonSessions', () => { notFound: [], errors: [{ sessionId: archivedId, error: failure }], }); + const reacquired = await service.acquireSessionWriterLease(archivedId, { + processKind: 'daemon', + reclaimPolicy: 'never', + }); + await reacquired.release(); + }); + + it('keeps independent unarchive sessions moving when one classification fails', async () => { + const failedId = '550e8400-e29b-41d4-a716-446655440021'; + const availableId = '550e8400-e29b-41d4-a716-446655440022'; + writeSessionFile(workspaceDir, availableId, 'archived'); + const service = new SessionService(workspaceDir); + const getLocation = service.getSessionLocation.bind(service); + const failure = new Error('classification failed'); + vi.spyOn(service, 'getSessionLocation').mockImplementation((sessionId) => + sessionId === failedId ? Promise.reject(failure) : getLocation(sessionId), + ); + + const result = await unarchiveDaemonSessions({ + sessionIds: [failedId, availableId], + service, + coordinator: new SessionArchiveCoordinator(), + }); + + expect(result.unarchived).toEqual([availableId]); + expect(result.errors).toEqual([{ sessionId: failedId, error: failure }]); + }); + + it('reports a gate race per session after another batch item was unarchived', async () => { + const unarchivedId = '550e8400-e29b-41d4-a716-446655440025'; + const blockedId = '550e8400-e29b-41d4-a716-446655440026'; + writeSessionFile(workspaceDir, unarchivedId, 'archived'); + writeSessionFile(workspaceDir, blockedId, 'archived'); + const service = new SessionService(workspaceDir); + const getLocation = service.getSessionLocation.bind(service); + const coordinator = new SessionArchiveCoordinator(); + let releaseBlocked!: () => void; + const blocked = new Promise((resolve) => { + releaseBlocked = resolve; + }); + let competingMaintenance: Promise | undefined; + vi.spyOn(service, 'getSessionLocation').mockImplementation((sessionId) => { + if (sessionId === unarchivedId && !competingMaintenance) { + competingMaintenance = coordinator.runExclusiveMany( + [blockedId], + () => blocked, + ); + } + return getLocation(sessionId); + }); + + const result = await unarchiveDaemonSessions({ + sessionIds: [unarchivedId, blockedId], + service, + coordinator, + }); + + try { + expect(result.unarchived).toEqual([unarchivedId]); + expect(result.errors).toEqual([ + { + sessionId: blockedId, + error: expect.any(SessionArchivingError), + }, + ]); + } finally { + releaseBlocked(); + await competingMaintenance; + } }); it('re-enables an archive-disabled task bound to the unarchived session', async () => { @@ -434,6 +894,24 @@ describe('unarchiveDaemonSessions', () => { expect(stranded!.enabled).toBe(true); // recovered expect(stranded!.disabledByArchive).toBeUndefined(); }); + + it('rejects with DaemonDrainingError after the coordinator is sealed', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440081'; + writeSessionFile(workspaceDir, sessionId, 'archived'); + const coordinator = new SessionArchiveCoordinator(); + await coordinator.sealMaintenanceAndWait(); + + await expect( + unarchiveDaemonSessions({ + sessionIds: [sessionId], + service: new SessionService(workspaceDir), + coordinator, + }), + ).rejects.toThrow(DaemonDrainingError); + expect( + fs.existsSync(sessionPath(workspaceDir, sessionId, 'archived')), + ).toBe(true); + }); }); describe('deleteDaemonSessions', () => { @@ -487,6 +965,186 @@ describe('deleteDaemonSessions', () => { const ids = (await readCronTasks(workspaceDir)).map((t) => t.id).sort(); expect(ids).toEqual(['other']); // bound task deleted, unbound survives }); + + it('does not delete while another writer holds the lease', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440071'; + writeSessionFile(workspaceDir, sessionId, 'active'); + const service = new SessionService(workspaceDir); + const lease = await service.acquireSessionWriterLease(sessionId, { + processKind: 'daemon', + reclaimPolicy: 'never', + }); + + const result = await deleteDaemonSessions({ + sessionIds: [sessionId], + service, + bridge: { closeSession: vi.fn().mockResolvedValue(undefined) }, + coordinator: new SessionArchiveCoordinator(), + }); + expect(result.removed).toEqual([]); + expect(result.errors).toEqual([ + { + sessionId, + error: 'This session is already open in another Qwen process.', + }, + ]); + expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe( + true, + ); + + await lease.release(); + }); + + it('reports a gate race per session after another batch item was deleted', async () => { + const removedId = '550e8400-e29b-41d4-a716-446655440073'; + const blockedId = '550e8400-e29b-41d4-a716-446655440074'; + writeSessionFile(workspaceDir, removedId, 'active'); + writeSessionFile(workspaceDir, blockedId, 'active'); + const coordinator = new SessionArchiveCoordinator(); + let releaseBlocked!: () => void; + const blocked = new Promise((resolve) => { + releaseBlocked = resolve; + }); + let competingMaintenance: Promise | undefined; + + try { + const result = await deleteDaemonSessions({ + sessionIds: [removedId, blockedId], + service: new SessionService(workspaceDir), + bridge: { + closeSession: vi.fn(async (sessionId) => { + if (sessionId === removedId) { + competingMaintenance = coordinator.runExclusiveMany( + [blockedId], + () => blocked, + ); + } + }), + }, + coordinator, + }); + + expect(result.removed).toEqual([removedId]); + expect(result.errors).toEqual([ + { + sessionId: blockedId, + error: expect.stringContaining('is being archived or unarchived'), + }, + ]); + expect( + fs.existsSync(sessionPath(workspaceDir, removedId, 'active')), + ).toBe(false); + expect( + fs.existsSync(sessionPath(workspaceDir, blockedId, 'active')), + ).toBe(true); + } finally { + releaseBlocked(); + await competingMaintenance; + } + }); + + it('skips orphan deletion when a new owner attached', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440072'; + writeSessionFile(workspaceDir, sessionId, 'active'); + const service = new SessionService(workspaceDir); + const acquire = vi.spyOn(service, 'acquireSessionWriterLease'); + + await expect( + deleteDaemonSessionIfOrphan({ + sessionId, + service, + bridge: { killSession: vi.fn().mockResolvedValue(false) }, + coordinator: new SessionArchiveCoordinator(), + }), + ).resolves.toBe(false); + expect(acquire).not.toHaveBeenCalled(); + expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe( + true, + ); + }); + + it('rejects with DaemonDrainingError after the coordinator is sealed', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440082'; + writeSessionFile(workspaceDir, sessionId, 'active'); + const coordinator = new SessionArchiveCoordinator(); + await coordinator.sealMaintenanceAndWait(); + + await expect( + deleteDaemonSessions({ + sessionIds: [sessionId], + service: new SessionService(workspaceDir), + bridge: { closeSession: vi.fn().mockResolvedValue(undefined) }, + coordinator, + }), + ).rejects.toThrow(DaemonDrainingError); + expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe( + true, + ); + }); + + it('deletes the transcript when killSession resolves true', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440083'; + writeSessionFile(workspaceDir, sessionId, 'active'); + const service = new SessionService(workspaceDir); + + await expect( + deleteDaemonSessionIfOrphan({ + sessionId, + service, + bridge: { killSession: vi.fn().mockResolvedValue(true) }, + coordinator: new SessionArchiveCoordinator(), + }), + ).resolves.toBe(true); + expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe( + false, + ); + }); + + it('deletes the transcript when killSession throws SessionNotFoundError', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440084'; + writeSessionFile(workspaceDir, sessionId, 'active'); + const service = new SessionService(workspaceDir); + + await expect( + deleteDaemonSessionIfOrphan({ + sessionId, + service, + bridge: { + killSession: vi + .fn() + .mockRejectedValue(new SessionNotFoundError(sessionId)), + }, + coordinator: new SessionArchiveCoordinator(), + }), + ).resolves.toBe(true); + expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe( + false, + ); + }); + + it('throws when the lease is held by another writer', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440085'; + writeSessionFile(workspaceDir, sessionId, 'active'); + const service = new SessionService(workspaceDir); + const lease = await service.acquireSessionWriterLease(sessionId, { + processKind: 'daemon', + reclaimPolicy: 'never', + }); + + await expect( + deleteDaemonSessionIfOrphan({ + sessionId, + service, + bridge: { killSession: vi.fn().mockResolvedValue(true) }, + coordinator: new SessionArchiveCoordinator(), + }), + ).rejects.toThrow(SessionWriterConflictError); + expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe( + true, + ); + + await lease.release(); + }); }); function writeSessionFile( @@ -494,9 +1152,10 @@ function writeSessionFile( sessionId: string, state: 'active' | 'archived', recordCwd = workspaceDir, + runtimeBaseDir?: string, ): void { const chatsDir = path.join( - new Storage(workspaceDir).getProjectDir(), + new Storage(workspaceDir, runtimeBaseDir).getProjectDir(), 'chats', ); const targetDir = @@ -521,9 +1180,10 @@ function sessionPath( workspaceDir: string, sessionId: string, state: 'active' | 'archived', + runtimeBaseDir?: string, ): string { const chatsDir = path.join( - new Storage(workspaceDir).getProjectDir(), + new Storage(workspaceDir, runtimeBaseDir).getProjectDir(), 'chats', ); return path.join( diff --git a/packages/cli/src/serve/server/session-archive.ts b/packages/cli/src/serve/server/session-archive.ts index 15abaa4832..a580c3f52d 100644 --- a/packages/cli/src/serve/server/session-archive.ts +++ b/packages/cli/src/serve/server/session-archive.ts @@ -46,9 +46,23 @@ export interface DaemonDeleteSessionsResult { export type DaemonDeleteErrorPhase = 'close' | 'remove' | 'delete'; +export class DaemonDrainingError extends Error { + override readonly name = 'DaemonDrainingError'; + readonly code = 'daemon_draining'; + + constructor() { + super('The daemon is draining and no longer accepts session maintenance.'); + } +} + export class SessionArchiveCoordinator { private readonly exclusive = new Set(); private readonly shared = new Map(); + private maintenanceSealed = false; + private activeMaintenance = 0; + private maintenanceDrain: + | { promise: Promise; resolve: () => void } + | undefined; assertNotTransitioning(sessionId: string): void { if (this.exclusive.has(sessionId)) { @@ -60,6 +74,9 @@ export class SessionArchiveCoordinator { sessionIds: string[], fn: () => Promise, ): Promise { + if (this.maintenanceSealed) { + throw new DaemonDrainingError(); + } const uniqueSessionIds = [...new Set(sessionIds)]; for (const sessionId of uniqueSessionIds) { this.assertNotTransitioning(sessionId); @@ -70,15 +87,36 @@ export class SessionArchiveCoordinator { for (const sessionId of uniqueSessionIds) { this.exclusive.add(sessionId); } + this.activeMaintenance++; try { return await fn(); } finally { for (const sessionId of uniqueSessionIds) { this.exclusive.delete(sessionId); } + this.activeMaintenance--; + if (this.activeMaintenance === 0) { + this.maintenanceDrain?.resolve(); + this.maintenanceDrain = undefined; + } } } + sealMaintenanceAndWait(): Promise { + this.maintenanceSealed = true; + if (this.activeMaintenance === 0) { + return Promise.resolve(); + } + if (!this.maintenanceDrain) { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + this.maintenanceDrain = { promise, resolve }; + } + return this.maintenanceDrain.promise; + } + async runSharedMany( sessionIds: string[], fn: () => Promise, @@ -105,6 +143,225 @@ export class SessionArchiveCoordinator { } } +type DaemonMaintenanceAction = 'delete' | 'archive' | 'unarchive'; + +interface LeaseMutationResult { + value?: T; + mutationApplied: boolean; + error?: unknown; + maintenanceError?: unknown; +} + +async function runWithDaemonWriterLease(params: { + action: DaemonMaintenanceAction; + sessionId: string; + service: SessionService; + mutate: ( + assertOwnedAndUnchanged: () => Promise, + ) => Promise<{ value: T; mutationApplied: boolean }>; + mutationAppliedAfterError: () => Promise; + afterMutationApplied: () => Promise; +}): Promise> { + const { + action, + sessionId, + service, + mutate, + mutationAppliedAfterError, + afterMutationApplied, + } = params; + let lease; + try { + lease = await service.acquireSessionWriterLease(sessionId, { + processKind: 'daemon', + reclaimPolicy: 'never', + }); + } catch (error) { + return { mutationApplied: false, error }; + } + + let value: T | undefined; + let mutationApplied = false; + let mutationError: unknown; + try { + const mutation = await mutate(() => lease.assertOwnedAndUnchanged()); + value = mutation.value; + mutationApplied = mutation.mutationApplied; + } catch (error) { + mutationError = error; + try { + mutationApplied = await mutationAppliedAfterError(); + } catch { + mutationApplied = false; + } + } + + let maintenanceError: unknown; + if (mutationApplied) { + try { + await afterMutationApplied(); + } catch (error) { + maintenanceError = error; + logSessionArchiveWarning( + `scheduled task lifecycle update failed action=${action} workspace=${safeLogValue( + service.getProjectRoot(), + )} session=${safeLogValue(sessionId)} error=${safeLogValue( + errorMessage(error), + )}`, + ); + } + } + + let releaseError: unknown; + try { + await lease.release(); + } catch (error) { + releaseError = error; + } + + if (releaseError !== undefined) { + logMaintenanceLeaseReleaseFailure({ + action, + workspace: service.getProjectRoot(), + sessionId, + error: releaseError, + mutationApplied, + }); + if (mutationError !== undefined) { + logSessionArchiveWarning( + `session maintenance mutation also failed action=${action} workspace=${safeLogValue( + service.getProjectRoot(), + )} session=${safeLogValue(sessionId)} error=${safeLogValue( + errorMessage(mutationError), + )}`, + ); + } + return { mutationApplied, error: releaseError, maintenanceError }; + } + if (mutationError !== undefined) { + return { mutationApplied, error: mutationError, maintenanceError }; + } + return { value, mutationApplied, maintenanceError }; +} + +function logMaintenanceLeaseReleaseFailure(params: { + action: DaemonMaintenanceAction; + workspace: string; + sessionId: string; + error: unknown; + mutationApplied: boolean; +}): void { + const errorKind = + typeof params.error === 'object' && + params.error !== null && + typeof (params.error as { errorKind?: unknown }).errorKind === 'string' + ? (params.error as { errorKind: string }).errorKind + : 'unknown'; + logSessionArchiveWarning( + `session maintenance lease release failed action=${params.action} workspace=${safeLogValue( + params.workspace, + )} session=${safeLogValue(params.sessionId)} errorKind=${safeLogValue( + errorKind, + )} mutationApplied=${params.mutationApplied}`, + ); +} + +async function classifySessionLocation( + service: SessionService, + sessionId: string, +): Promise { + return service.getSessionLocation(sessionId); +} + +function sessionLocationError(sessionId: string): Error { + return new Error(`Session archive conflict: ${sessionId}`); +} + +function updateScheduledTaskForMaintenance( + service: SessionService, + sessionId: string, + action: DaemonMaintenanceAction, +): Promise { + if (action === 'archive') { + return disableTasksForSessions(service.getProjectRoot(), [sessionId]); + } + if (action === 'unarchive') { + return enableTasksForSessions(service.getProjectRoot(), [sessionId]); + } + return removeTasksForSessions(service.getProjectRoot(), [sessionId]); +} + +type DeleteOneResult = + | { + kind: 'removed'; + mutationApplied: boolean; + } + | { + kind: 'notFound'; + mutationApplied: boolean; + } + | { + kind: 'error'; + error: unknown; + mutationApplied: boolean; + }; + +async function deletePersistedSessionWithLease( + service: SessionService, + sessionId: string, +): Promise { + const initialLocation = await classifySessionLocation(service, sessionId); + if (initialLocation === undefined) { + return { kind: 'notFound', mutationApplied: false }; + } + if (initialLocation === 'conflict') { + return { + kind: 'error', + error: sessionLocationError(sessionId), + mutationApplied: false, + }; + } + + const mutation = await runWithDaemonWriterLease({ + action: 'delete', + sessionId, + service, + mutate: async (assertOwnedAndUnchanged) => { + const lockedLocation = await classifySessionLocation(service, sessionId); + if (lockedLocation === undefined) { + return { + value: 'notFound' as const, + mutationApplied: false, + }; + } + if (lockedLocation === 'conflict') { + throw sessionLocationError(sessionId); + } + await assertOwnedAndUnchanged(); + const removed = await service.removeSession(sessionId); + return { + value: removed ? ('removed' as const) : ('notFound' as const), + mutationApplied: removed, + }; + }, + mutationAppliedAfterError: async () => + (await classifySessionLocation(service, sessionId)) === undefined, + afterMutationApplied: () => + updateScheduledTaskForMaintenance(service, sessionId, 'delete'), + }); + if (mutation.error !== undefined) { + return { + kind: 'error', + error: mutation.error, + mutationApplied: mutation.mutationApplied, + }; + } + return { + kind: mutation.value ?? 'notFound', + mutationApplied: mutation.mutationApplied, + }; +} + export async function deleteDaemonSessions(params: { sessionIds: string[]; service: SessionService; @@ -118,98 +375,131 @@ export async function deleteDaemonSessions(params: { }): Promise { const { sessionIds, service, bridge, coordinator, onError } = params; const uniqueSessionIds = [...new Set(sessionIds)]; - const closeErrors: Array<{ sessionId: string; error: string }> = []; - const removed: string[] = []; - const notFound: string[] = []; - const removeErrors: Array<{ sessionId: string; error: string }> = []; - for (const sessionId of uniqueSessionIds) { coordinator.assertNotTransitioning(sessionId); } - - await Promise.all( + const results = await Promise.all( uniqueSessionIds.map(async (sessionId) => { try { - // Keep close+remove under one gate so load/resume cannot recreate the - // same live session between bridge close and transcript deletion. - await coordinator.runExclusiveMany([sessionId], async () => { - let shouldRemove = false; + return await coordinator.runExclusiveMany([sessionId], async () => { try { - // Intentional: batch delete bypasses per-tab ownership. await bridge.closeSession(sessionId); - shouldRemove = true; - } catch (closeErr) { - if ( - closeErr instanceof SessionNotFoundError || - (closeErr instanceof Error && - closeErr.name === 'SessionNotFoundError') - ) { - shouldRemove = true; - } else { - const message = - closeErr instanceof Error ? closeErr.message : String(closeErr); - onError?.({ phase: 'close', sessionId, error: message }); - closeErrors.push({ sessionId, error: message }); + } catch (error) { + if (isSessionNotFoundError(error)) { + const result = await deletePersistedSessionWithLease( + service, + sessionId, + ); + if (result.kind === 'error') { + onError?.({ + phase: 'remove', + sessionId, + error: errorMessage(result.error), + }); + } + return result; } + onError?.({ + phase: 'close', + sessionId, + error: errorMessage(error), + }); + return { + kind: 'error' as const, + error, + mutationApplied: false, + }; } - if (!shouldRemove) return; - - try { - if (await service.removeSession(sessionId)) { - removed.push(sessionId); - } else { - notFound.push(sessionId); - } - } catch (removeErr) { - const message = - removeErr instanceof Error - ? removeErr.message - : String(removeErr); - onError?.({ phase: 'remove', sessionId, error: message }); - removeErrors.push({ sessionId, error: message }); + const result = await deletePersistedSessionWithLease( + service, + sessionId, + ); + if (result.kind === 'error') { + onError?.({ + phase: 'remove', + sessionId, + error: errorMessage(result.error), + }); } + return result; }); - } catch (err) { - if ( - err instanceof SessionArchivingError && - err.lockKind === 'exclusive' - ) { - throw err; + } catch (error) { + if (error instanceof DaemonDrainingError) { + throw error; } - const message = err instanceof Error ? err.message : String(err); - onError?.({ phase: 'delete', sessionId, error: message }); - closeErrors.push({ sessionId, error: message }); + onError?.({ + phase: 'delete', + sessionId, + error: errorMessage(error), + }); + return { + kind: 'error' as const, + error, + mutationApplied: false, + }; } }), ); - // Deleting a session permanently removes any scheduled task bound to it — - // the task existed only to run in that session. Best-effort: a failure here - // must not turn a successful session delete into an error, but LOG it (like - // the archive/unarchive paths) — the session is already gone, so a swallowed - // write failure leaves the still-enabled bound task a permanent ghost the - // keepalive retries a doomed revive on every tick. - await removeTasksForSessions(service.getProjectRoot(), removed).catch( - (err: unknown) => { - logSessionArchiveWarning( - `removeTasksForSessions failed for [${removed.join(', ')}]: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - }, - ); + const removed: string[] = []; + const notFound: string[] = []; + const errors: Array<{ sessionId: string; error: unknown }> = []; + for (let i = 0; i < results.length; i++) { + const sessionId = uniqueSessionIds[i]!; + const result = results[i]!; + if (result.kind === 'removed') { + removed.push(sessionId); + } else if (result.kind === 'notFound') { + notFound.push(sessionId); + } else { + errors.push({ sessionId, error: errorMessage(result.error) }); + } + } - return { removed, notFound, errors: [...closeErrors, ...removeErrors] }; + return { removed, notFound, errors }; +} + +export async function deleteDaemonSessionIfOrphan(params: { + sessionId: string; + service: SessionService; + bridge: Pick; + coordinator: SessionArchiveCoordinator; +}): Promise { + const { sessionId, service, bridge, coordinator } = params; + coordinator.assertNotTransitioning(sessionId); + const result = await coordinator.runExclusiveMany([sessionId], async () => { + let killed = false; + try { + killed = await bridge.killSession(sessionId, { + requireZeroAttaches: true, + }); + } catch (error) { + if (!isSessionNotFoundError(error)) throw error; + killed = true; + } + if (!killed) { + return undefined; + } + return deletePersistedSessionWithLease(service, sessionId); + }); + if (result === undefined) { + return false; + } + if (result.kind === 'error') { + throw result.error; + } + return true; } export async function assertSessionLoadable( workspaceCwd: string, sessionId: string, + runtimeBaseDir?: string, ): Promise { - const location = await new SessionService(workspaceCwd).getSessionLocation( - sessionId, - ); + const location = await new SessionService(workspaceCwd, { + runtimeBaseDir, + }).getSessionLocation(sessionId); if (location === 'archived') { throw new SessionArchivedError(sessionId); } @@ -222,10 +512,11 @@ export async function assertSessionLoadable( export async function assertSessionArchived( workspaceCwd: string, sessionId: string, + runtimeBaseDir?: string, ): Promise { - const location = await new SessionService(workspaceCwd).getSessionLocation( - sessionId, - ); + const location = await new SessionService(workspaceCwd, { + runtimeBaseDir, + }).getSessionLocation(sessionId); if (location === 'active') { throw new SessionNotArchivedError(sessionId); } @@ -244,53 +535,6 @@ function isSessionNotFoundError(err: unknown): boolean { ); } -interface SessionLocationBuckets { - active: string[]; - archived: string[]; - notFound: string[]; - errors: Array<{ sessionId: string; error: unknown }>; -} - -async function classifySessionLocations( - service: SessionService, - sessionIds: string[], -): Promise { - const result: SessionLocationBuckets = { - active: [], - archived: [], - notFound: [], - errors: [], - }; - const locationResults = await Promise.allSettled( - sessionIds.map(async (sessionId) => ({ - sessionId, - location: await service.getSessionLocation(sessionId), - })), - ); - for (let i = 0; i < locationResults.length; i++) { - const sessionId = sessionIds[i]!; - const locationResult = locationResults[i]!; - if (locationResult.status === 'rejected') { - result.errors.push({ sessionId, error: locationResult.reason }); - continue; - } - const location = locationResult.value.location; - if (location === undefined) { - result.notFound.push(sessionId); - } else if (location === 'archived') { - result.archived.push(sessionId); - } else if (location === 'conflict') { - result.errors.push({ - sessionId, - error: new Error(`Session archive conflict: ${sessionId}`), - }); - } else { - result.active.push(sessionId); - } - } - return result; -} - function logSessionArchiveResult( action: 'archive' | 'unarchive', result: { @@ -355,66 +599,135 @@ export async function archiveDaemonSessions(params: { }): Promise { const { sessionIds, service, bridge, coordinator } = params; const uniqueSessionIds = [...new Set(sessionIds)]; - const archived: string[] = []; - const alreadyArchived: string[] = []; - const notFound: string[] = []; - const errors: Array<{ sessionId: string; error: unknown }> = []; - - const initial = await classifySessionLocations(service, uniqueSessionIds); - const activeIds = initial.active; - alreadyArchived.push(...initial.archived); - notFound.push(...initial.notFound); - errors.push(...initial.errors); - - if (activeIds.length > 0) { - await coordinator.runExclusiveMany(activeIds, async () => { - const locked = await classifySessionLocations(service, activeIds); - const closableIds = locked.active; - alreadyArchived.push(...locked.archived); - notFound.push(...locked.notFound); - errors.push(...locked.errors); - - // Close+flush before moving JSONL: live writers keep the active path. - // If the later move fails, the active JSONL remains and a retry treats - // SessionNotFound as the recoverable "already closed" state. - const closeResults = await Promise.allSettled( - closableIds.map(async (sessionId) => { + for (const sessionId of uniqueSessionIds) { + coordinator.assertNotTransitioning(sessionId); + } + const results = await Promise.all( + uniqueSessionIds.map(async (sessionId) => { + try { + return await coordinator.runExclusiveMany([sessionId], async () => { try { await bridge.closeSession(sessionId, undefined, { requireAgentClose: true, }); - } catch (err) { - if (!isSessionNotFoundError(err)) { - throw err; + } catch (error) { + if (!isSessionNotFoundError(error)) { + return { + kind: 'error' as const, + error, + mutationApplied: false, + }; } } - }), - ); - const archiveIds: string[] = []; - for (let i = 0; i < closeResults.length; i++) { - const sessionId = closableIds[i]!; - const result = closeResults[i]!; - if (result.status === 'fulfilled') { - archiveIds.push(sessionId); - } else { - errors.push({ sessionId, error: result.reason }); - } - } - try { - const archiveResult = await service.archiveSessions(archiveIds, { - knownLocation: 'active', + const initialLocation = await classifySessionLocation( + service, + sessionId, + ); + if (initialLocation === undefined) { + return { kind: 'notFound' as const, mutationApplied: false }; + } + if (initialLocation === 'archived') { + return { + kind: 'alreadyArchived' as const, + mutationApplied: false, + }; + } + if (initialLocation === 'conflict') { + return { + kind: 'error' as const, + error: sessionLocationError(sessionId), + mutationApplied: false, + }; + } + + const mutation = await runWithDaemonWriterLease({ + action: 'archive', + sessionId, + service, + mutate: async (assertOwnedAndUnchanged) => { + const lockedLocation = await classifySessionLocation( + service, + sessionId, + ); + if (lockedLocation === undefined) { + return { + value: 'notFound' as const, + mutationApplied: false, + }; + } + if (lockedLocation === 'archived') { + return { + value: 'alreadyArchived' as const, + mutationApplied: false, + }; + } + if (lockedLocation === 'conflict') { + throw sessionLocationError(sessionId); + } + await assertOwnedAndUnchanged(); + const result = await service.archiveSessions([sessionId], { + knownLocation: 'active', + }); + if (result.errors[0]) throw result.errors[0].error; + if (result.archived.length > 0) { + return { + value: 'archived' as const, + mutationApplied: true, + }; + } + return { + value: + result.alreadyArchived.length > 0 + ? ('alreadyArchived' as const) + : ('notFound' as const), + mutationApplied: false, + }; + }, + mutationAppliedAfterError: async () => + (await classifySessionLocation(service, sessionId)) === + 'archived', + afterMutationApplied: () => + updateScheduledTaskForMaintenance(service, sessionId, 'archive'), + }); + if (mutation.error !== undefined) { + return { + kind: 'error' as const, + error: mutation.error, + mutationApplied: mutation.mutationApplied, + }; + } + return { + kind: mutation.value ?? 'notFound', + mutationApplied: mutation.mutationApplied, + }; }); - archived.push(...archiveResult.archived); - alreadyArchived.push(...archiveResult.alreadyArchived); - notFound.push(...archiveResult.notFound); - errors.push(...archiveResult.errors); - } catch (err) { - for (const sessionId of archiveIds) { - errors.push({ sessionId, error: err }); + } catch (error) { + if (error instanceof DaemonDrainingError) { + throw error; } + return { + kind: 'error' as const, + error, + mutationApplied: false, + maintenanceError: undefined, + }; } - }); + }), + ); + + const archived: string[] = []; + const alreadyArchived: string[] = []; + const notFound: string[] = []; + const errors: Array<{ sessionId: string; error: unknown }> = []; + for (let i = 0; i < results.length; i++) { + const sessionId = uniqueSessionIds[i]!; + const result = results[i]!; + if (result.kind === 'archived') archived.push(sessionId); + else if (result.kind === 'alreadyArchived') { + alreadyArchived.push(sessionId); + } else if (result.kind === 'notFound') notFound.push(sessionId); + else errors.push({ sessionId, error: result.error }); } logSessionArchiveResult('archive', { @@ -425,22 +738,6 @@ export async function archiveDaemonSessions(params: { errors, }); - // Archiving a session pauses any scheduled task bound to it (kept on disk, - // recoverable on unarchive). Best-effort — never fail the archive over it, but - // LOG a write failure: if the task's `enabled` flag isn't flipped, the - // keepalive still sees it enabled + bound and will revive the just-archived - // session so the task keeps firing. Logging makes that broken coupling - // diagnosable rather than silent. - await disableTasksForSessions(service.getProjectRoot(), archived).catch( - (err: unknown) => { - logSessionArchiveWarning( - `disableTasksForSessions failed for [${archived.join(', ')}]: ${ - err instanceof Error ? err.message : String(err) - } — bound tasks may keep firing until reconciled`, - ); - }, - ); - return { archived, alreadyArchived, notFound, errors }; } @@ -451,43 +748,145 @@ export async function unarchiveDaemonSessions(params: { }): Promise { const { sessionIds, service, coordinator } = params; const uniqueSessionIds = [...new Set(sessionIds)]; + for (const sessionId of uniqueSessionIds) { + coordinator.assertNotTransitioning(sessionId); + } + const results = await Promise.all( + uniqueSessionIds.map(async (sessionId) => { + try { + return await coordinator.runExclusiveMany([sessionId], async () => { + const initialLocation = await classifySessionLocation( + service, + sessionId, + ); + if (initialLocation === undefined) { + return { kind: 'notFound' as const, mutationApplied: false }; + } + if (initialLocation === 'active') { + let maintenanceError: unknown; + try { + await updateScheduledTaskForMaintenance( + service, + sessionId, + 'unarchive', + ); + } catch (error) { + maintenanceError = error; + logSessionArchiveWarning( + `scheduled task lifecycle update failed action=unarchive workspace=${safeLogValue( + service.getProjectRoot(), + )} session=${safeLogValue(sessionId)} error=${safeLogValue( + errorMessage(error), + )}`, + ); + } + return { + kind: 'alreadyActive' as const, + mutationApplied: false, + maintenanceError, + }; + } + if (initialLocation === 'conflict') { + return { + kind: 'error' as const, + error: sessionLocationError(sessionId), + mutationApplied: false, + }; + } + + const mutation = await runWithDaemonWriterLease({ + action: 'unarchive', + sessionId, + service, + mutate: async (assertOwnedAndUnchanged) => { + const lockedLocation = await classifySessionLocation( + service, + sessionId, + ); + if (lockedLocation === undefined) { + return { + value: 'notFound' as const, + mutationApplied: false, + }; + } + if (lockedLocation === 'active') { + return { + value: 'alreadyActive' as const, + mutationApplied: false, + }; + } + if (lockedLocation === 'conflict') { + throw sessionLocationError(sessionId); + } + await assertOwnedAndUnchanged(); + const result = await service.unarchiveSessions([sessionId], { + knownLocation: 'archived', + }); + if (result.errors[0]) throw result.errors[0].error; + if (result.unarchived.length > 0) { + return { + value: 'unarchived' as const, + mutationApplied: true, + }; + } + return { + value: + result.alreadyActive.length > 0 + ? ('alreadyActive' as const) + : ('notFound' as const), + mutationApplied: false, + }; + }, + mutationAppliedAfterError: async () => + (await classifySessionLocation(service, sessionId)) === 'active', + afterMutationApplied: () => + updateScheduledTaskForMaintenance( + service, + sessionId, + 'unarchive', + ), + }); + if (mutation.error !== undefined) { + return { + kind: 'error' as const, + error: mutation.error, + mutationApplied: mutation.mutationApplied, + }; + } + return { + kind: mutation.value ?? 'notFound', + mutationApplied: mutation.mutationApplied, + maintenanceError: mutation.maintenanceError, + }; + }); + } catch (error) { + if (error instanceof DaemonDrainingError) { + throw error; + } + return { + kind: 'error' as const, + error, + mutationApplied: false, + maintenanceError: undefined, + }; + } + }), + ); + const unarchived: string[] = []; const alreadyActive: string[] = []; const notFound: string[] = []; const errors: Array<{ sessionId: string; error: unknown }> = []; - - const initial = await classifySessionLocations(service, uniqueSessionIds); - const archivedIds = initial.archived; - alreadyActive.push(...initial.active); - notFound.push(...initial.notFound); - errors.push(...initial.errors); - - if (archivedIds.length > 0) { - await coordinator.runExclusiveMany(archivedIds, async () => { - const locked = await classifySessionLocations(service, archivedIds); - const unarchiveIds = locked.archived; - alreadyActive.push(...locked.active); - notFound.push(...locked.notFound); - errors.push(...locked.errors); - - if (unarchiveIds.length > 0) { - try { - const result = await service.unarchiveSessions(unarchiveIds, { - knownLocation: 'archived', - }); - unarchived.push(...result.unarchived); - alreadyActive.push(...result.alreadyActive); - notFound.push(...result.notFound); - errors.push(...result.errors); - } catch (err) { - // The service reports normal per-session failures in `result.errors`. - // Reaching this catch means the batch could not produce a result at all. - for (const sessionId of unarchiveIds) { - errors.push({ sessionId, error: err }); - } - } - } - }); + for (let i = 0; i < results.length; i++) { + const sessionId = uniqueSessionIds[i]!; + const result = results[i]!; + if (result.kind === 'unarchived') unarchived.push(sessionId); + else if (result.kind === 'alreadyActive') alreadyActive.push(sessionId); + else if (result.kind === 'notFound') notFound.push(sessionId); + else errors.push({ sessionId, error: result.error }); + if (result.maintenanceError !== undefined) { + errors.push({ sessionId, error: result.maintenanceError }); + } } logSessionArchiveResult('unarchive', { @@ -498,29 +897,5 @@ export async function unarchiveDaemonSessions(params: { errors, }); - // Unarchiving a session resumes any scheduled task bound to it (re-enabled, - // anchor reset to now). Also run it for sessions that were ALREADY active: - // enableTasksForSessions is idempotent (it only re-enables archive-disabled - // tasks), so re-unarchiving a session whose task was stranded - // (`disabledByArchive: true`) by a PRIOR failed enable recovers it — otherwise - // that task is unrecoverable (PATCH-enable 409s on the stale flag, keepalive - // skips it). Surface a write failure in `errors` (and log it) instead of - // swallowing, so a stranded task isn't left silent. - const resumeSessionIds = [...new Set([...unarchived, ...alreadyActive])]; - try { - await enableTasksForSessions(service.getProjectRoot(), resumeSessionIds); - } catch (err) { - logSessionArchiveWarning( - `enableTasksForSessions failed for [${resumeSessionIds.join(', ')}]: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - // Report against the full resume set: a failed already-active recovery must - // surface too, or its stranded task stays silently unrecoverable. - for (const sessionId of resumeSessionIds) { - errors.push({ sessionId, error: err }); - } - } - return { unarchived, alreadyActive, notFound, errors }; } 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..06a9e7777f 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); @@ -125,6 +165,7 @@ describe('VirtualSubagentSessions', () => { const runtime = { workspaceId: 'workspace-1', workspaceCwd: '/workspace', + sessionRuntimeBaseDir: Storage.getRuntimeBaseDir(), env: { mode: 'parent-process', overlayKeys: [] }, bridge: { getSessionTasksStatus: async () => ({ @@ -248,6 +289,7 @@ describe('VirtualSubagentSessions', () => { const runtime = { workspaceId: 'workspace-refresh-error', workspaceCwd: '/workspace', + sessionRuntimeBaseDir: Storage.getRuntimeBaseDir(), env: { mode: 'parent-process', overlayKeys: [] }, bridge: { getSessionTasksStatus: async () => ({ @@ -306,6 +348,7 @@ describe('VirtualSubagentSessions', () => { return { workspaceId, workspaceCwd: `/workspace/${workspaceId}`, + sessionRuntimeBaseDir: Storage.getRuntimeBaseDir(), env: { mode: 'parent-process', overlayKeys: [] }, bridge: { getSessionTasksStatus: async () => ({ @@ -373,6 +416,7 @@ describe('VirtualSubagentSessions', () => { const runtime = { workspaceId: 'workspace-batch', workspaceCwd: '/workspace', + sessionRuntimeBaseDir: Storage.getRuntimeBaseDir(), env: { mode: 'parent-process', overlayKeys: [] }, bridge: { getSessionTasksStatus: async () => ({ @@ -441,6 +485,7 @@ describe('VirtualSubagentSessions', () => { const runtime = { workspaceId: 'workspace-reload', workspaceCwd: '/workspace', + sessionRuntimeBaseDir: Storage.getRuntimeBaseDir(), env: { mode: 'parent-process', overlayKeys: [] }, bridge: { getSessionTasksStatus: async () => ({ @@ -552,6 +597,7 @@ describe('VirtualSubagentSessions', () => { const runtime = { workspaceId: 'running-workspace', workspaceCwd, + sessionRuntimeBaseDir: runtimeDir, env: { mode: 'runtime-overlay', overlayKeys: ['QWEN_RUNTIME_DIR'], @@ -728,6 +774,7 @@ describe('VirtualSubagentSessions', () => { const runtime = { workspaceId: 'legacy-workspace', workspaceCwd, + sessionRuntimeBaseDir: runtimeDir, env: { mode: 'runtime-overlay', overlayKeys: ['QWEN_RUNTIME_DIR'], diff --git a/packages/cli/src/serve/virtual-subagent-sessions.ts b/packages/cli/src/serve/virtual-subagent-sessions.ts index 4b7c4b7cf6..679653812a 100644 --- a/packages/cli/src/serve/virtual-subagent-sessions.ts +++ b/packages/cli/src/serve/virtual-subagent-sessions.ts @@ -735,10 +735,8 @@ export class VirtualSubagentSessions { }; } - const runtimeDir = runtime.env.effectiveEnv?.['QWEN_RUNTIME_DIR']; - const projectDir = Storage.runWithRuntimeBaseDir( - runtimeDir, - runtime.workspaceCwd, + const projectDir = Storage.runWithResolvedRuntimeBaseDir( + runtime.sessionRuntimeBaseDir, () => new Storage(runtime.workspaceCwd).getProjectDir(), ); const sessionDir = getSubagentSessionDir(projectDir, parentSessionId); @@ -785,10 +783,8 @@ export class VirtualSubagentSessions { ): Promise { // Pre-toolUseId transcripts cannot be linked exactly. This score is only a // best-effort compatibility path and identical parallel launches may tie. - const runtimeDir = runtime.env.effectiveEnv?.['QWEN_RUNTIME_DIR']; - const projectDir = Storage.runWithRuntimeBaseDir( - runtimeDir, - runtime.workspaceCwd, + const projectDir = Storage.runWithResolvedRuntimeBaseDir( + runtime.sessionRuntimeBaseDir, () => new Storage(runtime.workspaceCwd).getProjectDir(), ); const parentRecords = await readJsonl( @@ -882,10 +878,8 @@ export class VirtualSubagentSessions { parentSessionId: string, toolCallId: string, ): Promise { - const runtimeDir = runtime.env.effectiveEnv?.['QWEN_RUNTIME_DIR']; - const projectDir = Storage.runWithRuntimeBaseDir( - runtimeDir, - runtime.workspaceCwd, + const projectDir = Storage.runWithResolvedRuntimeBaseDir( + runtime.sessionRuntimeBaseDir, () => new Storage(runtime.workspaceCwd).getProjectDir(), ); const records = await readJsonl( @@ -903,6 +897,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/cli/src/serve/workspace-qualified-rest.test.ts b/packages/cli/src/serve/workspace-qualified-rest.test.ts index 2a3b22e911..14516e64ff 100644 --- a/packages/cli/src/serve/workspace-qualified-rest.test.ts +++ b/packages/cli/src/serve/workspace-qualified-rest.test.ts @@ -220,6 +220,7 @@ async function makeHarness(opts?: { const primary: WorkspaceRuntime = { workspaceId: 'same-as-path', workspaceCwd: primaryCwd, + sessionRuntimeBaseDir: path.join(primaryCwd, '.runtime'), primary: true, trusted: true, env: { mode: 'parent-process', overlayKeys: [] }, @@ -232,6 +233,7 @@ async function makeHarness(opts?: { const secondary: WorkspaceRuntime = { workspaceId: hashDaemonWorkspace(secondaryCwd), workspaceCwd: secondaryCwd, + sessionRuntimeBaseDir: path.join(secondaryCwd, '.runtime'), primary: false, trusted: opts?.secondaryTrusted ?? true, env: { mode: 'parent-process', overlayKeys: [] }, @@ -288,6 +290,7 @@ async function makeWindowsSelectorHarness() { const primary: WorkspaceRuntime = { workspaceId: 'primary-id', workspaceCwd: primaryCwd, + sessionRuntimeBaseDir: path.join(primaryCwd, '.runtime'), primary: true, trusted: true, env: { mode: 'parent-process', overlayKeys: [] }, @@ -299,6 +302,7 @@ async function makeWindowsSelectorHarness() { const windowsRuntime: WorkspaceRuntime = { workspaceId: 'windows-id', workspaceCwd: windowsCwd, + sessionRuntimeBaseDir: '/runtime/windows', primary: false, trusted: true, env: { mode: 'parent-process', overlayKeys: [] }, diff --git a/packages/cli/src/serve/workspace-registry.ts b/packages/cli/src/serve/workspace-registry.ts index 6d022eeb3a..b4026c2765 100644 --- a/packages/cli/src/serve/workspace-registry.ts +++ b/packages/cli/src/serve/workspace-registry.ts @@ -29,6 +29,7 @@ export interface WorkspaceRuntimeEnvMetadata { export interface WorkspaceRuntime { readonly workspaceId: string; readonly workspaceCwd: string; + readonly sessionRuntimeBaseDir: string; /** Optional presentation-only name. Workspace identity remains id/cwd. */ displayName?: string; readonly primary: boolean; diff --git a/packages/cli/src/serve/workspace-runtime-storage.ts b/packages/cli/src/serve/workspace-runtime-storage.ts new file mode 100644 index 0000000000..7b444ecab2 --- /dev/null +++ b/packages/cli/src/serve/workspace-runtime-storage.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + SessionService, + Storage, + type SessionServiceOptions, +} from '@qwen-code/qwen-code-core'; +import type { WorkspaceRuntime } from './workspace-registry.js'; + +export function runWithWorkspaceRuntimeStorage( + runtime: WorkspaceRuntime, + fn: () => T, +): T { + return Storage.runWithResolvedRuntimeBaseDir( + runtime.sessionRuntimeBaseDir, + fn, + ); +} + +export function createWorkspaceRuntimeSessionService( + runtime: WorkspaceRuntime, + options: Omit = {}, +): SessionService { + return new SessionService(runtime.workspaceCwd, { + ...options, + runtimeBaseDir: runtime.sessionRuntimeBaseDir, + }); +} 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/cli/src/ui/commands/cdCommand.test.ts b/packages/cli/src/ui/commands/cdCommand.test.ts index 2bc53ca4e0..28c5592c1c 100644 --- a/packages/cli/src/ui/commands/cdCommand.test.ts +++ b/packages/cli/src/ui/commands/cdCommand.test.ts @@ -339,6 +339,24 @@ describe('cdCommand', () => { }); }); + it('reports a successful move when MCP refresh fails afterward', async () => { + relocateWorkingDirectory.mockResolvedValue({ + mcpRefreshError: new Error('MCP failed'), + }); + + const result = (await cdCommand.action?.( + context, + '../next', + )) as MessageActionReturn; + const realNextDir = await realpath(nextDir); + + expect(result).toEqual({ + type: 'message', + messageType: 'warning', + content: `Moved to ${realNextDir}. MCP refresh failed: MCP failed`, + }); + }); + it('asks for confirmation before moving to an untrusted directory', async () => { context = createMockCommandContext({ invocation: { diff --git a/packages/cli/src/ui/commands/cdCommand.ts b/packages/cli/src/ui/commands/cdCommand.ts index 803827af3d..08d0e1ee06 100644 --- a/packages/cli/src/ui/commands/cdCommand.ts +++ b/packages/cli/src/ui/commands/cdCommand.ts @@ -179,6 +179,15 @@ export const cdCommand: SlashCommand = { }`, ); } + if (relocation.mcpRefreshError) { + warnings.push( + `MCP refresh failed: ${ + relocation.mcpRefreshError instanceof Error + ? relocation.mcpRefreshError.message + : String(relocation.mcpRefreshError) + }`, + ); + } } catch (error) { return { type: 'message' as const, 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/agents/agent-transcript.test.ts b/packages/core/src/agents/agent-transcript.test.ts index 5b9411beb1..5b559e5216 100644 --- a/packages/core/src/agents/agent-transcript.test.ts +++ b/packages/core/src/agents/agent-transcript.test.ts @@ -138,12 +138,14 @@ describe('agent-transcript', () => { status: 'running', subagentName: 'explore', resolvedApprovalMode: 'auto-edit', + executionAllowedTools: [], }); expect(readAgentMeta(metaPath)).toMatchObject({ agentId: 'a', status: 'running', subagentName: 'explore', + executionAllowedTools: [], }); }); }); @@ -273,6 +275,9 @@ describe('agent-transcript', () => { kind: 'fork', history: [], }); + expect(records[0]?.systemPayload).not.toHaveProperty( + 'executionAllowedTools', + ); }); it('writes a ROUND_TEXT event as an assistant record with text part', () => { diff --git a/packages/core/src/agents/agent-transcript.ts b/packages/core/src/agents/agent-transcript.ts index 48b24091a4..f970d588d3 100644 --- a/packages/core/src/agents/agent-transcript.ts +++ b/packages/core/src/agents/agent-transcript.ts @@ -128,6 +128,11 @@ export interface AgentMeta { lastUpdatedAt?: string; /** Resolved approval mode used when the agent was launched. */ resolvedApprovalMode?: string; + /** + * Immutable launch-time execution policy for a restricted fork. + * Absence preserves unrestricted execution; an empty list means deny-all. + */ + executionAllowedTools?: string[]; /** Launch-time CLI/runtime flags that should survive process restart. */ persistedCliFlags?: AgentPersistedCliFlags; /** Canonical subagent config name used to recreate this agent. */ diff --git a/packages/core/src/agents/background-agent-resume.test.ts b/packages/core/src/agents/background-agent-resume.test.ts index 0089ddb609..80fd2bdfb6 100644 --- a/packages/core/src/agents/background-agent-resume.test.ts +++ b/packages/core/src/agents/background-agent-resume.test.ts @@ -110,6 +110,7 @@ describe('BackgroundAgentResumeService', () => { copyDiscoveredToolsFrom: vi.fn(), getAllTools: vi.fn().mockReturnValue([]), getAllToolNames: vi.fn().mockReturnValue([]), + getTool: vi.fn(), stop: vi.fn().mockResolvedValue(undefined), warmAll: vi.fn().mockResolvedValue(undefined), getDeferredToolSummary: vi @@ -2028,11 +2029,16 @@ describe('BackgroundAgentResumeService', () => { }, tools: [{ name: 'Bash' }, { name: 'mcp__removed__search' }], }, + executionAllowedTools: ['Read'] as string[] | undefined, + }, + { + format: 'history-only bootstrap', + legacyCapabilities: {}, + executionAllowedTools: undefined as string[] | undefined, }, - { format: 'history-only bootstrap', legacyCapabilities: {} }, ])( 'resumes fork agents with the current parent prompt and live tool registry ($format)', - async ({ legacyCapabilities }) => { + async ({ legacyCapabilities, executionAllowedTools }) => { const sessionId = 'session-fork-resume'; const agentId = 'agent-fork-resume'; const metaPath = getAgentMetaPath(tempDir, sessionId, agentId); @@ -2049,6 +2055,9 @@ describe('BackgroundAgentResumeService', () => { status: 'running', subagentName: FORK_SUBAGENT_TYPE, resolvedApprovalMode: 'default', + ...(executionAllowedTools !== undefined + ? { executionAllowedTools } + : {}), }); fs.writeFileSync( outputFile, @@ -2113,39 +2122,61 @@ describe('BackgroundAgentResumeService', () => { isBackgrounded: true, }); - const execute = vi.fn(async (_context: unknown) => undefined); - const subagent = { - execute, - setExternalMessageProvider: vi.fn(), - getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }), - getExecutionSummary: () => ({ - totalTokens: 0, - outputTokens: 0, - totalDurationMs: 0, - }), - getTerminateMode: () => AgentTerminateMode.GOAL, - getFinalText: () => 'done', - }; - + const originalCreate = AgentHeadless.create; + let executeContext: unknown; + let deniedError: unknown; const createSpy = vi .spyOn(AgentHeadless, 'create') - .mockResolvedValue(subagent as unknown as AgentHeadless); + .mockImplementation(async (...args) => { + const subagent = await originalCreate(...args); + vi.spyOn(subagent, 'execute').mockImplementation(async (context) => { + executeContext = context; + if (executionAllowedTools === undefined) { + return; + } + const denial = await subagent + .getCore() + .processFunctionCalls( + [{ id: 'call-edit', name: 'Edit', args: {} }], + new AbortController(), + 'resume-policy-test', + 1, + [{ name: 'Read' }, { name: 'Edit' }], + ); + deniedError = + denial.messages[0]?.parts?.[0]?.functionResponse?.response?.[ + 'error' + ]; + }); + vi.spyOn(subagent, 'getTerminateMode').mockReturnValue( + AgentTerminateMode.GOAL, + ); + vi.spyOn(subagent, 'getFinalText').mockReturnValue('done'); + return subagent; + }); const currentSystemInstruction: Content = { role: 'system', parts: [{ text: 'current parent system instruction' }], }; - const { service, subagentManager } = createService({ + const { service, subagentManager, stubToolRegistry } = createService({ currentForkRuntime: { systemInstruction: currentSystemInstruction, advertisedTools: [ { name: 'Read', description: 'advertised current schema' }, + { name: 'Edit', description: 'advertised edit schema' }, { name: 'mcp__removed__search' }, ], registeredTools: [ { name: 'Read', description: 'registered current schema' }, + { name: 'Edit', description: 'registered edit schema' }, ], }, }); + const deniedBuild = vi.fn(); + stubToolRegistry.getTool.mockReturnValue({ + name: 'Edit', + build: deniedBuild, + }); const resumed = await service.resumeBackgroundAgent(agentId, 'continue'); expect(resumed).toBeDefined(); @@ -2166,12 +2197,13 @@ describe('BackgroundAgentResumeService', () => { max_turns: FORK_DEFAULT_MAX_TURNS, }); expect(createArgs?.[5]).toEqual({ - tools: ['Read'], + tools: ['Read', 'Edit'], + ...(executionAllowedTools !== undefined + ? { executionAllowedTools } + : {}), }); - expect(execute).toHaveBeenCalledTimes(1); - const executeCall = execute.mock.calls[0]; - expect(executeCall).toBeDefined(); - const contextArg = executeCall?.[0] as + expect(executeContext).toBeDefined(); + const contextArg = executeContext as | { get(key: string): unknown } | undefined; expect(contextArg).toBeDefined(); @@ -2182,6 +2214,14 @@ describe('BackgroundAgentResumeService', () => { 'Earlier capability listings in the conversation history are obsolete', ); expect(contextArg.get('task_prompt')).toContain('continue'); + if (executionAllowedTools !== undefined) { + expect(deniedError).toContain('execution allowlist'); + expect(deniedError).not.toContain('not found'); + expect(stubToolRegistry.getTool).not.toHaveBeenCalled(); + expect(deniedBuild).not.toHaveBeenCalled(); + } else { + expect(deniedError).toBeUndefined(); + } createSpy.mockRestore(); }, ); diff --git a/packages/core/src/agents/background-agent-resume.ts b/packages/core/src/agents/background-agent-resume.ts index 023cca585c..d03bd58803 100644 --- a/packages/core/src/agents/background-agent-resume.ts +++ b/packages/core/src/agents/background-agent-resume.ts @@ -980,6 +980,7 @@ export class BackgroundAgentResumeService { bgEventEmitter, resumeHistory ?? [], currentForkRuntime!, + meta.executionAllowedTools, ); } else { const resumeSubagentConfig = @@ -1659,6 +1660,7 @@ export class BackgroundAgentResumeService { eventEmitter: AgentEventEmitter, initialMessages: Content[], runtime: CurrentForkRuntime, + executionAllowedTools?: string[], ): Promise { const promptConfig: PromptConfig = { renderedSystemPrompt: structuredClone(runtime.systemInstruction), @@ -1666,6 +1668,9 @@ export class BackgroundAgentResumeService { }; const toolConfig: ToolConfig = { tools: [...runtime.toolNames], + ...(executionAllowedTools !== undefined + ? { executionAllowedTools: structuredClone(executionAllowedTools) } + : {}), }; return AgentHeadless.create( diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index 4d17acc123..2185383ef5 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -115,6 +115,34 @@ import { SUBAGENT_PLAN_LIFECYCLE_TOOLS, } from './subagent-plan-tool-policy.js'; +const EXECUTION_ALLOWLIST_ERROR_MAX_ITEMS = 8; +const EXECUTION_ALLOWLIST_ERROR_MAX_CHARS = 240; + +function summarizeExecutionAllowlist( + executionAllowedTools: readonly string[], +): string | undefined { + if (executionAllowedTools.length === 0) { + return undefined; + } + + const visibleTools = executionAllowedTools.slice( + 0, + EXECUTION_ALLOWLIST_ERROR_MAX_ITEMS, + ); + let summary = visibleTools + .map((toolName) => JSON.stringify(toolName)) + .join(', '); + const wasClipped = summary.length > EXECUTION_ALLOWLIST_ERROR_MAX_CHARS; + if (wasClipped) { + summary = `${summary.slice(0, EXECUTION_ALLOWLIST_ERROR_MAX_CHARS - 3)}...`; + } + const omittedCount = executionAllowedTools.length - visibleTools.length; + if (omittedCount > 0) { + return `${summary} (+${omittedCount} more)`; + } + return wasClipped ? `${summary} (truncated)` : summary; +} + /** * Result of a single reasoning loop invocation. */ @@ -342,6 +370,10 @@ export class AgentCore { readonly modelConfig: ModelConfig; readonly runConfig: RunConfig; readonly toolConfig?: ToolConfig; + private readonly executionAllowedTools?: readonly string[]; + private readonly executionAllowedExactTools?: ReadonlySet; + private readonly executionAllowedMcpPatterns?: readonly string[]; + private readonly executionAllowlistErrorSummary?: string; /** * Event emitter for this agent. Always present — if the caller doesn't * pass one, AgentCore allocates its own so the observable state below @@ -421,6 +453,22 @@ export class AgentCore { this.modelConfig = modelConfig; this.runConfig = runConfig; this.toolConfig = toolConfig; + if (toolConfig?.executionAllowedTools !== undefined) { + this.executionAllowedTools = Object.freeze([ + ...toolConfig.executionAllowedTools, + ]); + this.executionAllowedExactTools = new Set( + this.executionAllowedTools.filter( + (toolName) => !toolName.includes('*'), + ), + ); + this.executionAllowedMcpPatterns = Object.freeze( + this.executionAllowedTools.filter((toolName) => toolName.includes('*')), + ); + this.executionAllowlistErrorSummary = summarizeExecutionAllowlist( + this.executionAllowedTools, + ); + } this.eventEmitter = eventEmitter ?? new AgentEventEmitter(); this.hooks = hooks; this.runtimeView = runtimeView; @@ -1388,6 +1436,62 @@ export class AgentCore { ); } + private isToolExecutionAllowed(toolName: string): boolean { + if (this.executionAllowedTools === undefined) { + return true; + } + if (this.executionAllowedExactTools?.has(toolName)) { + return true; + } + if (!toolName.startsWith('mcp__')) { + return false; + } + + // Match MCP patterns against the registry's raw server/tool identity. + // Comparing provider-sanitized prefixes can merge distinct server names + // such as "repo.bad" and "repo/bad", so it is unsafe for an allowlist. + const registeredTool = this.runtimeContext + .getToolRegistry() + .getTool(toolName) as + | { serverName?: unknown; serverToolName?: unknown } + | undefined; + if ( + typeof registeredTool?.serverName !== 'string' || + typeof registeredTool.serverToolName !== 'string' + ) { + return false; + } + + const serverName = registeredTool.serverName; + const serverToolName = registeredTool.serverToolName; + const serverPattern = `mcp__${serverName}`; + const rawToolName = `${serverPattern}__${serverToolName}`; + if ( + this.executionAllowedExactTools?.has(serverPattern) || + this.executionAllowedExactTools?.has(rawToolName) + ) { + return true; + } + + return this.executionAllowedMcpPatterns!.some((pattern) => { + if (pattern === 'mcp__*') { + return true; + } + if (!pattern.startsWith('mcp__')) { + return false; + } + if (!pattern.endsWith('*')) { + return false; + } + + const toolPatternPrefix = `${serverPattern}__`; + return ( + pattern.startsWith(toolPatternPrefix) && + serverToolName.startsWith(pattern.slice(toolPatternPrefix.length, -1)) + ); + }); + } + /** * Processes a list of function calls via CoreToolScheduler. * @@ -1429,8 +1533,10 @@ export class AgentCore { ]), ); - // Build allowed tool names set for filtering - const allowedToolNames = new Set(toolsList.map((t) => t.name)); + // The model-visible declarations and the execution allowlist are separate: + // forks keep the parent's declaration prefix for cache sharing while + // optionally narrowing which declared tools may actually run. + const declaredToolNames = new Set(toolsList.map((t) => t.name)); const repeatedDuplicateCall = findRepeatedDuplicateProviderToolCall( uniqueFunctionCalls, (fc) => getProviderToolCallId(fc) ?? fc.id, @@ -1461,12 +1567,21 @@ export class AgentCore { const toolName = String(fc.name); const args = (fc.args ?? {}) as Record; - if (!allowedToolNames.has(fc.name)) { - const errorMessage = isPlanLifecycleToolUnavailableInSubagent(toolName) + let errorMessage: string | undefined; + if (!declaredToolNames.has(fc.name)) { + errorMessage = isPlanLifecycleToolUnavailableInSubagent(toolName) ? getSubagentPlanToolUnavailableMessage(toolName) : isLeaderOnlyToolUnavailableInSubagent(toolName) ? getLeaderOnlyToolUnavailableMessage(toolName) : `Tool "${toolName}" not found. Tools must use the exact names provided.`; + } else if (!this.isToolExecutionAllowed(toolName)) { + errorMessage = + this.executionAllowlistErrorSummary !== undefined + ? `Tool "${toolName}" is not allowed by this agent's execution allowlist. Allowed entries: ${this.executionAllowlistErrorSummary}.` + : `Tool "${toolName}" is not allowed by this agent's execution allowlist. No tools are allowed.`; + } + + if (errorMessage) { const functionResponsePart = { functionResponse: { id: callId, diff --git a/packages/core/src/agents/runtime/agent-headless.test.ts b/packages/core/src/agents/runtime/agent-headless.test.ts index 9a63799c9a..318a8de6fb 100644 --- a/packages/core/src/agents/runtime/agent-headless.test.ts +++ b/packages/core/src/agents/runtime/agent-headless.test.ts @@ -56,6 +56,8 @@ import type { } from './agent-types.js'; import { AgentTerminateMode } from './agent-types.js'; import { WriteFileTool } from '../../tools/write-file.js'; +import { ToolNames } from '../../tools/tool-names.js'; +import { normalizeToolNameForProvider } from '../../utils/tool-name-utils.js'; vi.mock('../../core/geminiChat.js'); vi.mock('../../core/contentGenerator.js', async (importOriginal) => { @@ -1392,9 +1394,549 @@ describe('subagent.ts', () => { 'file1.txt\nfile2.ts', ); + expect(listFilesInvocation.execute).toHaveBeenCalledTimes(1); expect(scope.getTerminateMode()).toBe(AgentTerminateMode.GOAL); }); + it('keeps declarations unchanged while enforcing the execution allowlist', async () => { + const readFileToolDef: FunctionDeclaration = { + name: ToolNames.READ_FILE, + description: 'Reads a file', + parameters: { type: Type.OBJECT, properties: {} }, + }; + const editFileToolDef: FunctionDeclaration = { + name: ToolNames.EDIT, + description: 'Edits a file', + parameters: { type: Type.OBJECT, properties: {} }, + }; + const { config } = await createMockConfig(); + + const readFileInvocation = { + params: { path: 'README.md' }, + getDescription: vi.fn().mockReturnValue('Read README.md'), + toolLocations: vi.fn().mockReturnValue([]), + getDefaultPermission: vi.fn().mockResolvedValue('allow'), + execute: vi.fn().mockResolvedValue({ + llmContent: 'file contents', + returnDisplay: 'file contents', + }), + }; + const editFileInvocation = { + params: { path: 'README.md' }, + getDescription: vi.fn().mockReturnValue('Edit README.md'), + toolLocations: vi.fn().mockReturnValue([]), + getDefaultPermission: vi.fn().mockResolvedValue('ask'), + execute: vi.fn(), + }; + const readFileTool = { + name: ToolNames.READ_FILE, + displayName: 'Read File', + description: 'Reads a file', + kind: 'READ' as const, + schema: readFileToolDef, + build: vi.fn().mockReturnValue(readFileInvocation), + canUpdateOutput: false, + isOutputMarkdown: true, + } as unknown as AnyDeclarativeTool; + const editFileTool = { + name: ToolNames.EDIT, + displayName: 'Edit File', + description: 'Edits a file', + kind: 'EDIT' as const, + schema: editFileToolDef, + build: vi.fn().mockReturnValue(editFileInvocation), + canUpdateOutput: false, + isOutputMarkdown: true, + } as unknown as AnyDeclarativeTool; + vi.mocked(config.getToolRegistry().getTool).mockImplementation( + (name: string) => + name === ToolNames.READ_FILE + ? readFileTool + : name === ToolNames.EDIT + ? editFileTool + : undefined, + ); + + mockSendMessageStream.mockImplementation( + createMockStream([ + [ + { + id: 'call_read', + name: ToolNames.READ_FILE, + args: { path: 'README.md' }, + }, + { + id: 'call_edit', + name: ToolNames.EDIT, + args: { path: 'README.md', old_string: 'a', new_string: 'b' }, + }, + ], + 'stop', + ]), + ); + + const toolCallEvents: AgentToolCallEvent[] = []; + const toolResultEvents: AgentToolResultEvent[] = []; + const approvalEvents: unknown[] = []; + const eventEmitter = new AgentEventEmitter(); + eventEmitter.on(AgentEventType.TOOL_CALL, (event: unknown) => { + toolCallEvents.push(event as AgentToolCallEvent); + }); + eventEmitter.on(AgentEventType.TOOL_RESULT, (event: unknown) => { + toolResultEvents.push(event as AgentToolResultEvent); + }); + eventEmitter.on( + AgentEventType.TOOL_WAITING_APPROVAL, + (event: unknown) => { + approvalEvents.push(event); + }, + ); + + const executionAllowedTools: string[] = [ToolNames.READ_FILE]; + const scope = await AgentHeadless.create( + 'fork', + config, + { systemPrompt: 'Test prompt' }, + defaultModelConfig, + defaultRunConfig, + { + tools: [readFileToolDef, editFileToolDef], + executionAllowedTools, + }, + eventEmitter, + ); + executionAllowedTools.push(ToolNames.EDIT); + await scope.execute(new ContextState()); + + const sentDeclarations = + mockSendMessageStream.mock.calls[0][1].config.tools[0] + .functionDeclarations; + expect(sentDeclarations).toStrictEqual([ + readFileToolDef, + editFileToolDef, + ]); + expect(JSON.stringify(sentDeclarations)).toBe( + JSON.stringify([readFileToolDef, editFileToolDef]), + ); + expect(readFileTool.build).toHaveBeenCalled(); + expect(readFileInvocation.execute).toHaveBeenCalledTimes(1); + expect(editFileTool.build).not.toHaveBeenCalled(); + expect(editFileInvocation.execute).not.toHaveBeenCalled(); + expect(approvalEvents).toHaveLength(0); + + const secondRoundParts = mockSendMessageStream.mock.calls[1][1] + .message as Part[]; + expect( + secondRoundParts.map((part) => part.functionResponse?.id), + ).toEqual(['call_read', 'call_edit']); + const deniedResponse = secondRoundParts.find( + (part) => part.functionResponse?.id === 'call_edit', + )?.functionResponse; + expect(deniedResponse?.name).toBe(ToolNames.EDIT); + expect(deniedResponse?.response?.['error']).toContain( + 'execution allowlist', + ); + expect(deniedResponse?.response?.['error']).not.toContain('fork_tools'); + expect(deniedResponse?.response?.['error']).not.toContain('not found'); + expect(toolCallEvents.map((event) => event.callId).sort()).toEqual([ + 'call_edit', + 'call_read', + ]); + expect( + toolResultEvents + .map((event) => ({ + callId: event.callId, + success: event.success, + })) + .sort((left, right) => left.callId.localeCompare(right.callId)), + ).toEqual([ + { callId: 'call_edit', success: false }, + { callId: 'call_read', success: true }, + ]); + }); + + it('treats an empty execution allowlist as deny-all', async () => { + const toolDef: FunctionDeclaration = { + name: ToolNames.READ_FILE, + description: 'Reads a file', + parameters: { type: Type.OBJECT, properties: {} }, + }; + const tool = { + name: ToolNames.READ_FILE, + schema: toolDef, + build: vi.fn(), + } as unknown as AnyDeclarativeTool; + const { config } = await createMockConfig({ + getTool: vi.fn().mockReturnValue(tool), + }); + mockSendMessageStream.mockImplementation( + createMockStream([ + [ + { + id: 'call_read', + name: ToolNames.READ_FILE, + args: { path: 'README.md' }, + }, + ], + 'stop', + ]), + ); + + const scope = await AgentHeadless.create( + 'fork', + config, + { systemPrompt: 'Test prompt' }, + defaultModelConfig, + defaultRunConfig, + { tools: [toolDef], executionAllowedTools: [] }, + ); + await scope.execute(new ContextState()); + + expect(tool.build).not.toHaveBeenCalled(); + const response = ( + mockSendMessageStream.mock.calls[1][1].message as Part[] + )[0]?.functionResponse; + expect(response?.id).toBe('call_read'); + expect(response?.response?.['error']).toContain('No tools are allowed'); + }); + + it('caps and decouples the execution allowlist denial message', async () => { + const toolDef: FunctionDeclaration = { + name: ToolNames.READ_FILE, + description: 'Reads a file', + parameters: { type: Type.OBJECT, properties: {} }, + }; + const tool = { + name: ToolNames.READ_FILE, + schema: toolDef, + build: vi.fn(), + } as unknown as AnyDeclarativeTool; + const { config } = await createMockConfig({ + getTool: vi.fn().mockReturnValue(tool), + }); + mockSendMessageStream.mockImplementation( + createMockStream([ + [ + { + id: 'call_read', + name: ToolNames.READ_FILE, + args: { path: 'README.md' }, + }, + ], + 'stop', + ]), + ); + const executionAllowedTools = Array.from( + { length: 12 }, + (_, index) => `tool_${index}_${'x'.repeat(50)}`, + ); + + const scope = await AgentHeadless.create( + 'fork', + config, + { systemPrompt: 'Test prompt' }, + defaultModelConfig, + defaultRunConfig, + { tools: [toolDef], executionAllowedTools }, + ); + await scope.execute(new ContextState()); + + const error = ( + mockSendMessageStream.mock.calls[1][1].message as Part[] + )[0]?.functionResponse?.response?.['error']; + expect(error).toContain('execution allowlist'); + expect(error).toContain('(+4 more)'); + expect(error).not.toContain('fork_tools'); + expect(String(error).length).toBeLessThan(400); + expect(tool.build).not.toHaveBeenCalled(); + }); + + it('matches an exact MCP server allowlist entry without crossing server boundaries', async () => { + const githubName = normalizeToolNameForProvider('mcp__github__search'); + const enterpriseName = normalizeToolNameForProvider( + 'mcp__github-enterprise__search', + ); + const githubDef: FunctionDeclaration = { + name: githubName, + description: 'Search GitHub', + parameters: { type: Type.OBJECT, properties: {} }, + }; + const enterpriseDef: FunctionDeclaration = { + name: enterpriseName, + description: 'Search GitHub Enterprise', + parameters: { type: Type.OBJECT, properties: {} }, + }; + const githubInvocation = { + params: {}, + getDescription: vi.fn().mockReturnValue('Search GitHub'), + toolLocations: vi.fn().mockReturnValue([]), + getDefaultPermission: vi.fn().mockResolvedValue('allow'), + execute: vi.fn().mockResolvedValue({ + llmContent: 'github result', + returnDisplay: 'github result', + }), + }; + const githubTool = { + name: githubName, + serverName: 'github', + serverToolName: 'search', + schema: githubDef, + build: vi.fn().mockReturnValue(githubInvocation), + canUpdateOutput: false, + isOutputMarkdown: true, + } as unknown as AnyDeclarativeTool; + const enterpriseTool = { + name: enterpriseName, + serverName: 'github-enterprise', + serverToolName: 'search', + schema: enterpriseDef, + build: vi.fn(), + canUpdateOutput: false, + isOutputMarkdown: true, + } as unknown as AnyDeclarativeTool; + const { config } = await createMockConfig({ + getTool: vi.fn((name: string) => + name === githubName + ? githubTool + : name === enterpriseName + ? enterpriseTool + : undefined, + ), + }); + mockSendMessageStream.mockImplementation( + createMockStream([ + [ + { id: 'call_github', name: githubName, args: {} }, + { id: 'call_enterprise', name: enterpriseName, args: {} }, + ], + 'stop', + ]), + ); + + const scope = await AgentHeadless.create( + 'fork', + config, + { systemPrompt: 'Test prompt' }, + defaultModelConfig, + defaultRunConfig, + { + tools: [githubDef, enterpriseDef], + executionAllowedTools: ['mcp__github'], + }, + ); + await scope.execute(new ContextState()); + + expect(githubInvocation.execute).toHaveBeenCalledTimes(1); + expect(enterpriseTool.build).not.toHaveBeenCalled(); + const responses = mockSendMessageStream.mock.calls[1][1] + .message as Part[]; + expect( + responses.find( + (part) => part.functionResponse?.id === 'call_enterprise', + )?.functionResponse?.response?.['error'], + ).toContain('execution allowlist'); + }); + + it('lets mcp__* match MCP tools without matching built-in tools', async () => { + const mcpName = normalizeToolNameForProvider('mcp__github__search'); + const mcpDef: FunctionDeclaration = { + name: mcpName, + description: 'Search GitHub', + parameters: { type: Type.OBJECT, properties: {} }, + }; + const builtinDef: FunctionDeclaration = { + name: ToolNames.READ_FILE, + description: 'Read a file', + parameters: { type: Type.OBJECT, properties: {} }, + }; + const mcpInvocation = { + params: {}, + getDescription: vi.fn().mockReturnValue('Search GitHub'), + toolLocations: vi.fn().mockReturnValue([]), + getDefaultPermission: vi.fn().mockResolvedValue('allow'), + execute: vi.fn().mockResolvedValue({ + llmContent: 'github result', + returnDisplay: 'github result', + }), + }; + const mcpTool = { + name: mcpName, + serverName: 'github', + serverToolName: 'search', + schema: mcpDef, + build: vi.fn().mockReturnValue(mcpInvocation), + canUpdateOutput: false, + isOutputMarkdown: true, + } as unknown as AnyDeclarativeTool; + const builtinTool = { + name: ToolNames.READ_FILE, + schema: builtinDef, + build: vi.fn(), + } as unknown as AnyDeclarativeTool; + const { config } = await createMockConfig({ + getTool: vi.fn((name: string) => + name === mcpName + ? mcpTool + : name === ToolNames.READ_FILE + ? builtinTool + : undefined, + ), + }); + mockSendMessageStream.mockImplementation( + createMockStream([ + [ + { id: 'call_mcp', name: mcpName, args: {} }, + { + id: 'call_builtin', + name: ToolNames.READ_FILE, + args: { path: 'README.md' }, + }, + ], + 'stop', + ]), + ); + + const scope = await AgentHeadless.create( + 'fork', + config, + { systemPrompt: 'Test prompt' }, + defaultModelConfig, + defaultRunConfig, + { + tools: [mcpDef, builtinDef], + executionAllowedTools: ['mcp__*'], + }, + ); + await scope.execute(new ContextState()); + + expect(mcpInvocation.execute).toHaveBeenCalledTimes(1); + expect(builtinTool.build).not.toHaveBeenCalled(); + const responses = mockSendMessageStream.mock.calls[1][1] + .message as Part[]; + expect( + responses.find((part) => part.functionResponse?.id === 'call_builtin') + ?.functionResponse?.response?.['error'], + ).toContain('execution allowlist'); + }); + + it('matches long MCP wildcard patterns by raw server identity and boundary', async () => { + const serverSuffix = 'a'.repeat(80); + const allowedServer = `repo.${serverSuffix}`; + const deniedServer = `repo/${serverSuffix}`; + const boundaryDeniedServer = `${allowedServer}__evil`; + const allowedName = normalizeToolNameForProvider( + `mcp__${allowedServer}__read`, + ); + const deniedName = normalizeToolNameForProvider( + `mcp__${deniedServer}__read`, + ); + const boundaryDeniedName = normalizeToolNameForProvider( + `mcp__${boundaryDeniedServer}__read`, + ); + const allowedDef: FunctionDeclaration = { + name: allowedName, + description: 'Reads from repo.bad', + parameters: { type: Type.OBJECT, properties: {} }, + }; + const deniedDef: FunctionDeclaration = { + name: deniedName, + description: 'Reads from repo/bad', + parameters: { type: Type.OBJECT, properties: {} }, + }; + const boundaryDeniedDef: FunctionDeclaration = { + name: boundaryDeniedName, + description: 'Reads from a server with a shared raw prefix', + parameters: { type: Type.OBJECT, properties: {} }, + }; + const allowedInvocation = { + params: {}, + getDescription: vi.fn().mockReturnValue('Read from repo.bad'), + toolLocations: vi.fn().mockReturnValue([]), + getDefaultPermission: vi.fn().mockResolvedValue('allow'), + execute: vi.fn().mockResolvedValue({ + llmContent: 'repo result', + returnDisplay: 'repo result', + }), + }; + const allowedTool = { + name: allowedName, + serverName: allowedServer, + serverToolName: 'read', + schema: allowedDef, + build: vi.fn().mockReturnValue(allowedInvocation), + canUpdateOutput: false, + isOutputMarkdown: true, + } as unknown as AnyDeclarativeTool; + const deniedTool = { + name: deniedName, + serverName: deniedServer, + serverToolName: 'read', + schema: deniedDef, + build: vi.fn(), + } as unknown as AnyDeclarativeTool; + const boundaryDeniedTool = { + name: boundaryDeniedName, + serverName: boundaryDeniedServer, + serverToolName: 'read', + schema: boundaryDeniedDef, + build: vi.fn(), + } as unknown as AnyDeclarativeTool; + const { config } = await createMockConfig({ + getTool: vi.fn((name: string) => + name === allowedName + ? allowedTool + : name === deniedName + ? deniedTool + : name === boundaryDeniedName + ? boundaryDeniedTool + : undefined, + ), + }); + mockSendMessageStream.mockImplementation( + createMockStream([ + [ + { id: 'call_repo', name: allowedName, args: {} }, + { id: 'call_repo2', name: deniedName, args: {} }, + { + id: 'call_boundary', + name: boundaryDeniedName, + args: {}, + }, + ], + 'stop', + ]), + ); + + const scope = await AgentHeadless.create( + 'fork', + config, + { systemPrompt: 'Test prompt' }, + defaultModelConfig, + defaultRunConfig, + { + tools: [allowedDef, deniedDef, boundaryDeniedDef], + executionAllowedTools: [`mcp__${allowedServer}__*`], + }, + ); + await scope.execute(new ContextState()); + + expect(allowedName).not.toBe(deniedName); + expect(allowedInvocation.execute).toHaveBeenCalledTimes(1); + expect(deniedTool.build).not.toHaveBeenCalled(); + expect(boundaryDeniedTool.build).not.toHaveBeenCalled(); + const responses = mockSendMessageStream.mock.calls[1][1] + .message as Part[]; + expect( + responses.find((part) => part.functionResponse?.id === 'call_repo2') + ?.functionResponse?.response?.['error'], + ).toContain('execution allowlist'); + expect( + responses.find( + (part) => part.functionResponse?.id === 'call_boundary', + )?.functionResponse?.response?.['error'], + ).toContain('execution allowlist'); + }); + it('should ignore duplicate provider tool-call ids across rounds', async () => { const listFilesToolDef: FunctionDeclaration = { name: 'list_files', diff --git a/packages/core/src/agents/runtime/agent-types.ts b/packages/core/src/agents/runtime/agent-types.ts index 3c4b9483e2..03c4213928 100644 --- a/packages/core/src/agents/runtime/agent-types.ts +++ b/packages/core/src/agents/runtime/agent-types.ts @@ -82,10 +82,17 @@ export type AgentExternalInput = export interface ToolConfig { /** * A list of tool names (from the tool registry) or full function declarations - * that the agent is permitted to use. + * exposed to the model. */ tools: Array; + /** + * Optional execution-layer allowlist. Tool declarations remain unchanged, + * but calls outside this list are rejected before scheduling or approval. + * Supports exact tool names and MCP server-level patterns. + */ + executionAllowedTools?: string[]; + /** * Optional list of tool names to exclude from the agent's tool pool. * Applied after the allowlist and MCP bypass. Supports MCP server-level diff --git a/packages/core/src/config/config.safe-mode.test.ts b/packages/core/src/config/config.safe-mode.test.ts index 695f739eb4..f26c69ac8d 100644 --- a/packages/core/src/config/config.safe-mode.test.ts +++ b/packages/core/src/config/config.safe-mode.test.ts @@ -324,7 +324,10 @@ describe('Config safe mode', () => { topTierMcpServers: { probe: { command: 'probe', args: [] } }, }); expect(config.getMcpServers()).toEqual({ - probe: { command: 'probe', args: [] }, + probe: { + command: 'probe', + args: [], + }, }); }); @@ -343,7 +346,10 @@ describe('Config safe mode', () => { }, }); expect(config.getMcpServers()).toEqual({ - probe: { command: 'probe', args: [] }, + probe: { + command: 'probe', + args: [], + }, }); }); }); diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 6a35d1a86c..01df7c697f 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -1874,6 +1874,28 @@ describe('Server Config (config.ts)', () => { expect(Object.keys(result!)).not.toContain('playwright'); }); + it('getMcpServers does not stamp cwd — cwd binding happens in populateMcpServerCommand', () => { + const explicitCwd = path.resolve('/explicit/mcp'); + const config = new Config({ + ...baseParams, + targetDir: path.resolve('/session/worktree'), + mcpServers: { + implicit: { command: 'node', args: ['server.js'] }, + explicit: { command: 'node', cwd: explicitCwd }, + remote: { httpUrl: 'https://example.test/mcp' }, + sdk: { type: 'sdk', command: 'placeholder' }, + tcpWithCommand: { tcp: 'tcp://example.test:9000', command: 'node' }, + }, + }); + + const servers = config.getMcpServers()!; + expect(servers['implicit']?.cwd).toBeUndefined(); + expect(servers['explicit']?.cwd).toBe(explicitCwd); + expect(servers['remote']?.cwd).toBeUndefined(); + expect(servers['sdk']?.cwd).toBeUndefined(); + expect(servers['tcpWithCommand']?.cwd).toBeUndefined(); + }); + it('isMcpServerDisabled supports glob patterns in excludedMcpServers', () => { const config = new Config({ ...baseParams, @@ -3099,6 +3121,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 @@ -5182,6 +5236,64 @@ describe('Server Config (config.ts)', () => { cwdSpy.mockRestore(); }); + it('relocateWorkingDirectory should reconcile MCP servers with the new session cwd', async () => { + const config = new Config({ + ...baseParams, + mcpServers: { local: { command: 'node', args: ['server.js'] } }, + }); + await config.initialize(); + const manager = ( + config.getToolRegistry() as unknown as { + __mcpManagerMock: { discoverAllMcpToolsIncremental: Mock }; + } + ).__mcpManagerMock; + await config.waitForMcpReady(); + manager.discoverAllMcpToolsIncremental.mockClear(); + const newDir = path.resolve('/path/to/other'); + const chdirSpy = vi.spyOn(process, 'chdir').mockImplementation(() => { + // Keep the test process in its original directory. + }); + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(newDir); + + await expect(config.relocateWorkingDirectory(newDir)).resolves.toEqual({}); + + expect(manager.discoverAllMcpToolsIncremental).toHaveBeenCalledOnce(); + expect(manager.discoverAllMcpToolsIncremental).toHaveBeenCalledWith(config); + + chdirSpy.mockRestore(); + cwdSpy.mockRestore(); + }); + + it('relocateWorkingDirectory should report MCP reconcile failures after moving', async () => { + const config = new Config({ + ...baseParams, + mcpServers: { local: { command: 'node' } }, + }); + await config.initialize(); + const manager = ( + config.getToolRegistry() as unknown as { + __mcpManagerMock: { discoverAllMcpToolsIncremental: Mock }; + } + ).__mcpManagerMock; + await config.waitForMcpReady(); + manager.discoverAllMcpToolsIncremental.mockRejectedValueOnce( + new Error('MCP failed'), + ); + const newDir = path.resolve('/path/to/other'); + const chdirSpy = vi.spyOn(process, 'chdir').mockImplementation(() => { + // Keep the test process in its original directory. + }); + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(newDir); + + const result = await config.relocateWorkingDirectory(newDir); + + expect(config.getTargetDir()).toBe(newDir); + expect(result.mcpRefreshError).toEqual(new Error('MCP failed')); + + chdirSpy.mockRestore(); + cwdSpy.mockRestore(); + }); + it('relocateWorkingDirectory should continue after recording flush fails', async () => { const config = new Config(baseParams); const newDir = path.resolve('/path/to/other'); @@ -5513,6 +5625,40 @@ describe('Server Config (config.ts)', () => { cwdSpy.mockRestore(); }); + it('relocateWorkingDirectory should report both memory and MCP refresh failures after moving', async () => { + const config = new Config({ + ...baseParams, + mcpServers: { local: { command: 'node' } }, + }); + await config.initialize(); + const manager = ( + config.getToolRegistry() as unknown as { + __mcpManagerMock: { discoverAllMcpToolsIncremental: Mock }; + } + ).__mcpManagerMock; + await config.waitForMcpReady(); + manager.discoverAllMcpToolsIncremental.mockRejectedValueOnce( + new Error('MCP failed'), + ); + vi.mocked(loadServerHierarchicalMemory).mockRejectedValueOnce( + new Error('memory failed'), + ); + const newDir = path.resolve('/path/to/other'); + const chdirSpy = vi.spyOn(process, 'chdir').mockImplementation(() => { + // Keep the test process in its original directory. + }); + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(newDir); + + const result = await config.relocateWorkingDirectory(newDir); + + expect(config.getTargetDir()).toBe(newDir); + expect(result.memoryRefreshError).toEqual(new Error('memory failed')); + expect(result.mcpRefreshError).toEqual(new Error('MCP failed')); + + chdirSpy.mockRestore(); + cwdSpy.mockRestore(); + }); + it('refreshHierarchicalMemory should include empty memory prompt when no managed auto-memory index exists', async () => { const config = new Config(baseParams); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 9057a5af91..0ae98dea0a 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -4565,7 +4565,10 @@ export class Config { newDir: string, expectedCanonicalDir?: string, opts?: { skipProcessChdir?: boolean; skipArtifactMigration?: boolean }, - ): Promise<{ memoryRefreshError?: unknown }> { + ): Promise<{ + memoryRefreshError?: unknown; + mcpRefreshError?: unknown; + }> { if ( !opts?.skipArtifactMigration && this.chatRecordingService?.hasWriteOwnership() @@ -4630,12 +4633,25 @@ export class Config { this.fileHistoryService = undefined; this.getFileReadCache().clear(); + let memoryRefreshError: unknown; try { await this.refreshHierarchicalMemory(); - return {}; } catch (error) { - return { memoryRefreshError: error }; + memoryRefreshError = error; } + + let mcpRefreshError: unknown; + try { + await this.waitForMcpReady(); + await this.refreshMcpServers(); + } catch (error) { + mcpRefreshError = error; + } + + return { + ...(memoryRefreshError !== undefined && { memoryRefreshError }), + ...(mcpRefreshError !== undefined && { mcpRefreshError }), + }; } /** @@ -4816,6 +4832,7 @@ export class Config { this.backgroundTaskRegistry.abortAll(); this.monitorRegistry.abortAll({ notify: false }); this.backgroundShellRegistry.abortAll(); + this.workflowRunRegistry.abortAll(); await this.cleanupArenaRuntime(); await this.cleanupTeamRuntime(); @@ -5332,6 +5349,10 @@ export class Config { this.recentlyRemovedMcpServers.add(name); } } + await this.refreshMcpServers(); + } + + private async refreshMcpServers(): Promise { if (!this.initialized) { // No tool registry yet — boot-time discovery will pick up the new map. this.debugLogger.debug( @@ -5339,13 +5360,12 @@ export class Config { ); return; } + if (this.mcpReconcileInProgress) { // Coalesce: a pass is already running. Mark that the desired state // advanced so its drain loop runs again with the latest config, and // await that in-flight pass — NOT a resolved promise — so this caller - // does not proceed (e.g. the hot-reload listener emitting approval events - // and logging "complete") before its coalesced change is actually - // reconciled, and so it observes a shared reconcile failure. + // does not proceed before its coalesced change is actually reconciled. this.mcpReconcilePending = true; this.debugLogger.debug( '[mcp-hot-reload] reconcile already in flight — coalescing into a follow-up pass', @@ -5354,8 +5374,7 @@ export class Config { } this.mcpReconcileInProgress = true; const registry = this.getToolRegistry(); - // Run pass 1 + its drain loop as a single promise, assigned BEFORE the - // first await so a coalesced caller arriving mid-flight can await it. + // Assign before the first await so a coalesced caller can await this pass. const runReconcile = (async () => { try { this.debugLogger.debug( @@ -5364,9 +5383,8 @@ export class Config { await registry .getMcpClientManager() .discoverAllMcpToolsIncremental(this); - // Drain any change that arrived while this pass was in flight. The pool - // path returns the in-flight promise rather than queuing, so awaiting - // is not enough — re-run once more to pick up the latest config. + // The pool path returns an in-flight promise, so re-run after any + // coalesced change to ensure the latest effective config is applied. let pass = 1; while (this.mcpReconcilePending) { this.mcpReconcilePending = false; @@ -5390,17 +5408,13 @@ export class Config { throw err; } finally { this.mcpReconcileInProgress = false; - // Clear the coalesce flag too: if a pass threw, a pending follow-up - // would otherwise stay stuck `true` and make the next (unrelated) - // reconcile run an extra no-op drain pass. The next real settings - // change re-triggers reconcile anyway. + // A failed pass must not leak a pending drain into the next reconcile. this.mcpReconcilePending = false; this.mcpReconcilePromise = undefined; } })(); this.mcpReconcilePromise = runReconcile; - // Propagate failure to this caller (and, via the shared promise, to any - // coalesced callers). Existing callers rely on the throw. + // Propagate failure to this caller and every coalesced caller. await runReconcile; } diff --git a/packages/core/src/config/storage.test.ts b/packages/core/src/config/storage.test.ts index cb54ba6db9..abded1a61f 100644 --- a/packages/core/src/config/storage.test.ts +++ b/packages/core/src/config/storage.test.ts @@ -642,6 +642,35 @@ describe('Storage – runtime base dir async context isolation', () => { expect(b).toBe(path.join(cwdB, '.qwen-b')); }); + it('lets a resolved runtime pin override later process env changes', async () => { + const pinned = path.resolve('workspace', 'pinned-runtime'); + process.env['QWEN_RUNTIME_DIR'] = path.resolve( + 'workspace', + 'ambient-runtime', + ); + + await Storage.runWithResolvedRuntimeBaseDir(pinned, async () => { + expect(Storage.getRuntimeBaseDir()).toBe(pinned); + await Promise.resolve(); + expect(new Storage('/workspace').getRuntimeBaseDir()).toBe(pinned); + }); + }); + + it('keeps a resolved runtime pin across nested configurable contexts', () => { + const pinned = path.resolve('workspace', 'pinned-runtime'); + + Storage.runWithResolvedRuntimeBaseDir(pinned, () => { + Storage.runWithRuntimeBaseDir( + path.resolve('workspace', 'nested-runtime'), + undefined, + () => { + expect(Storage.getRuntimeBaseDir()).toBe(pinned); + expect(new Storage('/workspace').getRuntimeBaseDir()).toBe(pinned); + }, + ); + }); + }); + it('pins an instance to the runtime dir where it was created', () => { const cwd = path.resolve('workspace', 'pinned'); const runtimeDir = path.join(cwd, '.qwen-a'); diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 7d0666d695..08dba1e791 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -42,9 +42,10 @@ export class Storage { * When null, falls back to getGlobalQwenDir(). */ private static runtimeBaseDir: string | null = null; - private static readonly runtimeBaseDirContext = new AsyncLocalStorage< - string | null - >(); + private static readonly runtimeBaseDirContext = new AsyncLocalStorage<{ + dir: string | null; + pinned: boolean; + }>(); constructor( targetDir: string, @@ -127,8 +128,24 @@ export class Storage { cwd: string | undefined, fn: () => T, ): T { + if (Storage.runtimeBaseDirContext.getStore()?.pinned) { + return fn(); + } const resolved = Storage.resolveRuntimeBaseDir(dir, cwd); - return Storage.runtimeBaseDirContext.run(resolved, fn); + return Storage.runtimeBaseDirContext.run( + { dir: resolved, pinned: false }, + fn, + ); + } + + static runWithResolvedRuntimeBaseDir(dir: string, fn: () => T): T { + // A managed workspace runtime owns this root for its full lifetime. + // Unlike the configurable context above, later process-env reloads must + // not redirect storage created inside this context. + return Storage.runtimeBaseDirContext.run( + { dir: path.resolve(dir), pinned: true }, + fn, + ); } static hasRuntimeBaseDirContext(): boolean { @@ -139,10 +156,14 @@ export class Storage { * Returns the base directory for all runtime output (temp files, debug logs, * session data, todos, insights, etc.). * - * Priority: QWEN_RUNTIME_DIR env var > setRuntimeBaseDir() value > getGlobalQwenDir() + * Priority: pinned runtime context > QWEN_RUNTIME_DIR env var > configurable context > setRuntimeBaseDir() value > getGlobalQwenDir() * @returns Absolute path to the runtime output base directory */ static getRuntimeBaseDir(): string { + const contextualDir = Storage.runtimeBaseDirContext.getStore(); + if (contextualDir?.pinned) { + return contextualDir.dir ?? Storage.getGlobalQwenDir(); + } const envDir = process.env['QWEN_RUNTIME_DIR']; if (envDir) { return ( @@ -150,9 +171,8 @@ export class Storage { ); } - const contextualDir = Storage.runtimeBaseDirContext.getStore(); if (contextualDir !== undefined) { - return contextualDir ?? Storage.getGlobalQwenDir(); + return contextualDir.dir ?? Storage.getGlobalQwenDir(); } if (Storage.runtimeBaseDir) { return Storage.runtimeBaseDir; 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/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/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..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'; @@ -628,6 +633,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) { 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/services/session-service-writer-lease.test.ts b/packages/core/src/services/session-service-writer-lease.test.ts new file mode 100644 index 0000000000..a4b7120c9c --- /dev/null +++ b/packages/core/src/services/session-service-writer-lease.test.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { SessionService } from './sessionService.js'; +import { + getSessionWriterLockPath, + SessionWriterConflictError, + SessionWriterUnavailableError, +} from './session-writer-lease.js'; + +const temporaryDirectories = new Set(); + +afterEach(async () => { + await Promise.all( + [...temporaryDirectories].map((directory) => + fs.rm(directory, { recursive: true, force: true }), + ), + ); + temporaryDirectories.clear(); +}); + +async function createService(): Promise<{ + runtimeBaseDir: string; + service: SessionService; +}> { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-session-service-lease-'), + ); + temporaryDirectories.add(root); + const runtimeBaseDir = path.join(root, 'runtime'); + const workspace = path.join(root, 'workspace'); + await fs.mkdir(workspace, { recursive: true }); + return { + runtimeBaseDir, + service: new SessionService(workspace, { runtimeBaseDir }), + }; +} + +describe('SessionService.acquireSessionWriterLease', () => { + it('uses the service runtime root and rejects a second writer', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440000'; + const { runtimeBaseDir, service } = await createService(); + const lease = await service.acquireSessionWriterLease(sessionId, { + processKind: 'daemon', + reclaimPolicy: 'never', + }); + + await expect( + fs.stat(getSessionWriterLockPath(runtimeBaseDir, sessionId)), + ).resolves.toBeDefined(); + await expect( + service.acquireSessionWriterLease(sessionId, { + processKind: 'daemon', + reclaimPolicy: 'never', + }), + ).rejects.toThrow(SessionWriterConflictError); + + await lease.release(); + }); + + it('rejects an invalid id before creating the lock directory', async () => { + const { runtimeBaseDir, service } = await createService(); + + await expect( + service.acquireSessionWriterLease('../invalid', { + processKind: 'daemon', + reclaimPolicy: 'never', + }), + ).rejects.toThrow(SessionWriterUnavailableError); + await expect( + fs.stat(path.dirname(getSessionWriterLockPath(runtimeBaseDir, 'valid'))), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); +}); 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..9396ef7564 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -46,6 +46,11 @@ import { } from './session-artifact-persistence.js'; import { SessionOrganizationService } from './session-organization-service.js'; import { SessionTranscriptTooLargeError } from './session-transcript-reader.js'; +import { + SessionWriterLease, + SessionWriterUnavailableError, + type SessionWriterProcessKind, +} from './session-writer-lease.js'; const debugLogger = createDebugLogger('SESSION'); @@ -339,6 +344,25 @@ export class SessionService { return this.projectRoot; } + async acquireSessionWriterLease( + sessionId: string, + options: { + processKind: SessionWriterProcessKind; + qwenVersion?: string | null; + reclaimPolicy: 'local' | 'never'; + }, + ): Promise { + if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) { + throw new SessionWriterUnavailableError(); + } + return SessionWriterLease.acquire({ + runtimeBaseDir: this.storage.getRuntimeBaseDir(), + sessionId, + transcriptPath: this.getSessionFilePath(sessionId, 'active'), + ...options, + }); + } + private warn(message: string): void { debugLogger.warn(message); this.onWarning?.(message); @@ -1666,6 +1690,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 +1736,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 +1747,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/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. | 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; } diff --git a/packages/core/src/tools/agent/agent.test.ts b/packages/core/src/tools/agent/agent.test.ts index 5d4f9b69d5..ba5d594131 100644 --- a/packages/core/src/tools/agent/agent.test.ts +++ b/packages/core/src/tools/agent/agent.test.ts @@ -20,6 +20,7 @@ import type { SubagentConfig } from '../../subagents/types.js'; import { BUBBLE_APPROVAL_MODE } from '../../subagents/types.js'; import { buildChildMessage, + buildForkedMessages, FORK_AGENT, FORK_DEFAULT_MAX_TURNS, runInForkContext, @@ -550,6 +551,31 @@ describe('AgentTool', () => { expect(properties.properties.fork_turns.oneOf).toHaveLength(2); }); + it('declares fork_tools as an optional execution-only allowlist', () => { + const properties = agentTool.schema.parametersJsonSchema as { + properties: { + fork_tools: { + type?: string; + default?: string[]; + description?: string; + items?: { type?: string }; + minItems?: number; + }; + }; + }; + + expect(properties.properties.fork_tools.type).toBe('array'); + expect(properties.properties.fork_tools.items?.type).toBe('string'); + expect(properties.properties.fork_tools.default).toBeUndefined(); + expect(properties.properties.fork_tools.minItems).toBeUndefined(); + expect(properties.properties.fork_tools.description).toContain( + 'Only valid with subagent_type "fork"', + ); + expect(properties.properties.fork_tools.description).toContain( + 'tool declarations remain unchanged', + ); + }); + it('documents that working_dir takes precedence over isolation', () => { const properties = agentTool.schema.parametersJsonSchema as { properties: { @@ -777,6 +803,121 @@ describe('AgentTool', () => { ).toMatch(/named teammate/i); }); + it('accepts fork_tools, including an empty deny-all list, for forks', () => { + expect( + agentTool.validateToolParams({ + ...validParams, + subagent_type: 'fork', + fork_tools: [ + ToolNames.READ_FILE, + 'unknown_exact_tool', + 'mcp__github', + 'mcp__*', + 'mcp__github__*', + 'mcp__github__read_*', + ], + }), + ).toBeNull(); + expect( + agentTool.validateToolParams({ + ...validParams, + subagent_type: 'fork', + fork_tools: [], + }), + ).toBeNull(); + }); + + it('rejects invalid fork_tools entries', () => { + expect( + agentTool.validateToolParams({ + ...validParams, + subagent_type: 'fork', + fork_tools: [' '], + }), + ).toMatch(/without surrounding whitespace/i); + expect( + agentTool.validateToolParams({ + ...validParams, + subagent_type: 'fork', + fork_tools: [' read_file '], + }), + ).toMatch(/without surrounding whitespace/i); + expect( + agentTool.validateToolParams({ + ...validParams, + subagent_type: 'fork', + fork_tools: null as unknown as string[], + }), + ).toMatch(/array of non-empty tool names/i); + expect( + agentTool.validateToolParams({ + ...validParams, + subagent_type: 'fork', + fork_tools: ['*'], + }), + ).toMatch(/omit it for unrestricted execution/i); + }); + + it.each([ + 'read*', + 'read_*', + 'mcp__github*', + 'mcp__*__read', + 'mcp__github__read*more', + 'mcp__github__read**', + 'mcp____*', + ])('rejects structurally invalid fork_tools wildcard %s', (toolName) => { + expect( + agentTool.validateToolParams({ + ...validParams, + subagent_type: 'fork', + fork_tools: [toolName], + }), + ).toMatch(/wildcard entries/i); + }); + + it.each([undefined, 'file-search'])( + 'rejects fork_tools for non-fork subagent_type=%s', + (subagentType) => { + expect( + agentTool.validateToolParams({ + ...validParams, + subagent_type: subagentType, + fork_tools: [ToolNames.READ_FILE], + }), + ).toMatch(/only be used with subagent_type "fork"/i); + }, + ); + + it('rejects fork_tools for named teammates', () => { + expect( + agentTool.validateToolParams({ + ...validParams, + subagent_type: 'fork', + fork_tools: [ToolNames.READ_FILE], + name: 'worker', + }), + ).toMatch(/named teammate/i); + }); + + it('reports fork_tools applicability before malformed entries', () => { + expect( + agentTool.validateToolParams({ + ...validParams, + subagent_type: 'general-purpose', + fork_tools: ['*'], + }), + ).toMatch(/only be used with subagent_type "fork"/i); + expect( + agentTool.validateToolParams({ + ...validParams, + subagent_type: 'fork', + name: 'worker', + fork_tools: ['*'], + }), + ).toMatch(/named teammate/i); + }); + it('accepts a subagent_type missing from the cache (may have been created after startup)', () => { const result = agentTool.validateToolParams({ ...validParams, @@ -2918,6 +3059,48 @@ describe('AgentTool', () => { ); }); + it('adds the execution restriction only when fork_tools is supplied', () => { + const unrestricted = buildChildMessage('inspect the implementation'); + const restricted = buildChildMessage('inspect the implementation', [ + ToolNames.READ_FILE, + 'mcp__github', + ]); + const denyAll = buildChildMessage('reason without tools', []); + + expect(unrestricted).not.toContain('TOOL EXECUTION RESTRICTION'); + expect(restricted).toContain('TOOL EXECUTION RESTRICTION'); + expect(restricted).toContain( + JSON.stringify([ToolNames.READ_FILE, 'mcp__github']), + ); + expect(restricted).toContain( + 'Other visible tool declarations are unavailable', + ); + expect(denyAll).toContain('may not execute any tools'); + }); + + it('includes the execution restriction in the synthetic fork suffix', () => { + const messages = buildForkedMessages( + 'inspect the implementation', + { + role: 'model', + parts: [ + { + functionCall: { + id: 'call-1', + name: ToolNames.READ_FILE, + args: { path: 'README.md' }, + }, + }, + ], + }, + [ToolNames.READ_FILE], + ); + + const suffix = messages[1]?.parts?.find((part) => part.text)?.text; + expect(suffix).toContain('TOOL EXECUTION RESTRICTION'); + expect(suffix).toContain(JSON.stringify([ToolNames.READ_FILE])); + }); + it('forks in interactive mode', async () => { const mockLoadedSubagent: SubagentConfig = { name: 'general-purpose', @@ -3189,6 +3372,55 @@ describe('AgentTool', () => { expect(FORK_AGENT.approvalMode).toBe(BUBBLE_APPROVAL_MODE); }); + it('passes fork_tools separately from inherited tool names', async () => { + const parentToolDecls = [ + { + name: ToolNames.READ_FILE, + description: 'Read a file', + parameters: { type: 'object', properties: {} }, + }, + { + name: ToolNames.EDIT, + description: 'Edit a file', + parameters: { type: 'object', properties: {} }, + }, + ]; + vi.mocked(config.getGeminiClient).mockReturnValue({ + getHistory: vi.fn().mockReturnValue([]), + getChat: vi.fn().mockReturnValue({ + getGenerationConfig: vi.fn().mockReturnValue({ + systemInstruction: 'parent system', + tools: [{ functionDeclarations: parentToolDecls }], + }), + }), + } as unknown as ReturnType); + + const invocation = ( + agentTool as AgentToolWithProtectedMethods + ).createInvocation({ + description: 'read only task', + prompt: 'inspect the implementation', + subagent_type: 'fork', + fork_tools: [ToolNames.READ_FILE], + }); + await invocation.execute(); + + const toolConfig = vi.mocked(AgentHeadless.create).mock.calls[0]?.[5]; + const promptConfig = vi.mocked(AgentHeadless.create).mock.calls[0]?.[2]; + expect(promptConfig).toMatchObject({ + renderedSystemPrompt: 'parent system', + }); + expect(toolConfig?.tools).toStrictEqual([ + ToolNames.READ_FILE, + ToolNames.EDIT, + ]); + expect(toolConfig?.executionAllowedTools).toEqual([ToolNames.READ_FILE]); + expect(mockContextState.set).toHaveBeenCalledWith( + 'task_prompt', + expect.stringContaining(JSON.stringify([ToolNames.READ_FILE])), + ); + }); + it('omitting subagent_type uses general-purpose, not fork', async () => { // Omission resolves to the regular general-purpose subagent, never a // context-inheriting fork — even in interactive mode. @@ -3363,6 +3595,7 @@ describe('AgentTool', () => { // ToolConfig inherits wildcard for first-turn fallback. const toolConfig = createArgs[5]; expect(toolConfig?.tools).toEqual(['*']); + expect(toolConfig?.executionAllowedTools).toBeUndefined(); // Fork returns the placeholder synchronously. const llmText = partToString(result.llmContent); @@ -5910,7 +6143,7 @@ describe('AgentTool', () => { }); }); - it('does not persist fork capability snapshots in the bootstrap transcript', async () => { + it('stores fork execution policy in the sidecar without capability snapshots in the bootstrap transcript', async () => { (config as unknown as Record)['isInteractive'] = vi .fn() .mockReturnValue(true); @@ -5919,6 +6152,7 @@ describe('AgentTool', () => { description: 'Fork task', prompt: 'Investigate issue', subagent_type: 'fork', + fork_tools: ['Read'], run_in_background: true, }; const generationConfig = { @@ -5941,6 +6175,7 @@ describe('AgentTool', () => { ); const attachSpy = vi.spyOn(transcript, 'attachJsonlTranscriptWriter'); + const writeMetaSpy = vi.spyOn(transcript, 'writeAgentMeta'); const createSpy = vi .spyOn(AgentHeadless, 'create') .mockResolvedValue(mockAgent); @@ -5954,14 +6189,24 @@ describe('AgentTool', () => { expect(writerOptions).toBeDefined(); expect(writerOptions).not.toHaveProperty('bootstrapSystemInstruction'); expect(writerOptions).not.toHaveProperty('bootstrapTools'); + expect(writerOptions).not.toHaveProperty( + 'bootstrapExecutionAllowedTools', + ); expect(writerOptions).toMatchObject({ bootstrapHistory: [{ role: 'model', parts: [{ text: 'Ready' }] }], launchTaskPrompt: expect.any(String), }); + expect(writeMetaSpy.mock.calls[0]?.[1]).toMatchObject({ + executionAllowedTools: ['Read'], + }); const createArgs = createSpy.mock.calls[0]; - expect(createArgs?.[5]).toEqual({ tools: ['Bash', 'Read'] }); + expect(createArgs?.[5]).toEqual({ + tools: ['Bash', 'Read'], + executionAllowedTools: ['Read'], + }); attachSpy.mockRestore(); + writeMetaSpy.mockRestore(); createSpy.mockRestore(); }); }); diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index c0826415b9..13b3d4faec 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -215,6 +215,11 @@ export interface AgentParams { * everything; a positive integer string inherits that many recent user turns. */ fork_turns?: ForkTurns; + /** + * Tool names a fork may execute. The fork's current model-visible + * declarations remain unchanged so the prompt-cache prefix is preserved. + */ + fork_tools?: string[]; run_in_background?: boolean; /** When set, spawn as a named teammate via TeamManager instead of a one-shot subagent. */ name?: string; @@ -249,6 +254,28 @@ export interface AgentParams { const debugLogger = createDebugLogger('AGENT'); +function isValidForkToolWildcard(toolName: string): boolean { + if (!toolName.includes('*')) { + return true; + } + if (toolName === 'mcp__*') { + return true; + } + if ( + !toolName.startsWith('mcp__') || + !toolName.endsWith('*') || + toolName.slice(0, -1).includes('*') + ) { + return false; + } + + // After removing `mcp__` and the trailing wildcard, a server-scoped tool + // pattern must still contain a non-empty raw server name followed by `__`. + // The tool-name prefix may be empty, as in `mcp__github__*`. + const patternBody = toolName.slice('mcp__'.length, -1); + return patternBody.lastIndexOf('__') > 0; +} + /** * Resolves and validates an `AgentParams.working_dir`: an EXISTING, * caller-owned git worktree that a sub-agent should be pinned to (e.g. the @@ -795,6 +822,15 @@ export class AgentTool extends BaseDeclarativeTool { description: 'Only valid with subagent_type "fork". Omit it or use "all" to inherit the full parent conversation; use a positive integer string such as "3" to inherit the most recent three real user turns. Tool responses and pure system reminders do not count as turns.', }, + fork_tools: { + type: 'array', + items: { + type: 'string', + minLength: 1, + }, + description: + 'Only valid with subagent_type "fork". Exact tool names and MCP server patterns this fork may execute. Entries cannot have surrounding whitespace; wildcard entries must be "mcp__*" or a trailing MCP tool-prefix pattern such as "mcp__github__read_*". The model-visible tool declarations remain unchanged for prompt-cache sharing, while the task prompt tells the fork about the restriction. Omit for unrestricted execution; use an empty array to reject every tool call.', + }, run_in_background: { type: 'boolean', default: true, @@ -894,7 +930,7 @@ The Agent tool launches specialized agents (subprocesses) that autonomously hand Available agent types and the tools they have access to: ${subagentDescriptions} -When using the Agent tool, specify a subagent_type to select which agent type to use. If omitted, the general-purpose agent is used. Top-level regular subagents run in the background by default and report their results through a completion notification; set \`run_in_background: false\` when you need a regular subagent's result inline before continuing. A fork (\`subagent_type: "fork"\`) inherits the parent conversation context. A background fork's result arrives through a completion notification. Forks inherit the full parent conversation by default; set \`fork_turns\` to a positive integer string to limit inheritance to that many recent real user turns. +When using the Agent tool, specify a subagent_type to select which agent type to use. If omitted, the general-purpose agent is used. Top-level regular subagents run in the background by default and report their results through a completion notification; set \`run_in_background: false\` when you need a regular subagent's result inline before continuing. A fork (\`subagent_type: "fork"\`) inherits the parent conversation context. A background fork's result arrives through a completion notification. Forks inherit the full parent conversation by default; set \`fork_turns\` to a positive integer string to limit inheritance to that many recent real user turns. Set \`fork_tools\` to restrict which of the still-visible parent tools the fork may execute. When NOT to use the Agent tool: - If you want to read a specific file path, use the ${ToolNames.READ_FILE} tool or the ${ToolNames.GLOB} tool instead of the ${ToolNames.AGENT} tool, to find the match more quickly @@ -914,7 +950,7 @@ Usage notes: - While background agents run, continue meaningful non-overlapping work. Wait for an agent only when its result blocks the next required step. - Reuse an existing background agent for related follow-up work instead of launching a duplicate: call ${ToolNames.LIST_AGENTS} to inspect the current roster, then call ${ToolNames.SEND_MESSAGE} with its \`task_id\`. Running agents receive the message at the next tool-round boundary; paused agents resume with it as their first continuation instruction; completed agents continue on their resident runtime when available and otherwise revive from their retained transcript. If the task is no longer retained or cannot be resumed or revived, launch a new agent. - Provide clear, detailed prompts so the agent can work autonomously and return exactly the information you need. -- Regular subagents and named teammates start without parent conversation history. Only fork agents accept \`fork_turns\`; omit it for the full conversation or use a positive integer string such as \`"3"\` for a bounded recent window. +- Regular subagents and named teammates start without parent conversation history. Only fork agents accept \`fork_turns\` and \`fork_tools\`; omit \`fork_turns\` for the full conversation and omit \`fork_tools\` for unrestricted execution. - Treat the agent's output as evidence, not as automatically correct. Verify factual claims, review code changes, and run relevant checks before integrating or relaying the result. - Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent - If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement. @@ -1111,6 +1147,34 @@ assistant: Uses the ${ToolNames.AGENT} tool to launch the test-runner agent } } + if (params.fork_tools !== undefined) { + if (params.subagent_type?.toLowerCase() !== FORK_SUBAGENT_TYPE) { + return 'Parameter "fork_tools" can only be used with subagent_type "fork".'; + } + if (params.name !== undefined) { + return 'Parameter "fork_tools" cannot be used when spawning a named teammate.'; + } + if ( + !Array.isArray(params.fork_tools) || + params.fork_tools.some( + (toolName) => + typeof toolName !== 'string' || + toolName.trim().length === 0 || + toolName.trim() !== toolName, + ) + ) { + return 'Parameter "fork_tools" must be an array of non-empty tool names without surrounding whitespace.'; + } + if (params.fork_tools.includes('*')) { + return 'Parameter "fork_tools" does not accept "*"; omit it for unrestricted execution.'; + } + if ( + params.fork_tools.some((toolName) => !isValidForkToolWildcard(toolName)) + ) { + return 'Parameter "fork_tools" wildcard entries must be "mcp__*" or a trailing MCP tool-prefix pattern such as "mcp__github__read_*".'; + } + } + if (params.isolation !== undefined) { if (params.isolation !== 'worktree') { return 'Parameter "isolation" must be "worktree" when set.'; @@ -1208,6 +1272,7 @@ assistant: Uses the ${ToolNames.AGENT} tool to launch the test-runner agent return { subagent_type: params.subagent_type, fork_turns: params.fork_turns, + fork_tools: params.fork_tools, // Include working_dir: it rebinds the child's cwd to another registered // worktree, which the AUTO-mode classifier must be able to see — a // launch that looks benign from subagent_type + prompt alone could be @@ -1578,6 +1643,7 @@ class AgentToolInvocation extends BaseToolInvocation { subagent: AgentHeadless; initialMessages?: Content[]; taskPrompt: string; + toolConfig: ToolConfig; }> { const geminiClient = this.config.getGeminiClient(); const forkTurns = normalizeForkTurns(this.params.fork_turns); @@ -1634,6 +1700,7 @@ class AgentToolInvocation extends BaseToolInvocation { const forkedMessages = buildForkedMessages( this.params.prompt, lastMessage, + this.params.fork_tools, ); if (forkedMessages.length > 0) { // Model had function calls: append tool responses + directive, @@ -1663,7 +1730,10 @@ class AgentToolInvocation extends BaseToolInvocation { // Default: directive with fork boilerplate as task_prompt if (!taskPrompt) { - taskPrompt = buildChildMessage(this.params.prompt); + taskPrompt = buildChildMessage( + this.params.prompt, + this.params.fork_tools, + ); } // Read the parent's live generationConfig (systemInstruction + tool @@ -1691,13 +1761,21 @@ class AgentToolInvocation extends BaseToolInvocation { }; toolConfig = { tools: parentToolNames.length > 0 ? parentToolNames : ['*'], + ...(this.params.fork_tools !== undefined + ? { executionAllowedTools: [...this.params.fork_tools] } + : {}), }; } else { promptConfig = { systemPrompt: FORK_AGENT.systemPrompt, initialMessages, }; - toolConfig = { tools: ['*'] }; + toolConfig = { + tools: ['*'], + ...(this.params.fork_tools !== undefined + ? { executionAllowedTools: [...this.params.fork_tools] } + : {}), + }; } const subagent = await AgentHeadless.create( @@ -1710,7 +1788,7 @@ class AgentToolInvocation extends BaseToolInvocation { eventEmitter, ); - return { subagent, initialMessages, taskPrompt }; + return { subagent, initialMessages, taskPrompt, toolConfig }; } // Runs the SubagentStop hook after execution. On a blocking decision, feeds @@ -2862,6 +2940,7 @@ class AgentToolInvocation extends BaseToolInvocation { let subagent: AgentHeadless; let taskPrompt: string; let initialMessages: Content[] | undefined; + let toolConfig: ToolConfig | undefined; // Per-spawn cleanup the subagent manager returns. The caller MUST // invoke this in the same `finally` block that wraps `execute()` — @@ -2879,6 +2958,7 @@ class AgentToolInvocation extends BaseToolInvocation { subagent = fork.subagent; taskPrompt = fork.taskPrompt; initialMessages = fork.initialMessages; + toolConfig = fork.toolConfig; } else { const result = await this.subagentManager.createAgentHeadless( subagentConfig, @@ -2978,6 +3058,7 @@ class AgentToolInvocation extends BaseToolInvocation { const bgSubagent = subagent; const bgInitialMessages = initialMessages; const bgTaskPrompt = taskPrompt; + const bgToolConfig = toolConfig; const bgSubagentDispose = subagentDispose; const registry = this.config.getBackgroundTaskRegistry(); @@ -3118,6 +3199,11 @@ class AgentToolInvocation extends BaseToolInvocation { isolation: this.params.isolation, lastUpdatedAt: new Date().toISOString(), resolvedApprovalMode, + ...(isFork && bgToolConfig?.executionAllowedTools !== undefined + ? { + executionAllowedTools: [...bgToolConfig.executionAllowedTools], + } + : {}), persistedCliFlags: capturePersistedCliFlags( this.config, resolvedApprovalMode, @@ -3908,6 +3994,11 @@ class AgentToolInvocation extends BaseToolInvocation { isolation: this.params.isolation, lastUpdatedAt: new Date().toISOString(), resolvedApprovalMode, + ...(isFork && toolConfig?.executionAllowedTools !== undefined + ? { + executionAllowedTools: [...toolConfig.executionAllowedTools], + } + : {}), persistedCliFlags: capturePersistedCliFlags( this.config, resolvedApprovalMode, diff --git a/packages/core/src/tools/agent/fork-subagent.ts b/packages/core/src/tools/agent/fork-subagent.ts index 02567bd7ac..419b56b4b4 100644 --- a/packages/core/src/tools/agent/fork-subagent.ts +++ b/packages/core/src/tools/agent/fork-subagent.ts @@ -186,6 +186,7 @@ export function selectForkHistory( export function buildForkedMessages( directive: string, assistantMessage: Content, + executionAllowedTools?: readonly string[], ): Content[] { const toolUseParts = assistantMessage.parts?.filter((part) => part.functionCall) || []; @@ -215,7 +216,7 @@ export function buildForkedMessages( parts: [ ...toolResultParts, { - text: buildChildMessage(directive), + text: buildChildMessage(directive, executionAllowedTools), }, ], }; @@ -264,7 +265,20 @@ export function buildPinnedWorktreeNotice(worktreeCwd: string): string { ); } -export function buildChildMessage(directive: string): string { +export function buildChildMessage( + directive: string, + executionAllowedTools?: readonly string[], +): string { + const executionRestriction = + executionAllowedTools === undefined + ? '' + : executionAllowedTools.length === 0 + ? `\n\nTOOL EXECUTION RESTRICTION: +You may not execute any tools, even though tool declarations remain visible. Do not attempt tool calls.` + : `\n\nTOOL EXECUTION RESTRICTION: +You may execute only tools matched by this allowlist: ${JSON.stringify(executionAllowedTools)}. +Other visible tool declarations are unavailable to you. Do not call them.`; + return `<${FORK_BOILERPLATE_TAG}> STOP. READ THIS FIRST. @@ -291,5 +305,5 @@ Output format (plain text labels, not markdown headers): Issues: -${FORK_DIRECTIVE_PREFIX}${directive}`; +${FORK_DIRECTIVE_PREFIX}${directive}${executionRestriction}`; } diff --git a/packages/core/src/tools/mcp-client-manager.test.ts b/packages/core/src/tools/mcp-client-manager.test.ts index bc5fd4ac85..ef1e33b346 100644 --- a/packages/core/src/tools/mcp-client-manager.test.ts +++ b/packages/core/src/tools/mcp-client-manager.test.ts @@ -9,7 +9,7 @@ import { McpClientManager, type McpClientManagerOptions, } from './mcp-client-manager.js'; -import { McpClient } from './mcp-client.js'; +import { McpClient, populateMcpServerCommand } from './mcp-client.js'; import type { ToolRegistry } from './tool-registry.js'; import { MCPServerConfig, type Config } from '../config/config.js'; import type { PromptRegistry } from '../prompts/prompt-registry.js'; @@ -53,6 +53,7 @@ function mkManager( isTrustedFolder: () => true, getMcpServers: () => ({}), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }), getWorkspaceContext: () => ({}), @@ -122,6 +123,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({ srv: {} }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }), getWorkspaceContext: () => ({}), @@ -134,6 +136,11 @@ describe('McpClientManager', () => { options: { pool: fakePool }, }); await manager.discoverAllMcpTools(mockConfig); + expect(populateMcpServerCommand).toHaveBeenCalledWith( + { srv: {} }, + undefined, + '/session/worktree', + ); expect(acquireSpy).toHaveBeenCalledTimes(1); expect(acquireSpy).toHaveBeenCalledWith( 'srv', @@ -185,6 +192,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({ srvA: {}, srvB: {} }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }), getWorkspaceContext: () => ({}), @@ -220,6 +228,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({ gated: {}, ok: {} }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }), getWorkspaceContext: () => ({}), @@ -254,6 +263,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({}), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getPromptRegistry: () => ({ removePromptsByServer }), getResourceRegistry: () => ({ removeResourcesByServer }), getWorkspaceContext: () => ({}), @@ -283,6 +293,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({}), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getPromptRegistry: () => ({ removePromptsByServer }), getResourceRegistry: () => ({ removeResourcesByServer }), getWorkspaceContext: () => ({}), @@ -342,6 +353,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({ srv: {} }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }), getWorkspaceContext: () => ({}), @@ -401,6 +413,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({}), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }), getWorkspaceContext: () => ({}), @@ -447,6 +460,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({}), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }), getWorkspaceContext: () => ({}), @@ -500,6 +514,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({ srv: {} }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }), getWorkspaceContext: () => ({}), @@ -559,6 +574,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({ srv: {} }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }), getWorkspaceContext: () => ({}), @@ -592,6 +608,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({ srv: {} }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }), getWorkspaceContext: () => ({}), @@ -634,6 +651,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({ srv: {} }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }), getWorkspaceContext: () => ({}), @@ -695,6 +713,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({ srv: {} }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }), getWorkspaceContext: () => ({}), @@ -762,6 +781,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({ srv: {} }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }), getWorkspaceContext: () => ({}), @@ -812,6 +832,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({ srv: {} }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }), getWorkspaceContext: () => ({}), @@ -854,6 +875,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({ srv: {} }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }), getWorkspaceContext: () => ({}), @@ -880,6 +902,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({ 'test-server': {} }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }), getWorkspaceContext: () => ({}), @@ -914,6 +937,7 @@ describe('McpClientManager', () => { 'without-instructions': {}, }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }), getWorkspaceContext: () => ({}), @@ -943,6 +967,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => false, getMcpServers: () => ({ 'test-server': {} }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }), getWorkspaceContext: () => ({}), @@ -969,6 +994,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => false, getMcpServers: () => ({ 'test-server': {} }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }), getWorkspaceContext: () => ({}), @@ -999,6 +1025,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({ 'pending-server': { scope: 'project' } }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }), getWorkspaceContext: () => ({}), @@ -1029,6 +1056,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({ 'approved-server': { scope: 'project' } }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }), getWorkspaceContext: () => ({}), @@ -1061,6 +1089,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({ 'test-server': {}, 'another-server': {} }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }) as unknown as PromptRegistry, getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), @@ -1099,6 +1128,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({ 'test-server': {} }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }) as unknown as PromptRegistry, getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), @@ -1133,6 +1163,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({ 'test-server': {} }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }) as unknown as PromptRegistry, getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), @@ -1175,6 +1206,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({ 'test-server': {} }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }) as unknown as PromptRegistry, getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), @@ -1237,6 +1269,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({ 'test-server': {} }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }) as unknown as PromptRegistry, getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), @@ -1308,6 +1341,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({ 'test-server': {} }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }) as unknown as PromptRegistry, getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), @@ -1377,6 +1411,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({ 'test-server': {} }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }) as unknown as PromptRegistry, getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), @@ -1428,6 +1463,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({}), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }) as unknown as PromptRegistry, getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), @@ -1467,6 +1503,7 @@ describe('McpClientManager', () => { broken: { command: 'node', args: [], discoveryTimeoutMs: 50 }, }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }) as unknown as PromptRegistry, getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), @@ -1539,6 +1576,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({ oauth: serverConfig }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }) as unknown as PromptRegistry, getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), @@ -1589,6 +1627,7 @@ describe('McpClientManager', () => { disabled: { command: 'node', args: [] }, }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }) as unknown as PromptRegistry, getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), @@ -1634,6 +1673,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({ foo: { command: 'node', args: [] } }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }) as unknown as PromptRegistry, getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), @@ -1693,6 +1733,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({ foo: { command: 'node', args } }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getPromptRegistry: () => ({ removePromptsByServer }) as unknown as PromptRegistry, getResourceRegistry: () => ({ removeResourcesByServer }), @@ -1757,6 +1798,7 @@ describe('McpClientManager', () => { // identical; only the per-session filter changes. getMcpServers: () => ({ foo: { command: 'node', includeTools } }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }) as unknown as PromptRegistry, getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), @@ -1812,6 +1854,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({ foo: { command: 'node', args } }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getPromptRegistry: () => ({ removePromptsByServer }) as unknown as PromptRegistry, getResourceRegistry: () => ({ removeResourcesByServer }), @@ -1873,6 +1916,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({ 'broken-auth': { command: 'node', args: [] } }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }) as unknown as PromptRegistry, getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), @@ -1932,6 +1976,7 @@ describe('McpClientManager', () => { huge: { command: 'node', args: [], discoveryTimeoutMs: 10_000_000 }, }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }) as unknown as PromptRegistry, getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), @@ -1989,6 +2034,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({ wsServer: { tcp: 'ws://example.test' } }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }) as unknown as PromptRegistry, getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), @@ -2031,6 +2077,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({ slow: serverConfig }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }) as unknown as PromptRegistry, getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), @@ -2152,6 +2199,7 @@ describe('McpClientManager', () => { slow: { command: 'node', args: [], discoveryTimeoutMs: 100 }, }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }) as unknown as PromptRegistry, getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), @@ -2208,6 +2256,7 @@ describe('McpClientManager', () => { slow: { command: 'node', args: [], discoveryTimeoutMs: 100 }, }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }) as unknown as PromptRegistry, getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), @@ -2276,6 +2325,7 @@ describe('McpClientManager', () => { isTrustedFolder: () => true, getMcpServers: () => ({ srv: { command: 'node', args: [] } }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }) as unknown as PromptRegistry, getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), @@ -2342,6 +2392,7 @@ describe('McpClientManager — PR 14 guardrails', () => { isTrustedFolder: () => true, getMcpServers: () => servers, getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }) as unknown as PromptRegistry, getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), @@ -2673,6 +2724,7 @@ describe('McpClientManager — PR 14 guardrails', () => { isTrustedFolder: () => true, getMcpServers: () => mcpServers, getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }) as unknown as PromptRegistry, getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), @@ -2844,6 +2896,7 @@ describe('McpClientManager — PR 14 guardrails', () => { isTrustedFolder: () => true, getMcpServers: () => ({ foo: { command: 'node', args } }), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getPromptRegistry: () => ({ removePromptsByServer }) as unknown as PromptRegistry, getResourceRegistry: () => ({ removeResourcesByServer }), @@ -3485,6 +3538,7 @@ describe('McpClientManager — PR 14b push events + hysteresis', () => { isTrustedFolder: () => true, getMcpServers: () => servers, getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }) as unknown as PromptRegistry, getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), @@ -4014,6 +4068,7 @@ describe('McpClientManager — addRuntimeMcpServer / removeRuntimeMcpServer (T2. isTrustedFolder: () => true, getMcpServers: () => ({}), getMcpServerCommand: () => undefined, + getTargetDir: () => '/session/worktree', getResourceRegistry: () => ({ removeResourcesByServer: vi.fn() }), getPromptRegistry: () => ({ removePromptsByServer: vi.fn() }), getWorkspaceContext: () => ({}), diff --git a/packages/core/src/tools/mcp-client-manager.ts b/packages/core/src/tools/mcp-client-manager.ts index d396fe6979..06f9630f6a 100644 --- a/packages/core/src/tools/mcp-client-manager.ts +++ b/packages/core/src/tools/mcp-client-manager.ts @@ -1034,6 +1034,20 @@ export class McpClientManager { } } + /** + * Single source of truth for the effective server map: the configured + * servers plus the `mcpServerCommand`-derived `mcp` server, each stamped + * with the session target dir as its cwd. Every discovery entry point + * resolves servers through here so the recipe cannot diverge. + */ + private getEffectiveMcpServers(): Record { + return populateMcpServerCommand( + this.cliConfig.getMcpServers() || {}, + this.cliConfig.getMcpServerCommand(), + this.cliConfig.getTargetDir(), + ); + } + /** * Initiates the tool discovery process for all configured MCP servers. * It connects to each server, discovers its available tools, and registers @@ -1056,10 +1070,7 @@ export class McpClientManager { } await this.stop(); - const servers = populateMcpServerCommand( - this.cliConfig.getMcpServers() || {}, - this.cliConfig.getMcpServerCommand(), - ); + const servers = this.getEffectiveMcpServers(); // mark the bulk pass active // so per-server `emitRefusedBatchIfAny` calls (which the inner @@ -1223,10 +1234,7 @@ export class McpClientManager { serverName: string, cliConfig: Config, ): Promise { - const servers = populateMcpServerCommand( - this.cliConfig.getMcpServers() || {}, - this.cliConfig.getMcpServerCommand(), - ); + const servers = this.getEffectiveMcpServers(); const serverConfig = servers[serverName]; if (!serverConfig) { return; @@ -1261,10 +1269,7 @@ export class McpClientManager { serverName: string, cliConfig: Config, ): Promise { - const servers = populateMcpServerCommand( - this.cliConfig.getMcpServers() || {}, - this.cliConfig.getMcpServerCommand(), - ); + const servers = this.getEffectiveMcpServers(); const serverConfig = servers[serverName]; if (!serverConfig) { return; @@ -1561,10 +1566,7 @@ export class McpClientManager { const sessionId = this.cliConfig.getSessionId(); const promptRegistry = this.cliConfig.getPromptRegistry(); const resourceRegistry = this.cliConfig.getResourceRegistry(); - const servers = populateMcpServerCommand( - this.cliConfig.getMcpServers() || {}, - this.cliConfig.getMcpServerCommand(), - ); + const servers = this.getEffectiveMcpServers(); // diff against the // current `pooledConnections` instead of releasing all then // re-acquiring everything. Pre-fix every incremental discovery @@ -2118,10 +2120,7 @@ export class McpClientManager { return this.discoverAllMcpToolsViaPool(cliConfig); } - const servers = populateMcpServerCommand( - this.cliConfig.getMcpServers() || {}, - this.cliConfig.getMcpServerCommand(), - ); + const servers = this.getEffectiveMcpServers(); // suppress per-server // length-1 batches inside this incremental pass — the @@ -2621,10 +2620,7 @@ export class McpClientManager { uri: string, options?: { signal?: AbortSignal }, ): Promise { - const servers = populateMcpServerCommand( - this.cliConfig.getMcpServers() || {}, - this.cliConfig.getMcpServerCommand(), - ); + const servers = this.getEffectiveMcpServers(); const serverConfig = servers[serverName]; if (this.cliConfig.isMcpServerDisabled(serverName)) { throw new Error(`MCP server '${serverName}' is disabled.`); diff --git a/packages/core/src/tools/mcp-client.test.ts b/packages/core/src/tools/mcp-client.test.ts index 4eba6b6aba..3be07a1213 100644 --- a/packages/core/src/tools/mcp-client.test.ts +++ b/packages/core/src/tools/mcp-client.test.ts @@ -2340,11 +2340,13 @@ lOTTGqPpwFUbw2EMOOpFYuIyzGMIpUNMBjE2gvJiqFQ= it('should discover tools via mcpServerCommand', () => { const commandString = 'command --arg1 value1'; - const out = populateMcpServerCommand({}, commandString); + const cwd = '/session/worktree'; + const out = populateMcpServerCommand({}, commandString, cwd); expect(out).toEqual({ mcp: { command: 'command', args: ['--arg1', 'value1'], + cwd, }, }); }); @@ -2352,6 +2354,44 @@ lOTTGqPpwFUbw2EMOOpFYuIyzGMIpUNMBjE2gvJiqFQ= it('should handle error if mcpServerCommand parsing fails', () => { expect(() => populateMcpServerCommand({}, 'derp && herp')).toThrowError(); }); + + it('stamps cwd onto implicit stdio servers', () => { + const cwd = '/session/worktree'; + const out = populateMcpServerCommand( + { + implicit: { command: 'node', args: ['server.js'] }, + explicit: { command: 'node', cwd: '/explicit' }, + remote: { httpUrl: 'https://example.test/mcp' }, + sdk: { type: 'sdk', command: 'placeholder' }, + tcpWithCommand: { tcp: 'tcp://example.test:9000', command: 'node' }, + }, + undefined, + cwd, + ); + expect(out['implicit']).toEqual({ + command: 'node', + args: ['server.js'], + cwd, + }); + expect(out['explicit']?.cwd).toBe('/explicit'); + expect(out['remote']?.cwd).toBeUndefined(); + expect(out['sdk']?.cwd).toBeUndefined(); + expect(out['tcpWithCommand']?.cwd).toBeUndefined(); + }); + + it('does not stamp cwd when cwd is undefined', () => { + const servers = { local: { command: 'node', args: [] } }; + const out = populateMcpServerCommand(servers, undefined); + expect(out['local']?.cwd).toBeUndefined(); + }); + + it('does not mutate the input map', () => { + const servers = { local: { command: 'node', args: [] } }; + const out = populateMcpServerCommand(servers, 'cmd --flag', '/wd'); + expect(servers).toEqual({ local: { command: 'node', args: [] } }); + expect(out['mcp']).toBeDefined(); + expect(out['local']?.cwd).toBe('/wd'); + }); }); describe('createTransport', () => { diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index 39af68fa39..7beaa1dd05 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -1159,7 +1159,11 @@ export async function discoverMcpTools( ): Promise { mcpDiscoveryState = MCPDiscoveryState.IN_PROGRESS; try { - mcpServers = populateMcpServerCommand(mcpServers, mcpServerCommand); + mcpServers = populateMcpServerCommand( + mcpServers, + mcpServerCommand, + cliConfig.getTargetDir(), + ); const discoveryPromises = Object.entries(mcpServers).map( ([mcpServerName, mcpServerConfig]) => @@ -1183,20 +1187,42 @@ export async function discoverMcpTools( export function populateMcpServerCommand( mcpServers: Record, mcpServerCommand: string | undefined, + cwd?: string, ): Record { + let result = mcpServers; if (mcpServerCommand) { const cmd = mcpServerCommand; const args = parse(cmd, process.env) as string[]; if (args.some((arg) => typeof arg !== 'string')) { throw new Error('failed to parse mcpServerCommand: ' + cmd); } - // use generic server name 'mcp' - mcpServers['mcp'] = { - command: args[0], - args: args.slice(1), + result = { + ...result, + mcp: { + command: args[0], + args: args.slice(1), + cwd, + }, }; } - return mcpServers; + if (cwd === undefined) return result; + // Stamp the session cwd onto implicit stdio servers so a worktree + // relocation rebinds them (cwd is part of the pool fingerprint). The + // predicate mirrors mcpTransportOf's order — tcp before command — so a + // config carrying both stays a websocket server and is not stamped. + return Object.fromEntries( + Object.entries(result).map(([name, server]) => [ + name, + server.command !== undefined && + server.httpUrl === undefined && + server.url === undefined && + server.tcp === undefined && + server.type !== 'sdk' && + server.cwd === undefined + ? { ...server, cwd } + : server, + ]), + ); } /** diff --git a/packages/core/src/tools/toAutoClassifierInput.test.ts b/packages/core/src/tools/toAutoClassifierInput.test.ts index 2965bd13a8..2fefe663bb 100644 --- a/packages/core/src/tools/toAutoClassifierInput.test.ts +++ b/packages/core/src/tools/toAutoClassifierInput.test.ts @@ -186,7 +186,7 @@ describe('SkillTool.toAutoClassifierInput', () => { // AgentTool ────────────────────────────────────────────────────────────── describe('AgentTool.toAutoClassifierInput', () => { - it('forwards full prompt, subagent_type, and fork_turns', () => { + it('forwards full prompt and fork constraints', () => { // Regression guard: prior implementation truncated to 200 chars, which // hid attack payloads placed after character 200 from the classifier // while the sub-agent still received the full text. Same attack surface @@ -203,10 +203,12 @@ describe('AgentTool.toAutoClassifierInput', () => { prompt: longPrompt, subagent_type: 'fork', fork_turns: '3', + fork_tools: ['read_file'], }, ); expect(result['subagent_type']).toBe('fork'); expect(result['fork_turns']).toBe('3'); + expect(result['fork_tools']).toEqual(['read_file']); expect(result['prompt']).toBe(longPrompt); expect((result['prompt'] as string).length).toBe(longPrompt.length); }); 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/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 c760266a7b..362b738aa2 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -75,7 +75,10 @@ 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. +// 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-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..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, @@ -1643,7 +1645,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 +1664,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) }, @@ -2455,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) { @@ -2466,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, @@ -5408,7 +5443,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 +5456,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/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 e4beb5aa19..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; } @@ -1744,6 +1753,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..4fa87e121f 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', @@ -2669,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/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'); 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 && ( +