Merge branch 'main' into fix/issue-10075-decouple-allowlist

This commit is contained in:
易良 authored and GitHub committed 2026-08-27 10:46:52 +08:00
commit bfa697a0e6
34 files changed
+3248 -102

No files matched your search

+99 -1
View File
@@ -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}`,
);
}
});
}
});
+1 -1
View File
@@ -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
+41
View File
@@ -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.
```
+70 -5
View File
@@ -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.
+4 -1
View File
@@ -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}`, {
+1
View File
@@ -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 },
+223 -18
View File
@@ -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 () => {
+24 -9
View File
@@ -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');
});
});
+5 -2
View File
@@ -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: {
+23 -2
View File
@@ -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;
+1
View File
@@ -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;
@@ -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;
+5 -1
View File
@@ -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': '取消置顶',
+194 -12
View File
@@ -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,