mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
Merge branch 'main' into fix/issue-10075-decouple-allowlist
This commit is contained in:
34 files changed
+3248
-102
No files matched your search
@@ -1,4 +1,5 @@
|
||||
// Runner-routing regression guards for ci.yml and serve-ab.yml.
|
||||
// Runner-routing regression guards for ci.yml, serve-ab.yml, and the
|
||||
// qwen-autofix.yml scan lane.
|
||||
//
|
||||
// classify_pr carries the routing logic TWICE — the `runs-on` expression
|
||||
// (which selects the classify job's own runner) and the `pick_runner` shell
|
||||
@@ -68,6 +69,13 @@ function evalRunsOn(expression, { ecsDisabled, eventName, sameRepo, assoc }) {
|
||||
/github\.event_name == 'merge_group'/,
|
||||
String(eventName === 'merge_group'),
|
||||
],
|
||||
// Longest term first as a convention; both patterns are quote-anchored
|
||||
// (the closing quote is part of each regex), so neither can match
|
||||
// inside the other and the substitution order is behaviorally inert.
|
||||
[
|
||||
/github\.event_name != 'pull_request_review'/,
|
||||
String(eventName !== 'pull_request_review'),
|
||||
],
|
||||
[
|
||||
/github\.event_name != 'pull_request'/,
|
||||
String(eventName !== 'pull_request'),
|
||||
@@ -80,6 +88,7 @@ function evalRunsOn(expression, { ecsDisabled, eventName, sameRepo, assoc }) {
|
||||
/contains\(fromJSON\('\["OWNER","MEMBER","COLLABORATOR"\]'\), github\.event\.pull_request\.author_association\)/,
|
||||
String(TRUSTED.includes(assoc)),
|
||||
],
|
||||
[/github\.repository == 'QwenLM\/qwen-code'/, 'true'],
|
||||
];
|
||||
let expr = expression.replace(/^\$\{\{\s*/, '').replace(/\s*\}\}$/, '');
|
||||
for (const [term, value] of substitutions) {
|
||||
@@ -421,3 +430,92 @@ describe('serve-ab.yml runner routing', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('qwen-autofix.yml scan-lane runner routing', () => {
|
||||
// route and review-scan gate the WHOLE fan-out: while they sit queued no
|
||||
// review-address leg starts. A hosted-runner backlog queued them past the
|
||||
// cron period, and the cron supersede rule then starved every scan round
|
||||
// (2026-08-25) — so pin the lane on the persistent pool, with the
|
||||
// fork-trust clause and the kill-switch intact.
|
||||
const autofixDoc = parse(
|
||||
readFileSync(join(workflowsDir, 'qwen-autofix.yml'), 'utf8'),
|
||||
);
|
||||
// evalRunsOn unwraps the winning fromJSON label to the array it names, so
|
||||
// compare against arrays, not the ECS/HOSTED string constants above.
|
||||
const ECS_LABELS = ['self-hosted', 'linux', 'x64', 'ecs-qwen'];
|
||||
const HOSTED_LABELS = ['ubuntu-latest'];
|
||||
|
||||
for (const jobName of ['route', 'review-scan']) {
|
||||
const runsOn = String(autofixDoc.jobs[jobName]['runs-on']);
|
||||
|
||||
it(`${jobName} reaches the persistent pool on schedule, dispatch, issue_comment, and issues`, () => {
|
||||
// issue_comment is route's /takeover and /retry lane, issues its
|
||||
// label/assign trigger lane for issue-autofix — pin both beside the
|
||||
// cron and dispatch triggers so a later event-allowlist narrowing of
|
||||
// the pool clause cannot silently demote either back to hosted.
|
||||
for (const eventName of [
|
||||
'schedule',
|
||||
'workflow_dispatch',
|
||||
'issue_comment',
|
||||
'issues',
|
||||
]) {
|
||||
assert.deepEqual(
|
||||
evalRunsOn(runsOn, {
|
||||
ecsDisabled: false,
|
||||
eventName,
|
||||
sameRepo: false,
|
||||
assoc: '',
|
||||
}),
|
||||
ECS_LABELS,
|
||||
`${jobName} must scan from the pool on ${eventName}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it(`${jobName} keeps untrusted fork PR lanes hosted`, () => {
|
||||
for (const eventName of ['pull_request', 'pull_request_review']) {
|
||||
assert.deepEqual(
|
||||
evalRunsOn(runsOn, {
|
||||
ecsDisabled: false,
|
||||
eventName,
|
||||
sameRepo: false,
|
||||
assoc: 'NONE',
|
||||
}),
|
||||
HOSTED_LABELS,
|
||||
`${jobName} fork lane (${eventName}) must stay hosted`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
evalRunsOn(runsOn, {
|
||||
ecsDisabled: false,
|
||||
eventName,
|
||||
sameRepo: true,
|
||||
assoc: 'NONE',
|
||||
}),
|
||||
ECS_LABELS,
|
||||
`${jobName} same-repo lane (${eventName}) must reach the pool`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it(`${jobName} obeys the kill-switch on every event`, () => {
|
||||
for (const eventName of [
|
||||
'schedule',
|
||||
'workflow_dispatch',
|
||||
'issue_comment',
|
||||
'pull_request',
|
||||
'pull_request_review',
|
||||
]) {
|
||||
assert.deepEqual(
|
||||
evalRunsOn(runsOn, {
|
||||
ecsDisabled: true,
|
||||
eventName,
|
||||
sameRepo: true,
|
||||
assoc: 'OWNER',
|
||||
}),
|
||||
HOSTED_LABELS,
|
||||
`kill-switch must win on ${eventName}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -34,7 +34,7 @@
|
||||
6495 pr-self-report-label.yml
|
||||
9646 qwen-autofix-fork-bridge.yml
|
||||
5942 qwen-autofix-fork-signal.yml
|
||||
397656 qwen-autofix.yml
|
||||
404284 qwen-autofix.yml
|
||||
7061 qwen-ci-flaky-rerun.yml
|
||||
158010 qwen-code-pr-review.yml
|
||||
79041 qwen-fleet-shepherd.yml
|
||||
|
||||
@@ -249,6 +249,7 @@ task-oriented guides — what a maintainer types and what happens next — see:
|
||||
- [145. review-address · Report dry-run / failure — CUMULATIVE timeout breaker — the sibling of the consecutive one above, for the…](#af-145)
|
||||
- [146. review-address · Report dry-run / failure — The agent committed (verify recorded committed=true before any gate could fail),…](#af-146)
|
||||
- [147. review-address · Report dry-run / failure — Same byte-budget hygiene as the English excerpt above. 3000 bytes ≈ 1000 CJK…](#af-147)
|
||||
- [148. route — Persistent pool, not hosted: a hosted backlog queued route past the cron period, and af-005's…](#af-148)
|
||||
|
||||
---
|
||||
|
||||
@@ -3727,3 +3728,43 @@ forbids HTML in failure.zh.md), but must not be able to open
|
||||
or close a <details>/<summary> that swallows the closing tag
|
||||
the workflow emits below.
|
||||
```
|
||||
|
||||
<a id="af-148"></a>
|
||||
|
||||
### 148. route — Persistent pool, not hosted: a hosted backlog queued route past the cron period, and af-005's supersede then starved every scan round.
|
||||
|
||||
In `route` and `review-scan`.
|
||||
|
||||
```text
|
||||
route and review-scan run on the persistent pool, not the
|
||||
hosted one. They are short trusted base-repo jobs, but they
|
||||
gate the WHOLE fan-out: while they sit queued, no
|
||||
review-address leg starts. On 2026-08-25 a hosted-runner
|
||||
backlog queued route for over 20 minutes — longer than the
|
||||
cron period — and af-005's newer-tick-supersedes-older rule
|
||||
then cancelled every still-queued round: nine consecutive
|
||||
schedule runs died without scanning while the ecs-qwen pool
|
||||
stood mostly idle. The supersede rule stays — it is right
|
||||
once route gets a runner in seconds; the fix is taking the
|
||||
hosted queue out of the critical path. review-scan moves
|
||||
with it because it shares the gate, and its own hosted waits
|
||||
delayed every fan-out by the same backlog. Both keep the
|
||||
fork-trust clause of the sibling lanes: pull_request and
|
||||
pull_request_review resolve this file from the PR's own
|
||||
merge commit, so only same-repo heads and write-access
|
||||
authors may reach the persistent pool; everything else stays
|
||||
hosted, and the kill-switch wins everywhere. Neither job
|
||||
checks code out — route only decides phases, and review-scan
|
||||
only calls the API — so the shared workspace needs no
|
||||
restore or wipe step here. The pool still leaves two marks
|
||||
on the lane, both closed in the same change. One: gh reads
|
||||
its config from the shared, attacker-writable $HOME, so both
|
||||
steps carry the heavy jobs' gh hardening preamble — a planted
|
||||
~/.config/gh/config.yml could reroute their gh calls into a
|
||||
local socket, taking CI_DEV_BOT_PAT with the scan and forged
|
||||
collaborator-permission answers with route. Two: review-scan
|
||||
fills a per-run WORKDIR with API dumps that no VM teardown
|
||||
removes on the pool, so it gets a fixed autofix* per-run path
|
||||
(the age sweep can reclaim it after a hard kill), an EXIT
|
||||
trap, and an always() cleanup step mirroring issue-autofix.
|
||||
```
|
||||
@@ -200,7 +200,10 @@ jobs:
|
||||
# Full rationale → qwen-autofix.md#af-004
|
||||
if: |-
|
||||
${{ github.repository == 'QwenLM/qwen-code' && (github.event_name != 'issue_comment' || (github.event.issue.pull_request && (startsWith(github.event.comment.body, '@qwen-code /takeover') || startsWith(github.event.comment.body, '@qwen-code /retry')))) && (github.event_name != 'pull_request' || github.event.label.name == 'autofix/takeover') && (github.event_name != 'pull_request_review' || github.event.pull_request.state == 'open') }}
|
||||
runs-on: 'ubuntu-latest'
|
||||
# Persistent pool, not hosted: a hosted backlog queued route past the cron
|
||||
# period, and af-005's supersede then starved every scan round.
|
||||
# Full rationale → qwen-autofix.md#af-148
|
||||
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event_name != ''pull_request'' && github.event_name != ''pull_request_review'' || github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON(''["OWNER","MEMBER","COLLABORATOR"]''), github.event.pull_request.author_association))) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}'
|
||||
timeout-minutes: 5
|
||||
concurrency:
|
||||
# Concurrency is keyed by TARGET, not shared and not fully unique:
|
||||
@@ -256,6 +259,23 @@ jobs:
|
||||
COMMENT_PR_AUTHOR: '${{ github.event.issue.user.login }}'
|
||||
HAS_PR_URL: '${{ github.event.issue.pull_request.url }}'
|
||||
run: |-
|
||||
# gh has its own reroute channels: pin the host, drop planted tokens,
|
||||
# and point gh at a fresh empty config dir — on the persistent pool
|
||||
# the shared ~/.config/gh is attacker-writable (config.yml can carry
|
||||
# http_unix_socket transport reroutes), and a forged collaborator-
|
||||
# permission response here would open the /takeover and /retry gates.
|
||||
# Mirrors the heavy jobs' preamble. Full rationale → qwen-autofix.md#af-148
|
||||
export GH_HOST=github.com
|
||||
unset GH_ENTERPRISE_TOKEN GH_TOKEN
|
||||
# `export VAR="$(...)"` reports export's status, not the
|
||||
# substitution's: a failing mktemp must abort the step, not continue
|
||||
# with an empty GH_CONFIG_DIR that gh treats as unset — falling back
|
||||
# to the shared ~/.config/gh this preamble closes.
|
||||
if ! GH_CONFIG_DIR="$(mktemp -d "${RUNNER_TEMP}/autofix-gh-config.XXXXXX")"; then
|
||||
echo "::error::could not create gh config dir; refusing to run gh without reroute hardening"
|
||||
exit 1
|
||||
fi
|
||||
export GH_CONFIG_DIR
|
||||
DO_ISSUE=false
|
||||
DO_REVIEW=false
|
||||
TAKEOVER_ACK=''
|
||||
@@ -2054,7 +2074,8 @@ jobs:
|
||||
needs: 'route'
|
||||
if: |-
|
||||
${{ needs.route.outputs.do_review == 'true' }}
|
||||
runs-on: 'ubuntu-latest'
|
||||
# Same pool decision as route. Full rationale → qwen-autofix.md#af-148
|
||||
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event_name != ''pull_request'' && github.event_name != ''pull_request_review'' || github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON(''["OWNER","MEMBER","COLLABORATOR"]''), github.event.pull_request.author_association))) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}'
|
||||
timeout-minutes: 15
|
||||
# A forced scan can write the same status comment as review-address.
|
||||
# Share its per-PR lock so neither writer can erase the other's state.
|
||||
@@ -2072,6 +2093,11 @@ jobs:
|
||||
enum_failed: '${{ steps.scan.outputs.enum_failed }}'
|
||||
env:
|
||||
REPO: '${{ github.repository }}'
|
||||
# Per-run home for the scan's API dumps. A fixed autofix* name (not
|
||||
# mktemp's tmp.*) so the age sweep in the heavy jobs can reclaim it
|
||||
# after a hard runner kill; cleaned normally by the always() step.
|
||||
# Full rationale → qwen-autofix.md#af-148
|
||||
WORKDIR: '/tmp/autofix-scan-${{ github.run_id }}'
|
||||
steps:
|
||||
- name: 'Scan for PRs with new feedback'
|
||||
id: 'scan'
|
||||
@@ -2083,6 +2109,24 @@ jobs:
|
||||
REVIEW_SENDER: '${{ needs.route.outputs.review_sender }}'
|
||||
DISPATCH_SOURCE: "${{ github.event_name == 'workflow_dispatch' && inputs.source || '' }}"
|
||||
run: |-
|
||||
# gh reroute hardening, mirroring the heavy jobs: pin the host, drop
|
||||
# planted tokens, and use a fresh empty config dir — the pool's
|
||||
# shared ~/.config/gh is attacker-writable, and a config.yml with an
|
||||
# http_unix_socket reroute would swallow this step's gh calls,
|
||||
# CI_DEV_BOT_PAT in the Authorization header included.
|
||||
# Full rationale → qwen-autofix.md#af-148
|
||||
export GH_HOST=github.com
|
||||
unset GH_ENTERPRISE_TOKEN GH_TOKEN
|
||||
# `export VAR="$(...)"` reports export's status, not the
|
||||
# substitution's: a failing mktemp must abort the step, not continue
|
||||
# with an empty GH_CONFIG_DIR that gh treats as unset — falling back
|
||||
# to the shared ~/.config/gh this preamble closes.
|
||||
if ! GH_CONFIG_DIR="$(mktemp -d "${RUNNER_TEMP}/autofix-gh-config.XXXXXX")"; then
|
||||
echo "::error::could not create gh config dir; refusing to run gh without reroute hardening"
|
||||
exit 1
|
||||
fi
|
||||
export GH_CONFIG_DIR
|
||||
|
||||
# Every lane that reaches this scan is supposed to hold the PAT:
|
||||
# route now declines the one event GitHub is known to run without
|
||||
# secrets (a fork PR's own review) before it can set do_review.
|
||||
@@ -2092,17 +2136,28 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Pre-clean before create, mirroring the heavy jobs: run_id is
|
||||
# public and sequential, and mkdir -p alone would succeed over a
|
||||
# dir or symlink pre-planted on the shared pool /tmp — the scan's
|
||||
# redirects would then land in an attacker-chosen place, and a
|
||||
# planted bot-prs.json would feed the forced-PR guard below.
|
||||
rm -rf "${WORKDIR}"
|
||||
(umask 077; mkdir -p "${WORKDIR}")
|
||||
|
||||
# Fleet visibility: every per-PR decision below also records a row so
|
||||
# the run summary shows the WHOLE managed fleet in one table.
|
||||
# Reconstructing this by hand (list bot PRs, regex each one's eval
|
||||
# markers, cross-check checks and fork state) was the only way to see
|
||||
# a stall, so stalls stayed invisible until someone went looking.
|
||||
FLEET_FILE="$(mktemp)"
|
||||
trap 'rm -f "${FLEET_FILE}"' EXIT
|
||||
# Inside WORKDIR, not mktemp's /tmp/tmp.*: on a cancelled run the
|
||||
# EXIT trap never fires, and only the always() step and the heavy
|
||||
# jobs' autofix* age sweep reclaim what this step leaves — neither
|
||||
# can reach a tmp.* name.
|
||||
FLEET_FILE="${WORKDIR}/fleet.tsv"
|
||||
trap 'rm -f "${FLEET_FILE}"; rm -rf "${WORKDIR}"' EXIT
|
||||
fleet_row() {
|
||||
printf '%s\t%s\t%s\n' "$1" "$2" "$3" >> "${FLEET_FILE}"
|
||||
}
|
||||
WORKDIR="$(mktemp -d)"
|
||||
|
||||
read_forced_pr_meta() {
|
||||
local attempt meta
|
||||
@@ -3264,6 +3319,16 @@ jobs:
|
||||
echo "targets=${TARGETS}" >> "${GITHUB_OUTPUT}"
|
||||
echo "has_targets=$([[ "${COUNT}" -gt 0 ]] && echo true || echo false)" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
# Nothing else removes the per-run WORKDIR; on the persistent pool
|
||||
# every scan would leave its API dumps behind forever. always() covers
|
||||
# cancellation (the in-step EXIT trap does not fire on a killed run);
|
||||
# only a hard runner kill abandons the dir, and the autofix* age sweep
|
||||
# in the heavy jobs reclaims it. Mirrors issue-autofix's cleanup step.
|
||||
# Full rationale → qwen-autofix.md#af-148
|
||||
- name: 'Clean up scan workdir'
|
||||
if: 'always()'
|
||||
run: 'rm -rf "${WORKDIR}"'
|
||||
|
||||
# ===========================================================================
|
||||
# REVIEW PHASE (build) — compile the trusted-base CLI bundle ONCE per scan
|
||||
# and fan it out to the address legs as an artifact. Each leg otherwise
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Daemon Extension Local Path Installation
|
||||
|
||||
## Problem
|
||||
|
||||
The CLI can install an Extension from a path on the daemon host, but the daemon
|
||||
REST install routes reject the same source. Callers that already placed an
|
||||
Extension on the daemon filesystem must therefore start a terminal command
|
||||
instead of using the asynchronous Extension operation API.
|
||||
|
||||
## Design
|
||||
|
||||
Allow an existing daemon-local path in the `source` field of both install
|
||||
contracts:
|
||||
|
||||
- `POST /workspace/extensions/install` remains the primary-workspace
|
||||
compatibility route and installs with user activation.
|
||||
- `POST /extensions/install` remains the process-global V2 route and uses its
|
||||
existing explicit user or workspace activation request.
|
||||
|
||||
No new route or SDK method is needed. `DaemonClient.installExtension()` and
|
||||
`DaemonClient.installUserExtension()` already send the shared
|
||||
`ExtensionInstallRequest.source` field and poll the existing operation model.
|
||||
The accepted source set becomes Git, GitHub, npm, or a path that exists on the
|
||||
daemon host and is classified as `local` by the existing source parser.
|
||||
|
||||
Local installation keeps the current CLI semantics: the Extension manager
|
||||
copies the source into managed Extension storage rather than linking it. A
|
||||
directory or a supported local archive path therefore uses the same parsing,
|
||||
conversion, consent, staging, commit, and cleanup logic already exercised by
|
||||
the CLI. The REST contract accepts only absolute local paths. This avoids
|
||||
resolving a relative path against the daemon process working directory and
|
||||
prevents an existing local `owner/repo` directory from shadowing a remote
|
||||
GitHub shorthand.
|
||||
|
||||
Advertise `extension_local_path_install` so clients can distinguish daemons
|
||||
that accept local paths from older daemons that reject them. The capability
|
||||
covers both install routes; clients must still preflight
|
||||
`extension_management_v2` before using the V2 route.
|
||||
|
||||
## Security and failure behavior
|
||||
|
||||
- Existing bearer authentication, strict mutation gating, workspace trust,
|
||||
client identity validation, explicit consent, operation admission, and
|
||||
runtime reconciliation remain unchanged.
|
||||
- The path is resolved and read by the daemon host, not the SDK caller.
|
||||
- Relative local paths are rejected; callers must send an absolute path in the
|
||||
daemon host's path syntax.
|
||||
- A missing path is rejected before the legacy compatibility route queues an
|
||||
operation. V2 preparation reports source parsing failures through the
|
||||
existing operation status contract.
|
||||
- Local sources do not accept URL credentials, `ref`, or `autoUpdate`.
|
||||
Existing npm registry and activation validation remains authoritative.
|
||||
- Installed metadata records the local source path, matching CLI behavior.
|
||||
|
||||
## Test plan
|
||||
|
||||
- Verify the primary-workspace route queues an existing local path and prepares
|
||||
it as `type: "local"`.
|
||||
- Verify the V2 route queues the same source and preserves its explicit initial
|
||||
activation.
|
||||
- Verify missing local paths, explicit consent, client identity, workspace
|
||||
trust, and unsupported remote source behavior remain unchanged.
|
||||
- Verify `/capabilities` advertises `extension_local_path_install`.
|
||||
- Install a minimal Extension from an absolute temporary directory through the
|
||||
daemon, poll the operation to success, and confirm the installed catalog
|
||||
entry.
|
||||
@@ -120,6 +120,8 @@ Workspace read-only snapshots: `workspace_mcp`, `workspace_skills`, `workspace_p
|
||||
|
||||
Extension management: `extension_management_v2` adds the global `/extensions/*` catalog/mutation/operation contract and the workspace activation projection. It is separate from the published `workspace_extensions` compatibility surface and from `workspace_qualified_rest_core`.
|
||||
|
||||
Local Extension installation: `extension_local_path_install` allows an absolute path on the daemon host in the existing `source` field of both Extension install routes. It is separate from `extension_management_v2` because the primary-workspace compatibility route also supports it, and clients must not send local paths to older daemons.
|
||||
|
||||
V2 Extension batch activation: `extension_batch_activation_v2` adds queued global default-activation and selected-workspace override batches to `extension_management_v2`. Clients must pre-flight it independently because older V2 daemons expose only singular activation routes.
|
||||
|
||||
Workspace-qualified session reads: `workspace_persisted_transcript`, `workspace_session_export`, `workspace_archived_session_export`, `workspace_session_live_state`. 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_session_live_state` is likewise independent from `workspace_qualified_rest_core` and is trusted-only: it serves the selected runtime's memory-only live-session snapshot and catalog version and does not extend the untrusted-secondary persisted read policy to live bridge state.
|
||||
|
||||
@@ -214,6 +214,7 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design
|
||||
'workspace_display_name',
|
||||
'workspace_qualified_rest_core', 'workspace_qualified_voice',
|
||||
'workspace_qualified_memory', 'extension_management_v2', 'extension_git_credentials',
|
||||
'extension_local_path_install',
|
||||
'workspace_persisted_transcript',
|
||||
'workspace_session_export', 'workspace_archived_session_export',
|
||||
'workspace_session_live_state',
|
||||
@@ -298,6 +299,8 @@ The same tag also exposes workspace-qualified project-agent CRUD at `/workspaces
|
||||
|
||||
`extension_git_credentials` advertises authenticated HTTPS Git installs on both `POST /workspace/extensions/install` and `POST /extensions/install`. Clients must preflight this tag before sending URL userinfo or `credentialPersistence`; older daemons reject URL credentials. The tag describes backend protocol support, not the availability of a keychain: stored mode reports the selected backend in the terminal operation result.
|
||||
|
||||
`extension_local_path_install` advertises daemon-local Extension sources on both `POST /workspace/extensions/install` and `POST /extensions/install`. The `source` must be an absolute path that exists on the daemon host. Relative paths remain unsupported so daemon process cwd cannot change source identity or shadow a GitHub `owner/repo` shorthand. The existing install operation copies the Extension into managed storage; it does not link the source. Clients must preflight this tag because older daemons reject local sources.
|
||||
|
||||
`extension_batch_activation_v2` adds `PUT /extensions/activation` and `PUT /workspaces/:workspace/extensions/activation`. Both accept 1–100 names in `extensionNames`, deduplicate them case-insensitively while preserving first-seen order, persist changed targets in one generation, and return one `202` operation handle. A target does not need to be installed when setting `enabled` or `disabled`: its name creates a desired-state declaration that is preserved when an Extension with that name is installed. The global route accepts `state: "enabled" | "disabled"`, writes V2 `defaultActivation`, and reconciles every registered runtime. The workspace route also accepts `"inherit"`, applies or clears exact overrides for the selected trusted runtime, and reconciles only that runtime. `inherit` does not declare an unknown name; an all-unknown clear reports `updated: false` and skips reconciliation. Singular activation routes remain installed-only and id-addressed.
|
||||
|
||||
### Extension Management V2 wire contract
|
||||
@@ -381,7 +384,7 @@ Install requires explicit consent and an initial activation:
|
||||
}
|
||||
```
|
||||
|
||||
For workspace-only initial activation use `{ "scope": "workspace", "workspaceId": "target-workspace-id" }`; the target must exist and be trusted. Daemon installs accept GitHub, Git, and npm sources. `ref` does not apply to npm, and `registry` applies only to npm. `ref`, `autoUpdate`, `allowPreRelease`, and `registry` are optional.
|
||||
For workspace-only initial activation use `{ "scope": "workspace", "workspaceId": "target-workspace-id" }`; the target must exist and be trusted. Daemon installs accept GitHub, Git, and npm sources. When `extension_local_path_install` is advertised, they also accept an absolute path that exists on the daemon host. `ref` does not apply to npm, `ref` and `autoUpdate` do not apply to local sources, and `registry` applies only to npm. `ref`, `autoUpdate`, `allowPreRelease`, and `registry` are optional.
|
||||
|
||||
When `extension_git_credentials` is advertised, an HTTPS Git source may include userinfo, for example `https://username:[email protected]/org/repository.git`. `credentialPersistence` is valid only with such a source. It is `stored` or `one_time` and defaults to `one_time` when omitted. Stored mode saves the credential through the daemon's hybrid secret storage and keeps only the clean repository URL in install metadata, so the extension remains updatable. One-time mode saves neither the repository URL nor the credential and creates a non-updatable `snapshot`; `autoUpdate: true` is rejected for this mode. Supplying the field without URL credentials, supplying invalid credentials, or using credentials with npm, archive, local, SSH, or non-Git sources returns `400`.
|
||||
|
||||
|
||||
@@ -413,6 +413,7 @@ describe('qwen serve — capabilities envelope', () => {
|
||||
'workspace_qualified_rest_core',
|
||||
'extension_management_v2',
|
||||
'extension_git_credentials',
|
||||
'extension_local_path_install',
|
||||
'workspace_persisted_transcript',
|
||||
'workspace_session_export',
|
||||
'workspace_archived_session_export',
|
||||
@@ -423,6 +424,53 @@ describe('qwen serve — capabilities envelope', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('qwen serve — local Extension install', () => {
|
||||
it('installs an absolute daemon-local directory into managed storage', async () => {
|
||||
const source = path.join(homeDir, 'local-extension-source');
|
||||
mkdirSync(source, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(source, 'qwen-extension.json'),
|
||||
JSON.stringify({
|
||||
name: 'daemon-local-path-e2e',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const accepted = await client.installExtension({ source, consent: true });
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await client.extensionOperationStatus(accepted.operationId)).status,
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBe('succeeded');
|
||||
|
||||
const operation = await client.extensionOperationStatus(
|
||||
accepted.operationId,
|
||||
);
|
||||
expect(operation.result).toMatchObject({
|
||||
status: 'installed',
|
||||
source,
|
||||
name: 'daemon-local-path-e2e',
|
||||
});
|
||||
const status = await client.workspaceExtensions();
|
||||
const installed = status.extensions.find(
|
||||
(extension) => extension.name === 'daemon-local-path-e2e',
|
||||
);
|
||||
expect(installed).toMatchObject({
|
||||
source,
|
||||
installType: 'local',
|
||||
});
|
||||
expect(installed?.path).not.toBe(source);
|
||||
expect(
|
||||
installed?.path.startsWith(
|
||||
`${path.join(homeDir, '.qwen', 'extensions')}${path.sep}`,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('qwen serve — transcript paging route', () => {
|
||||
const getTranscript = (sessionId: string, query = '') =>
|
||||
fetch(`${base}/session/${sessionId}/transcript${query}`, {
|
||||
|
||||
@@ -381,6 +381,7 @@ export const SERVE_CAPABILITY_REGISTRY = {
|
||||
// `workspace_extensions` contract.
|
||||
extension_management_v2: { since: 'v1' },
|
||||
extension_git_credentials: { since: 'v1' },
|
||||
extension_local_path_install: { since: 'v1' },
|
||||
// Workspace-qualified, daemon-local persisted transcript paging. The tag is
|
||||
// unconditional because the route also serves a trusted single-workspace
|
||||
// primary; authorization is evaluated for the selected runtime per request.
|
||||
|
||||
@@ -36,6 +36,7 @@ import { isBlockedAuthProviderHost } from '../server/auth-provider-helpers.js';
|
||||
import type { SendBridgeError } from '../server/error-response.js';
|
||||
import type { safeBody as safeBodyType } from '../server/request-helpers.js';
|
||||
import {
|
||||
isPortableAbsolutePath,
|
||||
requireTrustedWorkspaceRuntime,
|
||||
resolveWorkspaceRuntimeFromParam,
|
||||
sendGenerationClosedError,
|
||||
@@ -72,6 +73,37 @@ const extensionArchiveBodyParser = express.raw({
|
||||
limit: EXTENSION_ARCHIVE_UPLOAD_LIMIT,
|
||||
});
|
||||
|
||||
const assertDaemonExtensionInstallSource = (
|
||||
installMetadata: ExtensionInstallMetadata,
|
||||
source: string,
|
||||
ref: string | undefined,
|
||||
autoUpdate: boolean | undefined,
|
||||
): void => {
|
||||
if (installMetadata.type === 'local') {
|
||||
if (!path.isAbsolute(source)) {
|
||||
throw new Error(
|
||||
'Local extension sources must be absolute daemon-host paths; relative paths are not supported over the daemon endpoint.',
|
||||
);
|
||||
}
|
||||
if (ref || autoUpdate) {
|
||||
throw new Error(
|
||||
'`ref` and `autoUpdate` are not applicable for local extensions.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
installMetadata.type === 'git' ||
|
||||
installMetadata.type === 'github-release' ||
|
||||
installMetadata.type === 'npm'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
'Only GitHub, Git, npm, and absolute local path extension installs are supported over the daemon endpoint.',
|
||||
);
|
||||
};
|
||||
|
||||
const parseExtensionArchiveFilename = (
|
||||
value: unknown,
|
||||
): { filename: string; suffix: '.zip' | '.tar.gz' } | null => {
|
||||
@@ -1073,25 +1105,18 @@ export function registerWorkspaceExtensionRoutes(
|
||||
return;
|
||||
}
|
||||
const localSource =
|
||||
/^[A-Za-z]:[\\/]/.test(sourceValue) ||
|
||||
sourceValue.startsWith('/') ||
|
||||
sourceValue.startsWith('.');
|
||||
isPortableAbsolutePath(sourceValue) || sourceValue.startsWith('.');
|
||||
if (localSource) {
|
||||
try {
|
||||
const metadata = await parseInstallSource(sourceValue, {
|
||||
networkPolicy: 'public',
|
||||
});
|
||||
if (
|
||||
metadata.type !== 'git' &&
|
||||
metadata.type !== 'github-release' &&
|
||||
metadata.type !== 'npm'
|
||||
) {
|
||||
res.status(400).json({
|
||||
error:
|
||||
'Only GitHub, Git, and npm extension installs are supported over the daemon endpoint.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
assertDaemonExtensionInstallSource(
|
||||
metadata,
|
||||
sourceValue,
|
||||
refValue,
|
||||
autoUpdateValue,
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Invalid install source';
|
||||
@@ -1144,15 +1169,12 @@ export function registerWorkspaceExtensionRoutes(
|
||||
networkPolicy: 'public',
|
||||
});
|
||||
|
||||
if (
|
||||
installMetadata.type !== 'git' &&
|
||||
installMetadata.type !== 'github-release' &&
|
||||
installMetadata.type !== 'npm'
|
||||
) {
|
||||
throw new Error(
|
||||
'Only GitHub, Git, and npm extension installs are supported over the daemon endpoint.',
|
||||
);
|
||||
}
|
||||
assertDaemonExtensionInstallSource(
|
||||
installMetadata,
|
||||
sourceValue,
|
||||
refValue,
|
||||
autoUpdateValue,
|
||||
);
|
||||
if (
|
||||
gitCredential &&
|
||||
installMetadata.type !== 'git' &&
|
||||
@@ -1951,15 +1973,12 @@ export function registerWorkspaceExtensionRoutes(
|
||||
const metadata = await parseInstallSource(sourceValue, {
|
||||
networkPolicy: 'public',
|
||||
});
|
||||
if (
|
||||
metadata.type !== 'git' &&
|
||||
metadata.type !== 'github-release' &&
|
||||
metadata.type !== 'npm'
|
||||
) {
|
||||
throw new Error(
|
||||
'Only GitHub, Git, and npm extension installs are supported over the daemon endpoint.',
|
||||
);
|
||||
}
|
||||
assertDaemonExtensionInstallSource(
|
||||
metadata,
|
||||
sourceValue,
|
||||
typeof ref === 'string' ? ref : undefined,
|
||||
typeof autoUpdate === 'boolean' ? autoUpdate : undefined,
|
||||
);
|
||||
if (
|
||||
gitCredential &&
|
||||
metadata.type !== 'git' &&
|
||||
|
||||
@@ -342,6 +342,7 @@ describe('extension management v2 REST', () => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.features).toContain('extension_management_v2');
|
||||
expect(response.body.features).toContain('extension_git_credentials');
|
||||
expect(response.body.features).toContain('extension_local_path_install');
|
||||
expect(response.body.features).toContain('extension_batch_activation_v2');
|
||||
expect(response.body.features).not.toContain(
|
||||
'workspace_qualified_extensions',
|
||||
@@ -1684,6 +1685,138 @@ describe('extension management v2 REST', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('installs a daemon-local path through the global V2 route', async () => {
|
||||
const h = await makeHarness();
|
||||
mockExtensionManager();
|
||||
const source = path.join(h.scratch, 'local-extension');
|
||||
await fsp.mkdir(source);
|
||||
const prepareInstall = vi
|
||||
.spyOn(ExtensionManager.prototype, 'prepareExtensionInstall')
|
||||
.mockResolvedValue({} as never);
|
||||
vi.spyOn(
|
||||
ExtensionManager.prototype,
|
||||
'commitPreparedExtension',
|
||||
).mockResolvedValue({
|
||||
identity: { id: extensionId, name: 'local-extension' },
|
||||
version: '1.0.0',
|
||||
generation: 7,
|
||||
} as never);
|
||||
vi.spyOn(
|
||||
ExtensionManager.prototype,
|
||||
'disposePreparedExtension',
|
||||
).mockResolvedValue();
|
||||
try {
|
||||
const started = await auth(
|
||||
request(h.app)
|
||||
.post('/extensions/install')
|
||||
.send({
|
||||
source,
|
||||
consent: true,
|
||||
activation: {
|
||||
scope: 'workspace',
|
||||
workspaceId: h.secondary.workspaceId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(started.status).toBe(202);
|
||||
await expect(
|
||||
pollOperation(h.app, started.body.operationId),
|
||||
).resolves.toMatchObject({
|
||||
status: 'succeeded',
|
||||
result: {
|
||||
status: 'installed',
|
||||
source,
|
||||
name: 'local-extension',
|
||||
},
|
||||
});
|
||||
expect(prepareInstall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
installMetadata: expect.objectContaining({ source, type: 'local' }),
|
||||
initialActivation: {
|
||||
scope: 'workspace',
|
||||
workspacePath: h.secondary.workspaceCwd,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await fsp.rm(h.scratch, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not install a relative local path through the global V2 route', async () => {
|
||||
const h = await makeHarness();
|
||||
mockExtensionManager();
|
||||
const prepareInstall = vi.spyOn(
|
||||
ExtensionManager.prototype,
|
||||
'prepareExtensionInstall',
|
||||
);
|
||||
try {
|
||||
const started = await auth(
|
||||
request(h.app)
|
||||
.post('/extensions/install')
|
||||
.send({
|
||||
source: '.',
|
||||
consent: true,
|
||||
activation: { scope: 'user' },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(started.status).toBe(202);
|
||||
await expect(
|
||||
pollOperation(h.app, started.body.operationId),
|
||||
).resolves.toMatchObject({
|
||||
status: 'failed',
|
||||
error:
|
||||
'Local extension sources must be absolute daemon-host paths; relative paths are not supported over the daemon endpoint.',
|
||||
});
|
||||
expect(prepareInstall).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await fsp.rm(h.scratch, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ option: 'ref', installOptions: { ref: 'v1' } },
|
||||
{ option: 'autoUpdate', installOptions: { autoUpdate: true } },
|
||||
])(
|
||||
'does not install a daemon-local path with $option through the global V2 route',
|
||||
async ({ installOptions }) => {
|
||||
const h = await makeHarness();
|
||||
mockExtensionManager();
|
||||
const source = path.join(h.scratch, 'local-extension');
|
||||
await fsp.mkdir(source);
|
||||
const prepareInstall = vi.spyOn(
|
||||
ExtensionManager.prototype,
|
||||
'prepareExtensionInstall',
|
||||
);
|
||||
try {
|
||||
const started = await auth(
|
||||
request(h.app)
|
||||
.post('/extensions/install')
|
||||
.send({
|
||||
source,
|
||||
...installOptions,
|
||||
consent: true,
|
||||
activation: { scope: 'user' },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(started.status).toBe(202);
|
||||
await expect(
|
||||
pollOperation(h.app, started.body.operationId),
|
||||
).resolves.toMatchObject({
|
||||
status: 'failed',
|
||||
error:
|
||||
'`ref` and `autoUpdate` are not applicable for local extensions.',
|
||||
});
|
||||
expect(prepareInstall).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await fsp.rm(h.scratch, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ persistence: undefined, expected: 'one_time' as const },
|
||||
{ persistence: 'one_time' as const, expected: 'one_time' as const },
|
||||
|
||||
@@ -87,6 +87,7 @@ import {
|
||||
readSessionPrs,
|
||||
upsertSessionPr,
|
||||
type Extension,
|
||||
type ExtensionInstallMetadata,
|
||||
type CommittedExtensionMutation,
|
||||
type PrepareExtensionInstallOptions,
|
||||
type PreparedExtensionMutation,
|
||||
@@ -652,6 +653,7 @@ const EXPECTED_STAGE1_FEATURES = [
|
||||
'workspace_qualified_rest_core',
|
||||
'extension_management_v2',
|
||||
'extension_git_credentials',
|
||||
'extension_local_path_install',
|
||||
'workspace_persisted_transcript',
|
||||
'workspace_session_export',
|
||||
'workspace_archived_session_export',
|
||||
@@ -717,6 +719,7 @@ const EXPECTED_REGISTERED_FEATURES = [
|
||||
f !== 'workspace_qualified_rest_core' &&
|
||||
f !== 'extension_management_v2' &&
|
||||
f !== 'extension_git_credentials' &&
|
||||
f !== 'extension_local_path_install' &&
|
||||
f !== 'workspace_persisted_transcript' &&
|
||||
f !== 'workspace_session_export' &&
|
||||
f !== 'workspace_archived_session_export' &&
|
||||
@@ -775,6 +778,7 @@ const EXPECTED_REGISTERED_FEATURES = [
|
||||
'workspace_qualified_memory',
|
||||
'extension_management_v2',
|
||||
'extension_git_credentials',
|
||||
'extension_local_path_install',
|
||||
'workspace_persisted_transcript',
|
||||
'workspace_session_export',
|
||||
'workspace_archived_session_export',
|
||||
@@ -7619,30 +7623,231 @@ describe('createServeApp', () => {
|
||||
expect(res.body.error).toBe('`ref` must be a string');
|
||||
});
|
||||
|
||||
it('rejects unsupported local extension installs before queuing', async () => {
|
||||
it('queues extension installs from daemon-local paths', async () => {
|
||||
const localExtensionDir = await fsp.mkdtemp(
|
||||
path.join(os.tmpdir(), 'qwen-local-extension-'),
|
||||
);
|
||||
const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' };
|
||||
const bridge = fakeBridge({ knownClientIds: ['client-1'] });
|
||||
const app = createServeApp(
|
||||
{ ...tokenOpts, workspace: WS_BOUND },
|
||||
undefined,
|
||||
{ bridge },
|
||||
);
|
||||
let installMetadata: ExtensionInstallMetadata | undefined;
|
||||
const restore = mockExtensionManagerMethods({
|
||||
async prepareExtensionInstall(options) {
|
||||
installMetadata = options.installMetadata;
|
||||
return testExtension('local-extension');
|
||||
},
|
||||
});
|
||||
try {
|
||||
const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' };
|
||||
const bridge = fakeBridge({ knownClientIds: ['client-1'] });
|
||||
const app = createServeApp(
|
||||
{ ...tokenOpts, workspace: WS_BOUND },
|
||||
undefined,
|
||||
{ bridge },
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/workspace/extensions/install')
|
||||
.set('Host', `127.0.0.1:${tokenOpts.port}`)
|
||||
.set('Authorization', 'Bearer secret')
|
||||
.set('X-Qwen-Client-Id', 'client-1')
|
||||
.send({ source: localExtensionDir, consent: true });
|
||||
const res = await request(app)
|
||||
.post('/workspace/extensions/install')
|
||||
.set('Host', `127.0.0.1:${tokenOpts.port}`)
|
||||
.set('Authorization', 'Bearer secret')
|
||||
.set('X-Qwen-Client-Id', 'client-1')
|
||||
.send({ source: localExtensionDir, consent: true });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe(
|
||||
'Only GitHub, Git, and npm extension installs are supported over the daemon endpoint.',
|
||||
expect(res.status).toBe(202);
|
||||
await vi.waitFor(() => {
|
||||
expect(bridge.extensionEvents.at(-1)).toMatchObject({
|
||||
status: 'installed',
|
||||
source: localExtensionDir,
|
||||
name: 'local-extension',
|
||||
});
|
||||
});
|
||||
expect(installMetadata).toMatchObject({
|
||||
source: localExtensionDir,
|
||||
type: 'local',
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
await fsp.rm(localExtensionDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects relative local extension paths before queuing', async () => {
|
||||
const restore = mockExtensionManagerMethods();
|
||||
try {
|
||||
const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' };
|
||||
const bridge = fakeBridge({ knownClientIds: ['client-1'] });
|
||||
const app = createServeApp(
|
||||
{ ...tokenOpts, workspace: WS_BOUND },
|
||||
undefined,
|
||||
{ bridge },
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/workspace/extensions/install')
|
||||
.set('Host', `127.0.0.1:${tokenOpts.port}`)
|
||||
.set('Authorization', 'Bearer secret')
|
||||
.set('X-Qwen-Client-Id', 'client-1')
|
||||
.send({ source: '.', consent: true });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe(
|
||||
'Local extension sources must be absolute daemon-host paths; relative paths are not supported over the daemon endpoint.',
|
||||
);
|
||||
expect(bridge.extensionEvents).toEqual([]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects update options for daemon-local extension paths', async () => {
|
||||
const localExtensionDir = await fsp.mkdtemp(
|
||||
path.join(os.tmpdir(), 'qwen-local-extension-options-'),
|
||||
);
|
||||
expect(bridge.extensionEvents).toEqual([]);
|
||||
const restore = mockExtensionManagerMethods();
|
||||
try {
|
||||
const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' };
|
||||
const bridge = fakeBridge({ knownClientIds: ['client-1'] });
|
||||
const app = createServeApp(
|
||||
{ ...tokenOpts, workspace: WS_BOUND },
|
||||
undefined,
|
||||
{ bridge },
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/workspace/extensions/install')
|
||||
.set('Host', `127.0.0.1:${tokenOpts.port}`)
|
||||
.set('Authorization', 'Bearer secret')
|
||||
.set('X-Qwen-Client-Id', 'client-1')
|
||||
.send({
|
||||
source: localExtensionDir,
|
||||
consent: true,
|
||||
autoUpdate: true,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe(
|
||||
'`ref` and `autoUpdate` are not applicable for local extensions.',
|
||||
);
|
||||
expect(bridge.extensionEvents).toEqual([]);
|
||||
} finally {
|
||||
restore();
|
||||
await fsp.rm(localExtensionDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects refs for daemon-local extension paths', async () => {
|
||||
const localExtensionDir = await fsp.mkdtemp(
|
||||
path.join(os.tmpdir(), 'qwen-local-extension-ref-'),
|
||||
);
|
||||
const restore = mockExtensionManagerMethods();
|
||||
try {
|
||||
const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' };
|
||||
const bridge = fakeBridge({ knownClientIds: ['client-1'] });
|
||||
const app = createServeApp(
|
||||
{ ...tokenOpts, workspace: WS_BOUND },
|
||||
undefined,
|
||||
{ bridge },
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/workspace/extensions/install')
|
||||
.set('Host', `127.0.0.1:${tokenOpts.port}`)
|
||||
.set('Authorization', 'Bearer secret')
|
||||
.set('X-Qwen-Client-Id', 'client-1')
|
||||
.send({ source: localExtensionDir, consent: true, ref: 'v1' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe(
|
||||
'`ref` and `autoUpdate` are not applicable for local extensions.',
|
||||
);
|
||||
expect(bridge.extensionEvents).toEqual([]);
|
||||
} finally {
|
||||
restore();
|
||||
await fsp.rm(localExtensionDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unsupported archive URL extension installs', async () => {
|
||||
let prepareCalled = false;
|
||||
const restore = mockExtensionManagerMethods({
|
||||
async prepareExtensionInstall() {
|
||||
prepareCalled = true;
|
||||
return testExtension('archive-extension');
|
||||
},
|
||||
});
|
||||
try {
|
||||
const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' };
|
||||
const bridge = fakeBridge({ knownClientIds: ['client-1'] });
|
||||
const app = createServeApp(
|
||||
{ ...tokenOpts, workspace: WS_BOUND },
|
||||
undefined,
|
||||
{ bridge },
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/workspace/extensions/install')
|
||||
.set('Host', `127.0.0.1:${tokenOpts.port}`)
|
||||
.set('Authorization', 'Bearer secret')
|
||||
.set('X-Qwen-Client-Id', 'client-1')
|
||||
.send({
|
||||
source: 'https://example.com/extension.zip',
|
||||
consent: true,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(202);
|
||||
await vi.waitFor(() => {
|
||||
expect(bridge.extensionEvents.at(-1)).toMatchObject({
|
||||
status: 'failed',
|
||||
error:
|
||||
'Only GitHub, Git, npm, and absolute local path extension installs are supported over the daemon endpoint.',
|
||||
});
|
||||
});
|
||||
expect(prepareCalled).toBe(false);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not let an existing local path shadow owner/repo sources', async () => {
|
||||
const relativeSource = 'qwen-local-source-shadow/extension';
|
||||
const localSource = path.resolve(relativeSource);
|
||||
await fsp.mkdir(localSource, { recursive: true });
|
||||
let prepareCalled = false;
|
||||
const restore = mockExtensionManagerMethods({
|
||||
async prepareExtensionInstall() {
|
||||
prepareCalled = true;
|
||||
return testExtension('shadowed-extension');
|
||||
},
|
||||
});
|
||||
try {
|
||||
const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' };
|
||||
const bridge = fakeBridge({ knownClientIds: ['client-1'] });
|
||||
const app = createServeApp(
|
||||
{ ...tokenOpts, workspace: WS_BOUND },
|
||||
undefined,
|
||||
{ bridge },
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/workspace/extensions/install')
|
||||
.set('Host', `127.0.0.1:${tokenOpts.port}`)
|
||||
.set('Authorization', 'Bearer secret')
|
||||
.set('X-Qwen-Client-Id', 'client-1')
|
||||
.send({ source: relativeSource, consent: true });
|
||||
|
||||
expect(res.status).toBe(202);
|
||||
await vi.waitFor(() => {
|
||||
expect(bridge.extensionEvents.at(-1)).toMatchObject({
|
||||
status: 'failed',
|
||||
error:
|
||||
'Local extension sources must be absolute daemon-host paths; relative paths are not supported over the daemon endpoint.',
|
||||
});
|
||||
});
|
||||
expect(prepareCalled).toBe(false);
|
||||
} finally {
|
||||
restore();
|
||||
await fsp.rm(path.dirname(localSource), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects missing Windows local extension sources before queuing', async () => {
|
||||
|
||||
@@ -88,6 +88,14 @@ const debug = createDebugLogger('AGENTS_TEAM_MANAGER');
|
||||
// imported it from this module keep compiling.
|
||||
export type { TeamAgentHandle };
|
||||
|
||||
/** Delivery outcome of a {@link TeamManager.broadcast} call. */
|
||||
export interface BroadcastResult {
|
||||
/** Number of recipients the broadcast attempted (sender excluded). */
|
||||
total: number;
|
||||
/** Names of recipients whose delivery was rejected. */
|
||||
failedRecipients: string[];
|
||||
}
|
||||
|
||||
/** Configuration for spawning a teammate. */
|
||||
export interface TeammateSpawnConfig {
|
||||
/** Human-readable name (will be sanitized). */
|
||||
@@ -670,31 +678,38 @@ export class TeamManager {
|
||||
/**
|
||||
* Broadcast a message to all teammates and the leader
|
||||
* (except the sender).
|
||||
*
|
||||
* Returns the delivery outcome so the caller can distinguish complete
|
||||
* success from partial/total failure instead of assuming every
|
||||
* delivery landed.
|
||||
*/
|
||||
async broadcast(message: string, fromName: string): Promise<void> {
|
||||
const promises = this.teamFile.members
|
||||
async broadcast(message: string, fromName: string): Promise<BroadcastResult> {
|
||||
const recipients = this.teamFile.members
|
||||
.filter((m) => m.name.toLowerCase() !== fromName.toLowerCase())
|
||||
.map((m) => this.sendMessage(m.name, message, fromName));
|
||||
.map((m) => m.name);
|
||||
|
||||
// Also deliver to leader inbox if sender is not the leader.
|
||||
if (fromName.toLowerCase() !== LEADER_NAME) {
|
||||
promises.push(this.sendMessage(LEADER_NAME, message, fromName));
|
||||
recipients.push(LEADER_NAME);
|
||||
}
|
||||
|
||||
// allSettled, not all: a single recipient that terminated between
|
||||
// the member snapshot and the send throws (its queue is gone), and
|
||||
// Promise.all would reject the whole broadcast — making the leader
|
||||
// think every recipient failed when the rest were delivered fine.
|
||||
const results = await Promise.allSettled(promises);
|
||||
const failures = results.filter(
|
||||
(r): r is PromiseRejectedResult => r.status === 'rejected',
|
||||
const results = await Promise.allSettled(
|
||||
recipients.map((name) => this.sendMessage(name, message, fromName)),
|
||||
);
|
||||
if (failures.length > 0) {
|
||||
const failedRecipients = recipients.filter(
|
||||
(_, i) => results[i]?.status === 'rejected',
|
||||
);
|
||||
if (failedRecipients.length > 0) {
|
||||
debug.warn(
|
||||
`Broadcast: ${failures.length}/${results.length} send(s) failed ` +
|
||||
`Broadcast: ${failedRecipients.length}/${results.length} send(s) failed ` +
|
||||
`(recipient likely terminated).`,
|
||||
);
|
||||
}
|
||||
return { total: recipients.length, failedRecipients };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2049,6 +2049,33 @@ describe('TeamCoordinationHarness', () => {
|
||||
expect(w3.getReceivedMessages()).toHaveLength(1);
|
||||
expectTeamMessage(w3.getReceivedMessages()[0], 'w2', 'hello all');
|
||||
});
|
||||
|
||||
it('reports zero failures when every delivery lands (#10072)', async () => {
|
||||
const h = await createHarness();
|
||||
await h.spawnTeammate('w1');
|
||||
await h.spawnTeammate('w2');
|
||||
|
||||
// Recipients: w2 (member) + leader inbox.
|
||||
const result = await h.teamManager.broadcast('hello all', 'w1');
|
||||
|
||||
expect(result).toEqual({ total: 2, failedRecipients: [] });
|
||||
await h.waitForMessages('w2', 1);
|
||||
});
|
||||
|
||||
it('reports the recipients whose delivery was rejected (#10072)', async () => {
|
||||
const h = await createHarness();
|
||||
await h.spawnTeammate('w1');
|
||||
const w2 = await h.spawnTeammate('w2');
|
||||
|
||||
// w2 terminates between the member snapshot and the send: its
|
||||
// queue is dropped, so its delivery rejects while the leader
|
||||
// inbox write still lands.
|
||||
await w2.shutdown();
|
||||
|
||||
const result = await h.teamManager.broadcast('status update', 'w1');
|
||||
|
||||
expect(result).toEqual({ total: 2, failedRecipients: ['w2'] });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 6. Concurrent task claiming ──────────────────────────
|
||||
|
||||
@@ -93,6 +93,11 @@ function makeEnv() {
|
||||
sessionId = id;
|
||||
resumedData = data;
|
||||
},
|
||||
// `initialize()` calls restoreLoadedSkillsFromHistory, which resolves the
|
||||
// SKILL tool through the registry; this mock only exercises the telemetry
|
||||
// swap, so return an empty registry (no SKILL tool → the restore is a
|
||||
// no-op) rather than let the call throw `getToolRegistry is not a function`.
|
||||
getToolRegistry: () => ({ getTool: () => undefined }),
|
||||
};
|
||||
const client = new GeminiClient(config as Config);
|
||||
const fakeChat = {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Broadcast delivery-outcome contract for send_message(to: "*") — #10072.
|
||||
*
|
||||
* Uses the real TeamManager (via TeamCoordinationHarness) so a delivery
|
||||
* rejects the same way it does in production: a recipient terminates
|
||||
* between the member snapshot and the send, its per-agent queue is
|
||||
* dropped, and sendMessage refuses the delivery.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { SendMessageTool } from './send-message.js';
|
||||
import { BackgroundTaskRegistry } from '../agents/background-tasks.js';
|
||||
import type { ApprovalMode, Config } from '../config/config.js';
|
||||
import type { TeamManager } from '../agents/team/TeamManager.js';
|
||||
import { TeamCoordinationHarness } from '../agents/team/test-utils/coordination-harness.js';
|
||||
|
||||
// Mock Storage so all file I/O uses the harness's temp dir.
|
||||
vi.mock('../config/storage.js', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('../config/storage.js')>();
|
||||
let mockGlobalDir = '';
|
||||
return {
|
||||
...original,
|
||||
Storage: {
|
||||
...original.Storage,
|
||||
getGlobalQwenDir: () => mockGlobalDir,
|
||||
__setMockGlobalDir: (dir: string) => {
|
||||
mockGlobalDir = dir;
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { Storage } from '../config/storage.js';
|
||||
|
||||
function setMockDir(dir: string): void {
|
||||
(
|
||||
Storage as unknown as {
|
||||
__setMockGlobalDir: (d: string) => void;
|
||||
}
|
||||
).__setMockGlobalDir(dir);
|
||||
}
|
||||
|
||||
function makeConfig(teamManager: TeamManager): Config {
|
||||
return {
|
||||
getTeamManager: () => teamManager,
|
||||
getBackgroundTaskRegistry: () => new BackgroundTaskRegistry(),
|
||||
getApprovalMode: () => 'default' as ApprovalMode,
|
||||
} as unknown as Config;
|
||||
}
|
||||
|
||||
describe('SendMessageTool — broadcast delivery outcomes (#10072)', () => {
|
||||
let harness: TeamCoordinationHarness | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.cleanup();
|
||||
harness = undefined;
|
||||
});
|
||||
|
||||
async function createHarness(): Promise<TeamCoordinationHarness> {
|
||||
const h = await TeamCoordinationHarness.create();
|
||||
setMockDir(h.tmpDir);
|
||||
harness = h;
|
||||
return h;
|
||||
}
|
||||
|
||||
function broadcastInvocation(h: TeamCoordinationHarness) {
|
||||
const tool = new SendMessageTool(makeConfig(h.teamManager));
|
||||
return tool.build({ to: '*', message: 'sync for everyone' });
|
||||
}
|
||||
|
||||
it('does not claim complete success when a delivery is rejected', async () => {
|
||||
const h = await createHarness();
|
||||
await h.spawnTeammate('alice');
|
||||
const bob = await h.spawnTeammate('bob');
|
||||
|
||||
// bob terminates between the member snapshot and the send: its
|
||||
// queue is dropped, so its delivery rejects while alice's lands.
|
||||
await bob.shutdown();
|
||||
|
||||
const result = await broadcastInvocation(h).execute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
// Must not claim that every teammate received the message…
|
||||
expect(result.llmContent).not.toBe('Message broadcast to all teammates.');
|
||||
// …and must name the recipient that was not reached.
|
||||
expect(String(result.llmContent)).toContain('bob');
|
||||
});
|
||||
|
||||
it('still reports complete success when every delivery lands', async () => {
|
||||
const h = await createHarness();
|
||||
const alice = await h.spawnTeammate('alice');
|
||||
await h.spawnTeammate('bob');
|
||||
|
||||
const result = await broadcastInvocation(h).execute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.llmContent).toBe('Message broadcast to all teammates.');
|
||||
await h.waitForMessages('alice', 1);
|
||||
await h.waitForMessages('bob', 1);
|
||||
expect(alice.getReceivedMessages()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports failure when no delivery lands', async () => {
|
||||
const h = await createHarness();
|
||||
const alice = await h.spawnTeammate('alice');
|
||||
const bob = await h.spawnTeammate('bob');
|
||||
await alice.shutdown();
|
||||
await bob.shutdown();
|
||||
|
||||
const result = await broadcastInvocation(h).execute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.llmContent).not.toBe('Message broadcast to all teammates.');
|
||||
expect(String(result.llmContent)).toContain('alice');
|
||||
expect(String(result.llmContent)).toContain('bob');
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { BackgroundTaskRegistry } from '../agents/background-tasks.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import type { ApprovalMode, Config } from '../config/config.js';
|
||||
import { runWithTeammateIdentity } from '../agents/team/identity.js';
|
||||
import type { BroadcastResult } from '../agents/team/TeamManager.js';
|
||||
|
||||
const DEFAULT_MODE = 'default' as ApprovalMode;
|
||||
const PLAN_MODE = 'plan' as ApprovalMode;
|
||||
@@ -17,7 +18,7 @@ const PLAN_MODE = 'plan' as ApprovalMode;
|
||||
function makeTeamConfig(opts?: {
|
||||
teamManager?: {
|
||||
sendMessage: (...args: unknown[]) => Promise<void>;
|
||||
broadcast: (...args: unknown[]) => Promise<void>;
|
||||
broadcast: (...args: unknown[]) => Promise<BroadcastResult>;
|
||||
} | null;
|
||||
approvalMode?: ApprovalMode;
|
||||
}) {
|
||||
@@ -69,7 +70,9 @@ describe('SendMessageTool — team mode', () => {
|
||||
});
|
||||
|
||||
it('broadcasts with "*"', async () => {
|
||||
const broadcast = vi.fn().mockResolvedValue(undefined);
|
||||
const broadcast = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ total: 2, failedRecipients: [] });
|
||||
const tool = new SendMessageTool(
|
||||
makeTeamConfig({
|
||||
teamManager: {
|
||||
|
||||
@@ -242,8 +242,29 @@ class SendMessageInvocation extends BaseToolInvocation<
|
||||
try {
|
||||
if (to === '*') {
|
||||
const sender = getAgentName() ?? LEADER_NAME;
|
||||
await teamManager.broadcast(this.params.message, sender);
|
||||
const msg = 'Message broadcast to all teammates.';
|
||||
const { total, failedRecipients } = await teamManager.broadcast(
|
||||
this.params.message,
|
||||
sender,
|
||||
);
|
||||
if (failedRecipients.length === 0) {
|
||||
const msg = 'Message broadcast to all teammates.';
|
||||
return { llmContent: msg, returnDisplay: msg };
|
||||
}
|
||||
const reached = total - failedRecipients.length;
|
||||
if (reached === 0) {
|
||||
const msg =
|
||||
`Broadcast failed: delivery was rejected for all ${total} ` +
|
||||
`recipient(s): ${failedRecipients.join(', ')}.`;
|
||||
return {
|
||||
llmContent: msg,
|
||||
returnDisplay: msg,
|
||||
error: { message: msg },
|
||||
};
|
||||
}
|
||||
const msg =
|
||||
`Message broadcast delivered to ${reached} of ${total} ` +
|
||||
`recipient(s); delivery failed for: ${failedRecipients.join(', ')}. ` +
|
||||
`The listed recipients did not receive the message.`;
|
||||
return { llmContent: msg, returnDisplay: msg };
|
||||
}
|
||||
|
||||
|
||||
@@ -4326,6 +4326,7 @@ export interface DaemonWorkspaceExtensionsStatus {
|
||||
}
|
||||
|
||||
export interface ExtensionInstallRequest {
|
||||
/** Git, GitHub, npm, or an absolute path on the daemon host. */
|
||||
source: string;
|
||||
credentialPersistence?: 'stored' | 'one_time';
|
||||
ref?: string;
|
||||
|
||||
@@ -5898,6 +5898,33 @@ describe('DaemonClient', () => {
|
||||
});
|
||||
|
||||
describe('extension operations', () => {
|
||||
it.each(['/tmp/demo-extension', 'C:\\demo-extension'])(
|
||||
'sends daemon-local extension path %s unchanged',
|
||||
async (source) => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse(202, {
|
||||
accepted: true,
|
||||
operationId: 'op-local',
|
||||
}),
|
||||
);
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
|
||||
await expect(
|
||||
client.installExtension({ source, consent: true }, 'client-1'),
|
||||
).resolves.toEqual({ accepted: true, operationId: 'op-local' });
|
||||
|
||||
expect(calls[0]?.url).toBe(
|
||||
'http://daemon/workspace/extensions/install',
|
||||
);
|
||||
expect(calls[0]?.method).toBe('POST');
|
||||
expect(calls[0]?.headers['x-qwen-client-id']).toBe('client-1');
|
||||
expect(JSON.parse(calls[0]!.body!)).toEqual({
|
||||
source,
|
||||
consent: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('POSTs an extension archive as a binary body', async () => {
|
||||
let capturedUrl = '';
|
||||
let capturedInit: RequestInit | undefined;
|
||||
|
||||
@@ -13054,6 +13054,7 @@ export function App({
|
||||
const messageListContent = (
|
||||
<LiveMessageList
|
||||
ref={messageListRef}
|
||||
sessionKey={connection.sessionId}
|
||||
baselineBlocks={blocks}
|
||||
baselineSummary={blockChangeSummary}
|
||||
baselineMessages={messages}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type WebShellCustomization,
|
||||
} from '../customization';
|
||||
import type { Message } from '../adapters/types';
|
||||
import { summaryRunId } from './summaryRunId';
|
||||
|
||||
vi.mock('../WebShellContexts', async () => {
|
||||
const { createContext } = await import('react');
|
||||
@@ -243,7 +244,7 @@ describe('MessageItem tool group spacing', () => {
|
||||
const synthetic = render(
|
||||
<I18nProvider language="en">
|
||||
<CompactModeContext.Provider value={true}>
|
||||
{item(toolMsg('summary-agent-1'))}
|
||||
{item(toolMsg(summaryRunId('agent-1')))}
|
||||
</CompactModeContext.Provider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from './messages/AssistantMessage';
|
||||
import { SystemMessage } from './messages/SystemMessage';
|
||||
import { ToolGroup } from './messages/ToolGroup';
|
||||
import { isSummaryRunId } from './summaryRunId';
|
||||
import { PlanMessage } from './messages/PlanMessage';
|
||||
import { BtwMessage } from './messages/BtwMessage';
|
||||
import { UserShellMessage } from './messages/UserShellMessage';
|
||||
@@ -123,7 +124,7 @@ export const MessageItem = memo(function MessageItem({
|
||||
<ToolGroup
|
||||
tools={message.tools}
|
||||
thoughts={message.thoughts}
|
||||
compactSummary={compactMode && message.id.startsWith('summary-')}
|
||||
compactSummary={compactMode && isSummaryRunId(message.id)}
|
||||
pendingApproval={pendingApproval}
|
||||
workspaceCwd={workspaceCwd}
|
||||
isLocateFlashing={isLocateFlashing}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1133,6 +1133,7 @@ function collapseItems(
|
||||
waitForUnmatchedAgentCompletions: boolean;
|
||||
terminalBackgroundShellTaskIds: ReadonlySet<string>;
|
||||
automaticallyExpandedAgentKeys: ReadonlySet<string>;
|
||||
paginatedExpanded: ReadonlySet<string>;
|
||||
enabled: boolean;
|
||||
}> = {},
|
||||
): DisplayItem[] {
|
||||
@@ -1145,6 +1146,7 @@ function collapseItems(
|
||||
opts.waitForUnmatchedAgentCompletions ?? true,
|
||||
terminalBackgroundShellTaskIds: opts.terminalBackgroundShellTaskIds,
|
||||
automaticallyExpandedAgentKeys: opts.automaticallyExpandedAgentKeys,
|
||||
paginatedExpanded: opts.paginatedExpanded,
|
||||
enabled: opts.enabled ?? true,
|
||||
});
|
||||
}
|
||||
@@ -1220,6 +1222,50 @@ describe('applyTurnCollapse', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a turn expanded when its tail was shown before pagination completed its head', () => {
|
||||
const items = groupParallelAgents([
|
||||
makeUserMessage('u1'),
|
||||
makeMultiToolGroup('g1'),
|
||||
makeAssistantMessage('a1'),
|
||||
makeUserMessage('u2'),
|
||||
makeMultiToolGroup('g2'),
|
||||
makeAssistantMessage('a2'),
|
||||
]);
|
||||
const out = collapseItems(items, {
|
||||
paginatedExpanded: new Set(['u1']),
|
||||
});
|
||||
// The pagination-completed turn stays open (its steps are visible); the
|
||||
// following complete turn still collapses as usual.
|
||||
expect(rowIds(out)).toEqual([
|
||||
'u1',
|
||||
'tc-u1',
|
||||
'g1',
|
||||
'a1',
|
||||
'u2',
|
||||
'tc-u2',
|
||||
'a2',
|
||||
]);
|
||||
expect(collapseOf(out, 0)).toMatchObject({
|
||||
collapsed: false,
|
||||
hiddenCount: 1,
|
||||
});
|
||||
expect(collapseOf(out, 4)).toMatchObject({ collapsed: true });
|
||||
});
|
||||
|
||||
it('lets an explicit user toggle override the pagination keep-open', () => {
|
||||
const items = groupParallelAgents([
|
||||
makeUserMessage('u1'),
|
||||
makeMultiToolGroup('g1'),
|
||||
makeAssistantMessage('a1'),
|
||||
]);
|
||||
const out = collapseItems(items, {
|
||||
paginatedExpanded: new Set(['u1']),
|
||||
overrides: new Map([['u1', false]]),
|
||||
});
|
||||
expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'a1']);
|
||||
expect(collapseOf(out, 0)).toMatchObject({ collapsed: true });
|
||||
});
|
||||
|
||||
it('keeps every row but still tags the head when the turn is expanded', () => {
|
||||
const items = groupParallelAgents([
|
||||
makeUserMessage('u1'),
|
||||
|
||||
@@ -42,6 +42,7 @@ import { formatContextTokens } from '../utils/formatTokenCount';
|
||||
import { useWebShellPortalRoot } from '../portalRoot';
|
||||
import { useTranscriptRenderMode } from '../transcriptRenderMode';
|
||||
import { MessageItem } from './MessageItem';
|
||||
import { summaryRunFirstMemberId, summaryRunId } from './summaryRunId';
|
||||
import type { SessionContentGenerator } from './messages/AssistantMessage';
|
||||
import { MessageTimestamp } from './MessageTimestamp';
|
||||
import {
|
||||
@@ -88,6 +89,13 @@ interface MessageListProps {
|
||||
historyCapacityReached?: boolean;
|
||||
historyPaginationError?: boolean;
|
||||
onLoadOlderHistory?: (options?: { force?: boolean }) => Promise<void>;
|
||||
/**
|
||||
* Identity of the session whose transcript `messages` show. Block ids are
|
||||
* per-session ordinals, so changing it resets every session-scoped UI state
|
||||
* (collapse overrides, pagination keep-open, pending page snapshots, the
|
||||
* scroll anchor) — a direct session switch never renders empty messages.
|
||||
*/
|
||||
sessionKey?: string;
|
||||
transcriptBlockCount?: number;
|
||||
transcriptActivity?: {
|
||||
getSnapshot(): {
|
||||
@@ -293,7 +301,7 @@ export interface SessionTimelineRange {
|
||||
function isAgentOnlyToolGroup(msg: Message): boolean {
|
||||
return (
|
||||
msg.role === 'tool_group' &&
|
||||
!msg.id.startsWith('summary-') &&
|
||||
summaryRunFirstMemberId(msg.id) === undefined &&
|
||||
msg.tools.length === 1 &&
|
||||
isSubAgentToolCall(msg.tools[0])
|
||||
);
|
||||
@@ -302,7 +310,7 @@ function isAgentOnlyToolGroup(msg: Message): boolean {
|
||||
function isBackgroundAgentOnlyToolGroup(msg: Message): boolean {
|
||||
return (
|
||||
msg.role === 'tool_group' &&
|
||||
!msg.id.startsWith('summary-') &&
|
||||
summaryRunFirstMemberId(msg.id) === undefined &&
|
||||
msg.tools.length === 1 &&
|
||||
isBackgroundSubAgentToolCall(msg.tools[0])
|
||||
);
|
||||
@@ -411,7 +419,7 @@ function mergeCompactToolGroups(
|
||||
// Synthetic id so the aggregated group never collides with an original
|
||||
// message key: React then remounts instead of carrying the expanded
|
||||
// summary state into non-compact mode.
|
||||
id: `summary-${run[0]!.id}`,
|
||||
id: summaryRunId(run[0]!.id),
|
||||
role: 'tool_group',
|
||||
tools,
|
||||
...(thoughts.length > 0 ? { thoughts } : {}),
|
||||
@@ -430,7 +438,7 @@ function updateCompactStreamingThinkingTail(
|
||||
const group = messages[messages.length - 1];
|
||||
if (
|
||||
group?.role !== 'tool_group' ||
|
||||
!group.id.startsWith('summary-') ||
|
||||
summaryRunFirstMemberId(group.id) === undefined ||
|
||||
!group.thoughts?.length
|
||||
) {
|
||||
return undefined;
|
||||
@@ -448,7 +456,9 @@ function updateCompactStreamingThinkingTail(
|
||||
result[result.length - 1] = {
|
||||
...group,
|
||||
thoughts,
|
||||
...(group.id === `summary-${tail.id}` ? { timestamp: tail.timestamp } : {}),
|
||||
...(group.id === summaryRunId(tail.id)
|
||||
? { timestamp: tail.timestamp }
|
||||
: {}),
|
||||
};
|
||||
return result;
|
||||
}
|
||||
@@ -665,6 +675,14 @@ export interface ApplyTurnCollapseOptions {
|
||||
*/
|
||||
pendingApprovalCallId?: string | null;
|
||||
includeSubagentToolUsageInMetrics?: boolean;
|
||||
/**
|
||||
* Turns kept expanded despite being complete. Two sources: pagination
|
||||
* split-turn detection (the tail was on screen before pagination completed
|
||||
* the user prompt), and the anchor fallback (a turn collapsed while a
|
||||
* history anchor sat on it). Entries persist until the user toggles the
|
||||
* turn or the screen is cleared; an explicit user toggle always wins.
|
||||
*/
|
||||
paginatedExpanded?: ReadonlySet<string>;
|
||||
/** Master switch; when false the items pass through untouched. */
|
||||
enabled: boolean;
|
||||
}
|
||||
@@ -982,6 +1000,44 @@ function isTurnStartMessage(message: Message): boolean {
|
||||
return message.role === 'user' || message.role === 'user_shell';
|
||||
}
|
||||
|
||||
// Find the turn head (user message id) owning the message referenced by a
|
||||
// `msg:` row key, when the message is still present.
|
||||
function turnIdOfMessageRow(
|
||||
messages: Message[],
|
||||
rowKey: string,
|
||||
): string | undefined {
|
||||
if (!rowKey.startsWith('msg:')) return undefined;
|
||||
let messageId = rowKey.slice('msg:'.length);
|
||||
// Compact-aggregate row keys embed the run's first member id. Resolve
|
||||
// through it so the key still maps to its turn when looked up in the raw
|
||||
// message list, including after a page extending the run re-keyed the
|
||||
// aggregate.
|
||||
messageId = summaryRunFirstMemberId(messageId) ?? messageId;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i];
|
||||
if (message?.id !== messageId) continue;
|
||||
for (let j = i; j >= 0; j--) {
|
||||
const candidate = messages[j];
|
||||
if (candidate && isTurnStartMessage(candidate)) return candidate.id;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// True when the given turn currently renders as a collapsed turn_collapse row.
|
||||
function isTurnCollapsedInVisibleItems(
|
||||
items: DisplayItem[],
|
||||
turnId: string,
|
||||
): boolean {
|
||||
for (const item of items) {
|
||||
if (item.type === 'turn_collapse' && item.turnCollapse.turnId === turnId) {
|
||||
return item.turnCollapse.collapsed;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function timelineDetailSnippetForMessage(
|
||||
message: Message,
|
||||
t?: (key: string, vars?: Record<string, string | number>) => string,
|
||||
@@ -1798,6 +1854,7 @@ export function applyTurnCollapse(
|
||||
automaticallyExpandedAgentKeys,
|
||||
pendingApprovalCallId,
|
||||
includeSubagentToolUsageInMetrics = true,
|
||||
paginatedExpanded,
|
||||
enabled,
|
||||
}: ApplyTurnCollapseOptions,
|
||||
): DisplayItem[] {
|
||||
@@ -1953,7 +2010,8 @@ export function applyTurnCollapse(
|
||||
// turn is incomplete; otherwise it collapses once a newer turn starts. A
|
||||
// step-less turn (e.g. a plain "hi" reply) has nothing to fold, so it stays
|
||||
// expanded and shows a chevron-less metrics line. An explicit user toggle
|
||||
// always wins.
|
||||
// always wins; a turn whose tail was shown before pagination finished its
|
||||
// head stays open so that content never vanishes mid-read.
|
||||
const shouldStayOpen =
|
||||
isActiveTurn ||
|
||||
hasActiveAgent ||
|
||||
@@ -1967,7 +2025,9 @@ export function applyTurnCollapse(
|
||||
? true
|
||||
: overrides.has(turnId)
|
||||
? (overrides.get(turnId) as boolean)
|
||||
: shouldStayOpen;
|
||||
: paginatedExpanded?.has(turnId)
|
||||
? true
|
||||
: shouldStayOpen;
|
||||
const collapsed = !expanded;
|
||||
// Push the user message
|
||||
result.push({
|
||||
@@ -2727,6 +2787,7 @@ export const MessageList = memo(
|
||||
forwardRef<MessageListHandle, MessageListProps>(function MessageList(
|
||||
{
|
||||
messages,
|
||||
sessionKey,
|
||||
terminalBackgroundShellTaskIds,
|
||||
pendingApproval,
|
||||
onShowContextDetail,
|
||||
@@ -3262,6 +3323,20 @@ export const MessageList = memo(
|
||||
const [collapseOverrides, setCollapseOverrides] = useState<
|
||||
ReadonlyMap<string, boolean>
|
||||
>(() => new Map());
|
||||
// Turns kept expanded despite being complete. Two sources: pagination
|
||||
// split-turn detection (tail was on screen before the user prompt
|
||||
// arrived), and the anchor fallback (a turn collapsed while a history
|
||||
// anchor sat on it). Entries persist until the user toggles the turn or
|
||||
// the screen is cleared.
|
||||
const [paginatedExpandedTurns, setPaginatedExpandedTurns] = useState<
|
||||
ReadonlySet<string>
|
||||
>(() => new Set());
|
||||
// Pending split-turn detection snapshots keyed by load generation: a load
|
||||
// can resolve SDK-side before its page commits to the transcript, letting
|
||||
// a next load enqueue its own snapshot before the earlier page lands.
|
||||
const pendingPaginationTurnCompares = useRef(
|
||||
new Map<number, { ids: Set<string>; resolved: boolean }>(),
|
||||
);
|
||||
const [turnLayoutPending, startTurnLayoutTransition] = useTransition();
|
||||
const turnLayoutTransitionStarted = useRef(false);
|
||||
const turnLayoutRowTops = useRef(new Map<string, number>());
|
||||
@@ -3337,11 +3412,34 @@ export const MessageList = memo(
|
||||
useLayoutEffect(() => {
|
||||
mergedMessageCountRef.current = mergedMessages.length;
|
||||
}, [mergedMessages.length]);
|
||||
const rawMessagesRef = useRef(messages);
|
||||
useLayoutEffect(() => {
|
||||
rawMessagesRef.current = messages;
|
||||
}, [messages]);
|
||||
const [
|
||||
suppressOlderHistoryLoadingStatus,
|
||||
setSuppressOlderHistoryLoadingStatus,
|
||||
] = useState(false);
|
||||
|
||||
// A direct session switch never renders empty messages (the provider
|
||||
// batches the store reset and the replay into one notification), so the
|
||||
// /clear reset never runs for it. Block ids are per-session ordinals, so
|
||||
// every session-scoped state must drop when the displayed session
|
||||
// changes: stale keep-open and snapshot entries would collide with the
|
||||
// new session's reused ids, and an old anchor would restore the previous
|
||||
// session's scroll metrics into the new transcript.
|
||||
const [trackedSessionKey, setTrackedSessionKey] = useState(sessionKey);
|
||||
if (trackedSessionKey !== sessionKey) {
|
||||
setTrackedSessionKey(sessionKey);
|
||||
pendingPaginationTurnCompares.current.clear();
|
||||
olderHistoryLoadGeneration.current += 1;
|
||||
olderHistoryLoadInFlight.current = false;
|
||||
olderHistoryRetryBlocked.current = false;
|
||||
setOlderHistoryAnchor(null);
|
||||
setCollapseOverrides((prev) => (prev.size ? new Map() : prev));
|
||||
setPaginatedExpandedTurns((prev) => (prev.size ? new Set() : prev));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasOlderHistory) {
|
||||
olderHistoryRetryBlocked.current = false;
|
||||
@@ -3408,6 +3506,7 @@ export const MessageList = memo(
|
||||
turnFileChanges,
|
||||
turnArtifacts,
|
||||
turnScheduledTasks,
|
||||
paginatedExpandedTurns,
|
||||
] as const;
|
||||
const cached = visibleItemsCache.current;
|
||||
const currentTail = displayItems[displayItems.length - 1];
|
||||
@@ -3459,6 +3558,7 @@ export const MessageList = memo(
|
||||
automaticallyExpandedAgentKeys,
|
||||
pendingApprovalCallId: pendingApproval?.toolCallId ?? null,
|
||||
includeSubagentToolUsageInMetrics,
|
||||
paginatedExpanded: paginatedExpandedTurns,
|
||||
enabled: collapseEnabled,
|
||||
});
|
||||
let metricsApplied = false;
|
||||
@@ -3539,6 +3639,7 @@ export const MessageList = memo(
|
||||
turnFileChanges,
|
||||
turnArtifacts,
|
||||
turnScheduledTasks,
|
||||
paginatedExpandedTurns,
|
||||
]);
|
||||
const virtualizerItems =
|
||||
visibleItemsCache.current?.sourceMessages === messages
|
||||
@@ -3848,6 +3949,15 @@ export const MessageList = memo(
|
||||
});
|
||||
turnLayoutTransitionStarted.current = true;
|
||||
startTurnLayoutTransition(() => {
|
||||
// An explicit toggle is the user's own decision: it wins over the
|
||||
// pagination keep-open and returns the turn to normal collapse
|
||||
// semantics.
|
||||
setPaginatedExpandedTurns((prev) => {
|
||||
if (!prev.has(turnId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(turnId);
|
||||
return next;
|
||||
});
|
||||
setCollapseOverrides((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(turnId, nextExpanded);
|
||||
@@ -4069,6 +4179,40 @@ export const MessageList = memo(
|
||||
olderHistoryAnchor.rowKey &&
|
||||
!hasVisibleRowKey(olderHistoryAnchor.rowKey)
|
||||
) {
|
||||
// The anchor row can vanish because its turn collapsed while the page
|
||||
// was loading (a turn completed by pagination). Re-expand that turn so
|
||||
// the anchor can be restored instead of dropping the reader's position.
|
||||
const anchorTurnId = turnIdOfMessageRow(
|
||||
rawMessagesRef.current,
|
||||
olderHistoryAnchor.rowKey,
|
||||
);
|
||||
if (
|
||||
anchorTurnId !== undefined &&
|
||||
isTurnCollapsedInVisibleItems(visibleItems, anchorTurnId)
|
||||
) {
|
||||
// Re-expanding only helps when it can actually take effect: an
|
||||
// explicit user collapse override wins over the keep-open, and a
|
||||
// turn already marked keep-open yet still hidden cannot be helped.
|
||||
// Fall through and drop the anchor in those cases, or pagination
|
||||
// state stays pinned in-flight forever (every later load bails at
|
||||
// the in-flight guard).
|
||||
if (
|
||||
collapseOverrides.get(anchorTurnId) !== false &&
|
||||
!paginatedExpandedTurns.has(anchorTurnId)
|
||||
) {
|
||||
setPaginatedExpandedTurns((prev) =>
|
||||
prev.has(anchorTurnId) ? prev : new Set(prev).add(anchorTurnId),
|
||||
);
|
||||
// A page extending the turn's aggregated run re-keys the captured
|
||||
// row, so anchor on the turn's user row instead: the re-expanded
|
||||
// turn always renders it, and the restore path then adjusts the
|
||||
// scroll position rather than dropping the anchor.
|
||||
setOlderHistoryAnchor((anchor) =>
|
||||
anchor ? { ...anchor, rowKey: `msg:${anchorTurnId}` } : anchor,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (
|
||||
olderHistoryAnchor.generation === olderHistoryLoadGeneration.current
|
||||
) {
|
||||
@@ -4163,6 +4307,8 @@ export const MessageList = memo(
|
||||
mergedMessages.length,
|
||||
olderHistoryAnchor,
|
||||
visibleItems,
|
||||
collapseOverrides,
|
||||
paginatedExpandedTurns,
|
||||
]);
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
const totalVirtualSize = virtualizer.getTotalSize();
|
||||
@@ -4517,7 +4663,20 @@ export const MessageList = memo(
|
||||
: {})),
|
||||
});
|
||||
try {
|
||||
// Remember which messages were on screen before the page arrives so
|
||||
// a turn the daemon split across pages (tail shown first, head
|
||||
// completing later) can be detected and kept expanded.
|
||||
pendingPaginationTurnCompares.current.set(generation, {
|
||||
ids: new Set(rawMessagesRef.current.map((message) => message.id)),
|
||||
resolved: false,
|
||||
});
|
||||
await onLoadOlderHistory(force ? { force: true } : undefined);
|
||||
// The snapshot belongs to this load regardless of anchor state: a
|
||||
// superseding anchor drop bumps the generation mid-flight and would
|
||||
// otherwise orphan the entry unresolved at the FIFO head, wedging
|
||||
// every later split-turn detection.
|
||||
const compare = pendingPaginationTurnCompares.current.get(generation);
|
||||
if (compare) compare.resolved = true;
|
||||
if (generation === olderHistoryLoadGeneration.current) {
|
||||
setOlderHistoryAnchor((anchor) =>
|
||||
anchor?.generation === generation
|
||||
@@ -4526,6 +4685,9 @@ export const MessageList = memo(
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// A failed load commits no page, so its snapshot is always safe to
|
||||
// drop, including when the load was superseded mid-flight.
|
||||
pendingPaginationTurnCompares.current.delete(generation);
|
||||
if (generation === olderHistoryLoadGeneration.current) {
|
||||
olderHistoryRetryBlocked.current = true;
|
||||
olderHistoryLoadInFlight.current = false;
|
||||
@@ -4553,6 +4715,86 @@ export const MessageList = memo(
|
||||
void loadOlderHistory(true, true);
|
||||
}, [loadOlderHistory]);
|
||||
|
||||
// After a pagination page commits, mark turns whose user prompt just
|
||||
// arrived while their tail was already on screen (the daemon split the
|
||||
// turn across pages). `applyTurnCollapse` keeps those expanded so the
|
||||
// content the user is reading never silently collapses.
|
||||
useLayoutEffect(() => {
|
||||
const pending = pendingPaginationTurnCompares.current;
|
||||
if (pending.size === 0) return;
|
||||
// Skip locally-injected messages (recap ids `local-*`): /recap re-pins
|
||||
// them at index 0 on every render and pagination cannot move them, so
|
||||
// they never reflect a page landing and would pin the head-change check
|
||||
// forever, wedging split-turn detection for the rest of the session.
|
||||
const first = messages.find(
|
||||
(message) => !message?.id.startsWith('local-'),
|
||||
);
|
||||
if (!first) return;
|
||||
const oldest = pending.entries().next().value;
|
||||
if (!oldest) return;
|
||||
const [generation, compare] = oldest;
|
||||
// A page prepends older messages at the head; live messages that land
|
||||
// while the fetch is in flight append at the tail. Wait for the head to
|
||||
// change so a mid-flight live update cannot consume the snapshot before
|
||||
// the page commits (which would silently skip the split-turn detection).
|
||||
// Compare raw message ids, not the compact-mode aggregate ids: a page
|
||||
// that extends an aggregated run re-keys the synthetic summary id, but
|
||||
// the run members keep their daemon ids.
|
||||
if (compare.ids.has(first.id)) return;
|
||||
const newTurnIds: string[] = [];
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
if (
|
||||
!message ||
|
||||
!isTurnStartMessage(message) ||
|
||||
compare.ids.has(message.id)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let tailShown = false;
|
||||
for (let j = i + 1; j < messages.length; j++) {
|
||||
const next = messages[j];
|
||||
if (isTurnStartMessage(next)) break;
|
||||
if (compare.ids.has(next.id)) {
|
||||
tailShown = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (tailShown) newTurnIds.push(message.id);
|
||||
}
|
||||
if (newTurnIds.length === 0) {
|
||||
// A changed head while the load is resolved is the page landing, so
|
||||
// consume the snapshot even when it completed no split turn —
|
||||
// otherwise every later transcript update rescans it for the rest of
|
||||
// the session. While the load is still in flight a head change is
|
||||
// not the page (a transcript reload or session/branch switch);
|
||||
// discarding the snapshot there would let the page commit that
|
||||
// follows silently skip detection — the split turn collapses again,
|
||||
// the bug this code exists to fix.
|
||||
if (!compare.resolved) return;
|
||||
} else {
|
||||
setPaginatedExpandedTurns((prev) => {
|
||||
let next: Set<string> | null = null;
|
||||
for (const id of newTurnIds) {
|
||||
if (!prev.has(id)) {
|
||||
next ??= new Set(prev);
|
||||
next.add(id);
|
||||
}
|
||||
}
|
||||
return next ?? prev;
|
||||
});
|
||||
}
|
||||
pending.delete(generation);
|
||||
// The page that just committed is visible now: fold its ids into any
|
||||
// newer pending snapshot so that load's detection still recognizes the
|
||||
// tail a mid-turn page boundary showed.
|
||||
for (const remaining of pending.values()) {
|
||||
for (const message of messages) {
|
||||
if (message) remaining.ids.add(message.id);
|
||||
}
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
const pendingGeneration = pendingOlderHistoryTopLoad.current;
|
||||
if (
|
||||
@@ -4821,7 +5063,12 @@ export const MessageList = memo(
|
||||
pendingBottomFollowAfterCooldown.current = false;
|
||||
setShouldFollow(true);
|
||||
pendingScrollRef.current = null;
|
||||
// Drop the in-flight pagination snapshots too: without this a stale
|
||||
// pre-clear snapshot survives into the next session and can mislabel
|
||||
// a complete turn as keep-open (block ids are per-session ordinals).
|
||||
pendingPaginationTurnCompares.current.clear();
|
||||
setCollapseOverrides((prev) => (prev.size ? new Map() : prev));
|
||||
setPaginatedExpandedTurns((prev) => (prev.size ? new Set() : prev));
|
||||
}
|
||||
}, [messages.length, setShouldFollow]);
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(86px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
.metric span {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.caps {
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
.content strong,
|
||||
.content b {
|
||||
color: var(--foreground);
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.content h1,
|
||||
|
||||
@@ -427,9 +427,11 @@
|
||||
|
||||
.lineName {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.lineArg {
|
||||
font-size: 13px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
+60
@@ -548,6 +548,66 @@ describe('WebShellSidebar collapsed session group persistence', () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows the expanded attention pill short label with the full accessible name', async () => {
|
||||
active.sessions = [
|
||||
makeSession('attention-approval', {
|
||||
displayName: 'Needs approval',
|
||||
isWaitingForPermission: true,
|
||||
}),
|
||||
];
|
||||
active.data = active.sessions;
|
||||
renderSidebar();
|
||||
await flushSidebar();
|
||||
|
||||
let pill = container.querySelector<HTMLElement>(
|
||||
`.${sidebarStyles.sessionAttention}`,
|
||||
);
|
||||
expect(pill?.textContent).toBe('Approval');
|
||||
expect(pill?.getAttribute('aria-label')).toBe('Waiting for approval');
|
||||
expect(
|
||||
pill?.classList.contains(sidebarStyles.sessionAttentionUserInput),
|
||||
).toBe(false);
|
||||
|
||||
active.sessions = [
|
||||
makeSession('attention-approval-and-input', {
|
||||
displayName: 'Needs approval and input',
|
||||
isWaitingForPermission: true,
|
||||
isWaitingForUserQuestion: true,
|
||||
}),
|
||||
];
|
||||
active.data = active.sessions;
|
||||
renderSidebar();
|
||||
await flushSidebar();
|
||||
|
||||
pill = container.querySelector<HTMLElement>(
|
||||
`.${sidebarStyles.sessionAttention}`,
|
||||
);
|
||||
expect(pill?.textContent).toBe('Approval');
|
||||
expect(pill?.getAttribute('aria-label')).toBe('Waiting for approval');
|
||||
expect(
|
||||
pill?.classList.contains(sidebarStyles.sessionAttentionUserInput),
|
||||
).toBe(false);
|
||||
|
||||
active.sessions = [
|
||||
makeSession('attention-input', {
|
||||
displayName: 'Needs input',
|
||||
isWaitingForUserQuestion: true,
|
||||
}),
|
||||
];
|
||||
active.data = active.sessions;
|
||||
renderSidebar();
|
||||
await flushSidebar();
|
||||
|
||||
pill = container.querySelector<HTMLElement>(
|
||||
`.${sidebarStyles.sessionAttention}`,
|
||||
);
|
||||
expect(pill?.textContent).toBe('Input');
|
||||
expect(pill?.getAttribute('aria-label')).toBe('User input needed');
|
||||
expect(
|
||||
pill?.classList.contains(sidebarStyles.sessionAttentionUserInput),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps project sessions available from the collapsed sidebar', async () => {
|
||||
connection.capabilities = {
|
||||
qwenCodeVersion: '1.2.3',
|
||||
|
||||
@@ -3976,10 +3976,16 @@ export function WebShellSidebar({
|
||||
const isCurrent = isCurrentSession(session);
|
||||
const needsUserInput =
|
||||
!session.isWaitingForPermission && session.isWaitingForUserQuestion;
|
||||
const attentionLabel = session.isWaitingForPermission
|
||||
? t('sidebar.waitingForApproval')
|
||||
const attention = session.isWaitingForPermission
|
||||
? {
|
||||
full: t('sidebar.waitingForApproval'),
|
||||
short: t('sidebar.waitingForApprovalShort'),
|
||||
}
|
||||
: needsUserInput
|
||||
? t('sidebar.userInputNeeded')
|
||||
? {
|
||||
full: t('sidebar.userInputNeeded'),
|
||||
short: t('sidebar.userInputNeededShort'),
|
||||
}
|
||||
: null;
|
||||
const showPin = canOrganizeSession(session, 'pin');
|
||||
const showArchive =
|
||||
@@ -4079,15 +4085,15 @@ export function WebShellSidebar({
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{attentionLabel && (
|
||||
{attention && (
|
||||
<span
|
||||
className={cx(
|
||||
styles.sessionAttention,
|
||||
needsUserInput && styles.sessionAttentionUserInput,
|
||||
)}
|
||||
aria-label={attentionLabel}
|
||||
aria-label={attention.full}
|
||||
>
|
||||
{attentionLabel}
|
||||
{attention.short}
|
||||
</span>
|
||||
)}
|
||||
{session.hasActivePrompt ? (
|
||||
@@ -4095,7 +4101,7 @@ export function WebShellSidebar({
|
||||
className={styles.sessionLoading}
|
||||
aria-label={t('sidebar.running')}
|
||||
/>
|
||||
) : !attentionLabel && gitIcon ? (
|
||||
) : !attention && gitIcon ? (
|
||||
<span className={styles.sessionGitIcon}>{gitIcon}</span>
|
||||
) : null}
|
||||
{(showPin ||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Synthetic ids for compact-mode aggregated tool runs.
|
||||
* `mergeCompactToolGroups` builds them via `summaryRunId`; render paths detect
|
||||
* them through the shared predicate so the prefix scheme lives in one place.
|
||||
*/
|
||||
export const SUMMARY_RUN_ID_PREFIX = 'summary-';
|
||||
|
||||
export const summaryRunId = (firstMemberId: string): string =>
|
||||
`${SUMMARY_RUN_ID_PREFIX}${firstMemberId}`;
|
||||
|
||||
export const isSummaryRunId = (id: string): boolean =>
|
||||
id.startsWith(SUMMARY_RUN_ID_PREFIX);
|
||||
|
||||
export const summaryRunFirstMemberId = (id: string): string | undefined =>
|
||||
isSummaryRunId(id) ? id.slice(SUMMARY_RUN_ID_PREFIX.length) : undefined;
|
||||
@@ -584,7 +584,7 @@ const EN: Messages = {
|
||||
'common.previous': 'previous',
|
||||
'common.expand': 'Expand',
|
||||
'common.collapse': 'Collapse',
|
||||
'common.refresh': 'refresh',
|
||||
'common.refresh': 'Refresh',
|
||||
'common.search': 'Search',
|
||||
'common.valid': 'valid',
|
||||
'common.clients': (v) => `${v?.count ?? 0} clients`,
|
||||
@@ -1398,10 +1398,12 @@ const EN: Messages = {
|
||||
'sidebar.clients': (v) => `${v?.count ?? 0} client(s)`,
|
||||
'sidebar.running': 'Running',
|
||||
'sidebar.waitingForApproval': 'Waiting for approval',
|
||||
'sidebar.waitingForApprovalShort': 'Approval',
|
||||
'sidebar.sessionPr': (v) => `Pull Request #${v?.number ?? ''}`,
|
||||
'sidebar.sessionPrMultiple': (v) =>
|
||||
`Pull Request #${v?.number ?? ''} (${v?.count ?? 0} total)`,
|
||||
'sidebar.userInputNeeded': 'User input needed',
|
||||
'sidebar.userInputNeededShort': 'Input',
|
||||
'sidebar.completedUnread': 'Finished',
|
||||
'sidebar.pin': 'Pin',
|
||||
'sidebar.unpin': 'Unpin',
|
||||
@@ -4448,10 +4450,12 @@ const ZH: Messages = {
|
||||
'sidebar.clients': (v) => `${v?.count ?? 0} 个客户端`,
|
||||
'sidebar.running': '运行中',
|
||||
'sidebar.waitingForApproval': '等待批准',
|
||||
'sidebar.waitingForApprovalShort': '待批准',
|
||||
'sidebar.sessionPr': (v) => `合并请求 #${v?.number ?? ''}`,
|
||||
'sidebar.sessionPrMultiple': (v) =>
|
||||
`合并请求 #${v?.number ?? ''}(共 ${v?.count ?? 0} 个)`,
|
||||
'sidebar.userInputNeeded': '需要用户输入',
|
||||
'sidebar.userInputNeededShort': '需输入',
|
||||
'sidebar.completedUnread': '刚完成',
|
||||
'sidebar.pin': '置顶',
|
||||
'sidebar.unpin': '取消置顶',
|
||||
|
||||
@@ -9955,10 +9955,49 @@ exit 1
|
||||
expect(step).toContain('sudo -n apt-get install');
|
||||
expect(step).not.toContain('sudo apt-get');
|
||||
}
|
||||
// "Short jobs stay hosted" is an explicit design decision — only the
|
||||
// three heavy jobs may route onto the persistent pool.
|
||||
expect(routeJob).toContain("runs-on: 'ubuntu-latest'");
|
||||
expect(reviewScanJob).toContain("runs-on: 'ubuntu-latest'");
|
||||
// "Short jobs stay hosted" is the design rule — the carve-out is the
|
||||
// scan lane: route and review-scan gate the WHOLE fan-out, and a
|
||||
// hosted-runner backlog queued route past the cron period so the cron
|
||||
// supersede rule starved every scan round (2026-08-25). They share the
|
||||
// heavy jobs' exact expression, fork-trust clause and kill-switch
|
||||
// included. The command jobs stay hosted.
|
||||
expect(routeJob).toContain(ecsRunsOn);
|
||||
expect(reviewScanJob).toContain(ecsRunsOn);
|
||||
// af-148 carve-out premise: neither job checks code out, so the shared
|
||||
// workspace needs no restore/wipe step. Pin it, so adding a checkout
|
||||
// forces a deliberate update of the hygiene requirement.
|
||||
expect(routeJob).not.toContain('actions/checkout');
|
||||
expect(reviewScanJob).not.toContain('actions/checkout');
|
||||
// The scan's per-run WORKDIR must not outlive the run on the pool:
|
||||
// 0700 at creation and an always() cleanup step mirroring the heavy
|
||||
// jobs' teardown. The value pins the autofix* prefix — the contract
|
||||
// with the heavy jobs' age sweep, the only reclaim channel left after
|
||||
// a hard runner kill.
|
||||
expect(reviewScanJob).toContain(
|
||||
"WORKDIR: '/tmp/autofix-scan-${{ github.run_id }}'",
|
||||
);
|
||||
// Pre-clean immediately before create: run_id is public and mkdir -p
|
||||
// alone would accept a dir or symlink pre-planted on the shared /tmp.
|
||||
expect(reviewScanJob).toContain(
|
||||
'rm -rf "${WORKDIR}"\n (umask 077; mkdir -p "${WORKDIR}")',
|
||||
);
|
||||
// The fleet file lives inside WORKDIR so the always() step and the age
|
||||
// sweep reclaim it when the EXIT trap cannot (cancelled/killed run).
|
||||
expect(reviewScanJob).toContain('FLEET_FILE="${WORKDIR}/fleet.tsv"');
|
||||
expect(reviewScanJob).not.toContain('FLEET_FILE="$(mktemp)"');
|
||||
// The EXIT trap is the in-step cleanup twin of the always() step; it
|
||||
// must remove both the fleet file and the per-run dir.
|
||||
expect(reviewScanJob).toContain(
|
||||
'trap \'rm -f "${FLEET_FILE}"; rm -rf "${WORKDIR}"\' EXIT',
|
||||
);
|
||||
// Pin the cleanup step's command inside its own slice, not job-wide: a
|
||||
// no-op cleanup with the right name and if: shipped green before.
|
||||
const scanCleanupStep =
|
||||
reviewScanJob.match(
|
||||
/- name: 'Clean up scan workdir'[\s\S]*?(?=\n[ ]{6}- name: '|\n[ ]{2}# ==========|$)/,
|
||||
)?.[0] ?? '';
|
||||
expect(scanCleanupStep).toContain("if: 'always()'");
|
||||
expect(scanCleanupStep).toContain('rm -rf "${WORKDIR}"');
|
||||
for (const name of ['takeover-command', 'retry-command', 'takeover-ack']) {
|
||||
const job =
|
||||
workflow.match(
|
||||
@@ -10229,6 +10268,13 @@ exit 1
|
||||
// world-readable window on the shared /tmp.
|
||||
expect(step).toContain('(umask 077; mkdir -p "${WORKDIR}")');
|
||||
expect(step).not.toContain('chmod 700');
|
||||
// Consumer side of the scan lane's reclaim contract: after a hard
|
||||
// runner kill neither its EXIT trap nor its always() step fires, so
|
||||
// this sweep is the only channel left — the glob must keep matching
|
||||
// the scan WORKDIR basename pinned in the carve-out test.
|
||||
expect(step).toContain(
|
||||
"find /tmp -maxdepth 1 -name 'autofix*' -mmin +1440 -exec rm -rf {} + 2>/dev/null || true",
|
||||
);
|
||||
}
|
||||
// Per-run/per-target teardown after the artifact upload: nothing else
|
||||
// removes these dirs on the persistent pool (PR numbers only increase).
|
||||
@@ -10721,19 +10767,41 @@ exit 1
|
||||
// gh's own env channels are pinned/stripped BEFORE the first gh call in
|
||||
// each PAT step, so a $GITHUB_ENV-planted GH_HOST cannot reroute the
|
||||
// identity check and a planted GH_TOKEN cannot outrank the inline one.
|
||||
for (const [step, firstGh] of [
|
||||
[publishPrStep, 'GH_TOKEN="${GITHUB_TOKEN}" gh api user'],
|
||||
[pushAndReportStep, 'GH_TOKEN="${GITHUB_TOKEN}" gh api user'],
|
||||
[prepareStep, 'PR_LIVE="$(gh pr view'],
|
||||
// The scan lane shares the persistent pool with those PAT steps
|
||||
// (af-148), so route and review-scan carry the same reroute hardening
|
||||
// in the same loop — one preamble shape, one edit site: a planted
|
||||
// ~/.config/gh there would send the scan's CI_DEV_BOT_PAT or route's
|
||||
// collaborator-permission lookups into a local socket.
|
||||
for (const [step, firstGh, guardedMint] of [
|
||||
[publishPrStep, 'GH_TOKEN="${GITHUB_TOKEN}" gh api user', false],
|
||||
[pushAndReportStep, 'GH_TOKEN="${GITHUB_TOKEN}" gh api user', false],
|
||||
[prepareStep, 'PR_LIVE="$(gh pr view', false],
|
||||
[routeStep, 'gh api "repos/${REPO}/collaborators', true],
|
||||
[reviewScanJob, 'gh pr view', true],
|
||||
]) {
|
||||
const ghPin = step.indexOf('export GH_HOST=github.com');
|
||||
expect(ghPin).toBeGreaterThan(-1);
|
||||
expect(step).toMatch(/unset GH_ENTERPRISE_TOKEN GH_TOKEN/);
|
||||
// GH_CONFIG_DIR is PINNED to a fresh throwaway (unsetting it falls
|
||||
// back to the attacker-writable ~/.config/gh with http_unix_socket).
|
||||
expect(step).toContain(
|
||||
'export GH_CONFIG_DIR="$(mktemp -d "${RUNNER_TEMP}/autofix-gh-config.XXXXXX")"',
|
||||
);
|
||||
// route and review-scan additionally guard the mint itself: `export
|
||||
// VAR="$(mktemp ...)"` reports export's status, not the
|
||||
// substitution's, so a failing mktemp would sail through bash -e with
|
||||
// an empty value — gh's unset fallback — unless the assignment is
|
||||
// checked before export. The behavioural witness below turns red when
|
||||
// the guard goes; the heavy jobs' older preambles predate the guard
|
||||
// and are pinned in their current shape.
|
||||
if (guardedMint) {
|
||||
const guard =
|
||||
'if ! GH_CONFIG_DIR="$(mktemp -d "${RUNNER_TEMP}/autofix-gh-config.XXXXXX")"; then';
|
||||
expect(step).toContain(guard);
|
||||
expect(step).toMatch(/exit 1\n\s*fi\n\s*export GH_CONFIG_DIR\n/);
|
||||
expect(step.indexOf(guard)).toBeLessThan(step.indexOf(firstGh));
|
||||
} else {
|
||||
expect(step).toContain(
|
||||
'export GH_CONFIG_DIR="$(mktemp -d "${RUNNER_TEMP}/autofix-gh-config.XXXXXX")"',
|
||||
);
|
||||
}
|
||||
expect(step.indexOf(firstGh)).toBeGreaterThan(-1);
|
||||
expect(ghPin).toBeLessThan(step.indexOf(firstGh));
|
||||
}
|
||||
@@ -10956,6 +11024,111 @@ exit 1
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('aborts route and review-scan before any gh call when the gh config dir cannot be minted', () => {
|
||||
// Behavioural witness for the guarded-mint pin above: without the guard,
|
||||
// `export VAR="$(mktemp ...)"` reports export's status, not the
|
||||
// substitution's, so a failing mktemp sails through bash -e with an
|
||||
// empty GH_CONFIG_DIR and the step calls gh against the shared
|
||||
// ~/.config/gh fallback — the reroute hole the preamble closes. Run each
|
||||
// step under a mktemp that refuses ONLY the gh-config template (later
|
||||
// plain mktemp calls must keep working so the unguarded mutant provably
|
||||
// reaches gh) and a gh stub that records every call: the step must fail
|
||||
// loud before the first record. Probed: restoring the unguarded export
|
||||
// makes both cases record a gh call and drop the ::error abort.
|
||||
const realMktemp = spawnSync('bash', ['-c', 'command -v mktemp'], {
|
||||
encoding: 'utf8',
|
||||
}).stdout.trim();
|
||||
const cases = [
|
||||
{
|
||||
name: 'route',
|
||||
// The managed-PR review lane reaches the collaborator-permission
|
||||
// lookup (the first gh call) fastest.
|
||||
block: routeStep.match(/run: \|-\n([\s\S]*)$/)?.[1],
|
||||
env: {
|
||||
EVENT_NAME: 'pull_request_review',
|
||||
REPO: 'QwenLM/qwen-code',
|
||||
PR_BASE_REF: 'main',
|
||||
PR_HEAD_REPO: 'QwenLM/qwen-code',
|
||||
PR_AUTHOR: 'qwen-code-dev-bot',
|
||||
AUTOFIX_BOT: 'qwen-code-dev-bot',
|
||||
REVIEW_BOT: 'qwen-code-ci-bot',
|
||||
SENDER_LOGIN: 'qqqys',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'review-scan',
|
||||
// A forced PR reaches `gh pr view` through read_forced_pr_meta.
|
||||
block: reviewScanJob.match(
|
||||
/- name: 'Scan for PRs with new feedback'[\s\S]*?run: \|-\n([\s\S]*?)(?=\n {6}- name: ')/,
|
||||
)?.[1],
|
||||
env: {
|
||||
EVENT_NAME: 'schedule',
|
||||
FORCED_PR: '1234',
|
||||
REPO: 'QwenLM/qwen-code',
|
||||
GITHUB_TOKEN: 'pat',
|
||||
},
|
||||
},
|
||||
];
|
||||
for (const { name, block, env } of cases) {
|
||||
expect(block, name).toBeTruthy();
|
||||
const dir = mkdtempSync(join(tmpdir(), 'autofix-ghcfg-'));
|
||||
try {
|
||||
const bin = join(dir, 'bin');
|
||||
mkdirSync(bin);
|
||||
writeFileSync(
|
||||
join(bin, 'mktemp'),
|
||||
[
|
||||
'#!/bin/bash',
|
||||
'for a in "$@"; do',
|
||||
' case "$a" in *autofix-gh-config*) echo "mktemp: simulated failure: $a" >&2; exit 1 ;; esac',
|
||||
'done',
|
||||
`exec '${realMktemp}' "$@"`,
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
const callsFile = join(dir, 'gh-calls');
|
||||
writeFileSync(
|
||||
join(bin, 'gh'),
|
||||
`#!/bin/bash\nprintf '%s\\n' "$*" >> '${callsFile}'\nexit 0\n`,
|
||||
);
|
||||
chmodSync(join(bin, 'mktemp'), 0o755);
|
||||
chmodSync(join(bin, 'gh'), 0o755);
|
||||
const proc = spawnSync(
|
||||
'bash',
|
||||
[
|
||||
'-e',
|
||||
'-o',
|
||||
'pipefail',
|
||||
'-c',
|
||||
// The scan's forced-PR retry loop sleeps between attempts on the
|
||||
// mutant path; keep the probe fast without touching the block.
|
||||
`sleep() { :; }\n${block.replace(/^ {10}/gm, '')}`,
|
||||
],
|
||||
{
|
||||
env: {
|
||||
PATH: `${bin}:${process.env.PATH}`,
|
||||
RUNNER_TEMP: dir,
|
||||
WORKDIR: join(dir, 'workdir'),
|
||||
GITHUB_OUTPUT: join(dir, 'github-output'),
|
||||
...env,
|
||||
},
|
||||
encoding: 'utf8',
|
||||
},
|
||||
);
|
||||
expect(proc.stdout, name).toContain(
|
||||
'::error::could not create gh config dir',
|
||||
);
|
||||
expect(proc.status, name).not.toBe(0);
|
||||
expect(
|
||||
existsSync(callsFile),
|
||||
`${name} called gh after a failed gh-config mktemp`,
|
||||
).toBe(false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('runs both verification gates under a throwaway global git config', () => {
|
||||
// Same incident, the gate-side guard: the gates re-run branch tests on
|
||||
// the HOST, so runner ~/.gitconfig pollution failed tests the branch
|
||||
@@ -15827,7 +16000,10 @@ exit 1
|
||||
|
||||
// Replay the real helper + render block over fixtures.
|
||||
const lines = scan.split('\n');
|
||||
const hi = lines.findIndex((l) => l.trim() === 'FLEET_FILE="$(mktemp)"');
|
||||
const hi = lines.findIndex(
|
||||
(l) => l.trim() === 'FLEET_FILE="${WORKDIR}/fleet.tsv"',
|
||||
);
|
||||
expect(hi).toBeGreaterThan(-1);
|
||||
const hj = lines.findIndex((l, i) => i > hi && l.trim() === '}');
|
||||
const helper = lines.slice(hi, hj + 1).join('\n');
|
||||
expect(helper).toContain('fleet_row()');
|
||||
@@ -15849,6 +16025,9 @@ exit 1
|
||||
[
|
||||
'set -uo pipefail',
|
||||
'SUMMARY_FILE="$(mktemp)"',
|
||||
// The helper references the job-level WORKDIR (fleet file home);
|
||||
// its EXIT trap then reclaims this replay dir.
|
||||
'WORKDIR="$(mktemp -d)"',
|
||||
helper,
|
||||
'COUNT=1',
|
||||
"fleet_row 7329 'SELECTED' '1 review + 5 inline new (round 0/5)'",
|
||||
@@ -15884,6 +16063,9 @@ exit 1
|
||||
[
|
||||
'set -uo pipefail',
|
||||
'SUMMARY_FILE="$(mktemp)"',
|
||||
// The helper references the job-level WORKDIR (fleet file home);
|
||||
// its EXIT trap then reclaims this replay dir.
|
||||
'WORKDIR="$(mktemp -d)"',
|
||||
helper,
|
||||
'COUNT=0',
|
||||
render,
|
||||
|
||||
Reference in new issue
Block a user