Merge remote-tracking branch 'upstream/main' into feat/native-directory-picker

# Conflicts:
#	docs/users/qwen-serve.md
#	packages/cli/src/commands/channel/daemon-worker.test.ts
#	packages/cli/src/commands/channel/daemon-worker.ts
#	packages/cli/src/serve/channel-worker-supervisor.test.ts
#	packages/cli/src/serve/channel-worker-supervisor.ts
#	packages/cli/src/serve/pem-certificate-blocks.test.ts
#	packages/cli/src/serve/pem-certificate-blocks.ts
#	packages/cli/src/serve/process-env-guard.test.ts
#	packages/cli/src/serve/run-qwen-serve.test.ts
#	packages/cli/src/serve/run-qwen-serve.ts
This commit is contained in:
qqqys committed 2026-08-26 00:42:46 +08:00
commit 337443c889
2109 files changed
+62322 -384334

No files matched your search

+1 -1
View File
@@ -13,7 +13,7 @@
/.github/workflows/security-checks.yml @pomelo-nwu @wenshao
# --- Core package ---
/packages/core/ @wenshao @tanzhenxin @yiliang114 @LaZzyMan @doudouOUC
/packages/core/ @wenshao @tanzhenxin @yiliang114 @LaZzyMan @doudouOUC @qqqys
# --- CUA Driver & Mobile MCP ---
/packages/cua-driver/ @LaZzyMan
+7 -3
View File
@@ -324,8 +324,9 @@ run_deferred_upsert() {
if [[ "${UPSERT_OUT}" != *'__upsert_child_live__'* ]]; then
echo "::warning::deferred-findings upsert child never started (loader trace mode or exec failure); NOT persisted this round"
fi
# The child's output is agent-reachable content, so a line-start
# `::` is neutralized before it reaches this step's stdout.
# The child's output is agent-reachable content, so both workflow-command
# syntaxes are neutralized before it reaches this step's stdout (`##[`
# parses mid-line too — #9761).
while IFS= read -r _upsert_line; do
# Wrapper-authored lines carry a marker and are emitted
# VERBATIM so they still render as GitHub annotations; the
@@ -335,7 +336,10 @@ run_deferred_upsert() {
elif [[ "${_upsert_line}" == __upsert_trusted__* ]]; then
printf '%s\n' "${_upsert_line#__upsert_trusted__}"
else
printf '%s\n' "${_upsert_line//::/;;}"
# The canonical two-expression neutralizer, identical to every other
# echo site — one spelling for the whole family, so a syntax change
# cannot drift across two implementations.
printf '%s\n' "${_upsert_line}" | sed -e 's/::/;;/g' -e 's/##\[/##[/g'
fi
done <<< "${UPSERT_OUT}"
}
+64 -5
View File
@@ -31,7 +31,42 @@ GROWTH_ALLOWANCE="${WORKFLOW_SIZE_GROWTH_ALLOWANCE:-4096}"
# the slack for the next unreviewed 25 KB.
SLACK_BYTES=20000
# The ratchet compares the worktree against a checked-in baseline, so a
# workflow that grew on main without the same-PR baseline bump leaves every
# OTHER open PR failing a gate on a file it never touched (red-walled the
# queue twice in two weeks: #9747, #9822). When the caller passes the PR's
# base commit in WORKFLOW_SIZE_BASE_SHA, the missing-entry and growth
# branches below hard-fail only if the PR actually changed the file; a
# byte-identical copy means the staleness is main-side drift and earns a
# warning instead. An unresolvable base (local run, fetch failure) falls
# back to the strict failure — the ratchet fails closed, never open. One
# residual window stays by design: if main edits the same workflow again
# after the PR branched, the comparison against the new base sees the PR's
# older copy as different and fails closed until that PR rebases —
# self-healing, and still fail-closed, so it is left alone rather than
# wiring the PR's changed-files list into a gate that today needs no API
# call.
BASE_SHA="${WORKFLOW_SIZE_BASE_SHA:-}"
# Returns 0 when the worktree copy of $1 is byte-identical to the base
# commit, 1 when it differs (or no base was given), and 2 when the base
# cannot be resolved — the callers add a diagnostic on 2, because a
# transient fetch failure and genuine PR growth need opposite remedies.
file_matches_base() {
local file="$1"
[[ -n "${BASE_SHA}" ]] || return 1
if ! git rev-parse --verify --quiet "${BASE_SHA}^{commit}" >/dev/null &&
! git fetch --depth=1 --quiet origin "${BASE_SHA}"; then
return 2
fi
git show "${BASE_SHA}:${file}" 2>/dev/null | cmp -s - "${file}"
}
unresolvable_base_note() {
echo "::warning::base ${BASE_SHA} could not be resolved (git fetch failed?) — failing strict; if this PR did not touch ${1}, re-run the job."
}
status=0
warned_stale=0
declare -A baseline=()
if [[ -r "${BASELINE_FILE}" ]]; then
# The || clause keeps an unterminated final line, which read reports as a
@@ -72,17 +107,41 @@ for file in .github/workflows/*.yml .github/workflows/*.yaml; do
base="${baseline[${file##*/}]:-}"
if [[ -z "${base}" ]]; then
echo "::error file=${file}::${file} has no entry in ${BASELINE_FILE}. Add '${size} ${file##*/}' so its growth is tracked."
status=1
file_matches_base "${file}"
match=$?
if ((match == 0)); then
echo "::warning file=${file}::${file} has no entry in ${BASELINE_FILE}, but the file is unchanged from this PR's base — add '${size} ${file##*/}' on main so its growth is tracked; unrelated PRs are not blocked."
warned_stale=1
else
echo "::error file=${file}::${file} has no entry in ${BASELINE_FILE}. Add '${size} ${file##*/}' so its growth is tracked."
status=1
if ((match == 2)); then
unresolvable_base_note "${file}"
fi
fi
elif ((size > base + GROWTH_ALLOWANCE)); then
echo "::error file=${file}::${file} grew to ${size} bytes, $((size - base)) over its recorded ${base} (allowance ${GROWTH_ALLOWANCE}). Move prose into a sibling .md and long steps into .github/scripts/ — or, if the growth is real, update ${BASELINE_FILE} in this PR and say why."
status=1
file_matches_base "${file}"
match=$?
if ((match == 0)); then
echo "::warning file=${file}::${file} is ${size} bytes, $((size - base)) over its recorded ${base}, but the file is unchanged from this PR's base — the baseline went stale on main, not in this PR. Bump ${BASELINE_FILE} on main (a one-line PR saying why); unrelated PRs are not blocked."
warned_stale=1
else
echo "::error file=${file}::${file} grew to ${size} bytes, $((size - base)) over its recorded ${base} (allowance ${GROWTH_ALLOWANCE}). Move prose into a sibling .md and long steps into .github/scripts/ — or, if the growth is real, update ${BASELINE_FILE} in this PR and say why."
status=1
if ((match == 2)); then
unresolvable_base_note "${file}"
fi
fi
elif ((size + SLACK_BYTES < base)); then
echo "::warning file=${file}::${file} is ${size} bytes, $((base - size)) under its recorded ${base} — lower the entry in ${BASELINE_FILE} so the slack is not banked."
fi
done
if ((status == 0)); then
echo "✅ every workflow file is under the ${GATE_BYTES}-byte gate and within ${GROWTH_ALLOWANCE} bytes of its recorded baseline"
if ((warned_stale)); then
echo "✅ every workflow file is under the ${GATE_BYTES}-byte gate (stale-baseline warnings above — update ${BASELINE_FILE} on main)"
else
echo "✅ every workflow file is under the ${GATE_BYTES}-byte gate and within ${GROWTH_ALLOWANCE} bytes of its recorded baseline"
fi
fi
exit "${status}"
+124 -9
View File
@@ -6,6 +6,13 @@
// they drift, classify and the Test job land on different pools. These tests
// evaluate BOTH against the same event matrix — including the negative
// associations that must stay hosted — and assert they agree.
//
// test_windows carries a deliberately different policy. A pull_request run
// executes the workflow YAML from the PR's own merge commit, so any trust
// clause a PR can read it can also rewrite. The matrix evaluates the real
// expression text and asserts the only enforceable shape: every pull request
// stays hosted, and only the merge queue, schedule and dispatch reach the
// persistent pool.
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
@@ -28,8 +35,11 @@ const serveAbDoc = parse(
const TRUSTED = ['OWNER', 'MEMBER', 'COLLABORATOR'];
const ECS = '["self-hosted", "linux", "x64", "ecs-qwen"]';
const HOSTED = '["ubuntu-latest"]';
const WIN_ECS = ['self-hosted', 'Windows', 'X64', 'ecs-win'];
const WIN_HOSTED = ['windows-2022'];
const classifyRunsOn = String(ciDoc.jobs.classify_pr['runs-on']);
const windowsRunsOn = String(ciDoc.jobs.test_windows['runs-on']);
const pickRunner = ciDoc.jobs.classify_pr.steps.find(
(s) => s.id === 'pick_runner',
);
@@ -44,6 +54,48 @@ function simulateRunsOn({ ecsDisabled, sameRepo, assoc, mergeGroup }) {
return ecs ? ECS : HOSTED;
}
// Evaluates a real `runs-on` expression text with the routing inputs
// substituted, leaving only the &&/||/parenthesis skeleton — which matches
// GitHub's operator semantics closely enough for this fixed shape: both
// return the winning operand, and the winning operand is a fromJSON runner
// label, unwrapped here to the array it names. Any term the substitutions
// do not recognise fails loud, so an edited expression is re-read here
// instead of silently outgrowing the matrix.
function evalRunsOn(expression, { ecsDisabled, eventName, sameRepo, assoc }) {
const substitutions = [
[/vars\.MAINTAINER_ECS_RUNNER_DISABLED != 'true'/, String(!ecsDisabled)],
[
/github\.event_name == 'merge_group'/,
String(eventName === 'merge_group'),
],
[
/github\.event_name != 'pull_request'/,
String(eventName !== 'pull_request'),
],
[
/github\.event\.pull_request\.head\.repo\.full_name == github\.repository/,
String(sameRepo),
],
[
/contains\(fromJSON\('\["OWNER","MEMBER","COLLABORATOR"\]'\), github\.event\.pull_request\.author_association\)/,
String(TRUSTED.includes(assoc)),
],
];
let expr = expression.replace(/^\$\{\{\s*/, '').replace(/\s*\}\}$/, '');
for (const [term, value] of substitutions) {
expr = expr.replace(term, value);
}
expr = expr.replace(/fromJSON\('(\[[^\]]*\])'\)/g, '$1');
assert.doesNotMatch(
expr,
/github\.|vars\.|contains\(|fromJSON\(/,
`routing expression carries a term the matrix does not model: ${expr}`,
);
const selected = new Function(`return (${expr});`)();
assert.ok(Array.isArray(selected), `no runner label selected: ${expr}`);
return selected;
}
// Executes the real pick_runner shell with the same inputs and returns the
// selected runner exactly as CI would publish it.
function runPickRunner({ ecsDisabled, sameRepo, assoc, eventName, dispatch }) {
@@ -69,18 +121,19 @@ function runPickRunner({ ecsDisabled, sameRepo, assoc, eventName, dispatch }) {
return line.slice('Selected Linux runner: '.length);
}
const ASSOCIATIONS = [
...TRUSTED,
'CONTRIBUTOR',
'FIRST_TIME_CONTRIBUTOR',
'FIRST_TIMER',
'NONE',
'',
];
describe('ci.yml classify_pr runner routing', () => {
it('the expression and the shell step agree on every association', () => {
const associations = [
...TRUSTED,
'CONTRIBUTOR',
'FIRST_TIME_CONTRIBUTOR',
'FIRST_TIMER',
'NONE',
'',
];
for (const sameRepo of [true, false]) {
for (const assoc of associations) {
for (const assoc of ASSOCIATIONS) {
const expected = simulateRunsOn({
ecsDisabled: false,
sameRepo,
@@ -176,6 +229,68 @@ describe('ci.yml classify_pr runner routing', () => {
});
});
describe('ci.yml test_windows runner routing', () => {
it('keeps every pull request hosted, whoever opens it', () => {
// A pull_request run executes the workflow YAML from the PR's own merge
// commit: any PR this lane admits could rewrite `runs-on` in the same
// diff (editing this file is what classifies it platform-sensitive), so
// no trust clause evaluated on that event is enforceable. The enforceable
// shape is unconditional — pull requests never reach the persistent pool.
for (const sameRepo of [true, false]) {
for (const assoc of ASSOCIATIONS) {
assert.deepEqual(
evalRunsOn(windowsRunsOn, {
ecsDisabled: false,
eventName: 'pull_request',
sameRepo,
assoc,
}),
WIN_HOSTED,
`pull_request sameRepo=${sameRepo} assoc='${assoc}' must stay hosted`,
);
}
}
});
it('keeps the pool for every non-pull-request trigger', () => {
// The denial form exists so the queue, the nightly and dispatch runs stay
// on the pool without a pull_request context to read; an && / || flip in
// the gate must not exile them to hosted runners.
for (const eventName of ['merge_group', 'schedule', 'workflow_dispatch']) {
assert.deepEqual(
evalRunsOn(windowsRunsOn, {
ecsDisabled: false,
eventName,
sameRepo: false,
assoc: '',
}),
WIN_ECS,
`${eventName} must keep the pool`,
);
}
});
it('the kill-switch wins on every event', () => {
for (const eventName of [
'pull_request',
'merge_group',
'schedule',
'workflow_dispatch',
]) {
assert.deepEqual(
evalRunsOn(windowsRunsOn, {
ecsDisabled: true,
eventName,
sameRepo: true,
assoc: 'OWNER',
}),
WIN_HOSTED,
`kill-switch must win on ${eventName}`,
);
}
});
});
describe('serve-ab.yml runner routing', () => {
const runsOn = String(serveAbDoc.jobs.ab['runs-on']);
@@ -0,0 +1,157 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
// Does this change need the macOS and Windows lanes?
//
// Those lanes are the only signal this repository has about a host that is not
// Linux with a GNU userland, and they are expensive, so they run on the diffs
// whose behaviour the HOST decides rather than the code: shell scripts and the
// CI definitions that embed shell (a GNU-only flag, a BSD `sed`, a path
// separator), the script layer and its tests, the test-runner configuration
// that says which suites run where, and the handful of source subtrees named
// after a platform-coupled subsystem.
//
// This list is deliberately a NET, not a proof. It cannot see a platform
// assumption inside an ordinary source file — one that resolves a path, spawns
// a process, or compares two spellings of the same directory — and no
// path-based rule ever will. That gap is what the scheduled run on `main`
// exists for: this classifier buys an early signal on the diffs that carry the
// known failure class, and the nightly catches the rest a day later. Widening
// the list until it matches everything would just restore the cost the lanes
// were moved off pull requests to avoid.
//
// Fail-safe in every direction: an unreadable list, an unparsable entry, or no
// entries at all classifies as sensitive. A missed lane is a defect that ships;
// a needless lane is twenty minutes.
export const PLATFORM_SENSITIVE = 'true';
export const PLATFORM_INSENSITIVE = 'false';
// Anything a shell interprets. `.ps1`/`.bat`/`.cmd` are here for the same
// reason as `.sh`: they are the Windows lane's subject, not an exception to it.
const SHELL_SCRIPT = /\.(?:sh|bash|zsh|ps1|bat|cmd)$/i;
// Workflow and composite-action YAML embeds shell in `run:` blocks, and the
// scripts those blocks call are part of the same program.
const CI_DEFINITION = /^\.github\/(?:workflows|actions|scripts)\//;
// The script layer and its tests: this repository's build, release and CI
// helpers, the suites that drive them, and the fixtures those suites build out
// of real filesystem paths.
const SCRIPT_LAYER = /^scripts\//;
// Which suites run on which lane is itself platform-deciding: an exclusion list
// keyed on `process.platform` is exactly how a suite ends up unrun on one host
// and red on another.
const RUNNER_CONFIG = /(?:^|\/)vitest(?:\.[^/]*)?\.config\.[cm]?[jt]s$/i;
// The dependency and script manifests: a changed `test:ci`, a native module, or
// an optional per-platform dependency changes what each lane executes.
const MANIFEST = new Set(['package.json', 'package-lock.json']);
// Source subtrees whose subject IS the host.
//
// A keyword counts when it NAMES the thing: a whole path segment
// (`src/sandbox/index.ts`, `src/platform/paths.ts`) or the head of a file's
// stem (`pty-host.ts`, `win32.ts`, `shell.ts`). It does not count inside a
// compound that names something else — `packages/web-shell/**` is a browser
// UI, not a shell, and matching it there summoned both expensive lanes on
// every change to one of this repository's largest packages. Nor inside a
// longer word: `Shellfish.tsx`, `plateauDetector.ts`, `cryptic.ts`.
const SUBSYSTEMS =
'pty|tty|sandbox|seatbelt|shell|terminal|clipboard|platform|posix|darwin|macos|windows|win32|linux|keychain|codesign|installer|filesystem|audio';
// A directory or file segment that IS the keyword (optionally with an
// extension): `sandbox/`, `shell.ts`, `win32.test.ts`.
const SUBSYSTEM_SEGMENT = new RegExp(
`(?:^|/)(?:${SUBSYSTEMS})(?:\\.[^/]*)?(?:/|$)`,
'i',
);
// Or the keyword as the head of a hyphen/underscore-separated stem:
// `pty-host.ts`, `shell_exec.ts`. The head only — a trailing part belongs to
// whatever the leading word names.
const SUBSYSTEM_STEM_HEAD = new RegExp(
`(?:^|/)(?:${SUBSYSTEMS})[-_][^/]*(?:/|$)`,
'i',
);
function isSensitivePath(file) {
const p = String(file).replace(/\\/g, '/').replace(/^\.\//, '');
if (!p) return true;
return (
SHELL_SCRIPT.test(p) ||
CI_DEFINITION.test(p) ||
SCRIPT_LAYER.test(p) ||
RUNNER_CONFIG.test(p) ||
MANIFEST.has(p) ||
SUBSYSTEM_SEGMENT.test(p) ||
SUBSYSTEM_STEM_HEAD.test(p)
);
}
/**
* Every name an entry touches. A rename moves a file between two paths, and
* either side can be the sensitive one — a script moved out of `scripts/` is
* still a script change on the lane that ran it.
*/
function namesOf(entry) {
if (typeof entry === 'string') return [entry];
if (!entry || typeof entry !== 'object') return [];
return [entry.filename, entry.previous_filename].filter(
(n) => typeof n === 'string' && n.length > 0,
);
}
export function classifyChangedFiles(entries) {
if (!Array.isArray(entries) || entries.length === 0)
return PLATFORM_SENSITIVE;
for (const entry of entries) {
const names = namesOf(entry);
// An entry that carries no usable name is an unknown change, and an
// unknown change is sensitive.
if (names.length === 0) return PLATFORM_SENSITIVE;
if (names.some(isSensitivePath)) return PLATFORM_SENSITIVE;
}
return PLATFORM_INSENSITIVE;
}
/** The JSONL contract of classify-pr-profile.sh: one projected entry per line. */
export function parseChangedFiles(text) {
return (
String(text)
// `\r?\n`, matching the sibling classifier's reader: a CRLF listing would
// otherwise leave a trailing `\r` on every filename and defeat the
// end-anchored suffix rules above.
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line);
} catch {
// Not JSON: treat the raw line as a filename rather than dropping it.
return line;
}
})
);
}
function main() {
const filePath = process.argv[2];
if (!filePath) {
console.log(PLATFORM_SENSITIVE);
return;
}
try {
console.log(
classifyChangedFiles(parseChangedFiles(readFileSync(filePath, 'utf8'))),
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`::warning::Failed to read changed files: ${message}`);
console.log(PLATFORM_SENSITIVE);
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}
@@ -0,0 +1,235 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
PLATFORM_INSENSITIVE,
PLATFORM_SENSITIVE,
classifyChangedFiles,
parseChangedFiles,
} from './classify-platform-sensitivity.mjs';
test('an ordinary source change does not summon the expensive lanes', () => {
// The whole point of a gate: the common pull request pays nothing. If this
// ever flips, the lanes are back on every PR and the cost that moved them
// off is back with them.
assert.equal(
classifyChangedFiles([
'packages/cli/src/ui/components/Header.tsx',
'packages/core/src/prompts/system.ts',
'docs/users/configuration.md',
]),
PLATFORM_INSENSITIVE,
);
});
test('shell scripts pull in the lanes, on every dialect', () => {
for (const file of [
'scripts/build.sh',
'tools/release.bash',
'installer/setup.ps1',
'ci/run.bat',
'ci/run.cmd',
'deep/nested/dir/helper.SH',
'tools/setup.zsh',
]) {
assert.equal(
classifyChangedFiles([`packages/core/src/x.ts`, file]),
PLATFORM_SENSITIVE,
file,
);
}
});
test('CI definitions and the scripts they call are shell too', () => {
// The failure this gate exists for lived in a workflow's `run:` block and
// in the suite that drove it: `realpath -m` is GNU-only, so the guard it
// canonicalized with silently did nothing on macOS.
for (const file of [
'.github/workflows/ci.yml',
'.github/actions/configure-windows-runner/action.yml',
'.github/scripts/ci/classify-profile.mjs',
'scripts/tests/qwen-pr-review-workflow.test.js',
'scripts/version.js',
]) {
assert.equal(classifyChangedFiles([file]), PLATFORM_SENSITIVE, file);
}
});
test('the runner configuration decides which lane runs what', () => {
// An exclusion keyed on process.platform is how a suite ends up unrun on
// one host and red on another; a change to it must be seen by both lanes.
for (const file of [
'vitest.config.ts',
'scripts/tests/vitest.config.ts',
'vitest.terminal-bench.config.ts',
'packages/cli/vitest.config.mts',
]) {
assert.equal(classifyChangedFiles([file]), PLATFORM_SENSITIVE, file);
}
// Not every config is the runner's.
assert.equal(
classifyChangedFiles(['packages/cli/eslint.config.js']),
PLATFORM_INSENSITIVE,
);
});
test('the manifests change what each lane executes', () => {
assert.equal(classifyChangedFiles(['package.json']), PLATFORM_SENSITIVE);
assert.equal(classifyChangedFiles(['package-lock.json']), PLATFORM_SENSITIVE);
// A workspace manifest is not the root one; it reaches the lanes through
// the subsystem rules or not at all.
assert.equal(
classifyChangedFiles(['packages/webui/package.json']),
PLATFORM_INSENSITIVE,
);
});
test('platform-coupled subsystems match on segments, not substrings', () => {
for (const file of [
'packages/core/src/sandbox/index.ts',
'packages/cli/src/ui/pty-host.ts',
'packages/cli/src/utils/clipboard.ts',
'packages/core/src/tools/shell.ts',
'packages/cli/src/platform/paths.ts',
'packages/cli/src/utils/win32.ts',
]) {
assert.equal(classifyChangedFiles([file]), PLATFORM_SENSITIVE, file);
}
// A compound that names something else. `packages/web-shell/**` is one of
// this repository's largest packages and a browser UI, not a shell: the
// first spelling of this rule split on dashes anywhere in a segment and
// summoned both expensive lanes on every change to it. A directory that IS
// named for the subsystem still counts, wherever it sits.
for (const [file, expected] of [
['packages/web-shell/client/App.tsx', PLATFORM_INSENSITIVE],
['packages/web-shell/client/index.html', PLATFORM_INSENSITIVE],
['packages/web-shell/client/components/shell/Term.tsx', PLATFORM_SENSITIVE],
['packages/cli/src/pty-host/index.ts', PLATFORM_SENSITIVE],
]) {
assert.equal(classifyChangedFiles([file]), expected, file);
}
// The substring trap: these contain "shell", "pty", "os" or "platform"
// inside a longer word and must NOT drag both lanes in.
for (const file of [
'packages/webui/src/components/Shellfish.tsx',
'packages/core/src/utils/cryptic.ts',
'packages/cli/src/ui/emptyState.ts',
'packages/core/src/telemetry/uploader.ts',
'packages/cli/src/services/plateauDetector.ts',
]) {
assert.equal(classifyChangedFiles([file]), PLATFORM_INSENSITIVE, file);
}
});
test('native build workspaces are caught by the subsystem they name', () => {
// packages/audio-capture is a node-gyp module compiled per-host on exactly
// the two lanes this gate feeds: its native sources carry no shell
// extension, `mac_permission` names `mac` and not the keyword `macos`, and
// the workspace manifest is not the root one — so the subsystem rule is the
// only net under it. The keyword names the workspace directory, so every
// file below it counts.
for (const file of [
'packages/audio-capture/native/mac_permission.mm',
'packages/audio-capture/native/audio_capture.cc',
'packages/audio-capture/native/miniaudio.h',
'packages/audio-capture/install.js',
'packages/audio-capture/binding.gyp',
]) {
assert.equal(classifyChangedFiles([file]), PLATFORM_SENSITIVE, file);
}
// The keyword names a subsystem, not a file extension: an ordinary `.cc`
// elsewhere is still ordinary source.
assert.equal(
classifyChangedFiles(['packages/core/src/utils/parser.cc']),
PLATFORM_INSENSITIVE,
);
});
test('a rename is judged on both of its names', () => {
// A script moved out of the script layer is still a script change on the
// lane that used to run it — and one moved in is a new one to run.
assert.equal(
classifyChangedFiles([
{
filename: 'tools/build.mjs',
status: 'renamed',
previous_filename: 'scripts/build.mjs',
},
]),
PLATFORM_SENSITIVE,
);
assert.equal(
classifyChangedFiles([
{
filename: 'scripts/build.mjs',
status: 'renamed',
previous_filename: 'tools/build.mjs',
},
]),
PLATFORM_SENSITIVE,
);
assert.equal(
classifyChangedFiles([
{
filename: 'src/b.ts',
status: 'renamed',
previous_filename: 'src/a.ts',
},
]),
PLATFORM_INSENSITIVE,
);
});
test('every unknown answers sensitive, never insensitive', () => {
// A gate that fails open silently stops testing. Each of these is a way the
// input can arrive broken, and each one must still run the lanes.
assert.equal(classifyChangedFiles([]), PLATFORM_SENSITIVE);
assert.equal(classifyChangedFiles(null), PLATFORM_SENSITIVE);
assert.equal(classifyChangedFiles(undefined), PLATFORM_SENSITIVE);
assert.equal(classifyChangedFiles('scripts/x.sh'), PLATFORM_SENSITIVE);
assert.equal(classifyChangedFiles([{ status: 'added' }]), PLATFORM_SENSITIVE);
assert.equal(classifyChangedFiles([null]), PLATFORM_SENSITIVE);
assert.equal(classifyChangedFiles([{ filename: '' }]), PLATFORM_SENSITIVE);
});
test('parses the wrapper JSONL contract, and survives a non-JSON line', () => {
const parsed = parseChangedFiles(
[
'{"filename":"src/a.ts","status":"modified","previous_filename":null}',
'',
'scripts/raw-line.sh',
].join('\n'),
);
assert.equal(parsed.length, 2);
assert.equal(parsed[0].filename, 'src/a.ts');
assert.equal(parsed[1], 'scripts/raw-line.sh');
assert.equal(classifyChangedFiles(parsed), PLATFORM_SENSITIVE);
});
test('a CRLF listing does not smuggle a carriage return into a filename', () => {
// The suffix rules are end-anchored, so a trailing `\r` defeats every one
// of them and a script-layer change would classify as ordinary source. The
// sibling classifier splits on /\r?\n/ for the same reason.
const parsed = parseChangedFiles(
'{"filename":"scripts/build.sh","status":"modified"}\r\n{"filename":"src/a.ts","status":"modified"}\r\n',
);
assert.equal(parsed.length, 2);
assert.equal(parsed[0].filename, 'scripts/build.sh');
assert.equal(classifyChangedFiles(parsed), PLATFORM_SENSITIVE);
// And the raw-line path, where the `\r` would land on the name itself.
assert.equal(
classifyChangedFiles(parseChangedFiles('scripts/build.sh\r\nsrc/a.ts\r\n')),
PLATFORM_SENSITIVE,
);
});
test('windows path separators classify the same as posix ones', () => {
// The listing is API-shaped and uses forward slashes, but a caller feeding
// this from a local `git diff` on Windows must not silently classify a
// script layer change as ordinary source.
assert.equal(
classifyChangedFiles(['scripts\\tests\\install-script.test.js']),
PLATFORM_SENSITIVE,
);
});
+21 -8
View File
@@ -9,13 +9,25 @@
# meant the same PR could classify differently in each workflow, silently,
# because both fall back to `full` on their own errors.
#
# Usage: classify-pr-profile.sh <owner/repo> <pr-number>
# Prints the profile (docs_only | github_ci_only | full) on stdout.
# Usage: classify-pr-profile.sh <owner/repo> <pr-number> [mode]
# mode `profile` (default) prints docs_only | github_ci_only | full.
# mode `platform` prints true | false — whether the macOS and Windows lanes
# need to run for this PR. Both modes take the SAME listing through this
# script, for the reason above: a second copy of the listing contract is a
# second way for two call sites to see different files for the same PR.
# Exit codes: 0 classified; 2 file listing failed; 3 classifier failed.
set -euo pipefail
repo="${1:?usage: classify-pr-profile.sh <owner/repo> <pr-number>}"
pr="${2:?usage: classify-pr-profile.sh <owner/repo> <pr-number>}"
repo="${1:?usage: classify-pr-profile.sh <owner/repo> <pr-number> [mode]}"
pr="${2:?usage: classify-pr-profile.sh <owner/repo> <pr-number> [mode]}"
mode="${3:-profile}"
# Each mode also names what a TRUNCATED listing must fall back to — the
# conservative answer differs per mode (run everything vs. run the lanes).
case "$mode" in
profile) classifier='classify-profile.mjs'; truncated='full' ;;
platform) classifier='classify-platform-sensitivity.mjs'; truncated='true' ;;
*) echo "classify-pr-profile: unknown mode '${mode}'" >&2; exit 3 ;;
esac
# mktemp + trap, not a fixed name: the self-hosted pool is persistent and
# shared, so a predictable path is a leftover-file landmine, and ci.yml's
@@ -33,13 +45,14 @@ fi
# The list-files endpoint caps at 3,000 entries. A truncated listing can be
# all docs while an omitted later entry is source, so any mismatch against
# the PR's own changed-file count conservatively classifies as `full`.
# the PR's own changed-file count conservatively classifies as the mode's
# run-everything answer.
declared="$(gh api "repos/${repo}/pulls/${pr}" --jq '.changed_files')" || exit 2
retrieved="$(wc -l < "${files}")"
if [ "${retrieved}" -ne "${declared}" ]; then
echo "classify-pr-profile: retrieved ${retrieved} file entries but PR declares ${declared}; classifying full." >&2
echo "full"
echo "classify-pr-profile: retrieved ${retrieved} file entries but PR declares ${declared}; classifying ${truncated}." >&2
echo "${truncated}"
exit 0
fi
node "$(dirname "$0")/classify-profile.mjs" "${files}" || exit 3
node "$(dirname "$0")/${classifier}" "${files}" || exit 3
@@ -31,7 +31,7 @@ import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const wrapper = join(here, 'classify-pr-profile.sh');
function run(scenario, { stubNodeFailure = false } = {}) {
function run(scenario, { stubNodeFailure = false, mode } = {}) {
const dir = mkdtempSync(join(tmpdir(), 'classify-pr-profile-'));
const bin = join(dir, 'bin');
mkdirSync(bin);
@@ -60,6 +60,7 @@ function run(scenario, { stubNodeFailure = false } = {}) {
' list-fail) exit 1 ;;',
' docs-only) FIXTURE=\'[{"filename":"docs/users/a.md","status":"modified","previous_filename":null,"sha":"x","additions":1},{"filename":"README.md","status":"modified","previous_filename":null,"sha":"y","additions":1}]\' ;;',
' renamed-source) FIXTURE=\'[{"filename":"docs/new.md","status":"renamed","previous_filename":"packages/core/src/runtime.ts","sha":"z","additions":0}]\' ;;',
' script-change) FIXTURE=\'[{"filename":"scripts/tests/install-script.test.js","status":"modified","previous_filename":null,"sha":"s","additions":3}]\' ;;',
' truncated) FIXTURE=\'[{"filename":"docs/users/a.md","status":"modified","previous_filename":null,"sha":"x","additions":1}]\' ;;',
' declared-fails) FIXTURE=\'[{"filename":"docs/users/a.md","status":"modified","previous_filename":null,"sha":"x","additions":1}]\' ;;',
' *) exit 9 ;;',
@@ -70,6 +71,7 @@ function run(scenario, { stubNodeFailure = false } = {}) {
' truncated) echo 5 ;;',
' declared-fails) exit 1 ;;',
' docs-only) echo 2 ;;',
' script-change) echo 1 ;;',
' renamed-source) echo 1 ;;',
' *) exit 9 ;;',
' esac ;;',
@@ -82,7 +84,9 @@ function run(scenario, { stubNodeFailure = false } = {}) {
write('node', '#!/bin/bash\nexit 1\n');
}
try {
const stdout = execFileSync('bash', [wrapper, 'o/r', '42'], {
const argv = [wrapper, 'o/r', '42'];
if (mode) argv.push(mode);
const stdout = execFileSync('bash', argv, {
encoding: 'utf8',
env: {
...process.env,
@@ -126,3 +130,36 @@ test('exit 2 when the changed_files fetch fails after a successful listing', ()
const r = run('declared-fails');
assert.equal(r.code, 2);
});
// The platform mode added for the macOS/Windows lane gate. It shares this
// wrapper's listing so both gates always judge the same files — the reason the
// listing lives here — but it answers a different question, and each mode has
// its own conservative answer when the listing cannot be trusted.
test('platform mode: an ordinary docs change does not need the expensive lanes', () => {
assert.deepEqual(run('docs-only', { mode: 'platform' }), {
code: 0,
stdout: 'false',
});
});
test('platform mode: a script-layer change does', () => {
assert.deepEqual(run('script-change', { mode: 'platform' }), {
code: 0,
stdout: 'true',
});
});
test('platform mode: a truncated listing runs the lanes, not skips them', () => {
// The mode-specific half of the 3,000-file cap guard: `full` is the
// conservative answer for the profile, `true` for the lanes. Hardcoding
// either one for both modes silently skips the lanes on a large PR.
assert.deepEqual(run('truncated', { mode: 'platform' }), {
code: 0,
stdout: 'true',
});
});
test('an unknown mode fails loudly instead of falling back to a classifier', () => {
// A typo'd mode must not silently classify with the wrong question.
assert.equal(run('docs-only', { mode: 'platfrom' }).code, 3);
});
@@ -236,7 +236,10 @@ fi
if [[ -f "${WORKDIR}/failure.md" && -n "$(git status --porcelain)" ]]; then
echo "❌ Agent wrote failure.md after leaving a dirty workspace:"
git status --short
cat "${WORKDIR}/failure.md"
# Agent-written content on step stdout: both workflow-command syntaxes
# parse here (`##[` mid-line too — measured on #9761). Same reason
# 'Show run artifacts' neutralizes these files.
sed -e 's/::/;;/g' -e 's/##\[/##[/g' "${WORKDIR}/failure.md"
echo "outcome=failed" >> "${GITHUB_OUTPUT}"
echo "kiss_audit=${KISS_AUDIT:-false}" >> "${GITHUB_OUTPUT}"
if [[ "${AUDIT_VERDICT_RECORDED:-false}" == 'true' ]]; then
@@ -247,7 +250,7 @@ fi
if [[ -f "${WORKDIR}/failure.md" ]]; then
echo "🛑 Agent aborted intentionally:"
cat "${WORKDIR}/failure.md"
sed -e 's/::/;;/g' -e 's/##\[/##[/g' "${WORKDIR}/failure.md"
echo "outcome=failed" >> "${GITHUB_OUTPUT}"
echo "kiss_audit=${KISS_AUDIT:-false}" >> "${GITHUB_OUTPUT}"
if [[ "${AUDIT_VERDICT_RECORDED:-false}" == 'true' ]]; then
@@ -277,7 +280,7 @@ if [[ -s "${WORKDIR}/handoff.md" && -n "$(git status --porcelain)" \
&& "${AUDIT_VERDICT:-}" != 'conflict' ]]; then
echo "❌ Agent wrote handoff.md after leaving a dirty workspace:"
git status --short
sed 's/::/;;/g' "${WORKDIR}/handoff.md"
sed -e 's/::/;;/g' -e 's/##\[/##[/g' "${WORKDIR}/handoff.md"
echo "outcome=dirty_handoff" >> "${GITHUB_OUTPUT}"
echo "kiss_audit=${KISS_AUDIT:-false}" >> "${GITHUB_OUTPUT}"
if [[ "${AUDIT_VERDICT_RECORDED:-false}" == 'true' ]]; then
@@ -299,7 +302,7 @@ if [[ -s "${WORKDIR}/handoff.md" && "${committed_rc:-0}" -eq 1 \
&& "${AUDIT_VERDICT:-}" != 'conflict' ]]; then
echo "❌ Agent wrote handoff.md but the round HAS a commit — a brake violation:"
git log --oneline "origin/${BRANCH}..${BRANCH}"
sed 's/::/;;/g' "${WORKDIR}/handoff.md"
sed -e 's/::/;;/g' -e 's/##\[/##[/g' "${WORKDIR}/handoff.md"
echo "outcome=committed_handoff" >> "${GITHUB_OUTPUT}"
echo "kiss_audit=${KISS_AUDIT:-false}" >> "${GITHUB_OUTPUT}"
if [[ "${AUDIT_VERDICT_RECORDED:-false}" == 'true' ]]; then
@@ -321,10 +324,11 @@ if git diff --quiet "origin/${BRANCH}...${BRANCH}" \
&& [[ -s "${WORKDIR}/handoff.md" ]] \
&& [[ "${AUDIT_VERDICT:-}" != 'conflict' ]]; then
echo "🤝 Branch unchanged with a handoff — the agent stopped under instruction and deferred this item to a human:"
# Agent-written content: a line-start `::` would be parsed as a workflow
# command (::error::, ::add-mask::), the same reason 'Show run artifacts'
# neutralizes these files.
sed 's/::/;;/g' "${WORKDIR}/handoff.md"
# Agent-written content: both workflow-command syntaxes parse on step
# stdout — a line-start `::` (::error::, ::add-mask::) AND `##[` even
# mid-line (a quoted `##[add-matcher]` fails the step; measured on
#9761). The same reason 'Show run artifacts' neutralizes these files.
sed -e 's/::/;;/g' -e 's/##\[/##[/g' "${WORKDIR}/handoff.md"
echo "outcome=handoff" >> "${GITHUB_OUTPUT}"
echo "kiss_audit=${KISS_AUDIT:-false}" >> "${GITHUB_OUTPUT}"
if [[ "${AUDIT_VERDICT_RECORDED:-false}" == 'true' ]]; then
@@ -586,7 +590,9 @@ if git diff --quiet "origin/${BRANCH}...${BRANCH}"; then
# no-commit handoff was classified before the structural checks above.
if [[ -s "${WORKDIR}/no-action.md" ]]; then
echo "🟰 No action needed:"
cat "${WORKDIR}/no-action.md"
# Both command syntaxes, like every other echo of agent-written files
# (`##[` parses mid-line too — #9761).
sed -e 's/::/;;/g' -e 's/##\[/##[/g' "${WORKDIR}/no-action.md"
echo "verified_head=$(git rev-parse HEAD)" >> "${GITHUB_OUTPUT}"
echo "outcome=noop" >> "${GITHUB_OUTPUT}"
echo "kiss_audit=${KISS_AUDIT:-false}" >> "${GITHUB_OUTPUT}"
+9 -7
View File
@@ -49,9 +49,10 @@ gh_reason() {
local r=''
[[ -n "${GH_ERR}" && -s "${GH_ERR}" ]] &&
r="$(tr '\r\n\t' ' ' < "${GH_ERR}" | head -c 200)"
# `::` neutralized like every other agent/API-derived echo: an API error
# body is not trusted to be free of workflow-command syntax.
r="$(printf '%s' "${r}" | sed 's/::/;;/g')"
# Both workflow-command syntaxes neutralized like every other
# agent/API-derived echo: an API error body is not trusted to be free of
# them (`##[` parses mid-line too — #9761).
r="$(printf '%s' "${r}" | sed -e 's/::/;;/g' -e 's/##\[/##[/g')"
[[ -n "${r// /}" ]] && printf '%s' "${r}" || printf 'no stderr captured'
}
gh_err_reset() { [[ -n "${GH_ERR}" ]] && : > "${GH_ERR}"; }
@@ -67,9 +68,10 @@ OWN_FINDINGS="${WORKDIR}/deferred-findings.json"
# filters this round's feedback out of every later round, and the next run's
# workspace reset deletes the file — nothing re-derives them. So each abort
# says so and dumps what it had, for manual recovery from the run log.
# `::` is neutralized in the dump: the content is agent-influenced and a
# raw `::` at line start would be parsed as a workflow command (same reason
# `<!--` is neutralized at every publish site).
# Both workflow-command syntaxes are neutralized in the dump: the content is
# agent-influenced and would otherwise be parsed as a command — `::` at line
# start AND `##[` even mid-line (measured on #9761). Same reason `<!--` is
# neutralized at every publish site.
dump_file() {
[[ -s "$1" ]] || return 0
local size
@@ -85,7 +87,7 @@ dump_file() {
else
echo "--- $1"
fi
head -c 4000 "$1" | sed 's/::/;;/g'
head -c 4000 "$1" | sed -e 's/::/;;/g' -e 's/##\[/##[/g'
echo
}
lost() {
+3 -3
View File
@@ -18,7 +18,7 @@
4638 build-and-publish-image.yml
49610 cd-cua-driver.yml
2076 cd-mobile-mcp.yml
69782 ci.yml
81137 ci.yml
1482 codeql.yml
9389 comment-attachment-guard.yml
31677 desktop-release.yml
@@ -26,8 +26,8 @@
10005 dsw-swe-verified-release.yml
12340 e2e.yml
11394 finalize-release.yml
15871 live-host-release.yml
6384 live-host.yml
16647 live-host-release.yml
5950 live-host.yml
7642 main-ci-failure-issue.yml
1686 npm-cache.yml
7299 pr-force-push-reminder.yml
+165 -32
View File
@@ -12,6 +12,15 @@ on:
- 'main'
- 'release/**'
merge_group:
# A daily heartbeat for the macOS and Windows lanes ONLY. Those two are
# gated on `merge_group`, and the merge queue is not enabled on this
# repository — no queue run since 2026-07-02 — so they had stopped running
# entirely: skipped on every pull request, and never reached afterwards. The
# pull-request gate below catches the diffs a path list can recognise; this
# catches everything it cannot, one day later, on `main`. Every other job
# here excludes `schedule` explicitly, so a nightly run is exactly two jobs.
schedule:
- cron: '17 19 * * *'
workflow_dispatch:
inputs:
branch_ref:
@@ -50,7 +59,13 @@ env:
# BOTH the github_ci_only helper step and the full-profile Test step, so a
# new helper test can't be added to one path and silently dropped from the
# other.
HELPER_TESTS: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/classify-pr-profile.test.mjs .github/scripts/upsert-bot-comment.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/classify-release-notes.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/dsw-swe-verified/make-terminal-bench-manifest.test.mjs .github/scripts/resolve-sandbox-image.test.mjs .github/scripts/web-shell-visuals-publish.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/serve-ab-drive.test.mjs .github/scripts/qwen-triage-workflow.test.mjs .github/scripts/assign-issue-owner.test.mjs .github/scripts/auto-minimize-spam.test.mjs .github/scripts/ci-runner-routing.test.mjs'
HELPER_TESTS: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/classify-platform-sensitivity.test.mjs .github/scripts/ci/classify-pr-profile.test.mjs .github/scripts/upsert-bot-comment.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/classify-release-notes.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/dsw-swe-verified/make-terminal-bench-manifest.test.mjs .github/scripts/resolve-sandbox-image.test.mjs .github/scripts/web-shell-visuals-publish.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/serve-ab-drive.test.mjs .github/scripts/qwen-triage-workflow.test.mjs .github/scripts/assign-issue-owner.test.mjs .github/scripts/auto-minimize-spam.test.mjs .github/scripts/ci-runner-routing.test.mjs'
# The growth ratchet and its vitest mirror compare each workflow against
# the PR's base commit to tell "this PR grew the file" apart from "the
# baseline went stale on main" (#9904). Wired once here so every lane
# inherits it — the gate step and every `npm run test:ci` step, whatever
# it is named — instead of each step hand-wiring a copy.
WORKFLOW_SIZE_BASE_SHA: '${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}'
jobs:
classify_pr:
@@ -62,7 +77,11 @@ jobs:
# else a busy hosted pool delays it and blocks the ECS-bound jobs. The
# kill-switch is read here, so flipping it reverts everything to hosted.
# This runs-on and the pick_runner step below are the canonical home of
# the association routing; sdk-java.yml and serve-ab.yml mirror it.
# the association routing; sdk-java.yml and serve-ab.yml mirror it (the
# routing tests hold the mirrors to it). test_windows deliberately does
# not: a pull_request run executes the PR's own YAML, so no runs-on trust
# clause is enforceable there and its lane never admits pull requests to
# the pool.
runs-on: '${{ (vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON(''["OWNER","MEMBER","COLLABORATOR"]''), github.event.pull_request.author_association) || github.event_name == ''merge_group'')) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}'
continue-on-error: true
outputs:
@@ -133,6 +152,71 @@ jobs:
echo "ubuntu_runner=${ubuntu_runner}" >> "${GITHUB_OUTPUT}"
echo "Selected Linux runner: ${ubuntu_runner}"
# Does this pull request need the macOS and Windows lanes? They are the only
# signal this repository has about a host that is not Linux with a GNU
# userland, and they are expensive, so they run on the diffs whose behaviour
# the HOST decides — shell, CI definitions, the script layer, the runner
# config, the platform-coupled subtrees. The classifier is a net, not a
# proof; the scheduled run on `main` is what covers everything a path list
# cannot see.
#
# Its own job, not a step in classify_pr: that job's outputs pick the Linux
# runner for everything else, and a new failure mode there (this one needs a
# checkout, on a pool whose workspace other jobs can poison) would take the
# whole run's routing with it. Here a failure costs one skipped
# classification, which the gate reads as "run the lanes".
#
# Hosted, and the checkout is of the pull request's BASE commit: this runs
# before any review, so checking out the contributor's head would run their
# classifier with this job's token, and staying off the ECS pool keeps it
# away from the poisoned-workspace class entirely.
classify_platform:
name: 'Classify platform sensitivity'
if: "${{ github.event_name == 'pull_request' }}"
runs-on: 'ubuntu-latest'
timeout-minutes: 5
continue-on-error: true
permissions:
contents: 'read'
pull-requests: 'read'
outputs:
platform_sensitive: '${{ steps.platform.outputs.platform_sensitive }}'
steps:
- name: 'Check out the classifier from the base branch'
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: '${{ github.event.pull_request.base.sha }}'
persist-credentials: false
sparse-checkout: '.github/scripts/ci'
- name: 'Classify platform sensitivity'
id: 'platform'
env:
GH_TOKEN: '${{ github.token }}'
PR_NUMBER: '${{ github.event.pull_request.number }}'
IS_SAME_REPO_PR: '${{ github.event.pull_request.head.repo.full_name == github.repository }}'
run: |-
set -uo pipefail
# Fail-safe in every direction: only a confident `false` from the
# classifier skips the lanes. A classifier error, an unexpected
# word, a fork PR, or this whole job failing all end as "run" — a
# gate that fails open stops testing without ever saying so.
sensitive=true
if [ "${IS_SAME_REPO_PR}" = 'true' ]; then
set +e
classified="$(.github/scripts/ci/classify-pr-profile.sh "${GITHUB_REPOSITORY}" "${PR_NUMBER}" platform)"
rc=$?
set -e
case "${rc}:${classified}" in
0:true|0:false) sensitive="${classified}" ;;
*) echo "::warning::Platform-sensitivity classifier returned rc=${rc} '${classified}'; running the macOS and Windows lanes." ;;
esac
else
echo "Fork PR detected; running the macOS and Windows lanes."
fi
echo "platform_sensitive=${sensitive}" >> "${GITHUB_OUTPUT}"
echo "Platform-sensitive: ${sensitive}"
#
# Test: Node
#
@@ -143,7 +227,7 @@ jobs:
# report; the per-step skip_ci guards below make them no-op (pass) there.
# Not on push: the merge queue already tested the merged tree, so a
# post-merge re-run on `main` would be redundant.
if: "${{ !cancelled() && github.event_name != 'push' }}"
if: "${{ !cancelled() && github.event_name != 'push' && github.event_name != 'schedule' }}"
runs-on: '${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'') }}'
timeout-minutes: 60
outputs:
@@ -261,10 +345,13 @@ jobs:
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: "${{ github.event.inputs.branch_ref || (github.event_name == 'pull_request' && format('refs/pull/{0}/head', github.event.pull_request.number)) || (github.event_name == 'merge_group' && github.event.merge_group.head_sha) || github.ref }}"
# Shallow: nothing here walks git history (the verify guard below checks
# head.sha == HEAD, schema/tests touch only the working tree). On the
# in-repo ECS runner a full-history clone is the heaviest transfer and
# chokes the squid egress proxy, flaking checkout. depth 1 is enough.
# Shallow: nothing here walks git history except on demand (the
# verify guard below checks head.sha == HEAD; the size gate and its
# vitest mirror fetch the PR's base commit at depth 1 when the
# baseline went stale; everything else touches only the working
# tree). On the in-repo ECS runner a full-history clone is the
# heaviest transfer and chokes the squid egress proxy, flaking
# checkout. depth 1 is enough.
fetch-depth: 1
# Guard against a stale checkout (e.g. a caching egress proxy serving an old
@@ -389,10 +476,6 @@ jobs:
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
run: 'npm run check:desktop-isolation'
- name: 'Check voice guard mirror sync'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
run: 'npm run check:voice-guard-sync'
- name: 'Install linters'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
run: 'node scripts/lint.js --setup'
@@ -822,19 +905,48 @@ jobs:
packages/web-shell/client/e2e/playwright-report
if-no-files-found: 'ignore'
# macOS/Windows: slowest/costliest runners, rare platform regressions — run
# only in the merge queue. Skipped on PR (ubuntu is the fast PR signal) and on
# push (the queue already tested the merged tree, so a post-merge re-run is
# redundant). Two named jobs, not a matrix: a skipped matrix job reports one
# collapsed check name, never the per-OS required contexts, so PRs would sit
# "Expected" forever and never enter the queue. A skipped named job reports
# under its exact name and satisfies the required check (same as the
# Integration Tests job).
# macOS/Windows: slowest/costliest runners, and the only signal this
# repository has about a host that is not Linux with a GNU userland.
#
# They used to run in the merge queue alone. That queue is not enabled here —
# no `merge_group` run since 2026-07-02, and merges land as squashes — so the
# gate meant they never ran at all: reported as "skipped" on every pull
# request, and never reached afterwards. A macOS-only failure could ship and
# sit in `main` indefinitely, which is what happened in #9220 (a GNU-only
# `realpath -m` in a workflow guard, with the suite that pinned it red on
# every Mac).
#
# Three triggers now, in cost order: a pull request whose diff the
# platform-sensitivity classifier recognises (shell, CI definitions, the
# script layer, the runner config, the platform-coupled subtrees), the merge
# queue if it is ever enabled again, and a nightly run on `main` for
# everything a path list cannot see. The pull-request gate is fail-safe —
# only a confident `false` skips, so a broken classifier costs runner minutes
# rather than coverage.
#
# Two named jobs, not a matrix: a skipped matrix job reports one collapsed
# check name, never the per-OS contexts, so a required-check configuration
# would sit "Expected" forever. A skipped named job reports under its exact
# name. (No status check is required on `main` today — the ruleset carries
# only deletion, non-fast-forward and pull_request rules — so this shape is
# currently insurance, not a live constraint.)
test_macos:
name: 'Test (macos-latest, Node 22.x)'
needs: 'classify_pr'
if: "${{ !cancelled() && github.event_name == 'merge_group' }}"
needs:
- 'classify_pr'
- 'classify_platform'
if: |-
${{
!cancelled() && (
github.event_name == 'merge_group' ||
github.event_name == 'schedule' ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' &&
needs.classify_platform.outputs.platform_sensitive != 'false')
)
}}
runs-on: 'macos-latest'
timeout-minutes: 60
permissions:
contents: 'read'
steps:
@@ -910,16 +1022,31 @@ jobs:
# Windows counterpart of test_macos (see that job's note). ECS is the default
# with a windows-2022 kill-switch fallback; the check name stays unchanged so
# it matches the required-status-check context. The job is merge_group-only,
# so code reaching it is post-approval; maintainers can still queue fork PRs.
# The runs-on expression therefore needs only the kill switch. ECS-only
# tuning is gated on runner.environment; the hosted fallback is the pre-ECS
# job plus the checkout guard and a job-level timeout-minutes.
# it matches the required-status-check context. The lane now runs on pull
# requests too, but every pull request runs on hosted windows-2022: a
# pull_request run executes the workflow YAML from the PR's own merge commit,
# so any PR this lane admits could rewrite `runs-on` in the same diff that
# reaches it. A gate the gated tree controls is no gate; the pool is reached
# only by triggers an unreviewed PR cannot open — the post-approval merge
# queue, schedule and dispatch. ECS-only tuning is gated on runner.environment;
# the hosted fallback is the pre-ECS job plus the checkout guard and a
# job-level timeout-minutes.
test_windows:
name: 'Test (windows-latest, Node 22.x)'
needs: 'classify_pr'
if: "${{ !cancelled() && github.event_name == 'merge_group' }}"
runs-on: '${{ vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && fromJSON(''["self-hosted", "Windows", "X64", "ecs-win"]'') || fromJSON(''["windows-2022"]'') }}'
needs:
- 'classify_pr'
- 'classify_platform'
if: |-
${{
!cancelled() && (
github.event_name == 'merge_group' ||
github.event_name == 'schedule' ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' &&
needs.classify_platform.outputs.platform_sensitive != 'false')
)
}}
runs-on: '${{ vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && github.event_name != ''pull_request'' && fromJSON(''["self-hosted", "Windows", "X64", "ecs-win"]'') || fromJSON(''["windows-2022"]'') }}'
timeout-minutes: 60
permissions:
contents: 'read'
@@ -954,11 +1081,17 @@ jobs:
# Same stale-checkout guard as the Ubuntu gate: this job now runs on ECS,
# so fail loud if the checkout lacks the merge-queue head rather than
# silently testing the wrong tree into a merge.
# Written when this lane ran in the merge queue alone, so its expected
# SHA named only the queue's event: on any other trigger the input is
# empty and the step fails the whole lane before a single test runs.
# That is what the revived triggers hit first. Same event-aware shape as
# the Ubuntu gate now, and skipped where there is no head to verify —
# the scheduled and dispatch runs check out a branch by name.
- name: 'Verify checkout includes expected head commit'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && (github.event_name == 'pull_request' || github.event_name == 'merge_group') }}"
uses: './.github/actions/verify-checkout-head'
with:
expected_sha: '${{ github.event.merge_group.head_sha }}'
expected_sha: "${{ github.event_name == 'merge_group' && github.event.merge_group.head_sha || github.event.pull_request.head.sha }}"
# Avoid setup-node downloads on ECS, where nodejs.org may be unreachable
# through the egress proxy; reuse the machine's Node instead.
@@ -1272,7 +1405,7 @@ jobs:
desktop_shell:
name: 'Desktop Shell (${{ matrix.os }})'
needs: 'classify_pr'
if: "${{ !cancelled() && github.event_name != 'push' && needs.classify_pr.outputs.skip_ci != 'true' }}"
if: "${{ !cancelled() && github.event_name != 'push' && github.event_name != 'schedule' && needs.classify_pr.outputs.skip_ci != 'true' }}"
strategy:
fail-fast: false
matrix:
+36 -25
View File
@@ -6,10 +6,7 @@ on:
pull_request:
paths:
- '.github/workflows/live-host-release.yml'
- 'packages/desktop/apps/live-host/**'
- 'packages/desktop/bun.lock'
- 'packages/desktop/package.json'
- 'packages/desktop/scripts/bump-live-host-version.ts'
- 'packages/live-host/**'
workflow_dispatch:
inputs:
version:
@@ -42,11 +39,10 @@ permissions:
contents: 'read'
concurrency:
group: 'live-host-release-${{ github.event.pull_request.number || inputs.version }}'
group: "${{ github.event_name == 'workflow_dispatch' && 'live-host-release' || format('live-host-release-pr-{0}', github.event.pull_request.number) }}"
cancel-in-progress: false
env:
BUN_VERSION: '1.3.9'
NODE_VERSION: '22.20.0'
LIVE_HOST_FEED_TAG: 'live-host-latest'
@@ -80,6 +76,17 @@ jobs:
echo '::error::Live Host releases do not support SemVer build metadata.'
exit 1
fi
if [ "$GITHUB_EVENT_NAME" = 'workflow_dispatch' ]; then
if [[ "$version" == *-* ]]; then
version_is_prerelease=true
else
version_is_prerelease=false
fi
if [ "$version_is_prerelease" != "${{ inputs.prerelease }}" ]; then
echo '::error::The prerelease input must match the Live Host version.'
exit 1
fi
fi
if [ "$GITHUB_EVENT_NAME" = 'workflow_dispatch' ] && [ "${{ inputs.dry_run }}" = 'false' ] && [ "$GITHUB_REF_NAME" != 'main' ]; then
echo '::error::Published Live Host releases must run from main.'
exit 1
@@ -99,21 +106,17 @@ jobs:
with:
node-version: '${{ env.NODE_VERSION }}'
- uses: 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6' # v2
with:
bun-version: '${{ env.BUN_VERSION }}'
- name: 'Install dependencies'
working-directory: 'packages/desktop'
run: 'bun install --frozen-lockfile'
working-directory: 'packages/live-host'
run: 'npm ci'
- name: 'Set Live Host version'
working-directory: 'packages/desktop'
run: 'bun run bump-live-host-version "${{ needs.prepare.outputs.version }}"'
working-directory: 'packages/live-host'
run: 'npm run bump-version -- "${{ needs.prepare.outputs.version }}"'
- name: 'Test Live Host'
working-directory: 'packages/desktop'
run: 'bun run live-host:typecheck && bun run live-host:test'
working-directory: 'packages/live-host'
run: 'npm run typecheck && npm test'
- name: 'Import macOS certificate'
if: "${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == false }}"
@@ -191,13 +194,13 @@ jobs:
} >> "$GITHUB_ENV"
- name: 'Build packages'
working-directory: 'packages/desktop'
working-directory: 'packages/live-host'
env:
CSC_IDENTITY_AUTO_DISCOVERY: "${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == false }}"
run: 'bun run live-host:dist:mac:no-publish'
run: 'npm run dist:mac:no-publish'
- name: 'Verify release assets'
working-directory: 'packages/desktop/apps/live-host'
working-directory: 'packages/live-host'
env:
RELEASE_VERSION: '${{ needs.prepare.outputs.version }}'
shell: 'bash'
@@ -231,7 +234,7 @@ jobs:
- name: 'Verify signing and notarization'
if: "${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == false }}"
working-directory: 'packages/desktop'
working-directory: 'packages/live-host'
shell: 'bash'
run: |
set -euo pipefail
@@ -246,22 +249,22 @@ jobs:
fi
spctl -a -vv -t exec "$app"
xcrun stapler validate "$app"
done < <(find apps/live-host/release -mindepth 2 -maxdepth 2 -type d -name '*.app' -print0)
done < <(find release -mindepth 2 -maxdepth 2 -type d -name '*.app' -print0)
if [ "$app_count" -eq 0 ]; then echo '::error::No packaged Qwen Live Host application was found.'; exit 1; fi
dmg_count=0
while IFS= read -r -d '' dmg; do
dmg_count=$((dmg_count + 1))
hdiutil verify "$dmg"
done < <(find apps/live-host/release -type f -name '*.dmg' -print0)
done < <(find release -type f -name '*.dmg' -print0)
if [ "$dmg_count" -eq 0 ]; then echo '::error::No Qwen Live Host disk image was found.'; exit 1; fi
- uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
with:
name: 'qwen-live-host-macos'
path: |
packages/desktop/apps/live-host/release/*.dmg
packages/desktop/apps/live-host/release/*-manifest.json
packages/desktop/apps/live-host/release/*.zip
packages/live-host/release/*.dmg
packages/live-host/release/*-manifest.json
packages/live-host/release/*.zip
if-no-files-found: 'error'
retention-days: 14
@@ -322,6 +325,7 @@ jobs:
env:
GH_TOKEN: '${{ github.token }}'
FEED_TAG: '${{ env.LIVE_HOST_FEED_TAG }}'
RELEASE_VERSION: '${{ needs.prepare.outputs.version }}'
run: |
set -euo pipefail
stable_assets=(
@@ -330,6 +334,13 @@ jobs:
release-assets/Qwen-Live-Host-x64.zip
)
if gh release view "$FEED_TAG" >/dev/null 2>&1; then
current_feed="$(mktemp -d)"
gh release download "$FEED_TAG" --pattern 'Qwen-Live-Host-manifest.json' --dir "$current_feed" --clobber
current_version="$(node --input-type=module -e 'import { readFileSync } from "node:fs"; console.log(JSON.parse(readFileSync(process.argv[1], "utf8")).version)' "$current_feed/Qwen-Live-Host-manifest.json")"
if [ "$(printf '%s\n%s\n' "$current_version" "$RELEASE_VERSION" | sort -V | tail -n 1)" != "$RELEASE_VERSION" ]; then
echo "::error::Refusing to replace Live Host feed v$current_version with older v$RELEASE_VERSION."
exit 1
fi
gh release upload "$FEED_TAG" "${stable_assets[@]}" --clobber
else
gh release create "$FEED_TAG" "${stable_assets[@]}" --title 'Qwen Live Host latest' --notes 'Stable Qwen Live Host installer feed.' --latest=false
+14 -22
View File
@@ -7,10 +7,7 @@ on:
- '.github/workflows/live-host-release.yml'
- 'packages/cli/src/serve/conversations/**'
- 'packages/cli/src/serve/live/**'
- 'packages/desktop/apps/live-host/**'
- 'packages/desktop/bun.lock'
- 'packages/desktop/package.json'
- 'packages/desktop/scripts/bump-live-host-version.ts'
- 'packages/live-host/**'
- 'packages/sdk-typescript/src/daemon/types.ts'
merge_group:
workflow_dispatch:
@@ -36,29 +33,24 @@ jobs:
with:
node-version-file: '.nvmrc'
- name: 'Set up Bun'
uses: 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6' # v2
with:
bun-version: '1.3.9'
- name: 'Install desktop dependencies'
working-directory: 'packages/desktop'
run: 'bun install --frozen-lockfile'
- name: 'Install dependencies'
working-directory: 'packages/live-host'
run: 'npm ci'
- name: 'Typecheck Live Host'
working-directory: 'packages/desktop'
run: 'bun run live-host:typecheck'
working-directory: 'packages/live-host'
run: 'npm run typecheck'
- name: 'Test Live Host'
working-directory: 'packages/desktop'
run: 'bun run live-host:test'
working-directory: 'packages/live-host'
run: 'npm test'
- name: 'Build Live Host'
working-directory: 'packages/desktop'
run: 'bun run live-host:build'
working-directory: 'packages/live-host'
run: 'npm run build'
- name: 'Package unsigned Live Host app'
working-directory: 'packages/desktop/apps/live-host'
working-directory: 'packages/live-host'
run: |
set -euo pipefail
@@ -68,7 +60,7 @@ jobs:
*) echo "::error::Unsupported runner architecture: $(uname -m)"; exit 1 ;;
esac
CSC_IDENTITY_AUTO_DISCOVERY=false bunx electron-builder --config electron-builder.yml --mac dir "$builder_arch"
CSC_IDENTITY_AUTO_DISCOVERY=false npx electron-builder --config electron-builder.yml --mac dir "$builder_arch"
app="$(find release -type d -name 'Qwen Live Host.app' -print -quit)"
if [ -z "$app" ]; then
@@ -79,7 +71,7 @@ jobs:
test -x "$app/Contents/MacOS/Qwen Live Host"
test -f "$app/Contents/Resources/app.asar"
asar_entries="$(node ../../node_modules/@electron/asar/bin/asar.mjs list "$app/Contents/Resources/app.asar")"
asar_entries="$(npx --no-install asar list "$app/Contents/Resources/app.asar")"
if printf '%s\n' "$asar_entries" | grep -Eiq '(^|/)(native/|.*command[-_]?monitor|.*commandtap)'; then
echo '::error::Packaged app.asar contains a removed keyboard monitor or native helper.'
exit 1
@@ -94,7 +86,7 @@ jobs:
extracted_asar="$(mktemp -d)"
packaged_entitlements="$(mktemp)"
trap 'rm -rf "$extracted_asar"; rm -f "$packaged_entitlements"' EXIT
node ../../node_modules/@electron/asar/bin/asar.mjs extract "$app/Contents/Resources/app.asar" "$extracted_asar"
npx --no-install asar extract "$app/Contents/Resources/app.asar" "$extracted_asar"
test -d "$extracted_asar/dist"
if grep -RIEq 'openWebShellWindow|web-shell-security|host\.open_session|inputMonitoring|installUrl|loadURL\(|@modelcontextprotocol|node:child_process' "$extracted_asar/dist"; then
echo '::error::Packaged app contains a forbidden external backend, process launcher, WebShell window, or Input Monitoring path.'
+12 -3
View File
@@ -4,8 +4,17 @@ name: 'Main CI Failure Issue'
on:
workflow_run:
workflows: ['E2E Tests', 'SDK Python']
# 'Qwen Code CI' is watched for its SCHEDULED run only (see the event
# filter below): that nightly is the macOS and Windows lanes' only
# trigger outside a pull request, and a lane nobody is told about is a
# lane nobody looks at — which is how those two came to stop running.
workflows: ['E2E Tests', 'SDK Python', 'Qwen Code CI']
types: ['completed']
# Trigger-level branch filter, not only the `if` below: 'Qwen Code CI'
# completes on every pull request, and without this each of those would
# raise a workflow_run event here just to skip. The job filter still
# enforces main — this only keeps the event log honest.
branches: ['main']
defaults:
run:
@@ -18,7 +27,7 @@ jobs:
# and hands the finished title and body to the privileged job as outputs.
analyze:
name: 'Identify the failing tests'
if: "${{ github.repository == 'QwenLM/qwen-code' && github.event.workflow_run.conclusion == 'failure' && github.event.workflow_run.head_branch == 'main' && github.event.workflow_run.event == 'push' }}"
if: "${{ github.repository == 'QwenLM/qwen-code' && github.event.workflow_run.conclusion == 'failure' && github.event.workflow_run.head_branch == 'main' && (github.event.workflow_run.event == 'push' || (github.event.workflow_run.event == 'schedule' && github.event.workflow_run.name == 'Qwen Code CI')) }}"
runs-on: 'ubuntu-latest'
timeout-minutes: 10
permissions:
@@ -33,7 +42,7 @@ jobs:
body: '${{ steps.plan.outputs.body }}'
steps:
- name: 'Checkout'
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
persist-credentials: false
+10
View File
@@ -40,6 +40,16 @@ fails until the number is updated in the same PR. Growing a file is still
allowed — the ratchet only insists the growth be visible in review rather than
discovered at the wall.
The ratchet's blast radius is scoped to the PR that earned it (#9904). The
comparison above is worktree-vs-checked-in-baseline, so a file that grew on
main without the same-PR bump would otherwise fail every unrelated open PR on
a file it never touched — that red-walled the queue twice in two weeks
(#9747, #9822). Given the PR's base commit (`WORKFLOW_SIZE_BASE_SHA`, wired
in `ci.yml`), the gate hard-fails only when the PR's copy of the file differs
from the base; a byte-identical copy means the staleness is main-side drift
and earns a warning pointing at the one-line baseline-bump PR instead. An
unresolvable base keeps the strict failure — the gate fails closed.
### Steps that moved out, not just their prose
`review-address` · `Push and report` was 626 lines of inline shell — ~41 KB,
+36 -14
View File
@@ -1318,13 +1318,15 @@ jobs:
if [[ -f "${WORKDIR}/failure.md" && -n "$(git status --porcelain)" ]]; then
echo "❌ Agent wrote failure.md after leaving a dirty workspace:"
git status --short
cat "${WORKDIR}/failure.md"
# Agent-written content on step stdout: neutralize both command
# syntaxes (`##[` parses mid-line too — #9761).
sed -e 's/::/;;/g' -e 's/##\[/##[/g' "${WORKDIR}/failure.md"
exit 1
fi
if [[ -f "${WORKDIR}/failure.md" ]]; then
echo "🛑 Agent aborted intentionally:"
cat "${WORKDIR}/failure.md"
sed -e 's/::/;;/g' -e 's/##\[/##[/g' "${WORKDIR}/failure.md"
exit 1
fi
@@ -1395,12 +1397,14 @@ jobs:
for f in decision.json pr-title.txt pr-body.md e2e-report.md failure.md failure.zh.md fix.diff; do
if [[ -f "${WORKDIR}/${f}" ]]; then
echo "=============== ${f} ==============="
# Agent-written content on step STDOUT: a line-start `::` would
# be parsed as a workflow command (::error::, ::add-mask::), the
# same reason the PR-lane dump loop neutralizes it. (The two
# step-SUMMARY loops write to a file, where `::` is not
# Agent-written content on step STDOUT: both workflow-command
# syntaxes parse here — a line-start `::` (::error::,
# ::add-mask::) AND `##[` even mid-line (a quoted
# `##[add-matcher]` fails THIS step; measured on #9761). The
# same reason the PR-lane dump loop neutralizes both. (The two
# step-SUMMARY loops write to a file, where commands are not
# parsed.)
sed 's/::/;;/g' "${WORKDIR}/${f}"
sed -e 's/::/;;/g' -e 's/##\[/##[/g' "${WORKDIR}/${f}"
echo
fi
done
@@ -4699,7 +4703,17 @@ jobs:
fi
} > "${WORKDIR}/feedback.md"
echo '--- feedback.md ---'
cat "${WORKDIR}/feedback.md"
# Reviewer/bot comment bodies ride this file VERBATIM and this echo
# puts them on step stdout, which the runner scans for workflow
# commands in BOTH syntaxes: `::name::` AND the legacy `##[name]`,
# the latter parsed MID-line too — a quoted `##[add-matcher]` makes
# the runner load the rest of the line as a matcher file and fail
# THIS step before the agent runs. Measured on #9761: a review
# finding about that injection channel carried the payload strings
# as its example text, and five consecutive pre-agent crashes
# burned the takeover window in ~70 minutes. Neutralize both
# prefixes like every other untrusted echo.
cat "${WORKDIR}/feedback.md" | sed -e 's/::/;;/g' -e 's/##\[/##[/g'
# The agent below runs for up to 130 minutes and the verification gate adds
# more, but nothing reaches the PR thread until "Push and report" at the
@@ -5072,7 +5086,9 @@ jobs:
else
echo "::warning::could not merge carried deferrals across the repair (one of the two sets is unparseable); keeping the carried set and preserving this round's as deferred-findings.unmerged.json. Raw content follows:"
fi
head -c 4000 "${WORKDIR}/deferred-findings.json" | sed 's/::/;;/g'
# Both command syntaxes, like every other untrusted echo
# (`##[` parses mid-line too — #9761).
head -c 4000 "${WORKDIR}/deferred-findings.json" | sed -e 's/::/;;/g' -e 's/##\[/##[/g'
echo
rm -f "${WORKDIR}/deferred-findings.carry.next"
# Keep the discarded set ON DISK so the warning's pointer at
@@ -5300,10 +5316,12 @@ jobs:
for f in feedback.md address-summary.md no-action.md failure.md failure.zh.md handoff.md gate-rejection.md gate-advisories.md growth-audit.json agent-api-error agent-api-error-kind agent-timeout resolved-comments.txt comment-replies.json deferred-findings.json deferred-findings.carry.json deferred-findings.unmerged.json pr.diff; do
if [[ -f "${WORKDIR}/${f}" ]]; then
echo "=============== ${f} ==============="
# Agent-written content: a line-start `::` would be parsed as a
# workflow command (::error::, ::add-mask::), the same reason
# every other echo of these files neutralizes it.
sed 's/::/;;/g' "${WORKDIR}/${f}"
# Agent/reviewer-written content: both workflow-command syntaxes
# parse here — a line-start `::` (::error::, ::add-mask::) AND
# `##[` even mid-line (a quoted `##[add-matcher]` fails THIS
# step; measured on #9761 via the prepare echo of this same
# file). Neutralize both, like every other echo of these files.
sed -e 's/::/;;/g' -e 's/##\[/##[/g' "${WORKDIR}/${f}"
echo
fi
done
@@ -5995,7 +6013,11 @@ jobs:
elif [[ "${_upsert_line}" == __upsert_trusted__* ]]; then
printf '%s\n' "${_upsert_line#__upsert_trusted__}"
else
printf '%s\n' "${_upsert_line//::/;;}"
# The canonical two-expression neutralizer, identical to
# every other echo site — one spelling for the whole
# family, so a syntax change cannot drift across two
# implementations (`##[` parses mid-line too — #9761).
printf '%s\n' "${_upsert_line}" | sed -e 's/::/;;/g' -e 's/##\[/##[/g'
fi
done <<< "${UPSERT_OUT}"
fi
+1 -1
View File
@@ -607,7 +607,7 @@ jobs:
run: |-
# Explicit allowlist: new channel workspaces require release approval.
PUBLISH_MARKER="$(mktemp)"
for channel in dingtalk feishu github qqbot telegram wecom weixin; do
for channel in dingtalk dws feishu github qqbot telegram wecom weixin; do
echo "::group::Publishing @qwen-code/channel-${channel}"
(
cd "${channel}"
-1
View File
@@ -23,4 +23,3 @@ Thumbs.db
packages/vscode-ide-companion/schemas/settings.schema.json
packages/cli/src/services/insight/templates/insightTemplate.ts
packages/cua-driver/
packages/desktop/
-218
View File
@@ -1,218 +0,0 @@
---
name: desktop-pet
description: Create pixel-art desktop pet companions for Qwen Code. Generates a customized chibi spritesheet (1536×1872, 8×9 grid) for any character the user names — F1 drivers, anime characters, celebrities, fictional characters, animals, etc. Use when the user says "desktop pet", "桌宠", "桌面宠物", "想要XXX当桌宠", "换个宠物", or similar.
---
# Desktop Pet Creator
Create pixel-art chibi desktop pet companions for Qwen Code's floating pet window.
Given any character name, generate a complete pet package with animated spritesheet
and place it in `~/.qwen/pets/` where Qwen Code auto-discovers it.
## Prerequisites
- Python 3 with Pillow (`pip3 install Pillow`). Check before running the script:
```bash
python3 -c "from PIL import Image; print('OK')" 2>/dev/null || echo "Pillow not installed — run: pip3 install Pillow"
```
## Step 1: Identify the Character
Ask the user who they want as their desktop pet if not already specified.
Then research the character's visual appearance:
- **Team/organization colors** (e.g., McLaren papaya orange, Ferrari red)
- **Outfit/uniform** (racing suit, school uniform, armor, etc.)
- **Distinguishing features** (hair color/style, accessories, number, helmet)
- **Personality traits** (for animation style — energetic, calm, goofy, serious)
- **Iconic items** (steering wheel, lightsaber, guitar, etc.)
Use web search if needed. For well-known characters (F1 drivers, popular anime,
etc.), rely on training knowledge.
## Step 2: Design the Color Palette
Define 8–12 colors for the character. All colors must be distinct and work at
small pixel scale (3× = 9 px details).
| Color Role | Example (F1 Driver) | Example (Anime) |
|---|---|---|
| `outfit` | Team color `[255,135,32]` | Uniform `[30,30,50]` |
| `outfit_dark` | Darker shade | Darker shade |
| `outfit_light` | Lighter shade | Lighter shade |
| `skin` | Warm skin tone | Skin tone |
| `skin_dark` | Shadow skin | Shadow skin |
| `hair` | Character hair color | Character hair color |
| `accent` | Number/logo color | Eye color |
| `shoe` | Dark grey/black | Shoe color |
## Step 3: Generate the Spritesheet
Run the generation script. Always resolve the path relative to the skill's base
directory:
```bash
python3 <skill_dir>/scripts/gen_spritesheet.py \
--output ~/.qwen/pets/<character_id>/spritesheet.webp \
--config '{"colors":{...},"features":{...}}'
```
**Atlas format:** 1536×1872 px, RGBA, 8 cols × 9 rows, 192×208 px cells.
**Animation rows:**
| Row | State | Description |
|---|---|---|
| 0 | idle | Breathing + blinking (8 frames) |
| 1 | running-right | Running to the right (8 frames) |
| 2 | running-left | Running to the left (8 frames) |
| 3 | waving | Waving at user (8 frames) |
| 4 | jumping | Jumping celebration (8 frames) |
| 5 | failed | Sad/collapsed on error (8 frames) |
| 6 | waiting | Idle tapping (8 frames) |
| 7 | running | Generic running (8 frames) |
| 8 | review | Thinking/examining (8 frames) |
## Step 4: Create `pet.json`
Write the manifest to `~/.qwen/pets/<character_id>/pet.json`:
```json
{
"id": "<character_id>",
"displayName": "<Display Name>",
"description": "<Short description — who is this character?>",
"spritesheetPath": "spritesheet.webp"
}
```
Rules:
- `id`: lowercase, no spaces, URL-safe (e.g., `piastri`, `satoru`, `goku`)
- `displayName`: The name shown in the UI (e.g., "Piastri", "五条悟", "悟空")
- `description`: One short sentence describing the character
## Step 5: Verify and Activate
1. Confirm the files exist:
```bash
ls -lh ~/.qwen/pets/<character_id>/
```
2. Open the spritesheet for the user to check:
```bash
open ~/.qwen/pets/<character_id>/spritesheet.webp
```
3. Tell the user to activate: open Qwen Code **Settings → Appearance → Pet
Companion**, click **Refresh**, then select the new pet.
## Design Guidelines
### Chibi Proportions
- **Head**: ~40% of total height (big head = cute)
- **Body**: ~30% of total height
- **Legs**: ~25% of total height
- **Scale**: Each "pixel" in the art = 3×3 actual pixels (scale=3)
- **Character center**: approximately (96, 124) within the 192×208 cell
### Drawing Order (back to front)
1. Legs (behind body)
2. Body / outfit
3. Arms
4. Head shape
5. Hair (back layer)
6. Hair (front/top layer)
7. Face features (eyes, mouth, expression)
8. Accessories (hat, helmet, glasses, etc.)
9. Foreground details (number, logo, badge)
### Animation Tips
- **Idle**: subtle Y bob (0 to −2 px) + blink every 3rd–4th frame
- **Running**: alternating leg offset (±4 px), body tilt (±2 px), arm swing
- **Waving**: one arm raised high, alternating frames
- **Jumping**: Y offset curve (0 → −30 → 0), arms up
- **Failed**: body tilt increases, then collapse to sitting pose
- **Happy expression**: curved eyes (∧ shape), blush marks on cheeks
- **Sad expression**: straight eyebrows, downturned mouth
### Headgear Options (`features.headgear`)
`cap` · `helmet` · `hat` · `hood` · `crown` · `horns` · `ears` · `halo` · `headband` · `none`
### Hair Styles (`features.hair_style`)
`short` · `long` · `spiky` · `ponytail` · `bald`
### Extras (`features.extras` list)
`glasses` · `scarf` · `tail` · `wings` · `number` (set `features.number`) · `logo` · `sweat_drop`
## Example Characters
### F1 Driver (e.g., Piastri)
```json
{
"colors": {
"outfit": [255, 135, 32],
"outfit_dark": [220, 110, 20],
"outfit_light": [255, 170, 80],
"hair": [120, 80, 40],
"accent": [30, 30, 30]
},
"features": {
"headgear": "cap",
"number": "81",
"extras": ["logo"]
}
}
```
### Anime Character (e.g., Gojo Satoru)
```json
{
"colors": {
"outfit": [30, 30, 50],
"outfit_dark": [20, 20, 35],
"outfit_light": [60, 60, 80],
"hair": [230, 230, 250],
"accent": [100, 180, 255]
},
"features": {
"headgear": "none",
"hair_style": "spiky",
"extras": ["glasses"]
}
}
```
### Animal (e.g., Shiba Inu)
```json
{
"colors": {
"outfit": [220, 170, 100],
"outfit_dark": [180, 130, 70],
"outfit_light": [240, 200, 140],
"hair": [220, 170, 100]
},
"features": {
"headgear": "ears",
"extras": ["tail"]
}
}
```
## Troubleshooting
- **Pet not showing**: Click Refresh in Settings → Appearance → Pet Companion
- **Colors look wrong**: Check that RGB values are tuples, not hex strings
- **Spritesheet too large**: Must be under 5 MB (webp lossless usually ~8–50 KB)
- **Animation jittery**: Ensure all 8 frames per row are visually distinct but not jarring
@@ -1,558 +0,0 @@
#!/usr/bin/env python3
"""
Desktop Pet Spritesheet Generator for OpenWork.
Generates a 1536×1872 pixel-art chibi spritesheet (8 cols × 9 rows, 192×208 px cells)
customized via a JSON config describing colors, headgear, and features.
Usage:
python3 gen_spritesheet.py --output ~/.qwen/pets/mychar/spritesheet.webp --config '{...}'
python3 gen_spritesheet.py --output out.webp --config-file config.json
"""
import argparse
import json
import sys
import math
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError:
print("ERROR: Pillow is required. Install with: pip3 install Pillow")
sys.exit(1)
# --- Atlas layout ---
COLS, ROWS = 8, 9
CW, CH = 192, 208
W, H = COLS * CW, ROWS * CH
# --- Default color palette (Qwen capybara-like neutral) ---
DEFAULT_COLORS = {
"outfit": [100, 120, 180],
"outfit_dark": [70, 85, 140],
"outfit_light": [140, 160, 210],
"skin": [255, 218, 185],
"skin_dark": [230, 190, 155],
"hair": [80, 55, 35],
"hair_light": [110, 80, 55],
"accent": [30, 30, 30],
"shoe": [50, 50, 50],
"eye": [50, 70, 90],
"eye_white": [245, 245, 245],
"blush": [255, 160, 140],
"mouth": [180, 80, 80],
}
DEFAULT_FEATURES = {
"headgear": "none",
"extras": [],
"number": "",
"hair_style": "short",
"helmet_color": None,
}
def tuple_color(c):
if isinstance(c, list):
return tuple(c)
if isinstance(c, str) and c.startswith("#"):
h = c.lstrip("#")
return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
return c
def darken(color, amount=40):
return tuple(max(0, c - amount) for c in color[:3])
def lighten(color, amount=40):
return tuple(min(255, c + amount) for c in color[:3])
def fill_rect(draw, x, y, w, h, color):
draw.rectangle([x, y, x + w - 1, y + h - 1], fill=color)
def draw_headgear(draw, hx, hy, s, colors, features, flip=False):
"""Draw headgear on top of the head."""
hg = features.get("headgear", "none")
outfit = colors["outfit"]
outfit_dark = colors["outfit_dark"]
helmet_c = tuple_color(features.get("helmet_color") or outfit)
if hg == "cap":
cap_y = hy - 7 * s
fill_rect(draw, hx - 2*s, cap_y, 24*s, 5*s, outfit)
fill_rect(draw, hx + 2*s, cap_y - 2*s, 16*s, 3*s, outfit)
fill_rect(draw, hx + 4*s, cap_y - 3*s, 12*s, 2*s, outfit_dark)
if not flip:
fill_rect(draw, hx - 4*s, cap_y + 4*s, 26*s, 2*s, outfit_dark)
else:
fill_rect(draw, hx - 2*s, cap_y + 4*s, 26*s, 2*s, outfit_dark)
fill_rect(draw, hx + 7*s, cap_y + s, 6*s, 2*s, colors["accent"])
elif hg == "helmet":
cap_y = hy - 8 * s
fill_rect(draw, hx - 3*s, cap_y, 26*s, 8*s, helmet_c)
fill_rect(draw, hx + 2*s, cap_y - 2*s, 16*s, 3*s, helmet_c)
fill_rect(draw, hx + 4*s, cap_y - 3*s, 12*s, 2*s, darken(helmet_c, 20))
fill_rect(draw, hx, cap_y + 5*s, 20*s, 3*s, darken(helmet_c, 60))
fill_rect(draw, hx + 2*s, cap_y + 6*s, 16*s, s, lighten(helmet_c, 60))
elif hg == "hat":
cap_y = hy - 6 * s
fill_rect(draw, hx - 4*s, cap_y + 3*s, 28*s, 3*s, outfit_dark)
fill_rect(draw, hx + 2*s, cap_y - 2*s, 16*s, 6*s, outfit)
fill_rect(draw, hx + 4*s, cap_y - 3*s, 12*s, 2*s, outfit_dark)
elif hg == "hood":
cap_y = hy - 5 * s
fill_rect(draw, hx - 3*s, cap_y, 26*s, 4*s, outfit)
fill_rect(draw, hx - 4*s, cap_y + 2*s, 4*s, 8*s, outfit)
fill_rect(draw, hx + 20*s, cap_y + 2*s, 4*s, 8*s, outfit)
fill_rect(draw, hx + 4*s, cap_y - 2*s, 12*s, 3*s, outfit_dark)
elif hg == "crown":
cap_y = hy - 8 * s
gold = (255, 215, 0)
gold_dark = (200, 170, 0)
fill_rect(draw, hx + 2*s, cap_y + 2*s, 16*s, 4*s, gold)
fill_rect(draw, hx + 2*s, cap_y, 3*s, 3*s, gold)
fill_rect(draw, hx + 8*s, cap_y - s, 4*s, 3*s, gold)
fill_rect(draw, hx + 15*s, cap_y, 3*s, 3*s, gold)
fill_rect(draw, hx + 3*s, cap_y + s, s, s, (200, 50, 50))
fill_rect(draw, hx + 9*s, cap_y, 2*s, s, (50, 150, 200))
fill_rect(draw, hx + 16*s, cap_y + s, s, s, (50, 200, 50))
fill_rect(draw, hx + 2*s, cap_y + 5*s, 16*s, s, gold_dark)
elif hg == "horns":
horn_c = (80, 60, 50)
fill_rect(draw, hx - 2*s, hy - 6*s, 3*s, 8*s, horn_c)
fill_rect(draw, hx - 3*s, hy - 8*s, 2*s, 3*s, horn_c)
fill_rect(draw, hx + 19*s, hy - 6*s, 3*s, 8*s, horn_c)
fill_rect(draw, hx + 21*s, hy - 8*s, 2*s, 3*s, horn_c)
elif hg == "ears":
hair_c = colors["hair"]
inner = colors["skin"]
fill_rect(draw, hx - 3*s, hy - 8*s, 5*s, 8*s, hair_c)
fill_rect(draw, hx - 2*s, hy - 6*s, 3*s, 5*s, inner)
fill_rect(draw, hx + 18*s, hy - 8*s, 5*s, 8*s, hair_c)
fill_rect(draw, hx + 19*s, hy - 6*s, 3*s, 5*s, inner)
elif hg == "halo":
halo_c = (255, 255, 200)
cap_y = hy - 10 * s
fill_rect(draw, hx + 3*s, cap_y, 14*s, 2*s, halo_c)
fill_rect(draw, hx + 2*s, cap_y + s, s, s, halo_c)
fill_rect(draw, hx + 17*s, cap_y + s, s, s, halo_c)
fill_rect(draw, hx + 3*s, cap_y + 2*s, 14*s, s, darken(halo_c, 30))
elif hg == "headband":
fill_rect(draw, hx - 2*s, hy - 2*s, 24*s, 2*s, colors["accent"])
fill_rect(draw, hx + 18*s, hy - 2*s, 4*s, 6*s, colors["accent"])
def draw_extras(draw, cx, cy, s, colors, features, bx, by, expression):
"""Draw extra features like glasses, scarf, tail, wings."""
extras = features.get("extras", [])
hx = cx - 10 * s
hy = cy - 16 * s
if "glasses" in extras:
ey = hy + 8 * s
glass_c = (60, 60, 80)
fill_rect(draw, hx + 3*s, ey - s, 6*s, 5*s, glass_c)
fill_rect(draw, hx + 4*s, ey, 4*s, 3*s, (200, 220, 240))
fill_rect(draw, hx + 11*s, ey - s, 6*s, 5*s, glass_c)
fill_rect(draw, hx + 12*s, ey, 4*s, 3*s, (200, 220, 240))
fill_rect(draw, hx + 9*s, ey + s, 2*s, s, glass_c)
if "scarf" in extras:
scarf_c = lighten(colors["outfit"], 30)
fill_rect(draw, bx + 3*s, by - 2*s, 10*s, 3*s, scarf_c)
fill_rect(draw, bx + 4*s, by + s, 3*s, 6*s, scarf_c)
if "tail" in extras:
tail_c = colors["hair"]
fill_rect(draw, bx + 16*s, by + 10*s, 3*s, 3*s, tail_c)
fill_rect(draw, bx + 18*s, by + 8*s, 3*s, 3*s, tail_c)
fill_rect(draw, bx + 20*s, by + 6*s, 3*s, 3*s, tail_c)
fill_rect(draw, bx + 21*s, by + 4*s, 2*s, 3*s, tail_c)
if "wings" in extras:
wing_c = (240, 240, 255)
wing_dark = (200, 200, 220)
fill_rect(draw, bx - 6*s, by + 2*s, 5*s, 8*s, wing_c)
fill_rect(draw, bx - 8*s, by + 4*s, 3*s, 5*s, wing_c)
fill_rect(draw, bx - 5*s, by + 3*s, 3*s, 5*s, wing_dark)
fill_rect(draw, bx + 17*s, by + 2*s, 5*s, 8*s, wing_c)
fill_rect(draw, bx + 21*s, by + 4*s, 3*s, 5*s, wing_c)
fill_rect(draw, bx + 18*s, by + 3*s, 3*s, 5*s, wing_dark)
if "sweat_drop" in extras and expression in ("waiting", "failed"):
fill_rect(draw, hx + 18*s, hy + 2*s, 2*s, 3*s, (150, 200, 255))
fill_rect(draw, hx + 18*s, hy + s, s, s, (150, 200, 255))
def draw_character(draw, cx, cy, colors, features, scale=3, flip=False,
arm_angle=0, leg_offset=0, body_tilt=0, head_tilt=0,
expression="normal", arm_wave=False, jump_y=0, collapsed=False):
"""Draw a chibi character centered at (cx, cy)."""
s = scale
cy += jump_y
skin = colors["skin"]
skin_dark = colors["skin_dark"]
hair_c = colors["hair"]
hair_light = colors.get("hair_light", lighten(hair_c, 30))
outfit = colors["outfit"]
outfit_dark = colors["outfit_dark"]
outfit_light = colors["outfit_light"]
eye_c = colors["eye"]
eye_w = colors["eye_white"]
blush_c = colors["blush"]
mouth_c = colors.get("mouth", darken(skin, 80))
accent_c = colors["accent"]
shoe_c = colors["shoe"]
# --- LEGS ---
leg_spread = 4 * s
leg_left_x = cx - leg_spread - 2*s + body_tilt
leg_right_x = cx + leg_spread - 2*s + body_tilt
if collapsed:
fill_rect(draw, leg_left_x, cy + 18*s, 5*s, 6*s, outfit_dark)
fill_rect(draw, leg_right_x, cy + 18*s, 5*s, 6*s, outfit_dark)
fill_rect(draw, leg_left_x, cy + 24*s, 5*s, 2*s, shoe_c)
fill_rect(draw, leg_right_x, cy + 24*s, 5*s, 2*s, shoe_c)
else:
fill_rect(draw, leg_left_x + leg_offset, cy + 16*s, 5*s, 10*s, outfit_dark)
fill_rect(draw, leg_right_x - leg_offset, cy + 16*s, 5*s, 10*s, outfit_dark)
fill_rect(draw, leg_left_x + leg_offset - s, cy + 26*s, 7*s, 3*s, shoe_c)
fill_rect(draw, leg_right_x - leg_offset - s, cy + 26*s, 7*s, 3*s, shoe_c)
# --- BODY ---
bx = cx - 8*s + body_tilt
by = cy + 2*s
fill_rect(draw, bx, by, 16*s, 16*s, outfit)
fill_rect(draw, bx + s, by + s, 14*s, 2*s, outfit_light)
fill_rect(draw, bx + 5*s, by - s, 6*s, 2*s, (255, 255, 255))
# Number on chest
num = features.get("number", "")
if num:
try:
font_size = max(5 * s, 8)
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", font_size)
except Exception:
font = ImageFont.load_default()
bbox = draw.textbbox((0, 0), num, font=font)
tw = bbox[2] - bbox[0]
th = bbox[3] - bbox[1]
tx = bx + (16*s - tw) // 2
ty = by + (14*s - th) // 2 + s
draw.text((tx, ty), num, fill=accent_c, font=font)
# Logo area
if "logo" in features.get("extras", []) and not num:
fill_rect(draw, bx + 5*s, by + 5*s, 6*s, 4*s, accent_c)
fill_rect(draw, bx + 6*s, by + 6*s, 4*s, 2*s, outfit_light)
# --- ARMS ---
arm_y = by + 3*s
left_arm_x = bx - 5*s
right_arm_x = bx + 16*s
if arm_wave:
fill_rect(draw, left_arm_x, arm_y + 2*s, 5*s, 8*s, outfit)
fill_rect(draw, left_arm_x - s, arm_y + 10*s, 5*s, 4*s, skin)
fill_rect(draw, right_arm_x, arm_y - 8*s, 5*s, 10*s, outfit)
fill_rect(draw, right_arm_x, arm_y - 10*s, 6*s, 4*s, skin)
elif arm_angle > 0:
fill_rect(draw, left_arm_x - arm_angle, arm_y, 5*s, 10*s, outfit)
fill_rect(draw, left_arm_x - arm_angle - s, arm_y + 10*s, 5*s, 4*s, skin)
fill_rect(draw, right_arm_x + arm_angle, arm_y, 5*s, 10*s, outfit)
fill_rect(draw, right_arm_x + arm_angle + s, arm_y + 10*s, 5*s, 4*s, skin)
else:
fill_rect(draw, left_arm_x, arm_y, 5*s, 10*s, outfit)
fill_rect(draw, left_arm_x - s, arm_y + 10*s, 5*s, 4*s, skin)
fill_rect(draw, right_arm_x, arm_y, 5*s, 10*s, outfit)
fill_rect(draw, right_arm_x + s, arm_y + 10*s, 5*s, 4*s, skin)
# --- Scarf (drawn between body and head) ---
draw_extras(draw, cx, cy, s, colors, features, bx, by, expression)
# --- HEAD ---
hx = cx - 10*s + head_tilt
hy = cy - 16*s
# Hair back
fill_rect(draw, hx - s, hy - 2*s, 22*s, 6*s, hair_c)
# Head shape
fill_rect(draw, hx, hy, 20*s, 18*s, skin)
# Hair style
hair_style = features.get("hair_style", "short")
if hair_style == "long":
fill_rect(draw, hx - s, hy - 4*s, 22*s, 6*s, hair_c)
fill_rect(draw, hx + 2*s, hy - 5*s, 16*s, 3*s, hair_c)
fill_rect(draw, hx + 4*s, hy - 6*s, 12*s, 2*s, hair_light)
fill_rect(draw, hx - 2*s, hy, 3*s, 16*s, hair_c)
fill_rect(draw, hx + 19*s, hy, 3*s, 16*s, hair_c)
fill_rect(draw, hx - 3*s, hy + 14*s, 4*s, 4*s, hair_c)
fill_rect(draw, hx + 19*s, hy + 14*s, 4*s, 4*s, hair_c)
elif hair_style == "spiky":
fill_rect(draw, hx - s, hy - 4*s, 22*s, 6*s, hair_c)
fill_rect(draw, hx + s, hy - 7*s, 4*s, 4*s, hair_c)
fill_rect(draw, hx + 6*s, hy - 8*s, 4*s, 5*s, hair_c)
fill_rect(draw, hx + 11*s, hy - 7*s, 4*s, 4*s, hair_c)
fill_rect(draw, hx + 16*s, hy - 6*s, 3*s, 3*s, hair_c)
fill_rect(draw, hx - 2*s, hy, 3*s, 10*s, hair_c)
fill_rect(draw, hx + 19*s, hy, 3*s, 10*s, hair_c)
elif hair_style == "ponytail":
fill_rect(draw, hx - s, hy - 4*s, 22*s, 6*s, hair_c)
fill_rect(draw, hx + 2*s, hy - 5*s, 16*s, 3*s, hair_c)
fill_rect(draw, hx - 2*s, hy, 3*s, 10*s, hair_c)
fill_rect(draw, hx + 19*s, hy, 3*s, 10*s, hair_c)
fill_rect(draw, hx + 18*s, hy + 8*s, 3*s, 3*s, hair_c)
fill_rect(draw, hx + 19*s, hy + 10*s, 3*s, 8*s, hair_c)
fill_rect(draw, hx + 20*s, hy + 16*s, 2*s, 4*s, hair_light)
elif hair_style == "bald":
fill_rect(draw, hx + 2*s, hy - 2*s, 16*s, 2*s, skin_dark)
else: # short (default)
fill_rect(draw, hx - s, hy - 4*s, 22*s, 6*s, hair_c)
fill_rect(draw, hx + 2*s, hy - 5*s, 16*s, 3*s, hair_c)
fill_rect(draw, hx + 4*s, hy - 6*s, 12*s, 2*s, hair_light)
fill_rect(draw, hx - 2*s, hy, 3*s, 10*s, hair_c)
fill_rect(draw, hx + 19*s, hy, 3*s, 10*s, hair_c)
# --- FACE ---
ey = hy + 8*s
if expression == "blink":
fill_rect(draw, hx + 4*s, ey + s, 4*s, s, eye_c)
fill_rect(draw, hx + 12*s, ey + s, 4*s, s, eye_c)
elif expression == "happy":
fill_rect(draw, hx + 4*s, ey, 4*s, s, eye_c)
fill_rect(draw, hx + 3*s, ey + s, s, s, eye_c)
fill_rect(draw, hx + 8*s, ey + s, s, s, eye_c)
fill_rect(draw, hx + 12*s, ey, 4*s, s, eye_c)
fill_rect(draw, hx + 11*s, ey + s, s, s, eye_c)
fill_rect(draw, hx + 16*s, ey + s, s, s, eye_c)
fill_rect(draw, hx + 7*s, ey + 5*s, 6*s, s, mouth_c)
fill_rect(draw, hx + 6*s, ey + 4*s, s, s, mouth_c)
fill_rect(draw, hx + 13*s, ey + 4*s, s, s, mouth_c)
fill_rect(draw, hx + 2*s, ey + 3*s, 3*s, 2*s, blush_c)
fill_rect(draw, hx + 15*s, ey + 3*s, 3*s, 2*s, blush_c)
elif expression == "sad":
fill_rect(draw, hx + 4*s, ey, 4*s, 3*s, eye_w)
fill_rect(draw, hx + 5*s, ey + s, 2*s, 2*s, eye_c)
fill_rect(draw, hx + 12*s, ey, 4*s, 3*s, eye_w)
fill_rect(draw, hx + 13*s, ey + s, 2*s, 2*s, eye_c)
fill_rect(draw, hx + 3*s, ey - 2*s, 5*s, s, hair_c)
fill_rect(draw, hx + 12*s, ey - 2*s, 5*s, s, hair_c)
fill_rect(draw, hx + 8*s, ey + 5*s, 4*s, s, mouth_c)
fill_rect(draw, hx + 7*s, ey + 6*s, s, s, mouth_c)
fill_rect(draw, hx + 12*s, ey + 6*s, s, s, mouth_c)
elif expression == "surprised":
fill_rect(draw, hx + 3*s, ey - s, 5*s, 4*s, eye_w)
fill_rect(draw, hx + 4*s, ey, 3*s, 3*s, eye_c)
fill_rect(draw, hx + 5*s, ey + s, s, s, (255, 255, 255))
fill_rect(draw, hx + 12*s, ey - s, 5*s, 4*s, eye_w)
fill_rect(draw, hx + 13*s, ey, 3*s, 3*s, eye_c)
fill_rect(draw, hx + 14*s, ey + s, s, s, (255, 255, 255))
fill_rect(draw, hx + 8*s, ey + 4*s, 4*s, 3*s, mouth_c)
fill_rect(draw, hx + 9*s, ey + 5*s, 2*s, s, (180, 80, 80))
elif expression == "determined":
fill_rect(draw, hx + 4*s, ey, 4*s, 3*s, eye_w)
fill_rect(draw, hx + 6*s, ey + s, 2*s, 2*s, eye_c)
fill_rect(draw, hx + 12*s, ey, 4*s, 3*s, eye_w)
fill_rect(draw, hx + 14*s, ey + s, 2*s, 2*s, eye_c)
fill_rect(draw, hx + 3*s, ey - 2*s, 6*s, s, hair_c)
fill_rect(draw, hx + 11*s, ey - 2*s, 6*s, s, hair_c)
fill_rect(draw, hx + 8*s, ey + 5*s, 4*s, s, mouth_c)
else: # normal
fill_rect(draw, hx + 4*s, ey, 4*s, 3*s, eye_w)
fill_rect(draw, hx + 5*s, ey + s, 2*s, 2*s, eye_c)
fill_rect(draw, hx + 5*s, ey + s, s, s, (255, 255, 255))
fill_rect(draw, hx + 12*s, ey, 4*s, 3*s, eye_w)
fill_rect(draw, hx + 13*s, ey + s, 2*s, 2*s, eye_c)
fill_rect(draw, hx + 13*s, ey + s, s, s, (255, 255, 255))
fill_rect(draw, hx + 8*s, ey + 5*s, 4*s, s, mouth_c)
fill_rect(draw, hx + 7*s, ey + 4*s, s, s, mouth_c)
fill_rect(draw, hx + 12*s, ey + 4*s, s, s, mouth_c)
# --- HEADGEAR ---
draw_headgear(draw, hx, hy, s, colors, features, flip)
def gen_row(sheet, row, colors, features, frame_configs):
"""Generate one animation row from frame configs."""
for col, fc in enumerate(frame_configs):
img = Image.new("RGBA", (CW, CH), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
draw_character(
d, CW // 2 + fc.get("dx", 0), CH // 2 + 20,
colors, features, scale=3,
flip=fc.get("flip", False),
arm_angle=fc.get("arm_angle", 0),
leg_offset=fc.get("leg_offset", 0),
body_tilt=fc.get("body_tilt", 0),
head_tilt=fc.get("head_tilt", 0),
expression=fc.get("expression", "normal"),
arm_wave=fc.get("arm_wave", False),
jump_y=fc.get("jump_y", 0),
collapsed=fc.get("collapsed", False),
)
sheet.paste(img, (col * CW, row * CH), img)
def gen_all_animations(sheet, colors, features):
"""Generate all 9 animation rows."""
# Row 0: idle
gen_row(sheet, 0, colors, features, [
{"jump_y": 0, "expression": "normal"},
{"jump_y": -1, "expression": "normal"},
{"jump_y": -2, "expression": "blink"},
{"jump_y": -1, "expression": "normal"},
{"jump_y": 0, "expression": "normal"},
{"jump_y": -1, "expression": "normal"},
{"jump_y": 0, "expression": "normal"},
{"jump_y": 0, "expression": "blink"},
])
# Row 1: running right
gen_row(sheet, 1, colors, features, [
{"leg_offset": 0, "body_tilt": 0, "head_tilt": 0, "arm_angle": 0, "jump_y": 0, "dx": 8},
{"leg_offset": 4, "body_tilt": 1, "head_tilt": 1, "arm_angle": 2, "jump_y": -2, "dx": 8},
{"leg_offset": 0, "body_tilt": 2, "head_tilt": 2, "arm_angle": 3, "jump_y": -3, "dx": 8},
{"leg_offset": -4, "body_tilt": 1, "head_tilt": 1, "arm_angle": 2, "jump_y": -2, "dx": 8},
{"leg_offset": 0, "body_tilt": 0, "head_tilt": 0, "arm_angle": 0, "jump_y": 0, "dx": 8},
{"leg_offset": 4, "body_tilt": -1, "head_tilt": -1, "arm_angle": -2, "jump_y": -2, "dx": 8},
{"leg_offset": 0, "body_tilt": -2, "head_tilt": -2, "arm_angle": -3, "jump_y": -3, "dx": 8},
{"leg_offset": -4, "body_tilt": -1, "head_tilt": -1, "arm_angle": -2, "jump_y": -2, "dx": 8},
])
# Row 2: running left
gen_row(sheet, 2, colors, features, [
{"leg_offset": 0, "body_tilt": 0, "head_tilt": 0, "arm_angle": 0, "jump_y": 0, "flip": True, "dx": -8},
{"leg_offset": 4, "body_tilt": -1, "head_tilt": -1, "arm_angle": 2, "jump_y": -2, "flip": True, "dx": -8},
{"leg_offset": 0, "body_tilt": -2, "head_tilt": -2, "arm_angle": 3, "jump_y": -3, "flip": True, "dx": -8},
{"leg_offset": -4, "body_tilt": -1, "head_tilt": -1, "arm_angle": 2, "jump_y": -2, "flip": True, "dx": -8},
{"leg_offset": 0, "body_tilt": 0, "head_tilt": 0, "arm_angle": 0, "jump_y": 0, "flip": True, "dx": -8},
{"leg_offset": 4, "body_tilt": 1, "head_tilt": 1, "arm_angle": -2, "jump_y": -2, "flip": True, "dx": -8},
{"leg_offset": 0, "body_tilt": 2, "head_tilt": 2, "arm_angle": -3, "jump_y": -3, "flip": True, "dx": -8},
{"leg_offset": -4, "body_tilt": 1, "head_tilt": 1, "arm_angle": -2, "jump_y": -2, "flip": True, "dx": -8},
])
# Row 3: waving
gen_row(sheet, 3, colors, features, [
{"arm_wave": True, "jump_y": 0, "expression": "happy"},
{"arm_wave": False, "jump_y": -1, "expression": "happy"},
{"arm_wave": True, "jump_y": 0, "expression": "happy"},
{"arm_wave": False, "jump_y": -1, "expression": "happy"},
{"arm_wave": True, "jump_y": 0, "expression": "happy"},
{"arm_wave": False, "jump_y": 0, "expression": "happy"},
{"arm_wave": True, "jump_y": 0, "expression": "normal"},
{"arm_wave": False, "jump_y": 0, "expression": "normal"},
])
# Row 4: jumping
gen_row(sheet, 4, colors, features, [
{"jump_y": 0, "arm_angle": 0, "expression": "normal"},
{"jump_y": -8, "arm_angle": 3, "expression": "happy"},
{"jump_y": -20, "arm_angle": 5, "expression": "happy"},
{"jump_y": -30, "arm_angle": 5, "expression": "happy"},
{"jump_y": -35, "arm_angle": 5, "expression": "happy"},
{"jump_y": -25, "arm_angle": 3, "expression": "happy"},
{"jump_y": -10, "arm_angle": 0, "expression": "happy"},
{"jump_y": 0, "arm_angle": 0, "expression": "normal"},
])
# Row 5: failed
gen_row(sheet, 5, colors, features, [
{"body_tilt": 0, "head_tilt": 0, "expression": "sad"},
{"body_tilt": -1, "head_tilt": -2, "expression": "sad"},
{"body_tilt": -2, "head_tilt": -4, "expression": "sad"},
{"body_tilt": -3, "head_tilt": -6, "expression": "sad"},
{"body_tilt": -3, "head_tilt": -6, "expression": "sad", "collapsed": True},
{"body_tilt": -2, "head_tilt": -4, "expression": "sad", "collapsed": True},
{"body_tilt": -1, "head_tilt": -2, "expression": "sad"},
{"body_tilt": 0, "head_tilt": 0, "expression": "sad"},
])
# Row 6: waiting
gen_row(sheet, 6, colors, features, [
{"jump_y": 0, "head_tilt": 0, "expression": "normal"},
{"jump_y": -1, "head_tilt": 0, "expression": "normal"},
{"jump_y": 0, "head_tilt": 2, "expression": "normal"},
{"jump_y": -1, "head_tilt": 0, "expression": "normal"},
{"jump_y": 0, "head_tilt": -2, "expression": "blink"},
{"jump_y": 0, "head_tilt": 0, "expression": "blink"},
{"jump_y": 0, "head_tilt": 0, "expression": "normal"},
{"jump_y": 0, "head_tilt": 0, "expression": "normal"},
])
# Row 7: running (generic, same as row 1)
gen_row(sheet, 7, colors, features, [
{"leg_offset": 0, "body_tilt": 0, "head_tilt": 0, "arm_angle": 0, "jump_y": 0, "dx": 8},
{"leg_offset": 4, "body_tilt": 1, "head_tilt": 1, "arm_angle": 2, "jump_y": -2, "dx": 8},
{"leg_offset": 0, "body_tilt": 2, "head_tilt": 2, "arm_angle": 3, "jump_y": -3, "dx": 8},
{"leg_offset": -4, "body_tilt": 1, "head_tilt": 1, "arm_angle": 2, "jump_y": -2, "dx": 8},
{"leg_offset": 0, "body_tilt": 0, "head_tilt": 0, "arm_angle": 0, "jump_y": 0, "dx": 8},
{"leg_offset": 4, "body_tilt": -1, "head_tilt": -1, "arm_angle": -2, "jump_y": -2, "dx": 8},
{"leg_offset": 0, "body_tilt": -2, "head_tilt": -2, "arm_angle": -3, "jump_y": -3, "dx": 8},
{"leg_offset": -4, "body_tilt": -1, "head_tilt": -1, "arm_angle": -2, "jump_y": -2, "dx": 8},
])
# Row 8: review/thinking
gen_row(sheet, 8, colors, features, [
{"head_tilt": 0, "arm_angle": 2, "expression": "surprised"},
{"head_tilt": 2, "arm_angle": 2, "expression": "surprised", "jump_y": -1},
{"head_tilt": 4, "arm_angle": 2, "expression": "surprised", "jump_y": -1},
{"head_tilt": 4, "arm_angle": 0, "expression": "normal"},
{"head_tilt": 2, "arm_angle": 0, "expression": "happy"},
{"head_tilt": 0, "arm_angle": 0, "expression": "happy"},
{"head_tilt": 0, "arm_angle": 0, "expression": "normal"},
{"head_tilt": 0, "arm_angle": 0, "expression": "normal"},
])
def main():
parser = argparse.ArgumentParser(description="Generate desktop pet spritesheet")
parser.add_argument("--output", "-o", required=True, help="Output .webp file path")
parser.add_argument("--config", "-c", type=str, help="JSON config string")
parser.add_argument("--config-file", "-f", help="Path to JSON config file")
args = parser.parse_args()
config = {}
if args.config_file:
with open(args.config_file) as f:
config = json.load(f)
elif args.config:
config = json.loads(args.config)
# Merge colors with defaults (convert all to tuples)
colors = {k: tuple_color(v) for k, v in DEFAULT_COLORS.items()}
for k, v in config.get("colors", {}).items():
colors[k] = tuple_color(v)
# Auto-derive missing colors
if "hair_light" not in config.get("colors", {}):
colors["hair_light"] = lighten(colors["hair"], 30)
if "mouth" not in config.get("colors", {}):
colors["mouth"] = darken(colors["skin"], 80)
# Merge features with defaults
features = dict(DEFAULT_FEATURES)
features.update(config.get("features", {}))
sheet = Image.new("RGBA", (W, H), (0, 0, 0, 0))
gen_all_animations(sheet, colors, features)
import os
os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True)
sheet.save(args.output, "WEBP", quality=95, lossless=True)
print(f"Saved spritesheet to {args.output} ({sheet.size[0]}x{sheet.size[1]})")
if __name__ == "__main__":
main()
-102
View File
@@ -1,102 +0,0 @@
---
name: openwork-desktop-sync
description: Sync qwen-code packages/desktop with modelstudioai/openwork using commit-by-commit path migration, not subtree split or tree overwrite. Use when exporting qwen-code desktop changes to OpenWork, importing OpenWork desktop changes into qwen-code, preserving target-owned overlay files such as README.md, resolving sync conflicts, or preparing sync PR branches between the two repositories.
---
# OpenWork Desktop Sync
Use this skill to sync desktop changes between this qwen-code repo and an
OpenWork checkout. The repository script owns the Git mechanics:
```bash
OPENWORK_DIR=/path/to/openwork bun run desktop-openwork-sync --mode export
```
Default overlay is `README.md`. Overlay paths are excluded from migrated
commits and stay target-owned.
```bash
OPENWORK_OVERLAY_PATHS='README.md'
```
## Contract
This is commit-by-commit path migration, not snapshot replacement. The script
walks source commits from `source-base..source-head`, rewrites paths between
qwen-code `packages/desktop` and the OpenWork repository root, then applies each
commit with `git apply -3`.
Commits that already came from the receiving repository are skipped by their
sync trailers. During import, qwen-code-origin export commits are skipped;
during export, OpenWork-origin import commits are skipped.
Merge commits are not migrated as merge commits. The script migrates the regular
commits inside the merged branch; when it later sees the merge wrapper, it
checks that the regular commits were already handled and that the merge tree
matches Git's automatic merge result. If the merge wrapper contains manual
resolution changes, the sync stops so the agent can convert that resolution into
a normal follow-up commit.
Target-side changes are preserved unless a migrated source commit touches the
same hunk. If that happens, Git leaves a normal conflict for the agent to
resolve. Do not use `git subtree split` or full tree replacement for normal
sync.
Successful sync commits include trailers such as `Qwen-Code-Commit` or
`OpenWork-Commit`. Later syncs can use the latest trailer as the next source
base. The first sync needs an explicit source base when no previous sync trailer
exists:
```bash
bun run desktop-openwork-sync --mode export --source-base <qwen-code-ref>
bun run desktop-openwork-sync --mode import --source-base <openwork-ref>
```
## Modes
- `--mode export`: qwen-code `packages/desktop` commits -> OpenWork.
- `--mode import`: OpenWork commits -> qwen-code `packages/desktop`.
- `--mode auto`: guardrail only; use explicit directions for real sync.
## Workflow
1. Confirm repo paths and clean worktrees:
```bash
git rev-parse --show-toplevel
git -C /path/to/openwork rev-parse --show-toplevel
git status --short
git -C /path/to/openwork status --short
```
2. Run the requested direction:
```bash
OPENWORK_DIR=/path/to/openwork \
OPENWORK_OVERLAY_PATHS='README.md' \
bun run desktop-openwork-sync --mode export --source-base <qwen-code-ref>
```
3. If Git reports conflicts, resolve only the conflicted hunks, preserving
target-owned repository metadata unless the source change intentionally
updates that same behavior.
4. After sync, verify:
```bash
git status --short
git diff --check HEAD
git diff --name-status <target-base>..HEAD
```
5. If the user asked to publish, push the branch and create a PR after the
branch is clean.
## Rules
- Keep only `README.md` as the default overlay unless the user adds paths to
`OPENWORK_OVERLAY_PATHS`.
- OpenWork-specific files not touched by source commits must remain unchanged.
- Prefer PR branches. The script prints the push command for export branches.
- Do not manually import PR merge commits. Let the script migrate regular
commits and treat merge commits as wrappers.
-2
View File
@@ -88,5 +88,3 @@ ignore:
- 'vendor/'
- 'node_modules/'
- 'integration-tests/terminal-bench/'
- 'packages/desktop/.github/'
- 'packages/desktop/apps/electron/electron-builder.yml'
@@ -0,0 +1,65 @@
# Session storage lifecycle repair
## Problem
Session lifecycle operations currently depend on loading a transcript as a usable conversation. That is too strong for delete, archive, and unarchive. A regular transcript file can still need maintenance when it is empty, has a torn or malformed head, or belongs to a legacy child whose parent no longer exists.
This leaves physical files stranded even though the selected workspace and archive state are unambiguous.
## Scope
This change makes delete, archive, and unarchive operate on exact-spelling regular transcript files owned by the selected workspace. It also adds an explicit repair option for an active/archive conflict.
The change does not alter transcript loading, listing, export, organization, Live session identity, or general case-insensitive lookup. Normal generated session UUIDs remain canonical lowercase values.
## Storage classification
The lifecycle classifier inspects the requested UUID file in the active and archived directories.
- A missing path contributes no state.
- A regular empty or damaged file contributes physical state.
- A readable first record must identify the requested session and the selected workspace.
- A readable record that identifies another workspace is treated as not found.
- A symlink, directory, or other non-regular entry fails closed.
- Active and archived files together form a conflict.
The classifier captures file identity for physical-only maintenance. Immediately before unlink or rename, Core verifies that the same regular files still occupy the same paths.
## Routing
Workspace-qualified routes mutate only the selected trusted runtime.
Workspace-less ordinary routes retain their existing primary-workspace behavior. Internal Conversations runtimes may be selected only when the requested batch has one unambiguous owner. Runtime selection is repeated while holding the lifecycle coordinator lock; a changed or unavailable generation returns the existing retryable runtime error instead of mutating stale storage.
Provenance remains authoritative for loading and listing, but it does not prevent maintenance of an otherwise owned regular transcript. This permits cleanup of legacy orphan children without making them loadable.
## Conflict repair
Archive and unarchive keep the non-destructive default behavior: an active/archive conflict is returned as a per-session error and neither persisted copy is moved, removed, or overwritten. Archive still strictly closes a live session before classifying the conflict, so queued records may be flushed to the active transcript. The batch lifecycle routes, including workspace-qualified routes, return that outcome in a `200` response instead of the earlier workspace-qualified `409 session_conflict` envelope.
Callers may send `resolveConflicts: true`:
- archive keeps the archived transcript and removes the active transcript;
- unarchive keeps the active transcript and removes the archived transcript.
The response adds `resolvedConflicts`, containing IDs repaired by that request. Delete keeps its existing compatibility behavior and removes both copies without requiring an option.
## Writer and generation safety
Daemon maintenance runs under the per-session lifecycle coordinator and writer lock. Damaged transcripts use the same writer lock with a maintenance sentinel because the normal writer proof intentionally rejects torn JSONL. The sentinel does not bypass a certified handoff: if transcript bytes change after a writer seals its lock, takeover continues to fail closed with `SessionTranscriptChangedError` and requires operator intervention. Core still snapshots and verifies the actual transcript paths before committing the mutation.
The selected runtime generation is checked after coordinator acquisition, before the filesystem commit, and after the operation. A generation replacement therefore cannot silently redirect an in-flight mutation to another runtime.
## API compatibility
The REST and ACP archive/unarchive request bodies accept the optional boolean `resolveConflicts`. Omitting it preserves the non-mutating conflict behavior, while the workspace-qualified REST transport now reports the conflict in the batch result as described above. Non-boolean values are invalid requests.
Archive and unarchive responses add `resolvedConflicts`. SDK result types keep that field optional so clients remain compatible with older daemons, while new daemons always return the array. Existing SDK client-ID call forms remain valid; options use an additional overload position.
The `session_storage_conflict_repair` capability advertises support for the option and response field.
## Verification
The regression matrix covers empty, damaged, and legacy-orphan transcripts across delete, archive, and unarchive in Core, workspace-qualified internal routes, and unqualified unique-owner routing. Each success asserts the final active/archive file state, and moves preserve the original bytes.
Compatibility tests retain default archive and unarchive conflict behavior and delete-both behavior. Additional tests cover explicit repair in both directions, foreign or ambiguous ownership rejection, invalid option types, SDK request bodies, generation fencing, and replacement detection.
@@ -0,0 +1,89 @@
# Daemon ACP channel transport liveness
## Problem
The daemon knows when an ACP child exits or its bounded NDJSON transport rejects a frame, but it cannot detect a child that remains alive while its event loop or transport stops answering. Session active-work snapshots cannot fill this gap: silence from one Session is not proof that the shared channel is dead, and using that signal to recycle the child would destroy every multiplexed Session.
Layer 2 of #8586 adds a separate channel-level liveness contract. It does not change `activeWork`, Session cleanup, or logical Agent progress detection.
## Scope and ownership
One `AcpSessionBridge` owns one workspace runtime and at most one attach-available ACP child channel. The liveness monitor therefore belongs to `ChannelInfo`, not to a Session. A failure condemns the whole child because every Session on that transport is already unreachable.
The monitor covers only daemon-owned ACP children that acknowledge the v1 capability during `initialize`. Older or alternate children that do not acknowledge it keep the current behavior. Direct ACP consumers and channel adapters are unchanged.
## Wire contract
The daemon advertises this initialize metadata:
```json
{
"qwen.daemon.channelLiveness": { "v": 1 }
}
```
A supporting child echoes the same metadata in its initialize response. Once negotiated, the daemon periodically calls the read-only extension method:
```text
qwen/status/channel/ping { v: 1, nonce: N }
-> { v: 1, nonce: N }
```
`nonce` is a non-negative safe integer allocated monotonically for the life of the channel and wraps to zero after `Number.MAX_SAFE_INTEGER`. The daemon accepts only an exact version and nonce echo. A malformed response or a request rejection after a v1 acknowledgement is a definite protocol failure; it does not spend the timeout retry budget.
No Session id, work state, timeout, or process detail crosses this method.
## Timing and escalation
The fixed internal policy is:
- wait 15 seconds between healthy probes;
- allow 10 seconds for one response;
- after the first on-time timeout, retry immediately;
- after a second consecutive on-time timeout, fail the transport and recycle the channel.
This bounds detection to 35 seconds when a child wedges just after a healthy probe, while requiring two independent unanswered requests before taking down multiplexed Sessions. A successful response resets the consecutive-timeout count. These values are implementation policy, not public configuration, matching #8586's non-goal.
At most two requests can remain unresolved: the first timed-out request and its retry. ACP request ids keep a late first response separate from the retry. Once the channel is condemned, the existing transport teardown closes both.
## Host suspend and parent stalls
Every scheduled callback records its expected firing time using `performance.now()`. If either the interval callback or a probe-timeout callback runs more than one second after its expected monotonic deadline, the daemon treats the observation as a local scheduling gap and clears the miss count. A delayed interval starts a fresh interval; a delayed probe timeout keeps the same bounded request outstanding and gives it a fresh response window. It does not infer anything about the child from a timer the parent could not run on time.
This covers platforms whose monotonic clock advances across host suspend. On platforms where that clock pauses during suspend, the timer deadline pauses with it and no overdue callback is produced. Wall-clock changes never participate.
The rule also prevents a long daemon event-loop pause from charging a child timeout. It does not excuse an on-time timeout merely because the daemon was busy earlier; the immediate retry supplies the second observation.
## Lifecycle and failure path
The monitor starts only after initialize succeeds and the channel is published. Its timers are unreferenced so an otherwise idle daemon can exit. It stops on transport failure, channel exit, synchronous kill-all, and graceful shutdown; every callback also rechecks that the same channel remains live and is not dying.
A terminal liveness failure enters the existing transport-failure path before starting teardown:
1. mark the `ChannelInfo` dying synchronously so no new Session work is admitted;
2. retain a bounded failure code for telemetry;
3. clear in-flight extension refresh bookkeeping;
4. ask the daemon transport guard to fail and terminate the child, or call the channel's ordinary kill fallback for injected channels without a guard;
5. let the existing `channel.exited` handler remove every multiplexed Session, publish `session_died`, and release channel state.
This preserves the existing overlap invariant: a replacement can be spawned while the dying channel remains reachable to `killAllSync` until OS reap.
## Observability
The daemon emits one `channel.liveness_failed` telemetry event and one bounded stderr line when escalation begins. The existing `channel.exited` event then reports `transport_failed=true`, `transport_failure_initiated_teardown=true`, and either `acp_channel_liveness_timeout` or `acp_channel_liveness_protocol_error`.
No health or status response field is added. Public health remains an observation surface, not a synchronous probe.
## Compatibility and non-goals
- An unacknowledged capability disables the monitor for that channel.
- Existing Session active-work reporting and conditional close behavior do not change.
- A responsive process with a logically stalled Agent still answers ping; Layer 3 owns progress watchdogs.
- Runtime generation draining and recovery remain Layers 4 and 5.
- There is no public timeout setting, persistence change, transport retry, Session-specific kill, or inference from active-work snapshots.
## Verification
Unit coverage pins negotiation, exact ping echo validation, healthy cadence, timeout reset, two-timeout escalation, local timer-delay suppression, cleanup, legacy compatibility, and teardown of every Session on the failed channel. A child-side test pins the initialize acknowledgement and stateless ping response.
The E2E plan freezes only the ACP child with `SIGSTOP` to reproduce the current gap and verify channel recycle. Host-suspend safety is deterministic unit coverage because a real machine suspend cannot be made reliable or portable in CI.
@@ -0,0 +1,158 @@
# Signal-driven early severity floor (#9903)
Date: 2026-08-24
Issue: #9903 — act on the convergence/root-cause-clustering signal earlier
(auto-drop to `--severity-floor critical`) instead of only printing it.
## Problem
The convergence diagnosis (`packages/cli/src/commands/review/lib/convergence.ts`)
fires from round 3 when the same files keep producing first-time findings and
the new-finding rate is not falling, and its own `stem-surface` recommendation
already names the remedy: drop this PR's reviews to `--severity-floor
critical`. But nothing consumes that signal. Under `severityFloor: "auto"`
the floor resolves to critical on a fixed schedule — round 6
(`floorResolvesCritical` in `compose-review.ts`) — so rounds 3–5 keep posting
Suggestions inline at full volume while the body prints the advice. Each of
those rounds costs a full multi-hour review leg to re-derive the same
sibling set.
The issue's triage verdict fixed the shape: implement the **early-floor
variant** (the hard-gate variant is declined — the diagnosis keeps advisory
power), as a signal-driven early trigger on the existing `auto` posture
transition, at the same code site as the round-6 check, with a conservative
streak threshold and the drop disclosed in the round's post.
## Design
### The trigger signal
`volumeNotShrinking` — exactly the signal the `stem-surface` (floor-drop)
recommendation is matched to. The tool acts on the recommendation it already
prints, only when it prints it. A clusters-only recurrence with a falling
new-finding rate is a converging loop and never advances the trigger.
The measurement is the one the module already makes: `diagnoseConvergence`
reads FRESH drafts only, so carried-id re-posts of unfixed Criticals (steady
state) never count — the triage's first correctness constraint is inherited,
not re-implemented.
### The streak
A new ledger marker field, `flatRounds`: how many consecutive rounds the
first-time-finding rate did not fall. It advances on a firing round and
**resets on any non-firing round**. This is deliberately stricter than
`churnRounds`' carry-on-unmeasured: `churnRounds` arms a blocking Critical
where late filing loses the mechanism on exactly the churning PRs, while
`flatRounds` engages a disclosed, non-capping deferral posture where a false
engagement silently defers real Suggestions (the triage's second
constraint). A wiped streak costs one delayed engagement — the fail-open
direction every other input in this module family takes.
The engagement bar is **2 consecutive rounds** (`FLAT_STREAK_TO_ENGAGE`),
matching `CHURN_STREAK_TO_FILE`'s stated argument: two counted rounds is the
shortest window in which "not falling" is an observation rather than a
single step. In the issue's scenario (signal visible from round 3) the floor
engages at round 4 instead of round 6.
### Engagement and latch
The floor engages **on the round the streak reaches the bar** — `prevStreak
=== 1` and this round's signal fires — not the round after. Once the recorded
streak is at the bar it **latches**: later rounds engage on the recorded
streak alone, and the streak is pinned rather than re-measured.
The latch is forced by two failure modes of the alternative (re-measure every
round and stay engaged only while the streak holds):
- **The measurement dies under the floor.** Enforcement moves fresh
Suggestions into the deferral channel, so the posted-set trend the signal
reads goes quiet — not because the loop settled, but because the floor is
working. Re-measuring would release the floor the round after it engaged.
- **Circularity through the `floorChanged` guard.** The volume signal
refuses to compare two rounds that posted under different floors, and the
floor this round posts under is the thing the trigger is deciding.
Re-measuring against a pre-trigger floor assumption makes engagement flap
at period two.
A latched round measures nothing, so neither failure mode is reachable. The
latch spans only rounds 4–5 in the typical case — round 6 engages
unconditionally — and the operator's explicit `--severity-floor suggestion`
still turns the posture off at any round (the trigger lives only in the
`auto` arm).
### Trust boundaries (mirroring `churnRounds`)
The marker rides a public, writable review body, so the recovered streak is
hardened exactly like the churn streak:
- clamped to the marker's own `round` at parse (`parseLedger`) and at
side-file read (`prevLedgerFacts`) — a planted `flatRounds: 9999` cannot
reach the bar off one honest round;
- a **foreign winner's streak is stripped** at the `pr-context` recovery
seam — another account's marker cannot engage this account's floor;
- the side-file carry rules for anonymous recovery mirror the churn group's,
so the latch does not drop when this account's own marker leaves the walk.
Worst case for a forged own-account-looking streak: Suggestions move into a
**disclosed** deferral list for the rest of the PR. Nothing is withheld from
the record, no verdict is capped, and the operator override disengages.
### Disclosure
A new `CriticalFloorKind`, `'auto-signaled'`, names the early engagement
everywhere the kind is read back, so the round where Suggestions move at
round 4 says why instead of presenting an unexplained posture change:
- the deferral header / floor-enforcement note states the trigger ("the
first-time-finding rate has not fallen for N consecutive rounds");
- the convergence rendering's "already at the floor" wording covers the new
kind.
### Model-side contract (SKILL.md)
The model cannot evaluate the deterministic trend itself — that is why the
module exists — so its Step 6 routing follows the **marker**: a recovered
`flatRounds` at the bar means the floor is critical and Suggestions route to
the deferral channel. On the round the streak first reaches the bar the
model still drafts under the old posture and the code backstop
(`floorEnforcedReroute`) moves the drafted Suggestions — precisely the
backstop's stated job — and the posted body discloses the move.
## Implementation outline
1. `lib/ledger.ts`: `Ledger.flatRounds`, serialized beside `churnRounds`
(above the shed cascade, omitted at zero), parsed with the same
clamp-to-round.
2. `pr-context.ts`: persist/recover `flatRounds` with the churn group's
seam rules (foreign strip, anonymous carry).
3. `compose-review.ts`:
- `prevLedgerFacts` recovers `flatRounds` (clamped, travels with round);
- `composeReview` measures this round's `volumeNotShrinking` via the one
`diagnoseConvergence` statement (pre-reroute — identical to post-reroute
while the floor is not engaged, since no reroute is in flight), advances
or resets the streak, pins it when latched;
- `floorResolvesCritical` gains the signal arm: `auto` resolves critical
when the streak is at/past the bar — as the new
`CriticalFloorKind 'auto-signaled'`;
- the #9410 residual-risk advisory's floor-engagement conjunct reads the
caller's signal-inclusive enforcement state (`floorEnforcementEngaged`),
not a schedule-only re-derivation: it surfaces from the signal-engaged
round, not the round the schedule first proves (round 5, not round 7,
in the scenario above);
- the marker stamps `flatRounds` beside `churnRounds`.
4. `lib/convergence.ts`: `CriticalFloorKind` gains `'auto-signaled'` with
rendering wording.
5. `SKILL.md`: `auto` floor rule gains the signal-driven trigger; Step 6
routes on the marker's `flatRounds`; the deferral disclosure names it.
## Explicitly not done
- **No hard gate.** The "root cause not triaged / consider splitting the PR"
blocker is the triage-declined variant; the existing churn-census
non-convergence Critical already owns the blocking lane.
- **No release-on-settle.** Once latched, the floor stays engaged for the PR
(round 6 would engage anyway). A self-releasing floor re-opens the
oscillation and circularity the latch exists to avoid.
- **No new threshold vocabulary.** Bar = 2, the codebase's one existing
streak constant's argument, restated — not a tuned number.
@@ -0,0 +1,15 @@
# ACP workspace event capability
## Problem
Several serve-layer services accept the full ACP session bridge even though they only publish workspace events, or publish events plus validate an originating client id. This couples small services and their tests to a bridge interface with more than one hundred members.
## Design
Define a workspace event publisher capability and a two-method workspace event bridge capability. The full session bridge continues to implement both through interface extension, so runtime behavior and construction stay unchanged. Narrow the git-status watcher, device-flow fan-out, memory mutation routes, and GitHub setup route to the smallest applicable capability.
## Non-goals
- Split the bridge implementation or factory.
- Change event delivery, client validation, or bridge construction.
- Migrate session lifecycle, artifacts, permissions, or command invocation in this change.
@@ -0,0 +1,15 @@
# Web Shell context boundary
## Problem
Message rendering modules import shared React contexts from the application coordinator, while the application coordinator imports those message modules. The contexts are dependency-neutral, but their ownership creates a runtime import cycle and forces focused component tests to mock the full application module.
## Design
Move the compact-mode context, todo timeline context, todo detail context, and the existing todo provider into one small Web Shell context module. Keep all state creation and memoization in the application coordinator, preserve the existing provider values, and update consumers to import the contexts directly.
## Non-goals
- Move todo state or compact-mode state out of the application coordinator.
- Introduce a new state-management abstraction.
- Change provider nesting, context defaults, or rendering behavior.
@@ -0,0 +1,74 @@
# Report Findings Typed Contract
## Context
`/review` already canonicalizes its findings as data twice: `qwen review
findings` writes the typed artifact under `.qwen/tmp/`, and Step 8's
`save-artifact` + `record_artifact` publish a durable copy the Web Shell
renders (`CodeReviewArtifactDetail`). But both are files registered after the
fact. Every client rendering the session live — the terminal UI, the Web Shell
transcript, ACP hosts, the daemon TUI — receives only the Markdown
restatement of the same list, and after `--fix` (or a later `fix these
issues`) nothing in-band tells a client which findings are now closed.
## Design
A new core tool, `report_findings`, is the in-band half of the contract: one
call with `{level, findings[]}`, rendered by host UIs as a per-finding list.
Field names and enum spellings match the findings artifact exactly (`id`,
`severity`, `confidence`, `source`, `file`/`line`, `summary`, `shortSummary`,
`failureScenario`, `category`, `outcome`, `outcomeNote`), so the model copies
values out of the artifact instead of translating them. The tool sorts by
severity → confidence → location, derives and compresses `shortSummary` to 60
characters, rejects control characters and duplicate ids, and — mirroring
`review findings --outcomes` — refuses a call where some findings carry an
`outcome` and others do not. It persists nothing and decides no verdict; the
result is a `findings_list` structured `returnDisplay`.
The finding enums now live in core (`tools/report-findings.ts`);
`packages/cli/src/commands/review/findings.ts` re-exports them under its historical
names. The Web Shell renderer keeps its deliberate browser-side copy.
The `/review` skill calls the tool once after writing the findings artifact
(Step 6; low effort reports its unverified list with `level: "low"`), and
again after `--fix` with every finding carrying its outcome — a rule that
outlives Step 6B: any later in-session disposition change records outcomes
into the artifact and re-issues the call. The call is UI delivery: a failure
is disclosed and never alters artifacts or the verdict.
Rendering: the TUI gets a `FindingsDisplay` row list (severity color, id,
`file:line`, short summary, confidence marker, outcome badge); the daemon TUI
adapter passes `findings_list` through; history/recording compaction truncates
the free-text fields and applies an aggregate retained-display budget across
the list, keeping the most severe prefix and counting the evicted tail
(`omittedFindings`).
"Later calls replace the list" is rendered, not just validated: every
transcript surface — live history, restored history, recording/resume, and
the daemon projection — keeps only the last delivered `findings_list` and
collapses each earlier one to a one-line replacement marker, so an initial
report and its outcome re-report never show two checklists at once.
The outcome identity gate (`activeReportIds`) is a live-process contract:
the tool instance is cached by the registry for the session, but a cold
session resume constructs a fresh instance with no active identity, and an
outcome call is then validated on its own terms (all-or-nothing outcomes)
instead of against the pre-restart report. Persisting the identity across
restarts is deliberately out of scope; the transcript-side replacement above
does not depend on it.
The findings command's `--input` also accepts a saved review artifact or a
prior `--out` report (any object carrying the array as `findings`), because
Step 9 cleanup deletes the `findings-in.json` side file a later-session
outcome path would otherwise need.
## Verification
- Core tool unit tests: sorting, shortSummary derivation/compression, empty
list, outcome counting, partial-outcome refusal, duplicate ids, control
characters, schema violations, trimming.
- Compaction test: free-text fields truncate, typed fields survive.
- `FindingsDisplay` ink render tests: rows, outcomes with skip reason, empty
state.
- Existing `findings.ts`, `save-artifact`, ToolMessage, daemon adapter,
config-registration, SKILL parity and review-digest suites stay green.
@@ -0,0 +1,43 @@
# Web Shell collapsed thinking performance
## Problem
Pure assistant and thought tail appends wake the top-level `App`, even though
only the transcript row needs the new text. Compact activity summaries also
keep their complete tool and thought subtree mounted while collapsed, so hidden
rows continue reconciling streamed thought props.
## Design
The top-level app consumes a structural transcript snapshot. A store change
summary proves when an update is only an append to the active assistant or
thought block; those app-level notifications are ignored. Stores without a
change summary retain the existing behavior.
The message list separately consumes the live throttled snapshot and applies
the existing streaming-tail projector against the app's latest structural
messages. This updates only the visible tail without starting a second
background-agent reconciliation loop. Structural changes still flow through
the app and replace the baseline immediately. Insight protocol markers use a
full projection while retaining the unchanged message prefix.
Compact tool summaries mount their detail subtree only while expanded, except
for MCP Apps whose iframe state must survive a collapse. The summary button
remains live while collapsed; expanding reconstructs the current tool and
thought rows from props. Collapse and expansion are immediate and unanimated.
## Compatibility
Tool, permission, terminal, reset, history, and session changes remain
structural. Transcript callbacks continue receiving live snapshots from the
message-list boundary. Collapsing a compact group no longer preserves local
expanded state inside its hidden detail rows.
## Verification
- Prove structural snapshots ignore pure tail appends and resume on the next
structural change.
- Prove a collapsed compact group has no detail subtree and restores current
details when expanded.
- Run the deterministic folded-thought performance scenario and targeted unit
tests.
@@ -0,0 +1,23 @@
# Web Shell Vision Bridge Notice Localization
## Problem
Vision Bridge status is emitted as an ordinary English assistant message. Web Shell receives no semantic data, so a Chinese locale cannot translate it.
## Design
Keep the English text as an ACP fallback for clients that do not understand Qwen metadata. Add a discrete `vision_bridge_notice` source with structured status, counts, model display name, endpoint, and egress state. Web Shell projects that event as a standalone system message and formats it through its existing locale catalog. Invalid or missing metadata falls back to the original text.
Within a turn, the existing processed-step control hides the notice when collapsed and restores it when expanded.
Message rewriting passes these notices through without adding them to the rewritten assistant response.
Channel bridges continue forwarding the English fallback as ordinary response text.
This change is limited to prompt-level Vision Bridge notices. TUI, headless, external ACP clients, tool-result notices, and voice notices retain their current behavior.
## Verification
- The cancelled prompt-level notice carries fallback text and structured metadata.
- The Web Shell keeps the notice separate from adjacent assistant output.
- Chinese renders localized text; malformed metadata renders the fallback.
@@ -700,6 +700,14 @@ target `<newSessionId>.jsonl` must therefore be the last resource published.
Before creating target resources, compute and sanitize the final title. The
Core fork input includes that title, and Core appends its `custom_title` record
inside the staged transcript. There is no post-publication rename transaction.
Use the source session's picker display name (`customTitle || prompt`) as the
base, remove an existing generated fork suffix, and append the lowest available
numeric suffix: `Title(1)`, `Title(2)`, and so on. Explicitly requested names
remain unchanged before suffix allocation. If a custom title normalizes to
nothing (it was exactly a legacy `(Branch)` or `(Branch N)` token), no picker
name survives: the daemon route falls back to a session-id prefix while CLI
`/branch` falls back to the first prompt. The divergence is deliberate; both
clients allocate the numeric suffix from their chosen base.
### 14.2 Temporary resources
@@ -510,8 +510,8 @@ BMP 以 `image/bmp` 进入缩略图 data URL 和 daemon image block。Core 的
> 及其 BMP 尺寸解析)已作为孤儿代码删除。BMP 支持现在仅依赖 `SUPPORTED_IMAGE_MIME_TYPES`
> 接受清单与 converter 透传;token 计数使用 `compactionInputSlimming.ts` 中的固定
> `DEFAULT_IMAGE_TOKEN_ESTIMATE`。下文对 BMP 路径的 E2E/人工验收要求不变。
浏览器若不能解码缩略图,不影响附件数据传输,但 E2E 必须覆盖 Chromium 解码,
Firefox/Linux 必须完成人工验收。
> 浏览器若不能解码缩略图,不影响附件数据传输,但 E2E 必须覆盖 Chromium 解码,
> Firefox/Linux 必须完成人工验收。
提交后的 user transcript 还经过 `isSafeImageSrc`,因此其被动位图 data-URI allowlist 必须
加入精确的 `image/bmp;base64,`,否则 composer 预览可见而 user message 会静默隐藏 BMP。
+24 -11
View File
@@ -4,7 +4,8 @@
Expose Thinking and effort controls for the exact `qwen3.8-max` model in the
WebShell model popover, including the welcome state before a lazy session is
created. Acknowledged changes apply only to subsequent live-session requests.
created. A welcome selection applies to that lazy session before its first
prompt; acknowledged live changes apply only to subsequent requests.
## Design
@@ -29,7 +30,8 @@ capability.
WebShell applies the following priority:
1. When both `sessionId` and session context are absent, it may render the
selected model's workspace preview. The suffix and controls are read-only.
selected model's workspace preview. The controls update a local, one-shot
intent bound to that exact model.
2. Once a session id is allocated but its context has not arrived, WebShell
hides the preview.
3. Once context for that session arrives, its `currentValue`, options, and
@@ -39,18 +41,27 @@ WebShell applies the following priority:
The welcome preview deliberately does not create an empty session. Hosts such
as DataWorks use lazy creation so the first prompt can create or adopt the
real daemon session; pre-creating one would change that contract and leave
empty sessions behind. The preview is display-only: it does not persist,
queue, or apply a selection before session creation.
empty sessions behind. Selecting a welcome effort only records local intent.
The first prompt creates and attaches the session, sets the selected model,
applies the effort through the live config-option mutation, and then submits
the prompt. A model or effort mutation that cannot be confirmed fails closed:
WebShell releases the unused session, keeps the composer and intent available
for retry, and does not send the prompt.
The intent is cleared after successful preparation or after an unrelated
session is attached. It is not a workspace default and is never persisted or
broadcast. If the user changes models before submission, the model-bound
intent is not applied to the other model.
WebShell retains PR #8675's interaction design: the current reasoning state is
shown as a suffix on the model chip, reasoning options occupy the first model
popover, and model search is opened from its Model submenu.
Selecting `none` writes `reasoning: false` to the current session's live
generator configuration. Selecting an effort writes that effort and enables
reasoning. Reading the manifest does not inject a default into generation
configuration, so sessions that never use the controls retain main's existing
wire behavior.
Selecting `none` applies Thinking off to the next lazy session or the current
live session. Selecting an effort applies that effort and enables reasoning.
Reading the manifest alone does not inject a default into generation
configuration, so sessions that never change the controls retain main's
existing wire behavior.
If the live session already carries a generic effort outside the manifest
(`high` or `max`), ACP preserves that value through its existing generic
@@ -68,7 +79,9 @@ added.
Included:
- exact stable `qwen3.8-max` only;
- a read-only welcome preview with `xhigh` as the manifest default;
- an editable, one-shot welcome preview with `xhigh` as the manifest default;
- application after lazy attach and model selection but before the first
prompt;
- authoritative replacement by same-session context;
- the current WebShell conversation;
- Thinking on/off and `low`, `medium`, `xhigh` effort;
@@ -79,7 +92,7 @@ Excluded:
- persistence across sessions or restarts;
- persisted/default-model semantics;
- saving or queueing welcome-state effort changes;
- workspace-level, reusable, or cross-client reasoning defaults;
- preview, aliases, and future reasoning-control shapes;
- route and runtime models;
- TUI, channel, provider, auth-refresh, and runtime-snapshot behavior;
+17 -17
View File
@@ -77,23 +77,23 @@ an HTTP daemon. See the
## Repository layout
| Path | Responsibility |
| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `packages/cli` | The `qwen` executable, argument parsing, configuration assembly, Ink TUI, headless output, ACP entry point, `qwen serve`, and command-specific adapters. |
| `packages/core` | UI-independent agent orchestration, model-provider integration, prompt and context construction, tool registration and execution, permissions, sessions, memory, telemetry, and shared services. |
| `packages/acp-bridge` | ACP channel lifecycle, session multiplexing, event delivery, permission mediation, process spawning, and the filesystem seam shared by daemon and adapter hosts. |
| `packages/sdk-typescript` | Programmatic process execution through `query()` plus HTTP/SSE clients and transcript projection for `qwen serve`. |
| `packages/webui` | Shared React components and the daemon React adapter built on the TypeScript SDK. |
| `packages/web-shell` | The terminal-style browser UI built on `packages/webui` and the daemon SDK. |
| `packages/web-templates` | Web templates packaged as embeddable JavaScript and CSS strings. |
| `packages/audio-capture` | Native microphone capture for voice input. |
| `packages/channels` | The shared channel runtime and platform adapters for messaging services. |
| `packages/desktop`, `packages/vscode-ide-companion`, `packages/chrome-extension`, `packages/zed-extension` | Product and editor surfaces that adapt Qwen Code to their host environments. |
| `packages/sdk-java`, `packages/sdk-python` | Language-specific programmatic clients. |
| `packages/cua-driver`, `packages/mobile-mcp` | Computer-use and mobile-device integrations exposed through MCP-compatible boundaries. |
| `integration-tests` | End-to-end coverage for CLI, interactive, SDK, sandbox, hook, and terminal behavior. |
| `docs` and `docs-site` | User, developer, protocol, and design documentation plus the documentation site. |
| `scripts` | Build, packaging, release, validation, and repository-maintenance automation. |
| Path | Responsibility |
| ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `packages/cli` | The `qwen` executable, argument parsing, configuration assembly, Ink TUI, headless output, ACP entry point, `qwen serve`, and command-specific adapters. |
| `packages/core` | UI-independent agent orchestration, model-provider integration, prompt and context construction, tool registration and execution, permissions, sessions, memory, telemetry, and shared services. |
| `packages/acp-bridge` | ACP channel lifecycle, session multiplexing, event delivery, permission mediation, process spawning, and the filesystem seam shared by daemon and adapter hosts. |
| `packages/sdk-typescript` | Programmatic process execution through `query()` plus HTTP/SSE clients and transcript projection for `qwen serve`. |
| `packages/webui` | Shared React components and the daemon React adapter built on the TypeScript SDK. |
| `packages/web-shell` | The terminal-style browser UI built on `packages/webui` and the daemon SDK. |
| `packages/web-templates` | Web templates packaged as embeddable JavaScript and CSS strings. |
| `packages/audio-capture` | Native microphone capture for voice input. |
| `packages/channels` | The shared channel runtime and platform adapters for messaging services. |
| `packages/desktop-shell`, `packages/vscode-ide-companion`, `packages/chrome-extension`, `packages/zed-extension` | Product and editor surfaces that adapt Qwen Code to their host environments. |
| `packages/sdk-java`, `packages/sdk-python` | Language-specific programmatic clients. |
| `packages/cua-driver`, `packages/mobile-mcp` | Computer-use and mobile-device integrations exposed through MCP-compatible boundaries. |
| `integration-tests` | End-to-end coverage for CLI, interactive, SDK, sandbox, hook, and terminal behavior. |
| `docs` and `docs-site` | User, developer, protocol, and design documentation plus the documentation site. |
| `scripts` | Build, packaging, release, validation, and repository-maintenance automation. |
Most code lives in npm workspaces under `packages/`. A package should depend on
another package through its declared public exports rather than through a
@@ -244,6 +244,26 @@ only a storage-state transition; clients must call `session/load` or
load/resume, and mutations racing an archive transition return
`409 session_archiving`.
Empty, damaged, and orphaned regular transcript files remain eligible for these
lifecycle operations even when they cannot be loaded as conversations.
Ownership-safety checks can intentionally fail closed and require operator
intervention. A file changed after a writer sealed its certified handoff proof
fails with `SessionTranscriptChangedError` until the operator resolves the
sealed lock and changed bytes. A JSON-shaped first physical record that exceeds
the bounded ownership-read window fails with
`SessionTranscriptIdentityUnavailableError` until the record is repaired or
reduced; oversized damaged records with a non-object prefix remain eligible. A
parseable recovered record must contain string `sessionId` and `cwd` ownership
fields, and mixed local/foreign archive states also fail closed. When
`session_storage_conflict_repair` is advertised, archive and unarchive accept
`resolveConflicts: true`: archive keeps the archived copy, while unarchive keeps
the active copy. Without that option, active/archive conflicts do not move,
remove, or overwrite either persisted copy and are returned in the batch
`errors` array. Archive still strictly closes a live session before classifying
the conflict, which may flush queued records to the active transcript.
Workspace-qualified lifecycle routes now use that HTTP `200` batch envelope
instead of their earlier HTTP `409 session_conflict` response.
### Context Usage (`session_context_usage` capability tag)
`GET /session/:id/context-usage` returns structured context-window usage.
@@ -108,7 +108,7 @@ Baseline tags are not present in the `Map` and are advertised unconditionally. T
Foundation: `health`, `daemon_status`, `capabilities`.
Sessions: `session_create`, `session_id_override`, `session_scope_override`, `session_load`, `session_resume`, `unstable_session_resume`, `session_list`, `session_info`, `session_prompt`, `session_mid_turn_message_mutation`, `session_cancel`, `session_events`, `session_set_model`, `session_close`, `session_metadata`, `session_archive`, `session_export`, `session_transcript`, `session_context`, `session_context_usage`, `session_supported_commands`, `session_tasks`, `session_monitor_tool_correlation`, `session_stats`, `session_lsp`, `session_status`, `session_approval_mode_control`, `session_recap`, `session_btw`, **`session_shell_command`** (conditional), `session_language`, `session_rewind`, `session_hooks`, `session_branch`.
Sessions: `session_create`, `session_id_override`, `session_scope_override`, `session_load`, `session_resume`, `unstable_session_resume`, `session_list`, `session_info`, `session_prompt`, `session_mid_turn_message_mutation`, `session_cancel`, `session_events`, `session_set_model`, `session_close`, `session_metadata`, `session_archive`, `session_storage_conflict_repair`, `session_export`, `session_transcript`, `session_context`, `session_context_usage`, `session_supported_commands`, `session_tasks`, `session_monitor_tool_correlation`, `session_stats`, `session_lsp`, `session_status`, `session_approval_mode_control`, `session_recap`, `session_btw`, **`session_shell_command`** (conditional), `session_language`, `session_rewind`, `session_hooks`, `session_branch`.
Streaming: `slow_client_warning`, `typed_event_schema`.
+9 -5
View File
@@ -256,7 +256,7 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design
`session_organization` advertises custom session groups and pinning. It adds `GET/POST/PATCH/DELETE /workspace/:id/session-groups`, `PATCH /session/:id/organization`, and the opt-in organized list view `GET /workspace/:id/sessions?view=organized`. When both `session_organization` and `workspace_qualified_rest_core` are advertised, the workspace-qualified organization mutation `PATCH /workspaces/:workspace/session/:id/organization` is also available. The legacy mutation remains primary-workspace-only. Older daemons return `404` for the mutation/group routes and ignore the organized view contract, so WebShell/SDK clients must pre-flight these tags before showing the matching grouping or pinning UI.
`session_archive` advertises the v1 directory-state archive API: `POST /sessions/archive`, `POST /sessions/unarchive`, and `GET /workspace/:id/sessions?archiveState=active|archived`. Archived sessions cannot be loaded or resumed until they are unarchived.
`session_archive` advertises the v1 directory-state archive API: `POST /sessions/archive`, `POST /sessions/unarchive`, and `GET /workspace/:id/sessions?archiveState=active|archived`. Archived sessions cannot be loaded or resumed until they are unarchived. `session_storage_conflict_repair` advertises the additive `resolveConflicts` request option and `resolvedConflicts` response bucket described below.
`workspace_qualified_rest_core` advertises plural core REST routes under `/workspaces/:workspace/...`. The selector resolves as exact workspace id first, then as a URL-encoded absolute cwd after canonicalization. Newer single-workspace daemons include the primary runtime in `workspaces[]` even when `multi_workspace_sessions` is absent, allowing clients to discover the id required by workspace-qualified routes; clients should fall back to `capabilities.workspaceCwd` for older daemons that omit the array. Trust status and trust request routes are available for registered untrusted workspaces; file read routes follow the existing filesystem read policy. Registered untrusted secondary workspaces also expose persisted-only session and session-group catalogs: these reads do not attach to a session, start ACP, or merge live bridge state. File writes, catalog mutations, and other plural core routes require a trusted workspace unless a separate capability explicitly defines a narrower read-only policy, such as `workspace_persisted_transcript`. An untrusted primary continues to receive `403 { code: "untrusted_workspace" }` from the plural catalog and transcript routes; legacy singular primary routes keep their existing compatibility behavior. This tag covers the core file, status, settings, permissions, trust, lifecycle, MCP control, tool and skill toggles, memory, workspace agent CRUD, and session storage surfaces. It does not cover auth, voice, extensions, ACP/WebSocket transport, channel-worker routing, or workspace-qualified session export; pre-flight `workspace_session_export` or `workspace_archived_session_export` separately. Workspace trust is not an ACL: a client holding the daemon token can read every registered workspace surface allowed by this policy.
@@ -2435,7 +2435,7 @@ Archive one or more sessions. Archive is a state transition, not deletion: the J
Request:
```json
{ "sessionIds": ["<uuid>"] }
{ "sessionIds": ["<uuid>"], "resolveConflicts": true }
```
`sessionIds` must be a non-empty string array with at most 100 ids. Duplicates are collapsed.
@@ -2446,12 +2446,15 @@ Response:
{
"archived": ["<uuid>"],
"alreadyArchived": [],
"resolvedConflicts": ["<uuid>"],
"notFound": [],
"errors": []
}
```
`errors` entries have `{ "sessionId": "<uuid>", "error": "message" }`. Active and archived files with the same id are treated as a conflict and reported in `errors`; no file is overwritten.
`resolveConflicts` is optional and defaults to `false`. By default, active and archived files with the same id are reported in `errors`, and neither copy is moved, removed, or overwritten. Archiving a live session still performs the strict close described above before classifying the conflict, so that close may flush queued records to the active transcript. With `resolveConflicts: true`, archive keeps the archived copy, removes the active copy, and reports the id in both `archived` and `resolvedConflicts`. `errors` entries have `{ "sessionId": "<uuid>", "error": "message" }`.
Lifecycle conflicts are batch item outcomes: the workspace-less and workspace-qualified routes return HTTP `200` with the conflict in `errors`. This replaces the earlier workspace-qualified HTTP `409 session_conflict` envelope; clients that called that route must inspect the batch response. Internal-runtime REST batches preserve the safe conflict message while continuing to redact other per-session failure details.
### `POST /sessions/unarchive`
@@ -2460,7 +2463,7 @@ Restore archived sessions to the active directory. This does not resume the sess
Request:
```json
{ "sessionIds": ["<uuid>"] }
{ "sessionIds": ["<uuid>"], "resolveConflicts": true }
```
Response:
@@ -2469,12 +2472,13 @@ Response:
{
"unarchived": ["<uuid>"],
"alreadyActive": [],
"resolvedConflicts": ["<uuid>"],
"notFound": [],
"errors": []
}
```
If an active JSONL already exists for the id, unarchive reports a conflict in `errors` and does not overwrite it. Archive or unarchive in flight for the same id returns `409 session_archiving` before starting the batch.
`resolveConflicts` is optional and defaults to `false`. By default, simultaneous active and archived JSONL files produce a conflict in `errors`, and neither copy is moved, removed, or overwritten; an active-only session is returned in `alreadyActive`. With `resolveConflicts: true`, unarchive keeps the active copy, removes the archived copy, and reports the id in both `unarchived` and `resolvedConflicts`. Archive or unarchive in flight for the same id returns `409 session_archiving` before starting the batch.
ACP-over-HTTP uses the same request and response bodies through vendor methods `_qwen/sessions/archive` and `_qwen/sessions/unarchive`. The REST route table maps `POST /sessions/archive` and `POST /sessions/unarchive` to those methods for ACP transports.
+21 -21
View File
@@ -55,27 +55,27 @@ Creates a new query session with the Qwen Code.
#### QueryOptions
| Option | Type | Default | Description |
| ------------------------ | -------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `cwd` | `string` | `process.cwd()` | The working directory for the query session. Determines the context in which file operations and commands are executed. |
| `model` | `string` | - | The AI model to use (e.g., `'qwen-max'`, `'qwen-plus'`, `'qwen-turbo'`). Takes precedence over `OPENAI_MODEL` and `QWEN_MODEL` environment variables. |
| `pathToQwenExecutable` | `string` | Auto-detected | Path to the Qwen Code executable. Supports multiple formats: `'qwen'` (native binary from PATH), `'/path/to/qwen'` (explicit path), `'/path/to/cli.js'` (Node.js bundle), `'node:/path/to/cli.js'` (force Node.js runtime), `'bun:/path/to/cli.js'` (force Bun runtime). If not provided, auto-detects from: `QWEN_CODE_CLI_PATH` env var, `~/.volta/bin/qwen`, `~/.npm-global/bin/qwen`, `/usr/local/bin/qwen`, `~/.local/bin/qwen`, `~/node_modules/.bin/qwen`, `~/.yarn/bin/qwen`. |
| `permissionMode` | `'default' \| 'plan' \| 'auto-edit' \| 'auto' \| 'yolo'` | `'default'` | Permission mode controlling tool execution approval. See [Permission Modes](#permission-modes) for details. |
| `canUseTool` | `CanUseTool` | - | Custom permission handler for tool execution approval. Invoked when a tool requires confirmation. Must respond within 60 seconds or the request will be auto-denied. See [Custom Permission Handler](#custom-permission-handler). |
| `env` | `Record<string, string>` | - | Environment variables to pass to the Qwen Code process. Merged with the current process environment. |
| `systemPrompt` | `string \| QuerySystemPromptPreset` | - | System prompt configuration for the main session. Use a string to fully override the built-in Qwen Code system prompt, or a preset object to keep the built-in prompt and append extra instructions. |
| `mcpServers` | `Record<string, McpServerConfig>` | - | MCP (Model Context Protocol) servers to connect. Supports external servers (stdio/SSE/HTTP) and SDK-embedded servers. External servers are configured with transport options like `command`, `args`, `url`, `httpUrl`, etc. SDK servers use `{ type: 'sdk', name: string, instance: Server }`. |
| `abortController` | `AbortController` | - | Controller to cancel the query session. Call `abortController.abort()` to terminate the session and cleanup resources. |
| `debug` | `boolean` | `false` | Enable debug mode for verbose logging from the CLI process. |
| `maxSessionTurns` | `number` | `-1` (unlimited) | Maximum number of conversation turns before the session automatically terminates. Must be an integer. A turn consists of a user message and an assistant response. |
| `coreTools` | `string[]` | - | Uses the legacy `coreTools` / CLI `--core-tools` allowlist semantics. If specified, only matching core tools are registered for the session. This is separate from `permissions.allow`, which auto-approves matching tool calls but does not restrict tool registration. Example: `['read_file', 'edit', 'run_shell_command']`. |
| `excludeTools` | `string[]` | - | Equivalent to `permissions.deny` in settings.json. Excluded tools return a permission error immediately. Takes highest priority over all other permission settings. Supports tool name aliases and pattern matching: tool name (`'write_file'`), shell command prefix (`'Bash(rm *)'`), or path patterns (`'Read(.env)'`, `'Edit(/src/**)'`). |
| `allowedTools` | `string[]` | - | Equivalent to `permissions.allow` in settings.json. Matching tools bypass `canUseTool` callback and execute automatically. Only applies when tool requires confirmation. Supports same pattern matching as `excludeTools`. Example: `['Bash(git status)', 'Bash(npm test)']`. |
| `authType` | `'openai' \| 'qwen-oauth'` | `'openai'` | Authentication type for the AI service. Qwen OAuth free tier was discontinued on 2026-04-15; new SDK setups should use OpenAI-compatible authentication or another supported provider. |
| `agents` | `SubagentConfig[]` | - | Configuration for subagents that can be invoked during the session. Subagents are specialized AI agents for specific tasks or domains. |
| `includePartialMessages` | `boolean` | `false` | When `true`, the SDK emits incomplete messages as they are being generated, allowing real-time streaming of the AI's response. |
| `resume` | `string` | - | Resume a previous session by providing its session ID. Equivalent to CLI's `--resume` flag. |
| `sessionId` | `string` | - | Specify a session ID for the new session. Ensures SDK and CLI use the same ID without resuming history. Equivalent to CLI's `--session-id` flag. |
| Option | Type | Default | Description |
| ------------------------ | -------------------------------------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `cwd` | `string` | `process.cwd()` | The working directory for the query session. Determines the context in which file operations and commands are executed. |
| `model` | `string` | - | The AI model to use (e.g., `'qwen-max'`, `'qwen-plus'`, `'qwen-turbo'`). Takes precedence over `OPENAI_MODEL` and `QWEN_MODEL` environment variables. |
| `pathToQwenExecutable` | `string` | Auto-detected | Path to the Qwen Code executable. Supports multiple formats: `'qwen'` (native binary from PATH), `'/path/to/qwen'` (explicit path), `'/path/to/cli.js'` (Node.js bundle), `'node:/path/to/cli.js'` (force Node.js runtime), `'bun:/path/to/cli.js'` (force Bun runtime). If not provided, auto-detects from: `QWEN_CODE_CLI_PATH` env var, `~/.volta/bin/qwen`, `~/.npm-global/bin/qwen`, `/usr/local/bin/qwen`, `~/.local/bin/qwen`, `~/node_modules/.bin/qwen`, `~/.yarn/bin/qwen`. |
| `permissionMode` | `'default' \| 'plan' \| 'auto-edit' \| 'auto' \| 'yolo'` | `'default'` | Permission mode controlling tool execution approval. See [Permission Modes](#permission-modes) for details. |
| `canUseTool` | `CanUseTool` | - | Custom permission handler for tool execution approval. Invoked when a tool requires confirmation. Must respond within 60 seconds or the request will be auto-denied. See [Custom Permission Handler](#custom-permission-handler). |
| `env` | `Record<string, string>` | - | Environment variables to pass to the Qwen Code process. Merged with the current process environment. |
| `systemPrompt` | `string \| QuerySystemPromptPreset` | - | System prompt configuration for the main session. Use a string to fully override the built-in Qwen Code system prompt, or a preset object to keep the built-in prompt and append extra instructions. |
| `mcpServers` | `Record<string, McpServerConfig>` | - | MCP (Model Context Protocol) servers to connect. Supports external servers (stdio/SSE/HTTP) and SDK-embedded servers. External servers are configured with transport options like `command`, `args`, `url`, `httpUrl`, etc. SDK servers use `{ type: 'sdk', name: string, instance: Server }`. |
| `abortController` | `AbortController` | - | Controller to cancel the query session. Call `abortController.abort()` to terminate the session and cleanup resources. |
| `debug` | `boolean` | `false` | Enable debug mode for verbose logging from the CLI process. |
| `maxSessionTurns` | `number` | `-1` (unlimited) | Maximum number of conversation turns before the session automatically terminates. Must be an integer. A turn consists of a user message and an assistant response. |
| `coreTools` | `string[]` | - | Uses the legacy `coreTools` / CLI `--core-tools` allowlist semantics. If specified, only matching core tools are registered for the session. This is separate from `permissions.allow` in settings.json, which also activates a registry-level allowlist at startup: when at least one valid allow rule is configured there (malformed entries do not count), built-in tools not covered by any allow or ask rule are not registered (MCP tools, the `--json-schema` `structured_output` contract, the plan-mode lifecycle tools, `task_stop`, `tool_search`, and the `computer_use__*` family are exempt; requires restart, #9827). The SDK `allowedTools` parameter cannot activate the allowlist on its own, but while the allowlist is active its rules are merged into the effective allow set and count toward coverage, keeping covered built-ins registered. Example: `['read_file', 'edit', 'run_shell_command']`. |
| `excludeTools` | `string[]` | - | Equivalent to `permissions.deny` in settings.json. Excluded tools return a permission error immediately. Takes highest priority over all other permission settings. Supports tool name aliases and pattern matching: tool name (`'write_file'`), shell command prefix (`'Bash(rm *)'`), or path patterns (`'Read(.env)'`, `'Edit(/src/**)'`). |
| `allowedTools` | `string[]` | - | Equivalent to `permissions.allow` in settings.json for auto-approval. Matching tools bypass `canUseTool` callback and execute automatically. Only applies when tool requires confirmation. Unlike `permissions.allow` in settings.json, this parameter alone does not activate the registry allowlist; however, while a settings-provided allowlist is active, `allowedTools` rules are merged into the effective allow set and count toward coverage, so covered built-ins stay registered. Supports same pattern matching as `excludeTools`. Example: `['Bash(git status)', 'Bash(npm test)']`. |
| `authType` | `'openai' \| 'qwen-oauth'` | `'openai'` | Authentication type for the AI service. Qwen OAuth free tier was discontinued on 2026-04-15; new SDK setups should use OpenAI-compatible authentication or another supported provider. |
| `agents` | `SubagentConfig[]` | - | Configuration for subagents that can be invoked during the session. Subagents are specialized AI agents for specific tasks or domains. |
| `includePartialMessages` | `boolean` | `false` | When `true`, the SDK emits incomplete messages as they are being generated, allowing real-time streaming of the AI's response. |
| `resume` | `string` | - | Resume a previous session by providing its session ID. Equivalent to CLI's `--resume` flag. |
| `sessionId` | `string` | - | Specify a session ID for the new session. Ensures SDK and CLI use the same ID without resuming history. Equivalent to CLI's `--session-id` flag. |
> [!note]
> For `coreTools`, aliases like `Read`, `Edit`, and `Bash` also work, but invocation specifiers such as `Bash(git *)` are stripped. `coreTools` restricts tool registration, not invocation patterns.
@@ -657,6 +657,8 @@ Setting `reasoning: false` (the literal boolean) explicitly disables thinking on
On a `api.deepseek.com` baseURL, the OpenAI pipeline emits the explicit `thinking: { type: 'disabled' }` field that DeepSeek V4+ requires — the server-side default is `'enabled'`, so simply omitting `reasoning_effort` would still pay thinking latency/cost. Self-hosted DeepSeek backends (sglang/vllm) and other OpenAI-compatible servers do **not** receive this field; if you need to disable thinking on those, inject `thinking: { type: 'disabled' }` (or whatever knob your inference framework exposes) via `samplingParams`/`extra_body`.
On an `openrouter.ai` baseURL, the OpenAI pipeline emits OpenRouter's provider-level `reasoning: { enabled: false }` field when reasoning is disabled. Other OpenAI-compatible servers do not receive this OpenRouter-specific field; use `samplingParams`/`extra_body` for their native disable knob.
### Interaction with `samplingParams` (OpenAI-compatible only)
> [!warning]
+6 -6
View File
@@ -370,7 +370,7 @@ If you are experiencing performance issues with file searching (e.g., with `@` c
| `tools.visible` | array of strings | Deferred tool names made visible at startup without requiring `tool_search`. Listed tools appear alongside core tools in the initial session. Merged as a union across scopes. | `undefined` | |
| `tools.allowed` | array of strings | **Deprecated.** Use `permissions.allow` instead. Tool names that bypass the confirmation dialog. Automatically migrated to the `permissions` format on first load. | `undefined` | |
| `tools.approvalMode` | string | Sets the default approval mode for tool usage. | `auto` | Possible values: `plan` (analyze only, do not modify files or execute commands), `default` (require approval before file edits or shell commands run), `auto-edit` (automatically approve file edits), `auto` (LLM classifier auto-approves safe actions, blocks risky ones), `yolo` (automatically approve all tool calls) |
| `tools.discoveryCommand` | string | Command to run for tool discovery. | `undefined` | |
| `tools.discoveryCommand` | string | Command to run for tool discovery. When the `permissions.allow` registry allowlist is active, a discovered tool is registered only if an allow or ask rule covers it; uncovered discovered tools are hidden from the model instead of being advertised and then rejected at runtime. | `undefined` | |
| `tools.callCommand` | string | Defines a custom shell command for calling a specific tool that was discovered using `tools.discoveryCommand`. The shell command must meet the following criteria: It must take function `name` (exactly as in [function declaration](https://ai.google.dev/gemini-api/docs/function-calling#function-declarations)) as first command line argument. It must read function arguments as JSON on `stdin`, analogous to [`functionCall.args`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functioncall). It must return function output as JSON on `stdout`, analogous to [`functionResponse.response.content`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functionresponse). | `undefined` | |
| `tools.useRipgrep` | boolean | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` | |
| `tools.useBuiltinRipgrep` | boolean | Use the bundled ripgrep binary. When set to `false`, the system-level `rg` command will be used instead. This setting is only effective when `tools.useRipgrep` is `true`. | `true` | |
@@ -416,11 +416,11 @@ The permissions system provides fine-grained control over which tools can run, w
The first matching rule wins. Rules use the format `"ToolName"` or `"ToolName(specifier)"`.
| Setting | Type | Description | Default |
| ------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------- | ----------- |
| `permissions.allow` | array of strings | Rules for auto-approved tool calls (no confirmation needed). Merged across all scopes (user + project + system). | `undefined` |
| `permissions.ask` | array of strings | Rules for tool calls that always require user confirmation. Takes priority over `allow`. | `undefined` |
| `permissions.deny` | array of strings | Rules for blocked tool calls. Highest priority — overrides both `allow` and `ask`. | `undefined` |
| Setting | Type | Description | Default |
| ------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `permissions.allow` | array of strings | Rules for auto-approved tool calls (no confirmation needed). Merged across all scopes (user + project + system). Also acts as a registry-level allowlist: when at least one valid allow rule is configured (malformed entries do not count, and auto-approval-only sources such as the `--allowed-tools` CLI flag do not activate it), built-in tools not covered by any allow or ask rule are not registered at all — they disappear from `/tools` and their schemas are never sent to the model (MCP tools, the `--json-schema` `structured_output` contract, the plan-mode lifecycle tools `exit_plan_mode` / `enter_plan_mode` / `ask_user_question`, `task_stop`, `tool_search`, and the deferred `computer_use__*` family are exempt). Removing a rule mid-session revokes its auto-approval immediately but does not deregister an already-registered tool; deregistration takes effect on restart. Exception: under the AUTO approval mode, dangerous allow rules are stashed rather than active, so a mid-session removal cannot touch the stash — when AUTO mode is exited the stashed rule is restored and auto-approves again until the session restarts. Requires restart. | `undefined` |
| `permissions.ask` | array of strings | Rules for tool calls that always require user confirmation. Takes priority over `allow`. Ask rules also count toward the registry allowlist: a tool covered by an ask rule stays registered even when an allowlist is active, so "always require confirmation" never silently becomes "tool unavailable". | `undefined` |
| `permissions.deny` | array of strings | Rules for blocked tool calls. Highest priority — overrides both `allow` and `ask`. A whole-tool deny rule (no specifier) also removes the tool from the registry — for built-in tools and tools found via `tools.discoveryCommand`. MCP tools are exempt (their registration path only honours `disabledTools`): hide them with the per-server `excludeTools` / `tools.disabled` filters instead. Deny rules still block MCP tool calls at runtime. | `undefined` |
**Tool name aliases (any of these work in rules):**
+1
View File
@@ -3,6 +3,7 @@ export default {
telegram: 'Telegram',
weixin: 'WeChat',
dingtalk: 'DingTalk',
dws: 'DingTalk Workspace',
wecom: 'WeCom',
feishu: 'Feishu',
qqbot: 'QQ Bot',
+120
View File
@@ -0,0 +1,120 @@
# DingTalk Workspace (DWS)
The DWS channel uses an account already authenticated by the DingTalk Workspace CLI. It receives direct and group messages, recognizes DingTalk document-mention notification cards, and publishes the agent's response back to the originating message or document comment.
This is separate from the [DingTalk bot channel](./dingtalk). Keep using `type: "dingtalk"` for a dedicated application bot; use `type: "dws"` when Qwen Code should act through an existing DWS login.
## Prerequisites
Install DWS CLI 1.0.57 or newer on the host that runs Qwen Code, and ensure `dws` resolves from that process's `PATH`:
```bash
dws version --format json
```
Authenticate on the same host:
```bash
dws auth login
dws profile list --format json
dws auth status --format json
```
On a headless server, use `dws auth login --device`. A channel pins exactly one existing profile at startup. Set `profile` to an exact profile name or corpId, or omit it to pin the entry marked `isCurrent`. The channel treats every DWS login the same and does not depend on `user_id` metadata.
## Configuration
Add a channel to `~/.qwen/settings.json`:
```json
{
"channels": {
"dws-work": {
"type": "dws",
"profile": "profile-name-or-corp-id",
"senderPolicy": "pairing",
"groupPolicy": "pairing",
"watchTodos": true,
"groups": {
"*": { "requireMention": true }
},
"sessionScope": "chat_thread",
"cwd": "/path/to/your/project"
}
}
}
```
YOLO approval mode is available for answer bots that should run tool calls
without interactive confirmations:
```json
{
"channels": {
"dws-answers": {
"type": "dws",
"senderPolicy": "pairing",
"groupPolicy": "pairing",
"approvalMode": "yolo",
"cwd": "/path/to/answer-bot"
}
}
}
```
YOLO mode auto-approves every tool call. Use it only for a trusted bot account
and workspace.
`senderPolicy` and `groupPolicy` default to `pairing` for a newly managed DWS channel. Approve a user or group with the code returned by the channel:
```bash
qwen channel pairing approve dws-work CODE
```
`senderPolicy` controls direct-message senders, document-notification authors, native-todo creators, and senders in `open` or `allowlist` groups. `groupPolicy` controls group conversations. An approved pairing group follows the shared channel behavior and authorizes its members; open and allowlist groups must also pass `senderPolicy`.
`groups` controls mention behavior. A concrete group ID overrides `"*"`. With `requireMention: true`, only an @ message wakes the channel. With `requireMention: false`, ordinary messages are also received after the group and sender policies pass.
Group mentions use the real-time personal event stream first. The channel also checks recent `@` message history every five seconds, so mentions from external groups are recovered when DingTalk omits them from the personal event stream. Messages are deduplicated by conversation and message ID across both paths.
When a message quotes another DingTalk message, the quoted text is included as reply context for the agent on both the real-time and history fallback paths.
## Document Mentions
There is no document or knowledge-base watch list. To start a document task:
1. Add a DingTalk document comment that @mentions the authenticated account.
2. Enable the option that sends a DingTalk notification to that account.
3. DWS delivers the notification card through the account's direct-message history.
The channel extracts the document ID, comment key, and request from that notification. It reads the referenced document for context, adds DingTalk's `暗中观察` eyes reaction while the task runs, and replies to the original document comment. The real-time DWS event stream is used when it contains the card; a five-second incremental history check covers cards omitted by the current event stream.
Comments that do not generate a notification are ignored by design. Duplicate notification messages for the same document comment execute only once. Document tasks follow `senderPolicy` and support `approvalMode` `default`, `plan`, or `yolo`; `default` is used when omitted.
## Native Todo Changes
Set `watchTodos: true` to poll the selected DWS profile's pending native todos where the account is an executor. The option defaults to `false` so adding a DWS channel never executes existing todos implicitly.
The first successful scan establishes a baseline and does not start historical todos. Later scans run a task when a todo is newly assigned, reopened, or its actionable fields change, including its title, priority, deadline, or assignees. The final response is added as a comment on the originating todo. Comment-only metadata and modification timestamps are excluded from change detection so the channel's own response cannot trigger a loop. Completion or removal drops the todo from the pending set; reopening it creates a new trigger.
Native todos follow `senderPolicy` using the todo creator identity. Under `pairing`, the channel adds one pairing-code comment and keeps the todo pending; after the creator is approved locally, a later poll can process the unchanged todo. Polling runs every 30 seconds and remains scoped to the pinned profile's current organization.
## Starting and Verifying
Run the channel directly:
```bash
qwen channel start dws-work
```
Or let the daemon own it:
```bash
qwen serve --workspace /path/to/your/project --channel dws-work
```
Do not run both forms at once because they share the channel-service lease.
For local verification, send a direct message from another account, approve pairing if required, and verify the eyes reaction appears while the task runs. Then add a document comment with @mention notification enabled. The channel should react to the notification message, read the document, and post the final answer under the original comment. A comment with notification disabled should produce no task.
The channel ignores events from sender IDs that DWS identifies as the authenticated account, preventing reply and pairing loops without inferring identity from message text. Starting the IM sources requires that authoritative self-identity: if the authenticated account exposes no openDingTalkId and no earlier session under the same profile recorded one, the channel refuses to connect. A reconnect that temporarily loses the ID keeps filtering on the previously recorded self sender IDs.
+3 -3
View File
@@ -2,7 +2,7 @@
Channels let you interact with a Qwen Code agent from messaging platforms like Telegram, WeChat, QQ, DingTalk, WeCom, or Feishu, instead of the terminal. You send messages from your phone or desktop chat app, and the agent responds just like it would in the CLI.
Code-hosting platforms (starting with [GitHub](./github)) are also supported via polling adapters — the agent monitors notifications and responds to @mentions on issues and pull requests.
Code-hosting platforms (starting with [GitHub](./github)) and authenticated workspace accounts (starting with [DingTalk Workspace](./dws)) are also supported through channels.
## How It Works
@@ -17,7 +17,7 @@ All channels share one agent process with isolated sessions per user. Each chann
## Quick Start
1. Set up a bot on your messaging platform (see channel-specific guides: [Telegram](./telegram), [WeChat](./weixin), [QQ Bot](./qqbot), [DingTalk](./dingtalk), [WeCom](./wecom), [Feishu](./feishu), [GitHub](./github))
1. Set up a bot or authenticated workspace account (see channel-specific guides: [Telegram](./telegram), [WeChat](./weixin), [QQ Bot](./qqbot), [DingTalk](./dingtalk), [DingTalk Workspace](./dws), [WeCom](./wecom), [Feishu](./feishu), [GitHub](./github))
2. Add the channel configuration to `~/.qwen/settings.json`
3. Run `qwen channel start` to start all channels, or `qwen channel start <name>` for a single channel
@@ -52,7 +52,7 @@ Channels are configured under the `channels` key in `settings.json`. Each channe
| Option | Required | Description |
| ------------------------ | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | Yes | Channel type: `telegram`, `weixin`, `qq`, `dingtalk`, `wecom`, `feishu`, `github`, or a custom type from an extension (see [Plugins](./plugins)) |
| `type` | Yes | Channel type: `telegram`, `weixin`, `qq`, `dingtalk`, `dws`, `wecom`, `feishu`, `github`, `gitlab`, or a custom type from an extension (see [Plugins](./plugins)) |
| `token` | Telegram | Bot token. Supports `$ENV_VAR` syntax to read from environment variables. Not needed for WeChat, DingTalk, WeCom, or Feishu |
| `clientId` | DingTalk, Feishu | DingTalk AppKey or Feishu App ID. Supports `$ENV_VAR` syntax |
| `clientSecret` | DingTalk, Feishu | DingTalk AppSecret or Feishu App Secret. Supports `$ENV_VAR` syntax |
+1 -1
View File
@@ -365,7 +365,7 @@ If you switch models (via `/model`) and re-review the same PR, `/review` detects
# → "Previous review used qwen3-coder. Running full review with gpt-4o for a second opinion."
```
The model match also gates incremental scoping, not just the skip: "clean up to the cached commit" is the previous model's verdict, so when new commits have landed since the cached review, a model mismatch never scopes to `lastCommitSha..HEAD` — the range is the full diff, noting "Previous round was reviewed by qwen3-coder. Running full review with gpt-4o." — unless an anchor certified by the model now running is recovered from the last posted review (below), which scopes the range instead. The previous round's findings still carry over to be re-ruled; only the anchor does not. The same gate binds the anchor recovered from the last posted review's machine-ledger marker when the cache is absent or its anchor is unusable (CI, another clone): it scopes the incremental range only if the model now running certified it — a marker certified by a different model, or carrying no model (a review posted with `review.attribution` off, or one from before the field), falls back to the full diff.
The model match also gates incremental scoping, not just the skip: "clean up to the cached commit" is the previous model's verdict, so when new commits have landed since the cached review, a model mismatch never scopes to `lastCommitSha..HEAD` — the range is the full diff, noting "Previous round was reviewed by qwen3-coder. Running full review with gpt-4o." — unless an anchor certified by the model now running is recovered from the last posted review (below), which scopes the range instead. The previous round's findings still carry over to be re-ruled; only the anchor does not. The same gate binds the anchor recovered from the last posted review's machine-ledger marker when the cache is absent or its anchor is unusable (CI, another clone): it scopes the incremental range only if the model now running certified it — a marker certified by a different model, or carrying no model (a review posted with `review.attribution` off, or one from before the field), falls back to the full diff. A round that did not close cleanly posts its marker without an anchor (it cannot certify a range), but that loss is not sticky when the round's work list survived whole: recovery grafts the anchor forward from the most recent earlier marker your own account posted with one, so a single non-clean round no longer forces every later round to re-read the full diff — the next round scopes `anchor..HEAD`, which re-covers the range the non-clean round could not certify. A size-capped round's graft is refused (dropped findings would fall outside the grafted scope and retire silently), so later rounds keep re-reading the full diff until a complete marker lands.
Cache is stored in `.qwen/review-cache/` and tracks both the commit SHA and model ID. Make sure this directory is in your `.gitignore` (a broader rule like `.qwen/*` also works). On GitHub, if the cached commit was rebased or force-pushed away, it falls back to a full review; Aone rules the cached anchor differently — see its paragraph below. Only high-effort reviews consult or write the cache — a `--effort low|medium` quick pass never counts as "already reviewed".
+1 -1
View File
@@ -398,7 +398,7 @@ Notes:
- **Scope is TLS termination only** — no auto-generation, no ACME / Let's Encrypt. This is a LAN / dev convenience; for internet-facing deployments terminate TLS at a reverse proxy (see the threat model below).
- **Channel workers dial the daemon back over `https://`** — so they need to trust the serving certificate too. A self-signed cert (or a fullchain that carries its own root) needs nothing: the daemon injects it into each worker's `NODE_EXTRA_CA_CERTS`. The mkcert flow above is **CA-issued**, so the leaf alone cannot anchor the chain — export `NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem"` in the daemon's launch environment before starting with `--channel`. An operator-set value is _merged_ with the daemon cert, not replaced. Without it the daemon boots green while every channel worker restart-loops on `UNABLE_TO_VERIFY_LEAF_SIGNATURE`; the daemon log names the gap at boot.
- **An IPv6 wildcard bind is dialled back on `::1`, not `127.0.0.1`** — `--hostname ::` (or `[::]`) binds an IPv6 socket, and an empty `--hostname` binds one too when IPv6 is available (when it is not, Node falls back to binding `0.0.0.0`). A kernel with `net.ipv6.bindv6only=1` — or a host with no IPv4 at all — will not answer `127.0.0.1` on an IPv6 wildcard socket, so workers are sent to the loopback of the family actually bound: `[::1]` for the IPv6 bind, `127.0.0.1` on the IPv4 fallback. A serving certificate for an IPv6 wildcard bind therefore needs `::1` in its SANs (`mkcert ... ::1`); the boot trust diagnostic names the gap if it doesn't. `--hostname 0.0.0.0` is unchanged and still needs `127.0.0.1`.
- **Rotating `--tls-cert` in place needs a daemon restart** — the daemon serves the bytes it read at boot, so until it restarts, respawned workers can load newer contents than the daemon presents. Their handshakes fail exactly when **the trust anchor rotates out with the certificate** — always for a self-signed cert, since the leaf is its own anchor (`DEPTH_ZERO_SELF_SIGNED_CERT`), and for a fullchain only if its root is replaced too (`SELF_SIGNED_CERT_IN_CHAIN`). Rotating a fullchain to a renewed leaf under the **same** carried root keeps verifying: that root still anchors the old leaf the daemon is still serving. With an operator `NODE_EXTRA_CA_CERTS` CA set — the mkcert flow above — that CA anchors both generations for the same reason; only the serving side lags until the restart.
- **Rotating `--tls-cert` in place needs a daemon restart** — the daemon serves the bytes it read at boot, so until it restarts, respawned workers can load newer contents than the daemon presents and their handshakes fail.
## CLI flags
+1 -1
View File
@@ -51,9 +51,9 @@ export default tseslint.config(
'docs-site/.next/**',
'docs-site/out/**',
'.qwen/**',
'packages/desktop/**',
'packages/desktop-shell/runtime/**',
'packages/desktop-shell/src-tauri/target/**',
'packages/live-host/**', // standalone Electron app with its own Node test conventions
'packages/cua-driver/**', // vendored trycua/cua driver (Rust + scripts); not qwen-code TS
'packages/mobile-mcp/**', // vendored mobile-next/mobile-mcp; has own eslint config
],
@@ -357,6 +357,7 @@ describe('qwen serve — capabilities envelope', () => {
'session_status',
'session_close',
'session_archive',
'session_storage_conflict_repair',
'session_metadata',
'session_organization',
'session_export',
+3 -2
View File
@@ -11,7 +11,7 @@
"composite": false,
// The root turns `noPropertyAccessFromIndexSignature` on, which forced
// bracket-access rewrites in production SDK sources just to satisfy this
// test program (packages/desktop already sets it false). Relax it here so
// test program. Relax it here so
// packages keep their own compiler regime and the tests keep dot access.
"noPropertyAccessFromIndexSignature": false,
// Matches packages/cli. The suite drives browser-side code in
@@ -135,7 +135,7 @@
// sites share one declaration — mixing src and dist declarations
// produces duplicate-private-class errors.
"@qwen-code/channel-base": ["../packages/channels/base/src/index.ts"],
// cli's channel-registry.ts imports the eight builtin channel
// cli's channel-registry.ts imports the nine builtin channel
// adapters and html.ts imports web-templates; without entries they
// resolve through their exports maps to dist, leaving the typecheck
// dependent on those packages being built.
@@ -146,6 +146,7 @@
"@qwen-code/channel-dingtalk": [
"../packages/channels/dingtalk/src/index.ts"
],
"@qwen-code/channel-dws": ["../packages/channels/dws/src/index.ts"],
"@qwen-code/channel-wecom": ["../packages/channels/wecom/src/index.ts"],
"@qwen-code/channel-feishu": ["../packages/channels/feishu/src/index.ts"],
"@qwen-code/channel-qqbot": ["../packages/channels/qqbot/src/index.ts"],
+29 -2
View File
@@ -18,11 +18,12 @@
"packages/channels/feishu",
"packages/channels/qqbot",
"packages/channels/github",
"packages/channels/dws",
"packages/channels/gitlab",
"packages/channels/plugin-example",
"integrations/external-context",
"!packages/desktop",
"!packages/desktop-shell"
"!packages/desktop-shell",
"!packages/live-host"
],
"dependencies": {
"@testing-library/dom": "^10.4.1",
@@ -5285,6 +5286,10 @@
"resolved": "packages/channels/dingtalk",
"link": true
},
"node_modules/@qwen-code/channel-dws": {
"resolved": "packages/channels/dws",
"link": true
},
"node_modules/@qwen-code/channel-feishu": {
"resolved": "packages/channels/feishu",
"link": true
@@ -19028,6 +19033,7 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -19049,6 +19055,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -19070,6 +19077,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -19091,6 +19099,7 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -19112,6 +19121,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -19133,6 +19143,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -19154,6 +19165,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -19175,6 +19187,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -19196,6 +19209,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -19217,6 +19231,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -19238,6 +19253,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -29472,6 +29488,16 @@
"typescript": "^5.0.0"
}
},
"packages/channels/dws": {
"name": "@qwen-code/channel-dws",
"version": "0.22.0",
"dependencies": {
"@qwen-code/channel-base": "0.22.0"
},
"devDependencies": {
"typescript": "^5.0.0"
}
},
"packages/channels/feishu": {
"name": "@qwen-code/channel-feishu",
"version": "0.22.0",
@@ -29623,6 +29649,7 @@
"@qwen-code/audio-capture": "file:../audio-capture",
"@qwen-code/channel-base": "file:../channels/base",
"@qwen-code/channel-dingtalk": "file:../channels/dingtalk",
"@qwen-code/channel-dws": "file:../channels/dws",
"@qwen-code/channel-feishu": "file:../channels/feishu",
"@qwen-code/channel-github": "file:../channels/github",
"@qwen-code/channel-gitlab": "file:../channels/gitlab",
+3 -4
View File
@@ -15,11 +15,12 @@
"packages/channels/feishu",
"packages/channels/qqbot",
"packages/channels/github",
"packages/channels/dws",
"packages/channels/gitlab",
"packages/channels/plugin-example",
"integrations/external-context",
"!packages/desktop",
"!packages/desktop-shell"
"!packages/desktop-shell",
"!packages/live-host"
],
"repository": {
"type": "git",
@@ -90,9 +91,7 @@
"telemetry": "node scripts/telemetry.js",
"check:lockfile": "node scripts/check-lockfile.js",
"check:desktop-isolation": "node scripts/check-desktop-isolation.js",
"check:voice-guard-sync": "node scripts/check-voice-guard-sync.js",
"check:serve-fast-path-bundle": "node scripts/clean-package-build-artifacts.js && npm run build -- --cli-only && cross-env DEV=true npm run bundle && node scripts/check-serve-fast-path-bundle.js",
"desktop-openwork-sync": "bun run scripts/desktop-openwork-sync.ts",
"clean": "node scripts/clean.js",
"pre-commit": "node scripts/pre-commit.js"
},
+173 -1
View File
@@ -87,6 +87,8 @@ import {
ACTIVE_WORK_MAX_SNAPSHOT_SESSIONS,
ACTIVE_WORK_NOTIFICATION_METHOD,
ACTIVE_WORK_STALE_INTERVALS,
CHANNEL_LIVENESS_META_KEY,
CHANNEL_LIVENESS_VERSION,
gradeActiveWorkCoverage,
CHANNEL_STARTUP_PROFILE_META_KEY,
CHANNEL_STARTUP_PROFILE_VERSION,
@@ -94,6 +96,11 @@ import {
WORKTREE_MCP_DEFER_META_KEY,
LOAD_REPLAY_HIDE_INHERITED_META_KEY,
} from './bridgeTypes.js';
import {
CHANNEL_LIVENESS_INTERVAL_MS,
CHANNEL_LIVENESS_PROBE_TIMEOUT_MS,
CHANNEL_LIVENESS_TIMEOUT_CODE,
} from './channel-liveness.js';
import {
ApprovalMode,
SESSION_ARTIFACT_PERSISTENCE_VERSION,
@@ -276,6 +283,18 @@ function activeWorkInitializeResponse(
};
}
function channelLivenessInitializeResponse(): InitializeResponse {
return {
protocolVersion: PROTOCOL_VERSION,
agentInfo: { name: 'channel-liveness-agent', version: '0' },
authMethods: [],
agentCapabilities: {},
_meta: {
[CHANNEL_LIVENESS_META_KEY]: { v: CHANNEL_LIVENESS_VERSION },
},
};
}
function agentHold(id: string) {
return { category: 'agent' as const, id };
}
@@ -11857,6 +11876,155 @@ describe('createAcpSessionBridge', () => {
void killPromise;
});
describe('channel liveness', () => {
it('advertises v1 but stays disabled when the child does not acknowledge it', async () => {
vi.useFakeTimers();
try {
const handle = makeChannel();
const bridge = makeBridge({
channelFactory: async () => handle.channel,
});
await bridge.spawnOrAttach({ workspaceCwd: WS_A });
expect(handle.agent.initializeCalls[0]?._meta).toMatchObject({
[CHANNEL_LIVENESS_META_KEY]: { v: CHANNEL_LIVENESS_VERSION },
});
await vi.advanceTimersByTimeAsync(
CHANNEL_LIVENESS_INTERVAL_MS + CHANNEL_LIVENESS_PROBE_TIMEOUT_MS * 2,
);
expect(handle.agent.extMethodCalls).not.toContainEqual(
expect.objectContaining({
method: SERVE_STATUS_EXT_METHODS.channelPing,
}),
);
expect(handle.killed).toBe(false);
await bridge.shutdown();
} finally {
vi.useRealTimers();
}
});
it('fails the transport after two unanswered channel probes', async () => {
vi.useFakeTimers();
try {
const event = vi.fn();
const channelLifecycle = vi.fn();
const handle = makeChannel({
initializeImpl: () => channelLivenessInitializeResponse(),
extMethodImpl: (method) =>
method === SERVE_STATUS_EXT_METHODS.channelPing
? new Promise(() => {})
: {},
});
const baseKill = handle.channel.kill;
const transportFail = vi.fn((error: unknown) => {
void baseKill();
return error;
});
handle.channel = {
...handle.channel,
transportGuard: {
maxActiveHandlers: 10,
maxActiveHandlerBytes: 1024 * 1024,
reserveOutboundOperation: () => () => {},
reservePreparedResponse: () => {},
fail: transportFail,
},
};
const bridge = makeBridge({
channelFactory: async () => handle.channel,
sessionScope: 'thread',
telemetry: {
captureContext: () => undefined,
runWithContext: async (_captured, fn) => await fn(),
withSpan: async (_operation, _attributes, fn) => await fn(),
event,
injectPromptContext: (request) => request,
metrics: {
sessionLifecycle: vi.fn(),
channelLifecycle,
promptQueueWait: vi.fn(),
promptDuration: vi.fn(),
cancelled: vi.fn(),
},
},
});
const first = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const second = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
expect(bridge.sessionCount).toBe(2);
const firstEvents: BridgeEvent[] = [];
const secondEvents: BridgeEvent[] = [];
const firstDrain = (async () => {
for await (const event of bridge.subscribeEvents(first.sessionId)) {
firstEvents.push(event);
}
})();
const secondDrain = (async () => {
for await (const event of bridge.subscribeEvents(second.sessionId)) {
secondEvents.push(event);
}
})();
await vi.advanceTimersByTimeAsync(CHANNEL_LIVENESS_INTERVAL_MS);
const firstProbeCalls = handle.agent.extMethodCalls.filter(
({ method }) => method === SERVE_STATUS_EXT_METHODS.channelPing,
);
expect(firstProbeCalls).toEqual([
{
method: SERVE_STATUS_EXT_METHODS.channelPing,
params: { v: CHANNEL_LIVENESS_VERSION, nonce: 0 },
},
]);
await vi.advanceTimersByTimeAsync(CHANNEL_LIVENESS_PROBE_TIMEOUT_MS);
expect(transportFail).not.toHaveBeenCalled();
expect(
handle.agent.extMethodCalls.filter(
({ method }) => method === SERVE_STATUS_EXT_METHODS.channelPing,
),
).toHaveLength(2);
await vi.advanceTimersByTimeAsync(CHANNEL_LIVENESS_PROBE_TIMEOUT_MS);
await handle.channel.exited;
await Promise.all([firstDrain, secondDrain]);
expect(transportFail).toHaveBeenCalledOnce();
expect(transportFail.mock.calls[0]?.[0]).toMatchObject({
code: CHANNEL_LIVENESS_TIMEOUT_CODE,
});
expect(bridge.sessionCount).toBe(0);
expect(firstEvents.at(-1)).toMatchObject({
type: 'session_died',
data: { sessionId: first.sessionId, reason: 'channel_closed' },
});
expect(secondEvents.at(-1)).toMatchObject({
type: 'session_died',
data: { sessionId: second.sessionId, reason: 'channel_closed' },
});
expect(channelLifecycle).toHaveBeenCalledWith('exit', false);
expect(event).toHaveBeenCalledWith(
'channel.liveness_failed',
expect.objectContaining({
'qwen-code.daemon.channel.session_count': 2,
'qwen-code.daemon.channel.transport_error_code':
CHANNEL_LIVENESS_TIMEOUT_CODE,
}),
);
expect(event).toHaveBeenCalledWith(
'channel.exited',
expect.objectContaining({
'qwen-code.daemon.channel.transport_failed': true,
'qwen-code.daemon.channel.transport_failure_initiated_teardown': true,
'qwen-code.daemon.channel.transport_error_code':
CHANNEL_LIVENESS_TIMEOUT_CODE,
}),
);
await bridge.shutdown();
} finally {
vi.useRealTimers();
}
});
});
it('transport failure marks the channel dying before process exit', async () => {
const handles: ChannelHandle[] = [];
const failures: Array<ReturnType<typeof deferred<unknown>>> = [];
@@ -26794,8 +26962,12 @@ describe('createAcpSessionBridge', () => {
expect(stderrSpy).toHaveBeenCalledWith(
expect.stringContaining('updated session metadata'),
);
// The audit line prints the id through JSON.stringify, which escapes
// the backslashes of a Windows-spelled session id.
expect(stderrSpy).toHaveBeenCalledWith(
expect.stringContaining(session.sessionId),
expect.stringContaining(
JSON.stringify(session.sessionId).slice(1, -1),
),
);
expect(stderrSpy).toHaveBeenCalledWith(
expect.stringContaining('pr=9517'),
+62 -3
View File
@@ -135,6 +135,8 @@ import {
ACTIVE_WORK_HOLD_CATEGORIES,
ACTIVE_WORK_MAX_SESSION_HOLDS,
ACTIVE_WORK_STALE_INTERVALS,
CHANNEL_LIVENESS_META_KEY,
CHANNEL_LIVENESS_VERSION,
clampActiveWorkIntervalMs,
type ActiveWorkHeartbeatCapabilityV1,
type ActiveWorkHoldCategory,
@@ -163,6 +165,11 @@ import {
isValidTrustedModelPrompt,
sessionCloseDrainBudgetMs,
} from './bridgeTypes.js';
import {
startChannelLivenessMonitor,
type ChannelLivenessMonitor,
type ChannelLivenessFailure,
} from './channel-liveness.js';
import { getChannelStartupProfileAttributes } from './channel-startup-profile.js';
import type {
BridgeSession,
@@ -966,6 +973,7 @@ interface ChannelInfo {
/** Highest snapshot sequence applied; guards against reordering only. */
seq: number;
};
channelLiveness?: ChannelLivenessMonitor;
handshakeComplete: boolean;
}
@@ -3281,6 +3289,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
context?: string,
): Promise<void> {
ci.isDying = true;
ci.channelLiveness?.stop();
await ci.channel.kill().catch((err) => {
writeStderrLine(
`qwen serve: channel kill failed${context ? ` (${context})` : ''}: ${String(err)}`,
@@ -3432,6 +3441,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
if (!channelShouldReapWhenIdle(ci) || !hasNoChannelWork(ci)) return;
ci.emptyReapPending = false;
ci.isDying = true;
ci.channelLiveness?.stop();
await ci.channel.kill().catch(() => {
/* best-effort — channel.exited handler still runs */
});
@@ -4131,6 +4141,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
info.transportFailed = true;
info.transportFailureCode = safeTransportFailureCode(error);
info.isDying = true;
info.channelLiveness?.stop();
clearInFlightExtensionRefreshes(info.connection);
};
void channel.transportFailed?.then(
@@ -4176,6 +4187,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
// the SIGTERM grace window — even if a concurrent `spawnOrAttach`
// has already reassigned `channelInfo` to a fresh channel.
void channel.exited.then((exitInfo) => {
info.channelLiveness?.stop();
clearInFlightExtensionRefreshes(info.connection);
if (channelInfo === info) cancelIdleTimer();
if (info.workspaceMcpDiscoveryTimer) {
@@ -4295,6 +4307,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
// registered, so failure paths (init throw, timeout, late
// shutdown) only need to mark dying + kill — the handler does
// the alive-set cleanup when the OS reaps the child.
let channelLivenessNegotiated = false;
try {
await telemetry.withSpan(
'channel.initialize',
@@ -4316,6 +4329,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
[CHANNEL_STARTUP_PROFILE_META_KEY]: {
v: CHANNEL_STARTUP_PROFILE_VERSION,
},
[CHANNEL_LIVENESS_META_KEY]: {
v: CHANNEL_LIVENESS_VERSION,
},
[PRIVATE_PARENT_CAPABILITY_META_KEY]:
privateParentCapability,
},
@@ -4364,6 +4380,12 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
seq: 0,
};
}
const channelLivenessCapability = isRecord(response._meta)
? response._meta[CHANNEL_LIVENESS_META_KEY]
: undefined;
channelLivenessNegotiated =
isRecord(channelLivenessCapability) &&
channelLivenessCapability['v'] === CHANNEL_LIVENESS_VERSION;
try {
const attributes = getChannelStartupProfileAttributes(
response,
@@ -4418,6 +4440,38 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
// caller.
channelInfo = info;
info.handshakeComplete = true;
if (channelLivenessNegotiated) {
const failChannelLiveness = (error: ChannelLivenessFailure) => {
if (info.isDying || !aliveChannels.has(info)) return;
markTransportFailed(error);
telemetry.event('channel.liveness_failed', {
'qwen-code.daemon.acp_channel.id': info.id,
'qwen-code.daemon.channel.session_count': info.sessionIds.size,
'qwen-code.daemon.channel.transport_error_code': error.code,
});
writeStderrLine(
`qwen serve: channel liveness failed (${error.code}); killing channel`,
);
if (info.channel.transportGuard) {
info.channel.transportGuard.fail(error);
} else {
void killChannelWithLog(info, 'channel liveness failure');
}
};
info.channelLiveness = startChannelLivenessMonitor({
probe: (nonce) =>
info.connection.extMethod(SERVE_STATUS_EXT_METHODS.channelPing, {
v: CHANNEL_LIVENESS_VERSION,
nonce,
}),
onFailure: failChannelLiveness,
isActive: () =>
channelInfo === info &&
aliveChannels.has(info) &&
!info.isDying &&
!shuttingDown,
});
}
telemetry.metrics?.channelLifecycle('spawn');
return info;
})();
@@ -4559,6 +4613,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
// stays set until OS reap so `killAllSync` mid-SIGTERM still
// finds a target (BkUyD invariant).
ci.isDying = true;
ci.channelLiveness?.stop();
await ci.channel.kill().catch(() => {
/* best-effort — channel.exited handler still runs */
});
@@ -11162,11 +11217,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
return await entry.attachments.remove(attachmentId);
},
async deleteSessionAttachments(sessionId) {
async deleteSessionAttachments(sessionId, options) {
const store =
byId.get(sessionId)?.attachments ??
new SessionAttachmentStore(opts.sessionAttachmentsRoot, sessionId);
await store.delete();
await store.delete(options);
},
removePendingPrompt(sessionId, promptId, context) {
@@ -12565,6 +12620,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
});
}
for (const info of channels) {
info.channelLiveness?.stop();
try {
info.channel.killSync();
} catch {
@@ -12605,7 +12661,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
// `ensureChannel` past the gate would still see the dying
// state and not attach).
const channels = Array.from(aliveChannels);
for (const ci of channels) ci.isDying = true;
for (const ci of channels) {
ci.isDying = true;
ci.channelLiveness?.stop();
}
// Drain mediator pending state before clearing byId so awaiting
// `requestPermission` callers unwind. Each `forgetSession`
// settles all matching pending as session_closed; the bridge's
+24 -15
View File
@@ -252,6 +252,8 @@ export const REQUESTED_SESSION_ID_META_KEY = 'qwen-code/sessionId';
export const CHANNEL_STARTUP_PROFILE_META_KEY =
'qwen.daemon.channelStartupProfile';
export const CHANNEL_STARTUP_PROFILE_VERSION = 1 as const;
export const CHANNEL_LIVENESS_META_KEY = 'qwen.daemon.channelLiveness';
export const CHANNEL_LIVENESS_VERSION = 1 as const;
export const ACTIVE_WORK_HEARTBEAT_META_KEY = 'qwen.daemon.activeWorkHeartbeat';
export const ACTIVE_WORK_HEARTBEAT_VERSION = 1 as const;
/** Reporting cadence the daemon asks for; the child may choose another value
@@ -1265,7 +1267,24 @@ export type RuntimeMcpServerRemoveResult =
}
| { name: string; skipped: true; reason: 'not_present' };
export interface AcpSessionBridge {
export interface WorkspaceEventPublisher {
/**
* Workspace-level event fan-out for mutations that change daemon-wide state.
* Best-effort per session; closed buses silently skipped.
*/
publishWorkspaceEvent(event: Omit<BridgeEvent, 'id' | 'v'>): void;
}
export interface WorkspaceEventBridge extends WorkspaceEventPublisher {
/**
* Union of every live session's `clientIds`. Used by workspace-level
* mutation routes to validate the optional `X-Qwen-Client-Id` header.
* Returns a snapshot — callers must not mutate.
*/
knownClientIds(): ReadonlySet<string>;
}
export interface AcpSessionBridge extends WorkspaceEventBridge {
/** Read-only daemon diagnostics for status endpoints. */
getDaemonStatusSnapshot(): BridgeDaemonStatusSnapshot;
@@ -1570,19 +1589,6 @@ export interface AcpSessionBridge {
*/
getHeartbeatState(sessionId: string): BridgeHeartbeatState | undefined;
/**
* Workspace-level event fan-out for mutations that change daemon-wide state.
* Best-effort per session; closed buses silently skipped.
*/
publishWorkspaceEvent(event: Omit<BridgeEvent, 'id' | 'v'>): void;
/**
* Union of every live session's `clientIds`. Used by workspace-level
* mutation routes to validate the optional `X-Qwen-Client-Id` header.
* Returns a snapshot — callers must not mutate.
*/
knownClientIds(): ReadonlySet<string>;
/**
* Generic workspace-status query delegated through the live ACP channel.
* Returns `idle()` when no child is running. Used by DaemonWorkspaceService
@@ -1952,7 +1958,10 @@ export interface AcpSessionBridge {
): Promise<boolean>;
/** Delete all persisted attachments after the session itself is deleted. */
deleteSessionAttachments(sessionId: string): Promise<void>;
deleteSessionAttachments(
sessionId: string,
options?: { assertCanCommit?: () => void },
): Promise<void>;
/** Remove a queued or promoted mid-turn message. */
removeMidTurnMessage(
@@ -0,0 +1,267 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CHANNEL_LIVENESS_VERSION } from './bridgeTypes.js';
import {
CHANNEL_LIVENESS_INTERVAL_MS,
CHANNEL_LIVENESS_PROBE_TIMEOUT_MS,
CHANNEL_LIVENESS_PROTOCOL_ERROR_CODE,
CHANNEL_LIVENESS_TIMER_LATE_TOLERANCE_MS,
CHANNEL_LIVENESS_TIMEOUT_CODE,
startChannelLivenessMonitor,
} from './channel-liveness.js';
function deferred<T>(): {
promise: Promise<T>;
resolve(value: T): void;
} {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
afterEach(() => {
vi.useRealTimers();
});
describe('startChannelLivenessMonitor', () => {
it('keeps a healthy channel on the negotiated cadence', async () => {
vi.useFakeTimers();
const probe = vi.fn(async (nonce: number) => ({
v: CHANNEL_LIVENESS_VERSION,
nonce,
}));
const onFailure = vi.fn();
const monitor = startChannelLivenessMonitor({
probe,
onFailure,
isActive: () => true,
now: () => 0,
});
await vi.advanceTimersByTimeAsync(CHANNEL_LIVENESS_INTERVAL_MS);
await vi.advanceTimersByTimeAsync(CHANNEL_LIVENESS_INTERVAL_MS);
expect(probe.mock.calls).toEqual([[0], [1]]);
expect(onFailure).not.toHaveBeenCalled();
monitor.stop();
});
it('fails only after two consecutive on-time timeouts', async () => {
vi.useFakeTimers();
const probe = vi.fn(() => new Promise<never>(() => {}));
const onFailure = vi.fn();
startChannelLivenessMonitor({
probe,
onFailure,
isActive: () => true,
now: () => 0,
});
await vi.advanceTimersByTimeAsync(CHANNEL_LIVENESS_INTERVAL_MS);
await vi.advanceTimersByTimeAsync(CHANNEL_LIVENESS_PROBE_TIMEOUT_MS);
expect(probe).toHaveBeenCalledTimes(2);
expect(onFailure).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(CHANNEL_LIVENESS_PROBE_TIMEOUT_MS);
expect(onFailure).toHaveBeenCalledOnce();
expect(onFailure.mock.calls[0]?.[0]).toMatchObject({
code: CHANNEL_LIVENESS_TIMEOUT_CODE,
});
});
it('resets the timeout count when the immediate retry succeeds', async () => {
vi.useFakeTimers();
const probe = vi
.fn<(nonce: number) => Promise<unknown>>()
.mockImplementationOnce(() => new Promise<never>(() => {}))
.mockImplementation(async (nonce) => ({
v: CHANNEL_LIVENESS_VERSION,
nonce,
}));
const onFailure = vi.fn();
const monitor = startChannelLivenessMonitor({
probe,
onFailure,
isActive: () => true,
now: () => 0,
});
await vi.advanceTimersByTimeAsync(
CHANNEL_LIVENESS_INTERVAL_MS + CHANNEL_LIVENESS_PROBE_TIMEOUT_MS,
);
expect(probe).toHaveBeenCalledTimes(2);
expect(onFailure).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(CHANNEL_LIVENESS_INTERVAL_MS);
expect(probe).toHaveBeenCalledTimes(3);
expect(onFailure).not.toHaveBeenCalled();
monitor.stop();
});
it('does not charge a callback delayed by the parent event loop', async () => {
vi.useFakeTimers();
let monotonicNow = 0;
const probe = vi.fn(async (nonce: number) => ({
v: CHANNEL_LIVENESS_VERSION,
nonce,
}));
const onFailure = vi.fn();
const monitor = startChannelLivenessMonitor({
probe,
onFailure,
isActive: () => true,
now: () => monotonicNow,
});
monotonicNow =
CHANNEL_LIVENESS_INTERVAL_MS +
CHANNEL_LIVENESS_TIMER_LATE_TOLERANCE_MS +
1;
await vi.advanceTimersByTimeAsync(CHANNEL_LIVENESS_INTERVAL_MS);
expect(probe).not.toHaveBeenCalled();
expect(onFailure).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(CHANNEL_LIVENESS_INTERVAL_MS);
expect(probe).toHaveBeenCalledOnce();
expect(onFailure).not.toHaveBeenCalled();
monitor.stop();
});
it('clears an existing timeout streak after a delayed callback', async () => {
vi.useFakeTimers();
let monotonicNow = 0;
const probe = vi.fn(() => new Promise<never>(() => {}));
const onFailure = vi.fn();
startChannelLivenessMonitor({
probe,
onFailure,
isActive: () => true,
now: () => monotonicNow,
});
await vi.advanceTimersByTimeAsync(CHANNEL_LIVENESS_INTERVAL_MS);
await vi.advanceTimersByTimeAsync(CHANNEL_LIVENESS_PROBE_TIMEOUT_MS);
expect(probe).toHaveBeenCalledTimes(2);
expect(onFailure).not.toHaveBeenCalled();
monotonicNow =
CHANNEL_LIVENESS_PROBE_TIMEOUT_MS +
CHANNEL_LIVENESS_TIMER_LATE_TOLERANCE_MS +
1;
await vi.advanceTimersByTimeAsync(CHANNEL_LIVENESS_PROBE_TIMEOUT_MS);
expect(probe).toHaveBeenCalledTimes(2);
expect(onFailure).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(CHANNEL_LIVENESS_PROBE_TIMEOUT_MS);
expect(probe).toHaveBeenCalledTimes(3);
expect(onFailure).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(CHANNEL_LIVENESS_PROBE_TIMEOUT_MS);
expect(onFailure).toHaveBeenCalledOnce();
expect(onFailure.mock.calls[0]?.[0]).toMatchObject({
code: CHANNEL_LIVENESS_TIMEOUT_CODE,
});
});
it('does not let a late response reset the current probe streak', async () => {
vi.useFakeTimers();
const first = deferred<unknown>();
const probe = vi
.fn<(nonce: number) => Promise<unknown>>()
.mockImplementationOnce(() => first.promise)
.mockImplementation(() => new Promise<never>(() => {}));
const onFailure = vi.fn();
startChannelLivenessMonitor({
probe,
onFailure,
isActive: () => true,
now: () => 0,
});
await vi.advanceTimersByTimeAsync(
CHANNEL_LIVENESS_INTERVAL_MS + CHANNEL_LIVENESS_PROBE_TIMEOUT_MS,
);
expect(probe).toHaveBeenCalledTimes(2);
first.resolve({ v: CHANNEL_LIVENESS_VERSION, nonce: 0 });
await Promise.resolve();
expect(onFailure).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(CHANNEL_LIVENESS_PROBE_TIMEOUT_MS);
expect(onFailure).toHaveBeenCalledOnce();
expect(onFailure.mock.calls[0]?.[0]).toMatchObject({
code: CHANNEL_LIVENESS_TIMEOUT_CODE,
});
});
it('fails a malformed response without spending the retry budget', async () => {
vi.useFakeTimers();
const probe = vi.fn(async () => ({
v: CHANNEL_LIVENESS_VERSION,
nonce: 99,
}));
const onFailure = vi.fn();
startChannelLivenessMonitor({
probe,
onFailure,
isActive: () => true,
now: () => 0,
});
await vi.advanceTimersByTimeAsync(CHANNEL_LIVENESS_INTERVAL_MS);
expect(probe).toHaveBeenCalledOnce();
expect(onFailure).toHaveBeenCalledOnce();
expect(onFailure.mock.calls[0]?.[0]).toMatchObject({
code: CHANNEL_LIVENESS_PROTOCOL_ERROR_CODE,
});
});
it('fails a rejected probe without spending the retry budget', async () => {
vi.useFakeTimers();
const probe = vi.fn(() => {
throw new Error('ping rejected');
});
const onFailure = vi.fn();
startChannelLivenessMonitor({
probe,
onFailure,
isActive: () => true,
now: () => 0,
});
await vi.advanceTimersByTimeAsync(CHANNEL_LIVENESS_INTERVAL_MS);
expect(probe).toHaveBeenCalledOnce();
expect(onFailure).toHaveBeenCalledOnce();
expect(onFailure.mock.calls[0]?.[0]).toMatchObject({
code: CHANNEL_LIVENESS_PROTOCOL_ERROR_CODE,
});
});
it('cancels an active timeout when stopped', async () => {
vi.useFakeTimers();
const probe = vi.fn(() => new Promise<never>(() => {}));
const onFailure = vi.fn();
const monitor = startChannelLivenessMonitor({
probe,
onFailure,
isActive: () => true,
now: () => 0,
});
await vi.advanceTimersByTimeAsync(CHANNEL_LIVENESS_INTERVAL_MS);
monitor.stop();
await vi.advanceTimersByTimeAsync(CHANNEL_LIVENESS_PROBE_TIMEOUT_MS * 3);
expect(probe).toHaveBeenCalledOnce();
expect(onFailure).not.toHaveBeenCalled();
});
});
+168
View File
@@ -0,0 +1,168 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { performance } from 'node:perf_hooks';
import { CHANNEL_LIVENESS_VERSION } from './bridgeTypes.js';
export const CHANNEL_LIVENESS_INTERVAL_MS = 15_000;
export const CHANNEL_LIVENESS_PROBE_TIMEOUT_MS = 10_000;
export const CHANNEL_LIVENESS_FAILURE_THRESHOLD = 2;
export const CHANNEL_LIVENESS_TIMER_LATE_TOLERANCE_MS = 1_000;
export const CHANNEL_LIVENESS_TIMEOUT_CODE = 'acp_channel_liveness_timeout';
export const CHANNEL_LIVENESS_PROTOCOL_ERROR_CODE =
'acp_channel_liveness_protocol_error';
export class ChannelLivenessFailure extends Error {
constructor(
readonly code:
| typeof CHANNEL_LIVENESS_TIMEOUT_CODE
| typeof CHANNEL_LIVENESS_PROTOCOL_ERROR_CODE,
) {
super(
code === CHANNEL_LIVENESS_TIMEOUT_CODE
? 'ACP channel failed consecutive liveness probes'
: 'ACP channel returned an invalid liveness response',
);
this.name = 'ChannelLivenessFailure';
}
}
interface ChannelLivenessMonitorOptions {
probe(nonce: number): Promise<unknown>;
onFailure(error: ChannelLivenessFailure): void;
isActive(): boolean;
now?: () => number;
}
export interface ChannelLivenessMonitor {
stop(): void;
}
type ProbeOutcome =
| { kind: 'response'; response: unknown }
| { kind: 'rejected' }
| { kind: 'timeout' }
| { kind: 'local_delay' }
| { kind: 'stopped' };
function isValidResponse(response: unknown, nonce: number): boolean {
return (
typeof response === 'object' &&
response !== null &&
!Array.isArray(response) &&
(response as Record<string, unknown>)['v'] === CHANNEL_LIVENESS_VERSION &&
(response as Record<string, unknown>)['nonce'] === nonce
);
}
export function startChannelLivenessMonitor(
options: ChannelLivenessMonitorOptions,
): ChannelLivenessMonitor {
const now = options.now ?? (() => performance.now());
let stopped = false;
let timer: NodeJS.Timeout | undefined;
let consecutiveTimeouts = 0;
let nextNonce = 0;
let resolveStopped!: (outcome: ProbeOutcome) => void;
const stoppedOutcome = new Promise<ProbeOutcome>((resolve) => {
resolveStopped = resolve;
});
const isActive = () => !stopped && options.isActive();
const stop = () => {
stopped = true;
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
resolveStopped({ kind: 'stopped' });
};
const fail = (error: ChannelLivenessFailure) => {
if (!isActive()) return;
stop();
options.onFailure(error);
};
const timerWasLate = (expectedAt: number) =>
now() > expectedAt + CHANNEL_LIVENESS_TIMER_LATE_TOLERANCE_MS;
const runProbe = async () => {
if (!isActive()) return;
const nonce = nextNonce;
nextNonce = nonce === Number.MAX_SAFE_INTEGER ? 0 : Math.max(0, nonce + 1);
const response = Promise.resolve()
.then(() => options.probe(nonce))
.then<ProbeOutcome, ProbeOutcome>(
(value) => ({ kind: 'response', response: value }),
() => ({ kind: 'rejected' }),
);
let outcome: ProbeOutcome;
while (true) {
const expectedAt = now() + CHANNEL_LIVENESS_PROBE_TIMEOUT_MS;
const timeout = new Promise<ProbeOutcome>((resolve) => {
timer = setTimeout(() => {
timer = undefined;
resolve(
timerWasLate(expectedAt)
? { kind: 'local_delay' }
: { kind: 'timeout' },
);
}, CHANNEL_LIVENESS_PROBE_TIMEOUT_MS);
timer.unref();
});
outcome = await Promise.race([response, timeout, stoppedOutcome]);
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
if (outcome.kind === 'stopped' || !isActive()) return;
if (outcome.kind !== 'local_delay') break;
consecutiveTimeouts = 0;
}
if (outcome.kind === 'response') {
if (!isValidResponse(outcome.response, nonce)) {
fail(new ChannelLivenessFailure(CHANNEL_LIVENESS_PROTOCOL_ERROR_CODE));
return;
}
consecutiveTimeouts = 0;
schedule(CHANNEL_LIVENESS_INTERVAL_MS);
return;
}
if (outcome.kind === 'rejected') {
await Promise.resolve();
fail(new ChannelLivenessFailure(CHANNEL_LIVENESS_PROTOCOL_ERROR_CODE));
return;
}
consecutiveTimeouts++;
if (consecutiveTimeouts >= CHANNEL_LIVENESS_FAILURE_THRESHOLD) {
fail(new ChannelLivenessFailure(CHANNEL_LIVENESS_TIMEOUT_CODE));
return;
}
void runProbe();
};
function schedule(delayMs: number): void {
if (!isActive()) return;
const expectedAt = now() + delayMs;
timer = setTimeout(() => {
timer = undefined;
if (!isActive()) return;
if (timerWasLate(expectedAt)) {
consecutiveTimeouts = 0;
schedule(CHANNEL_LIVENESS_INTERVAL_MS);
return;
}
void runProbe();
}, delayMs);
timer.unref();
}
schedule(CHANNEL_LIVENESS_INTERVAL_MS);
return { stop };
}
@@ -763,6 +763,64 @@ describe('SessionAttachmentStore', () => {
}
});
it('checks the runtime generation before deleting persisted attachments', async () => {
const root = await fs.mkdtemp(
path.join(tmpdir(), 'qwen-attachment-fence-'),
);
const store = new SessionAttachmentStore(root, 'session-a');
const reference = await store.putAttachment(
Uint8Array.of(1),
'application/octet-stream',
'kept.bin',
);
const generationClosed = new Error('generation closed');
await expect(
store.delete({
assertCanCommit: () => {
throw generationClosed;
},
}),
).rejects.toBe(generationClosed);
await expect(
fs.stat(path.join(root, 'session-session-a', reference.attachmentId)),
).resolves.toBeDefined();
await fs.rm(root, { recursive: true, force: true });
});
it('deletes a claimed tombstone without touching a successor directory', async () => {
const root = await fs.mkdtemp(
path.join(tmpdir(), 'qwen-attachment-successor-'),
);
const store = new SessionAttachmentStore(root, 'session-a');
const reference = await store.putAttachment(
Uint8Array.of(1),
'application/octet-stream',
'old.bin',
);
const directory = path.join(root, 'session-session-a');
const remove = fs.rm.bind(fs);
const rmSpy = vi.spyOn(fs, 'rm').mockImplementationOnce(async (target) => {
expect(target).not.toBe(directory);
await fs.mkdir(directory, { recursive: true });
await fs.writeFile(path.join(directory, 'new.bin'), Uint8Array.of(2));
await remove(target, { recursive: true, force: true });
});
try {
await store.delete();
await expect(
fs.stat(path.join(directory, 'new.bin')),
).resolves.toBeDefined();
await expect(
fs.stat(path.join(directory, reference.attachmentId)),
).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
rmSpy.mockRestore();
await fs.rm(root, { recursive: true, force: true });
}
});
it('forgets attachments whose backing file disappeared', async () => {
const root = await fs.mkdtemp(path.join(tmpdir(), 'qwen-attachment-gone-'));
const store = new SessionAttachmentStore(root, 'session-a');
+18 -2
View File
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { randomUUID } from 'node:crypto';
import { promises as fs, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import * as path from 'node:path';
@@ -555,9 +556,11 @@ export class SessionAttachmentStore {
await fs.rm(directory, { recursive: true, force: true });
}
async delete(): Promise<void> {
async delete(options: { assertCanCommit?: () => void } = {}): Promise<void> {
options.assertCanCommit?.();
this.closing = true;
await this.waitForCopy();
options.assertCanCommit?.();
if (!this.closed) {
this.closed = true;
this.pendingItems = 0;
@@ -567,7 +570,20 @@ export class SessionAttachmentStore {
const directory =
this.persistentDirectory ??
(await this.directoryPromise?.catch(() => undefined));
if (directory) await fs.rm(directory, { recursive: true, force: true });
if (directory) {
options.assertCanCommit?.();
const tombstone = path.join(
path.dirname(directory),
`.${path.basename(directory)}.deleting-${randomUUID()}`,
);
try {
await fs.rename(directory, tombstone);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
throw error;
}
await fs.rm(tombstone, { recursive: true, force: true });
}
}
private assertStored(reference: SessionAttachmentReference): void {
+1
View File
@@ -108,6 +108,7 @@ export class MissingCliEntryError extends Error {
}
export const SERVE_STATUS_EXT_METHODS = {
channelPing: 'qwen/status/channel/ping',
workspaceMcp: 'qwen/status/workspace/mcp',
workspaceMcpTools: 'qwen/status/workspace/mcp/tools',
workspaceMcpResources: 'qwen/status/workspace/mcp/resources',
@@ -588,6 +588,31 @@ describe('AcpBridge', () => {
);
});
it('emits discrete vision bridge notices as text chunks', () => {
const bridge = new AcpBridge({
cliEntryPath: '/tmp/qwen',
cwd: '/tmp',
}) as unknown as TestableAcpBridge;
const textChunks: Array<[string, string]> = [];
bridge.on('textChunk', (sessionId, text) => {
textChunks.push([sessionId, text]);
});
bridge.handleSessionUpdate({
sessionId: 's-1',
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'Vision bridge cancelled.' },
_meta: {
source: 'vision_bridge_notice',
qwenDiscreteMessage: true,
},
},
});
expect(textChunks).toEqual([['s-1', 'Vision bridge cancelled.']]);
});
it('emits a completed background response separately from the active turn', () => {
const bridge = new AcpBridge({
cliEntryPath: '/tmp/qwen',
+6
View File
@@ -403,6 +403,12 @@ export class AcpBridge extends EventEmitter implements ChannelAgentBridge {
content.text
) {
this.emit('backgroundResponse', sessionId, content.text);
} else if (
meta['source'] === 'vision_bridge_notice' &&
content?.type === 'text' &&
content.text
) {
this.emit('textChunk', sessionId, content.text);
}
break;
}
@@ -761,6 +761,45 @@ describe('DaemonChannelBridge', () => {
bridge.stop();
});
it('emits discrete vision bridge notices as text chunks', async () => {
const events = new EventQueue();
const session = createFakeSession(events);
const bridge = new DaemonChannelBridge({
cwd: '/repo',
sessionFactory: vi.fn().mockResolvedValue(session),
});
const textChunks: Array<[string, string]> = [];
bridge.on('textChunk', (sessionId, text) => {
textChunks.push([sessionId, text]);
});
await bridge.start();
await bridge.newSession('/repo');
events.push({
id: 1,
v: 1,
type: 'session_update',
data: {
sessionId: 'session-1',
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'Vision bridge cancelled.' },
_meta: {
source: 'vision_bridge_notice',
qwenDiscreteMessage: true,
},
},
},
});
await vi.waitFor(() => {
expect(textChunks).toEqual([['session-1', 'Vision bridge cancelled.']]);
});
events.close();
bridge.stop();
});
it('ignores a rewritten background response to avoid duplicate delivery', async () => {
const events = new EventQueue();
const session = createFakeSession(events);
@@ -737,6 +737,8 @@ export class DaemonChannelBridge
text
) {
this.emit('backgroundResponse', sessionId, text);
} else if (meta['source'] === 'vision_bridge_notice' && text) {
this.emit('textChunk', sessionId, text);
}
break;
}
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@qwen-code/channel-dws",
"version": "0.22.0",
"description": "DingTalk Workspace channel adapter for Qwen Code",
"repository": {
"type": "git",
"url": "git+https://github.com/QwenLM/qwen-code.git",
"directory": "packages/channels/dws"
},
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc --build",
"test": "vitest run",
"test:ci": "vitest run"
},
"dependencies": {
"@qwen-code/channel-base": "0.22.0"
},
"devDependencies": {
"typescript": "^5.0.0"
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,54 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { execFile } from 'node:child_process';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DwsClient, DwsCommandError } from './dws-client.js';
vi.mock('node:child_process', () => ({ execFile: vi.fn() }));
describe('DWS command process', () => {
afterEach(() => {
vi.useRealTimers();
vi.mocked(execFile).mockReset();
});
it('escalates a timed-out command to SIGKILL', async () => {
vi.useFakeTimers();
let callback!: (
error: NodeJS.ErrnoException | null,
stdout: string,
stderr: string,
) => void;
const child = {
exitCode: null as number | null,
kill: vi.fn((signal: NodeJS.Signals) => {
if (signal === 'SIGKILL') {
child.exitCode = 1;
callback(Object.assign(new Error('killed'), { code: null }), '', '');
}
return true;
}),
};
vi.mocked(execFile).mockImplementation(((
_file,
_args,
_options,
receivedCallback,
) => {
callback = receivedCallback;
return child;
}) as typeof execFile);
const result = new DwsClient({ executable: '/opt/dws' })
.assertCompatible()
.catch((error: unknown) => error);
await vi.advanceTimersByTimeAsync(50_000);
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
await expect(result).resolves.toBeInstanceOf(DwsCommandError);
});
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,39 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { dwsProcessEnvironment } from './dws-environment.js';
describe('DWS process environment', () => {
it('keeps runtime and DWS settings without forwarding unrelated secrets', () => {
expect(
dwsProcessEnvironment({
HOME: '/srv/qwen',
Path: '/usr/local/bin:/usr/bin',
HTTPS_PROXY: 'http://proxy.example',
LC_ALL: 'en_US.UTF-8',
NODE_EXTRA_CA_CERTS: '/etc/ssl/corp.pem',
DWS_DISABLE_KEYCHAIN: '1',
DWS_AGENT_PRODUCT: 'openclaw',
AONE_SANDBOX_ID: 'sandbox-secret',
XDG_DATA_HOME: '/srv/qwen/data',
OPENAI_API_KEY: 'model-secret',
QWEN_SERVER_TOKEN: 'daemon-secret',
OTHER_CHANNEL_SECRET: 'channel-secret',
}),
).toEqual({
HOME: '/srv/qwen',
Path: '/usr/local/bin:/usr/bin',
HTTPS_PROXY: 'http://proxy.example',
LC_ALL: 'en_US.UTF-8',
NODE_EXTRA_CA_CERTS: '/etc/ssl/corp.pem',
DWS_DISABLE_KEYCHAIN: '1',
DWS_AGENT_PRODUCT: 'qwen-code',
XDG_DATA_HOME: '/srv/qwen/data',
NO_COLOR: '1',
});
});
});
@@ -0,0 +1,52 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import process from 'node:process';
const SAFE_KEYS = new Set([
'APPDATA',
'COMSPEC',
'HOME',
'HTTP_PROXY',
'HTTPS_PROXY',
'LANG',
'LC_ALL',
'LC_CTYPE',
'LOCALAPPDATA',
'NO_PROXY',
'NODE_EXTRA_CA_CERTS',
'PATH',
'PATHEXT',
'SSL_CERT_DIR',
'SSL_CERT_FILE',
'SYSTEMROOT',
'TEMP',
'TMP',
'TMPDIR',
'TZ',
'USERPROFILE',
'WINDIR',
]);
export function dwsProcessEnvironment(
source: NodeJS.ProcessEnv = process.env,
): NodeJS.ProcessEnv {
const environment: NodeJS.ProcessEnv = {};
for (const [key, value] of Object.entries(source)) {
const normalizedKey = key.toUpperCase();
if (
value !== undefined &&
(SAFE_KEYS.has(normalizedKey) ||
normalizedKey.startsWith('DWS_') ||
normalizedKey.startsWith('XDG_'))
) {
environment[key] = value;
}
}
environment['DWS_AGENT_PRODUCT'] = 'qwen-code';
environment['NO_COLOR'] = '1';
return environment;
}
@@ -0,0 +1,183 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { fileURLToPath } from 'node:url';
import { describe, expect, it, vi } from 'vitest';
import {
DwsEventProcessError,
startDwsEventProcess,
} from './dws-event-stream.js';
describe('DWS event process', () => {
it('becomes ready, forwards NDJSON lines, and stops by closing stdin', async () => {
let resolveLine!: (line: string) => void;
const line = new Promise<string>((resolve) => {
resolveLine = resolve;
});
const onError = vi.fn();
const fixture = fileURLToPath(
new URL('./fixtures/dws-event-source.mjs', import.meta.url),
);
const subscription = await startDwsEventProcess(
process.execPath,
[fixture],
resolveLine,
onError,
);
await expect(line).resolves.toBe('{"type":"fixture"}');
subscription.stop();
await expect(subscription.closed).resolves.toBeUndefined();
expect(onError).not.toHaveBeenCalled();
});
it('preserves structured retry guidance from a stopped consumer', async () => {
const onError = vi.fn();
const fixture = fileURLToPath(
new URL('./fixtures/dws-event-error-source.mjs', import.meta.url),
);
const subscription = await startDwsEventProcess(
process.execPath,
[fixture],
vi.fn(),
onError,
);
await subscription.closed;
const error = onError.mock.calls[0]?.[0];
expect(error).toBeInstanceOf(DwsEventProcessError);
expect(error).toMatchObject({
message: 'subscription denied',
retryable: false,
retryAfterMs: 3_000,
});
});
it('clears stale process errors after a healthy event', async () => {
const onLine = vi.fn();
const onError = vi.fn();
const fixture = fileURLToPath(
new URL('./fixtures/dws-event-recovered-source.mjs', import.meta.url),
);
const subscription = await startDwsEventProcess(
process.execPath,
[fixture],
onLine,
onError,
);
await subscription.closed;
expect(onLine).toHaveBeenCalledWith('{"type":"recovered"}');
expect(onError).toHaveBeenCalledOnce();
expect(onError.mock.calls[0]?.[0]).toMatchObject({
message: 'DWS event consumer stopped (0).',
retryable: undefined,
});
});
it('preserves a terminal error across stdout buffered after exit', async () => {
let releaseFirstLine!: () => void;
const firstLineBlocked = new Promise<void>((resolve) => {
releaseFirstLine = resolve;
});
let markFirstLineStarted!: () => void;
const firstLineStarted = new Promise<void>((resolve) => {
markFirstLineStarted = resolve;
});
const onError = vi.fn();
const fixture = fileURLToPath(
new URL('./fixtures/dws-event-postmortem-source.mjs', import.meta.url),
);
const subscription = await startDwsEventProcess(
process.execPath,
[fixture],
async (line) => {
if (line === '{"sequence":1}') {
markFirstLineStarted();
await firstLineBlocked;
}
},
onError,
);
await firstLineStarted;
await new Promise((resolve) => setTimeout(resolve, 100));
releaseFirstLine();
await subscription.closed;
expect(onError).toHaveBeenCalledOnce();
expect(onError.mock.calls[0]?.[0]).toMatchObject({
message: 'subscription denied',
retryable: false,
});
});
it('serializes a burst of inbound event lines', async () => {
let active = 0;
let maxActive = 0;
const handled: string[] = [];
const fixture = fileURLToPath(
new URL('./fixtures/dws-event-burst-source.mjs', import.meta.url),
);
const subscription = await startDwsEventProcess(
process.execPath,
[fixture],
async (line) => {
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise((resolve) => setTimeout(resolve, 5));
handled.push(line);
active -= 1;
},
vi.fn(),
);
await vi.waitFor(() => expect(handled).toHaveLength(3));
subscription.stop();
await subscription.closed;
expect(maxActive).toBe(1);
expect(handled).toEqual([
'{"sequence":1}',
'{"sequence":2}',
'{"sequence":3}',
]);
});
it('contains errors thrown by both the line and error handlers', async () => {
const unhandled: unknown[] = [];
const onUnhandled = (error: unknown): void => {
unhandled.push(error);
};
process.on('unhandledRejection', onUnhandled);
try {
const fixture = fileURLToPath(
new URL('./fixtures/dws-event-source.mjs', import.meta.url),
);
const subscription = await startDwsEventProcess(
process.execPath,
[fixture],
async () => {
throw new Error('line failed');
},
() => {
throw new Error('error handler failed');
},
);
subscription.stop();
await subscription.closed;
await new Promise((resolve) => setImmediate(resolve));
expect(unhandled).toEqual([]);
} finally {
process.off('unhandledRejection', onUnhandled);
}
});
});
@@ -0,0 +1,223 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { spawn } from 'node:child_process';
import readline from 'node:readline';
import { sanitizeLogText } from '@qwen-code/channel-base';
import { dwsProcessEnvironment } from './dws-environment.js';
const READY_TIMEOUT_MS = 15_000;
const STOP_TIMEOUT_MS = 5_000;
export class DwsEventProcessError extends Error {
constructor(
message: string,
readonly retryable?: boolean,
readonly retryAfterMs?: number,
) {
super(message);
this.name = 'DwsEventProcessError';
}
}
export interface DwsEventSubscription {
stop(): void;
closed: Promise<void>;
}
export type DwsEventProcessStarter = (
executable: string,
args: string[],
onLine: (line: string) => void | Promise<void>,
onError: (error: Error) => void,
) => Promise<DwsEventSubscription>;
function processError(code?: number | null): DwsEventProcessError {
return new DwsEventProcessError(
`DWS event consumer stopped${code === undefined || code === null ? '' : ` (${code})`}.`,
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function findValue(value: unknown, keys: ReadonlySet<string>): unknown {
const pending = [value];
while (pending.length > 0) {
const current = pending.pop();
if (Array.isArray(current)) {
for (let index = current.length - 1; index >= 0; index--) {
pending.push(current[index]);
}
continue;
}
if (!isRecord(current)) continue;
for (const [key, candidate] of Object.entries(current)) {
if (keys.has(key)) return candidate;
}
const values = Object.values(current);
for (let index = values.length - 1; index >= 0; index--) {
pending.push(values[index]);
}
}
return undefined;
}
function parseEventError(line: string): DwsEventProcessError | undefined {
const jsonStart = line.indexOf('{');
if (jsonStart < 0) return undefined;
let parsed: unknown;
try {
parsed = JSON.parse(line.slice(jsonStart)) as unknown;
} catch {
return undefined;
}
const retryable = findValue(parsed, new Set(['retryable']));
const retryAfter = findValue(
parsed,
new Set(['retry_after_seconds', 'retryAfterSeconds']),
);
const nextRetry = findValue(
parsed,
new Set(['next_retry_at', 'nextRetryAt']),
);
const message = findValue(parsed, new Set(['message', 'hint']));
if (
typeof retryable !== 'boolean' &&
typeof retryAfter !== 'number' &&
typeof nextRetry !== 'string'
) {
return undefined;
}
let retryAfterMs: number | undefined;
if (typeof retryAfter === 'number' && Number.isFinite(retryAfter)) {
retryAfterMs = Math.max(0, retryAfter * 1_000);
} else if (typeof nextRetry === 'string') {
const timestamp = Date.parse(nextRetry);
if (Number.isFinite(timestamp))
retryAfterMs = Math.max(0, timestamp - Date.now());
}
return new DwsEventProcessError(
sanitizeLogText(
typeof message === 'string' ? message : 'DWS event subscription failed.',
300,
),
typeof retryable === 'boolean' ? retryable : undefined,
retryAfterMs,
);
}
export const startDwsEventProcess: DwsEventProcessStarter = (
executable,
args,
onLine,
onError,
) =>
new Promise((resolve, reject) => {
const child = spawn(executable, args, {
env: dwsProcessEnvironment(),
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
});
const stdout = readline.createInterface({ input: child.stdout });
const stderr = readline.createInterface({ input: child.stderr });
let state: 'pending' | 'ready' | 'failed' = 'pending';
let stopping = false;
let lastError: DwsEventProcessError | undefined;
let killTimer: ReturnType<typeof setTimeout> | undefined;
let lineQueue = Promise.resolve();
let resolveClosed!: () => void;
const closed = new Promise<void>((done) => {
resolveClosed = done;
});
const settleStartupError = (error: Error): void => {
if (state !== 'pending') return;
state = 'failed';
clearTimeout(readyTimer);
reject(error);
};
const reportError = (error: unknown): void => {
try {
onError(error instanceof Error ? error : new Error(String(error)));
} catch {
return;
}
};
const stop = (): void => {
if (stopping) return;
stopping = true;
child.stdin.end();
if (child.exitCode === null) {
child.kill('SIGTERM');
killTimer = setTimeout(() => {
if (child.exitCode === null) child.kill('SIGKILL');
}, STOP_TIMEOUT_MS);
killTimer.unref?.();
}
};
const readyTimer = setTimeout(() => {
stop();
settleStartupError(
new Error(
`DWS event consumer did not become ready within ${READY_TIMEOUT_MS / 1000} seconds.`,
),
);
}, READY_TIMEOUT_MS);
readyTimer.unref?.();
stdout.on('line', (line) => {
if (child.exitCode === null && child.signalCode === null) {
lastError = undefined;
}
child.stdout.pause();
lineQueue = lineQueue
.then(() => onLine(line))
.catch((error: unknown) => {
reportError(error);
})
.finally(() => {
if (!stopping) child.stdout.resume();
});
});
stderr.on('line', (line) => {
if (line.includes('[event] ready') && state === 'pending') {
state = 'ready';
clearTimeout(readyTimer);
resolve({ stop, closed });
return;
}
lastError = parseEventError(line) ?? lastError;
});
child.once('error', (error) => {
const resolvedError = new DwsEventProcessError(
`Failed to start DWS event consumer: ${sanitizeLogText(error.message, 300)}`,
);
if (state === 'pending') settleStartupError(resolvedError);
else if (state === 'ready' && !stopping) lastError = resolvedError;
});
child.once('close', (code) => {
clearTimeout(readyTimer);
if (killTimer) clearTimeout(killTimer);
void lineQueue.finally(() => {
stdout.close();
stderr.close();
resolveClosed();
if (state === 'pending') {
settleStartupError(lastError ?? processError(code));
} else if (state === 'ready' && !stopping) {
reportError(lastError ?? processError(code));
}
});
});
});
@@ -0,0 +1,6 @@
import process from 'node:process';
process.stdin.resume();
process.stderr.write('[event] ready\n');
process.stdout.write('{"sequence":1}\n{"sequence":2}\n{"sequence":3}\n');
process.stdin.on('end', () => process.exit(0));
@@ -0,0 +1,12 @@
import process from 'node:process';
import { setTimeout } from 'node:timers';
process.stdin.resume();
process.stderr.write('[event] ready\n');
setTimeout(() => {
const depth = 20_000;
process.stderr.write(
`${'{"nested":'.repeat(depth)}{"message":"subscription denied","retryable":false,"retry_after_seconds":3}${'}'.repeat(depth)}\n`,
() => process.exit(1),
);
}, 10);
@@ -0,0 +1,17 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import process from 'node:process';
process.stderr.write('[event] ready\n');
process.stdout.write('{"sequence":1}\n');
globalThis.setTimeout(() => {
process.stdout.write('{"sequence":2}\n');
process.stderr.write(
'{"message":"subscription denied","retryable":false}\n',
() => process.exit(1),
);
}, 10);
@@ -0,0 +1,11 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import process from 'node:process';
process.stderr.write('[event] ready\n');
process.stderr.write('{"message":"stale marker","retryable":false}\n');
process.stdout.write('{"type":"recovered"}\n', () => process.exit(0));
@@ -0,0 +1,6 @@
import process from 'node:process';
process.stdin.resume();
process.stderr.write('[event] ready\n');
process.stdout.write('{"type":"fixture"}\n');
process.stdin.on('end', () => process.exit(0));
+81
View File
@@ -0,0 +1,81 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { plugin } from './index.js';
describe('DWS channel plugin', () => {
it('registers a standalone DWS channel without replacing DingTalk', () => {
expect(plugin.channelType).toBe('dws');
expect(plugin.displayName).toBe('DingTalk Workspace');
expect(plugin.defaultSessionScope).toBe('chat_thread');
expect(plugin.requiredConfigFields).toBeUndefined();
expect(plugin.management?.fields.map((field) => field.key)).toEqual([
'profile',
'groupPolicy',
'senderPolicy',
'allowedUsers',
'watchTodos',
]);
});
it('accepts the default @ message source', () => {
expect(plugin.management?.validateConfig?.({})).toBeUndefined();
});
it('defaults sender and group access to pairing', () => {
const groupPolicy = plugin.management?.fields.find(
(field) => field.key === 'groupPolicy',
);
expect(groupPolicy?.default).toBe('pairing');
expect(groupPolicy?.options?.map((option) => option.value)).toEqual([
'pairing',
'allowlist',
'open',
'disabled',
]);
expect(
plugin.management?.fields.find((field) => field.key === 'senderPolicy')
?.default,
).toBe('pairing');
expect(
plugin.management?.fields.find((field) => field.key === 'watchTodos')
?.default,
).toBeUndefined();
});
it('ignores removed source settings', () => {
expect(
plugin.management?.validateConfig?.({ imUserIds: 'legacy-user' }),
).toBeUndefined();
expect(
plugin.management?.validateConfig?.({ imGroupIds: 'legacy-group' }),
).toBeUndefined();
expect(
plugin.management?.validateConfig?.({ disableAtMessages: 'legacy' }),
).toBeUndefined();
});
it('allows yolo approval mode', () => {
expect(
plugin.management?.validateConfig?.({
approvalMode: 'yolo',
}),
).toBeUndefined();
expect(
plugin.management?.validateConfig?.({ approvalMode: 'auto' }),
).toContain('require approvalMode');
});
it('rejects ambiguous profiles and invalid todo inputs', () => {
expect(
plugin.management?.validateConfig?.({ profile: 'corp:a,corp:b' }),
).toContain('exactly one login profile');
expect(
plugin.management?.validateConfig?.({ watchTodos: 'true' }),
).toContain('must be a boolean');
});
});
+118
View File
@@ -0,0 +1,118 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { ChannelPlugin } from '@qwen-code/channel-base';
import { DwsChannel } from './dws-channel.js';
export { DwsChannel };
export { DwsClient, DwsCommandError, parseDwsImEvent } from './dws-client.js';
export type {
DwsCommandOutcome,
DwsClientLike,
DwsClientOptions,
DwsIdentity,
DwsImMessage,
DwsImSource,
DwsImTarget,
DwsMessageHistoryPage,
DwsTodoTask,
} from './dws-client.js';
export {
DwsEventProcessError,
startDwsEventProcess,
} from './dws-event-stream.js';
export type {
DwsEventProcessStarter,
DwsEventSubscription,
} from './dws-event-stream.js';
export const plugin: ChannelPlugin = {
channelType: 'dws',
displayName: 'DingTalk Workspace',
envResolvableConfigFields: ['profile'],
defaultSessionScope: 'chat_thread',
management: {
fields: [
{
key: 'profile',
label: 'DWS profile',
kind: 'string',
envResolvable: true,
description:
'Exact profile name or corpId from dws profile list. Leave empty to pin the active profile at startup',
},
{
key: 'groupPolicy',
label: 'Group Policy',
kind: 'enum',
required: true,
default: 'pairing',
description:
'Controls which DingTalk group conversations may start tasks',
options: [
{ value: 'pairing', label: 'Pairing' },
{ value: 'allowlist', label: 'Allowlist' },
{ value: 'open', label: 'Open' },
{ value: 'disabled', label: 'Disabled' },
],
},
{
key: 'senderPolicy',
label: 'Sender Policy',
kind: 'enum',
required: true,
default: 'pairing',
description:
'Controls which DingTalk users may start direct-message, document-notification, native-todo, and non-paired group tasks',
options: [
{ value: 'pairing', label: 'Pairing' },
{ value: 'allowlist', label: 'Allowlist' },
{ value: 'open', label: 'Open' },
],
},
{
key: 'allowedUsers',
label: 'Allowed Users',
kind: 'string-list',
description: 'DingTalk IDs used by Allowlist and Pairing policies',
},
{
key: 'watchTodos',
label: 'Watch Native Todos',
kind: 'boolean',
description:
'Poll pending todos assigned to this DWS account and run newly assigned or changed tasks',
},
],
validateConfig: (config) => {
if (
config['profile'] !== undefined &&
(typeof config['profile'] !== 'string' ||
!config['profile'].trim() ||
config['profile'].includes(','))
) {
return 'DWS profile must select exactly one login profile.';
}
if (
config['approvalMode'] !== undefined &&
config['approvalMode'] !== 'default' &&
config['approvalMode'] !== 'plan' &&
config['approvalMode'] !== 'yolo'
) {
return 'DWS channels require approvalMode "default", "plan", or "yolo".';
}
if (
config['watchTodos'] !== undefined &&
typeof config['watchTodos'] !== 'boolean'
) {
return 'DWS watchTodos must be a boolean.';
}
return undefined;
},
},
createChannel: (name, config, bridge, options) =>
new DwsChannel(name, config, bridge, options),
};
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"declarationMap": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts"],
"references": [{ "path": "../base" }]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from 'vitest/config';
import path from 'node:path';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
globals: true,
},
resolve: {
alias: {
// Resolve the in-repo channel-base to its live SOURCE so a package-local
// test run (e.g. `cd packages/channels/dws && vitest`) doesn't depend
// on a prior `tsc --build` of base — its dist may be absent or stale during
// development. Mirrors the other channel packages' configs.
'@qwen-code/channel-base': path.resolve(
__dirname,
'../base/src/index.ts',
),
},
},
});
+37 -8
View File
@@ -214,6 +214,21 @@ export class QQChannel extends ChannelBase {
private _reconnectId: number = 0;
private blockStreaming: boolean = false;
private flushedSessions: Set<string> = new Set();
/**
* Sessions with a prompt turn currently in flight, tracked via
* onPromptStart/onPromptEnd.
*
* This is the discriminator the cron textChunk handler uses to tell
* "prompt-response chunk" from "cron/non-prompt chunk". streamState
* cannot serve that role (#6094): it is never populated when
* blockStreaming is 'on' (onResponseChunk early-returns), so prompt
* chunks leak into cronBuffer; and a residual entry from a finished
* turn's unsettled flush silently blocks cron delivery. This set is
* reliable because ChannelBase always brackets a prompt turn with
* onPromptStart and onPromptEnd (onPromptEnd runs in the prompt path's
* finally, even on error/cancel), independent of streaming config.
*/
private activePromptSessions: Set<string> = new Set();
private readonly qqStatePath: string;
/**
* Path to the global sessions.json managed by start.ts.
@@ -302,7 +317,13 @@ export class QQChannel extends ChannelBase {
return;
}
if (!wasInCronFlow) return;
if (this.streamState.has(sessionId)) return;
// Sessions with an active prompt turn belong to the prompt path
// (which delivers the response itself) — never capture their chunks
// into the cron buffer. Keyed on activePromptSessions rather than
// streamState (#6094): streamState is empty under blockStreaming:'on'
// (prompt chunks would be duplicated) and can linger after a turn
// ends (cron chunks would be silently dropped).
if (this.activePromptSessions.has(sessionId)) return;
let entry = this.cronBuffer.get(sessionId);
if (!entry) {
entry = { buffer: '', timer: null };
@@ -968,24 +989,31 @@ export class QQChannel extends ChannelBase {
this.flushingSessions.clear();
this.pendingStreamDelete.clear();
this.flushedSessions.clear();
this.activePromptSessions.clear();
}
/**
* QQ Bot API V2 does not provide a typing indicator endpoint.
* ChannelBase calls these hooks to signal prompt start/end;
* they are intentionally no-ops for this channel.
* QQ Bot API V2 does not provide a typing indicator endpoint, but these
* hooks still maintain activePromptSessions — the cron textChunk
* discriminator (see activePromptSessions). ChannelBase always pairs the
* two calls per prompt turn (onPromptEnd runs in the prompt path's
* finally, even on error/cancel).
*/
protected override onPromptStart(
_chatId: string,
_sessionId: string,
sessionId: string,
_messageId?: string,
): void {}
): void {
this.activePromptSessions.add(sessionId);
}
protected override onPromptEnd(
_chatId: string,
_sessionId: string,
sessionId: string,
_messageId?: string,
): void {}
): void {
this.activePromptSessions.delete(sessionId);
}
// ── Streaming (idle-flush with per-session buffers) ────────────
@@ -1290,6 +1318,7 @@ export class QQChannel extends ChannelBase {
this.flushingSessions.delete(sessionId);
this.pendingStreamDelete.delete(sessionId);
this.flushedSessions.delete(sessionId);
this.activePromptSessions.delete(sessionId);
super.onSessionDied(sessionId);
}
// ── State Persistence (cross-server context continuation) ──────
+185 -11
View File
@@ -54,6 +54,7 @@ vi.mock('@qwen-code/channel-base', () => ({
protected handleInbound(_env: unknown): Promise<void> {
return Promise.resolve();
}
protected onSessionDied(_sessionId: string): void {}
},
SessionRouter: class {
restoreSessions(): Promise<void> {
@@ -77,7 +78,9 @@ function mockResponse(
return { ok, status, text: async () => '' };
}
function makeChannel(): QQChannelClass {
function makeChannel(
configOverrides?: Record<string, unknown>,
): QQChannelClass {
textChunkHandlers.length = 0;
const router = {
@@ -109,6 +112,7 @@ function makeChannel(): QQChannelClass {
appID: 'test-app-id',
appSecret: 'test-secret',
'cron-msg-experimental': true,
...configOverrides,
},
bridge as unknown as import('@qwen-code/channel-base').AcpBridge,
{ router } as unknown as Record<string, unknown>,
@@ -312,21 +316,22 @@ describe('cronTextHandler', () => {
stderrSpy.mockRestore();
});
// A6: streamState isolation — cron handler skips sessions owned by prompt path
it('streamState isolation: cron handler skips sessions with existing streamState entry', async () => {
// A6: prompt-path isolation — cron handler skips sessions with an active
// prompt turn. (Discriminator is activePromptSessions, keyed by
// onPromptStart/onPromptEnd — see #6094. A bare streamState entry no
// longer blocks cron; covered by the #6094 item 2 test below.)
it('prompt-path isolation: cron handler skips sessions with an active prompt turn', async () => {
const ch = makeChannel();
const pvt = ch as unknown as Record<string, unknown>;
pvt['_ready'] = true;
pvt['_inCronFlow'] = 1;
// Pre-populate streamState for this session — prompt path owns it
const ss = pvt['streamState'] as Map<string, unknown>;
ss.set('sess-stream', {
chatId: 'test-chat',
buffer: 'existing prompt text',
timer: null,
retryCount: 0,
});
// ChannelBase brackets the prompt turn with onPromptStart/onPromptEnd.
(
ch as unknown as {
onPromptStart: (chatId: string, sessionId: string) => void;
}
).onPromptStart('test-chat', 'sess-stream');
triggerTextChunk('sess-stream', 'should be ignored by cron');
await flushSetImmediate();
@@ -568,3 +573,172 @@ describe('disconnect cron cleanup', () => {
expect(pvt['_inCronFlow']).toBe(0);
});
});
// ---------------------------------------------------------------------------
// prompt/cron discriminator (issue #6094)
// ---------------------------------------------------------------------------
describe('prompt/cron textChunk discriminator (#6094)', () => {
beforeEach(() => {
vi.clearAllMocks();
textChunkHandlers.length = 0;
mockSendQQMessage.mockResolvedValue(mockResponse(true));
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
async function flushSetImmediate(): Promise<void> {
await vi.advanceTimersByTimeAsync(0);
}
function promptHooks(ch: QQChannelClass): {
onPromptStart: (chatId: string, sessionId: string) => void;
onPromptEnd: (chatId: string, sessionId: string) => void;
} {
return ch as unknown as {
onPromptStart: (chatId: string, sessionId: string) => void;
onPromptEnd: (chatId: string, sessionId: string) => void;
};
}
// #6094 item 1: with blockStreaming:'on', onResponseChunk early-returns
// without populating streamState, so the streamState.has(sessionId) guard
// cannot tell prompt chunks from cron chunks. While a cron flow is active,
// every prompt-response chunk leaks into cronBuffer and is re-sent by the
// 2s idle flush on top of the BlockStreamer delivery.
it('item 1: blockStreaming=on prompt chunks during a cron flow are not duplicated into cronBuffer', async () => {
const ch = makeChannel({ blockStreaming: 'on' });
const pvt = ch as unknown as Record<string, unknown>;
pvt['_ready'] = true;
// ChannelBase brackets every prompt turn with onPromptStart/onPromptEnd.
promptHooks(ch).onPromptStart('test-chat', 'sess-prompt');
// A cron flow is active concurrently (scheduled-message flow in flight).
pvt['_inCronFlow'] = 1;
// Prompt response chunk arrives. With blockStreaming:'on' the streaming
// path early-returns, so no streamState entry exists for this session.
triggerTextChunk('sess-prompt', 'prompt response text');
await flushSetImmediate();
const cronBuffer = pvt['cronBuffer'] as Map<string, { buffer: string }>;
// The prompt text belongs to an active prompt — it must not be captured
// by the cron buffer (BlockStreamer already delivers it).
expect(cronBuffer.has('sess-prompt')).toBe(false);
await vi.advanceTimersByTimeAsync(2000);
expect(mockSendQQMessage).not.toHaveBeenCalled();
promptHooks(ch).onPromptEnd('test-chat', 'sess-prompt');
});
// Regression guard for item 1 fix: genuine cron chunks (no active prompt
// for the session) must still be buffered and delivered with
// blockStreaming:'on'.
it('item 1 regression: cron chunks without an active prompt are still delivered (blockStreaming=on)', async () => {
const ch = makeChannel({ blockStreaming: 'on' });
const pvt = ch as unknown as Record<string, unknown>;
pvt['_ready'] = true;
pvt['_inCronFlow'] = 1;
triggerTextChunk('sess-cron', 'cron text');
await flushSetImmediate();
await vi.advanceTimersByTimeAsync(2000);
expect(mockSendQQMessage).toHaveBeenCalledTimes(1);
expect(mockSendQQMessage).toHaveBeenCalledWith(
'https://api.sgroup.qq.com',
'/v2/users/test-chat/messages',
'test-token',
{ msg_type: 2, markdown: { content: 'cron text' } },
);
});
// #6094 item 2: a lingering streamState entry from an earlier prompt (e.g.
// cancelled/errored turn whose cleanup has not settled) silently drops all
// subsequent cron textChunks for the same sessionId because the guard keys
// on streamState. The guard must key on whether a prompt turn is actually
// active, not on residual streaming state.
it('item 2: lingering streamState entry after prompt end does not block cron delivery', async () => {
const ch = makeChannel();
const pvt = ch as unknown as Record<string, unknown>;
pvt['_ready'] = true;
// A prompt turn streams a partial answer, then ends (ChannelBase always
// runs onPromptEnd in its finally, even on error/cancel).
promptHooks(ch).onPromptStart('test-chat', 'sess-leak');
(
ch as unknown as {
onResponseChunk: (
chatId: string,
chunk: string,
sessionId: string,
) => void;
}
).onResponseChunk('test-chat', 'partial answer', 'sess-leak');
promptHooks(ch).onPromptEnd('test-chat', 'sess-leak');
// The streamState entry lingers until its idle flush settles.
const ss = pvt['streamState'] as Map<string, unknown>;
expect(ss.has('sess-leak')).toBe(true);
// A cron flow now emits output for the same session.
pvt['_inCronFlow'] = 1;
triggerTextChunk('sess-leak', 'cron text');
await flushSetImmediate();
const cronBuffer = pvt['cronBuffer'] as Map<string, { buffer: string }>;
expect(cronBuffer.get('sess-leak')?.buffer).toBe('cron text');
await vi.advanceTimersByTimeAsync(2000);
// The cron text must be delivered despite the lingering streamState.
const sentBodies = mockSendQQMessage.mock.calls.map(
(call) => call[3] as { markdown?: { content?: string } },
);
expect(
sentBodies.some((body) => body.markdown?.content === 'cron text'),
).toBe(true);
});
// After the prompt turn ends, cron chunks for the session flow again.
it('resumes cron capture after the prompt turn ends', async () => {
const ch = makeChannel();
const pvt = ch as unknown as Record<string, unknown>;
pvt['_ready'] = true;
pvt['_inCronFlow'] = 1;
promptHooks(ch).onPromptStart('test-chat', 'sess-resume');
promptHooks(ch).onPromptEnd('test-chat', 'sess-resume');
triggerTextChunk('sess-resume', 'cron after prompt');
await flushSetImmediate();
const cronBuffer = pvt['cronBuffer'] as Map<string, { buffer: string }>;
expect(cronBuffer.get('sess-resume')?.buffer).toBe('cron after prompt');
});
// Session-death cleanup: a dead session must not stay marked as an active
// prompt (mirrors streamState cleanup in onSessionDied).
it('onSessionDied clears the active-prompt marker', async () => {
const ch = makeChannel();
const pvt = ch as unknown as Record<string, unknown>;
pvt['_ready'] = true;
pvt['_inCronFlow'] = 1;
promptHooks(ch).onPromptStart('test-chat', 'sess-died');
(
ch as unknown as { onSessionDied: (sessionId: string) => void }
).onSessionDied('sess-died');
triggerTextChunk('sess-died', 'cron after death');
await flushSetImmediate();
const cronBuffer = pvt['cronBuffer'] as Map<string, { buffer: string }>;
expect(cronBuffer.get('sess-died')?.buffer).toBe('cron after death');
});
});
+1
View File
@@ -49,6 +49,7 @@
"@qwen-code/audio-capture": "file:../audio-capture",
"@qwen-code/channel-base": "file:../channels/base",
"@qwen-code/channel-dingtalk": "file:../channels/dingtalk",
"@qwen-code/channel-dws": "file:../channels/dws",
"@qwen-code/channel-feishu": "file:../channels/feishu",
"@qwen-code/channel-github": "file:../channels/github",
"@qwen-code/channel-gitlab": "file:../channels/gitlab",
+498 -18
View File
@@ -638,8 +638,12 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({
readonly rpcCode = -32023;
readonly errorKind = 'session_writer_unavailable';
},
computeUniqueBranchTitle: vi.fn(
async (baseName: string) => `${baseName} (Branch)`,
computeUniqueBranchTitle: vi.fn(async (baseName: string) => `${baseName}(1)`),
// The real helper: the route tests below must exercise the shipped
// normalization, not a copy that can silently drift from it.
normalizeDerivedBranchTitle: vi.fn(
(await importOriginal<typeof import('@qwen-code/qwen-code-core')>())
.normalizeDerivedBranchTitle,
),
Storage: {
getGlobalQwenDir: vi.fn(() => '/tmp/qwen-global-test'),
@@ -1036,6 +1040,8 @@ import {
ACTIVE_WORK_HOLD_CATEGORIES,
ACTIVE_WORK_LEGACY_HOLD_CATEGORIES,
ACTIVE_WORK_NOTIFICATION_METHOD,
CHANNEL_LIVENESS_META_KEY,
CHANNEL_LIVENESS_VERSION,
CHANNEL_STARTUP_PROFILE_META_KEY,
CHANNEL_STARTUP_PROFILE_VERSION,
PROMPT_CANCEL_METHOD,
@@ -2845,6 +2851,69 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
await agentPromise;
});
it('negotiates and answers channel liveness without a Session', async () => {
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
const response = (await agent.initialize({
clientCapabilities: {},
_meta: {
[CHANNEL_LIVENESS_META_KEY]: { v: CHANNEL_LIVENESS_VERSION },
},
})) as { _meta?: Record<string, unknown> };
expect(response._meta).toMatchObject({
[CHANNEL_LIVENESS_META_KEY]: { v: CHANNEL_LIVENESS_VERSION },
});
await expect(
agent.extMethod(SERVE_STATUS_EXT_METHODS.channelPing, {
v: CHANNEL_LIVENESS_VERSION,
nonce: 42,
}),
).resolves.toEqual({ v: CHANNEL_LIVENESS_VERSION, nonce: 42 });
await expect(
agent.extMethod(SERVE_STATUS_EXT_METHODS.channelPing, {
v: CHANNEL_LIVENESS_VERSION,
nonce: -1,
}),
).rejects.toThrow('Invalid channel liveness ping');
mockConnectionState.resolve();
await agentPromise;
});
it('does not acknowledge an unrequested channel liveness capability', async () => {
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
const response = (await agent.initialize({
clientCapabilities: {},
})) as { _meta?: Record<string, unknown> };
expect(response._meta?.[CHANNEL_LIVENESS_META_KEY]).toBeUndefined();
mockConnectionState.resolve();
await agentPromise;
});
/** Installs a beginClose mock that tracks gate hold state; the returned
* getter is asserted false after refusal/timeout paths to pin that the
* close gate is always released. */
@@ -5261,6 +5330,42 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
},
);
it.skipIf(process.platform === 'win32')(
'rejects a Live managed relocation through a symlinked allowed root',
async () => {
await withEmptyTrustedFolders(async (directory) => {
const realRoot = path.join(directory, 'real-root');
const target = path.join(realRoot, 'conversation-symlinked-root');
await fs.mkdir(target, { recursive: true, mode: 0o700 });
const linkedRoot = path.join(directory, 'Conversations');
await fs.symlink(realRoot, linkedRoot);
const canonicalTarget = await fs.realpath(target);
const settings = makeSessionSettings({
mcpServers: {},
security: { folderTrust: { enabled: true } },
});
const { agent, agentPromise, sessionId } = await bootRelocatableSession(
settings,
'expected-capability',
);
try {
await expect(
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionCd, {
sessionId,
path: canonicalTarget,
allowedRoots: [linkedRoot],
managedRelocation: 'live-conversation',
}),
).rejects.toThrow('owner-only allowed root');
} finally {
mockConnectionState.resolve();
await agentPromise;
}
});
},
);
it('sessionArtifactsPersist rejects a missing session id', async () => {
const { agent, agentPromise } = await bootAcpAgent();
@@ -11914,11 +12019,14 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
const VALID_SESSION_ID = '12345678-1234-1234-1234-1234567890ab';
function mockSessionServiceLoad(result: unknown) {
function mockSessionServiceLoad(result: unknown, onRead?: () => void) {
vi.mocked(SessionService).mockImplementation(
() =>
({
loadSession: vi.fn().mockResolvedValue(result),
loadSession: vi.fn().mockImplementation(async () => {
onRead?.();
return result;
}),
}) as unknown as InstanceType<typeof SessionService>,
);
}
@@ -12005,6 +12113,9 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
expect.anything(),
expect.anything(),
gaps,
// Non-live loadUpdates replays have no live stream to deliver a
// trailing result, so dangling calls must still finalize.
expect.objectContaining({ finalizeDangling: true }),
);
mockConnectionState.resolve();
@@ -12321,6 +12432,202 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
await agentPromise;
});
it('live session load finalizes dangling calls because the restore gate drains active turns', async () => {
const innerConfig = await setupSessionMocks(VALID_SESSION_ID);
innerConfig.getSessionRuntimeBaseDir = vi
.fn()
.mockReturnValue('/tmp/qwen-runtime-test');
vi.mocked(SessionService).mockImplementation(
() =>
({
readLiveRestoreProjection: vi.fn().mockResolvedValue({
replay: {
records: [{ role: 'user' }],
gaps: [],
},
}),
}) as unknown as InstanceType<typeof SessionService>,
);
mockHistoryReplay.mockResolvedValue(undefined);
const { agent, agentPromise } = await bootAcpAgent();
const loadSession = (params: Record<string, unknown>) =>
(
agent as unknown as {
loadSession: (p: Record<string, unknown>) => Promise<unknown>;
}
).loadSession(params);
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
// The restore gate drains active turns and blocks new ones before the
// replay runs, so the live path must finalize regardless of turn state;
// sampling isTurnIdle() under the gate is structurally false and would
// keep genuinely abandoned calls pending forever.
lastSessionMock!.isTurnIdle.mockReturnValue(false);
await loadSession({
cwd: '/tmp',
sessionId: VALID_SESSION_ID,
mcpServers: [],
});
expect(mockHistoryReplay).toHaveBeenLastCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
expect.objectContaining({ finalizeDangling: true }),
);
mockConnectionState.resolve();
await agentPromise;
});
it('qwen/session/loadUpdates keeps dangling transcript calls in flight while a prompt is active', async () => {
const innerConfig = await setupSessionMocks(VALID_SESSION_ID);
innerConfig.getSessionRuntimeBaseDir = vi
.fn()
.mockReturnValue('/tmp/qwen-runtime-test');
mockSessionServiceLoad({
conversation: {
messages: [{ role: 'user' }],
startTime: 'start',
lastUpdated: 'end',
},
});
mockHistoryReplay.mockResolvedValue(undefined);
const { agent, agentPromise } = await bootAcpAgent();
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
lastSessionMock!.isTurnIdle.mockReturnValue(false);
let finishPrompt: ((value: unknown) => void) | undefined;
lastSessionMock!.prompt.mockImplementation(
() =>
new Promise((resolve) => {
finishPrompt = resolve;
}),
);
const prompt = agent.prompt({ sessionId: VALID_SESSION_ID, prompt: [] });
await vi.waitFor(() => expect(lastSessionMock!.prompt).toHaveBeenCalled());
await agent.extMethod('qwen/session/loadUpdates', {
sessionId: VALID_SESSION_ID,
cwd: '/tmp',
});
expect(mockHistoryReplay).toHaveBeenLastCalledWith(
expect.anything(),
expect.anything(),
undefined,
expect.objectContaining({ finalizeDangling: false }),
);
// The diagnostic must be a single interpolated string: debugLogger does
// no printf substitution, so %s placeholders would ship unexpanded.
expect(mockDebugLogger.debug).toHaveBeenCalledWith(
expect.stringContaining(
'[ACP] restore replay finalizeDangling=false (idleBeforeRead=false, idleAtReplay=false)',
),
);
finishPrompt?.({ stopReason: 'end_turn' });
await prompt;
lastSessionMock!.isTurnIdle.mockReturnValue(true);
await agent.extMethod('qwen/session/loadUpdates', {
sessionId: VALID_SESSION_ID,
cwd: '/tmp',
});
expect(mockHistoryReplay).toHaveBeenLastCalledWith(
expect.anything(),
expect.anything(),
undefined,
expect.objectContaining({ finalizeDangling: true }),
);
mockConnectionState.resolve();
await agentPromise;
});
it('qwen/session/loadUpdates keeps a dangling call pending when a turn settles during the read', async () => {
const innerConfig = await setupSessionMocks(VALID_SESSION_ID);
innerConfig.getSessionRuntimeBaseDir = vi
.fn()
.mockReturnValue('/tmp/qwen-runtime-test');
// Anchor the settle flip to the read boundary: the session is active
// before the read and the mocked sessionService.loadSession flips it to
// idle inside the read window (mirrors the sessionTranscript test that
// toggles state inside readPage.mockImplementationOnce). Keying the
// isTurnIdle mock by call order instead would let a mutation that moves
// the before-read sample across the read survive.
mockSessionServiceLoad(
{
conversation: {
messages: [{ role: 'user' }],
startTime: 'start',
lastUpdated: 'end',
},
},
() => lastSessionMock!.isTurnIdle.mockReturnValue(true),
);
mockHistoryReplay.mockResolvedValue(undefined);
const { agent, agentPromise } = await bootAcpAgent();
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
// Active before the read, idle by replay time: the before-read sample
// alone must keep the trailing call pending.
lastSessionMock!.isTurnIdle.mockReturnValue(false);
await agent.extMethod('qwen/session/loadUpdates', {
sessionId: VALID_SESSION_ID,
cwd: '/tmp',
});
expect(mockHistoryReplay).toHaveBeenLastCalledWith(
expect.anything(),
expect.anything(),
undefined,
expect.objectContaining({ finalizeDangling: false }),
);
mockConnectionState.resolve();
await agentPromise;
});
it('qwen/session/loadUpdates keeps a dangling call pending when a turn starts during the read', async () => {
const innerConfig = await setupSessionMocks(VALID_SESSION_ID);
innerConfig.getSessionRuntimeBaseDir = vi
.fn()
.mockReturnValue('/tmp/qwen-runtime-test');
// Anchor the start flip to the read boundary: the session is idle
// before the read and the mocked sessionService.loadSession flips it to
// active inside the read window. Keying the isTurnIdle mock by call
// order instead would let a mutation that moves the replay-time sample
// across the read survive.
mockSessionServiceLoad(
{
conversation: {
messages: [{ role: 'user' }],
startTime: 'start',
lastUpdated: 'end',
},
},
() => lastSessionMock!.isTurnIdle.mockReturnValue(false),
);
mockHistoryReplay.mockResolvedValue(undefined);
const { agent, agentPromise } = await bootAcpAgent();
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
// Idle before the read, active by replay time: the replay-time sample
// alone must keep the trailing call pending.
lastSessionMock!.isTurnIdle.mockReturnValue(true);
await agent.extMethod('qwen/session/loadUpdates', {
sessionId: VALID_SESSION_ID,
cwd: '/tmp',
});
expect(mockHistoryReplay).toHaveBeenLastCalledWith(
expect.anything(),
expect.anything(),
undefined,
expect.objectContaining({ finalizeDangling: false }),
);
mockConnectionState.resolve();
await agentPromise;
});
it('disposes a pending transcript config superseded by newer settings', async () => {
const oldSettings = makeCoreSettings('English');
const newSettings = makeCoreSettings('Japanese');
@@ -16259,6 +16566,7 @@ describe('QwenAgent extMethod renameSession routing', () => {
{
cwd: '/workspace-other',
sessionId: liveSessionId,
name: '创建 MR 描述生成 Skill',
},
);
@@ -16271,12 +16579,12 @@ describe('QwenAgent extMethod renameSession routing', () => {
expect(sessionService.forkSession).toHaveBeenCalledWith(
liveSessionId,
expect.any(String),
{ title: 'Source session (Branch)' },
{ title: '创建 MR 描述生成 Skill(1)' },
);
expect(sessionService.renameSession).not.toHaveBeenCalled();
expect(result).toMatchObject({
title: 'Source session (Branch)',
displayName: 'Source session (Branch)',
title: '创建 MR 描述生成 Skill(1)',
displayName: '创建 MR 描述生成 Skill(1)',
});
mockConnectionState.resolve();
@@ -16284,22 +16592,112 @@ describe('QwenAgent extMethod renameSession routing', () => {
});
it.each([
{
sourceTitle: 'Source session(2)',
name: undefined,
atRecordId: undefined,
persistedDisplayName: undefined,
expectedTitle: 'Source session(1)',
},
{
sourceTitle: 'Source session(2)',
name: 'Source session(2)',
atRecordId: 'checkpoint-1',
persistedDisplayName: undefined,
expectedTitle: 'Source session(2)(1)',
},
{
sourceTitle: 'Recorder title',
name: 'Picker title(2)',
atRecordId: undefined,
persistedDisplayName: undefined,
expectedTitle: 'Picker title(2)(1)',
},
{
sourceTitle: 'Recorder title',
name: 'Roadmap (2026)',
atRecordId: undefined,
persistedDisplayName: undefined,
expectedTitle: 'Roadmap (2026)(1)',
},
{
sourceTitle: 'Source session (Branch)',
name: undefined,
atRecordId: undefined,
persistedDisplayName: undefined,
expectedTitle: 'Source session(1)',
},
{
sourceTitle: 'Source session (Branch 2)',
expectedTitle: 'Source session (Branch)',
name: undefined,
atRecordId: undefined,
persistedDisplayName: undefined,
expectedTitle: 'Source session(1)',
},
{
sourceTitle: '(Branch)',
name: undefined,
atRecordId: undefined,
persistedDisplayName: undefined,
expectedTitle: '550e8400(1)',
},
{
sourceTitle: '(Branch 2)',
name: undefined,
atRecordId: undefined,
persistedDisplayName: undefined,
expectedTitle: '550e8400(1)',
},
{
sourceTitle: undefined,
expectedTitle: '550e8400 (Branch)',
name: undefined,
atRecordId: undefined,
persistedDisplayName: 'Prompt session',
expectedTitle: 'Prompt session(1)',
},
{
sourceTitle: undefined,
name: 'Sprint (2)',
atRecordId: 'checkpoint-2',
persistedDisplayName: 'Sprint (2)',
expectedTitle: 'Sprint (2)(1)',
},
{
sourceTitle: undefined,
name: undefined,
atRecordId: undefined,
persistedDisplayName: undefined,
expectedTitle: '550e8400(1)',
},
{
sourceTitle: '',
name: undefined,
atRecordId: undefined,
persistedDisplayName: undefined,
expectedTitle: '550e8400(1)',
},
{
sourceTitle: undefined,
name: undefined,
atRecordId: undefined,
persistedDisplayName: ' ',
expectedTitle: '550e8400(1)',
},
])(
'derives the branch title from $sourceTitle',
async ({ sourceTitle, expectedTitle }) => {
async ({
sourceTitle,
name,
atRecordId,
persistedDisplayName,
expectedTitle,
}) => {
const recording = makeRecordingService();
recording.getCurrentCustomTitle.mockReturnValue(sourceTitle);
const sessionService = {
forkSession: vi.fn().mockResolvedValue(undefined),
findSessionTitlesByPrefix: vi.fn().mockResolvedValue([]),
getSessionDisplayName: vi.fn().mockResolvedValue(persistedDisplayName),
renameSession: vi.fn().mockResolvedValue(true),
removeSession: vi.fn().mockResolvedValue(undefined),
};
@@ -16315,18 +16713,26 @@ describe('QwenAgent extMethod renameSession routing', () => {
{
cwd: '/tmp',
sessionId: liveSessionId,
...(name !== undefined ? { name } : {}),
...(atRecordId !== undefined ? { atRecordId } : {}),
},
);
expect(sessionService.forkSession).toHaveBeenCalledWith(
liveSessionId,
expect.any(String),
{ title: expectedTitle },
{
title: expectedTitle,
...(atRecordId !== undefined ? { atRecordId } : {}),
},
);
expect(result).toMatchObject({
title: expectedTitle,
displayName: expectedTitle,
});
expect(sessionService.getSessionDisplayName).toHaveBeenCalledTimes(
sourceTitle === undefined && name === undefined ? 1 : 0,
);
mockConnectionState.resolve();
await agentPromise;
@@ -16352,7 +16758,7 @@ describe('QwenAgent extMethod renameSession routing', () => {
{
cwd: '/tmp',
sessionId: liveSessionId,
name: 'Side task',
name: 'Side task(2)',
},
);
@@ -16364,15 +16770,85 @@ describe('QwenAgent extMethod renameSession routing', () => {
sourceType: 'side_task',
sourceId: liveSessionId,
},
title: 'Side task',
title: 'Side task(2)',
},
);
expect(recording.runWithWriteBarrier).toHaveBeenCalledOnce();
expect(sessionService.renameSession).not.toHaveBeenCalled();
expect(sessionService.removeSession).not.toHaveBeenCalled();
expect(result).toMatchObject({
title: 'Side task',
displayName: 'Side task',
title: 'Side task(2)',
displayName: 'Side task(2)',
});
mockConnectionState.resolve();
await agentPromise;
});
it('falls back to the session id for an empty normalized side-task title', async () => {
const recording = makeRecordingService();
recording.getCurrentCustomTitle.mockReturnValue('(Branch)');
const sessionService = {
forkSession: vi.fn().mockResolvedValue(undefined),
};
const innerConfig = makeLiveSessionInnerConfig(recording);
innerConfig.getSessionService.mockReturnValue(
sessionService as unknown as SessionService,
);
const { agent, agentPromise } = await bootAgent(innerConfig);
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
const result = await agent.extMethod(
SERVE_CONTROL_EXT_METHODS.sessionSideTask,
{
cwd: '/tmp',
sessionId: liveSessionId,
},
);
expect(sessionService.forkSession).toHaveBeenCalledWith(
liveSessionId,
expect.any(String),
expect.objectContaining({ title: '550e8400' }),
);
expect(result).toMatchObject({
title: '550e8400',
displayName: '550e8400',
});
mockConnectionState.resolve();
await agentPromise;
});
it('normalizes the source custom title for a nameless side task', async () => {
const recording = makeRecordingService();
recording.getCurrentCustomTitle.mockReturnValue('My Project(2)');
const sessionService = {
forkSession: vi.fn().mockResolvedValue(undefined),
};
const innerConfig = makeLiveSessionInnerConfig(recording);
innerConfig.getSessionService.mockReturnValue(
sessionService as unknown as SessionService,
);
const { agent, agentPromise } = await bootAgent(innerConfig);
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
const result = await agent.extMethod(
SERVE_CONTROL_EXT_METHODS.sessionSideTask,
{
cwd: '/tmp',
sessionId: liveSessionId,
},
);
expect(sessionService.forkSession).toHaveBeenCalledWith(
liveSessionId,
expect.any(String),
expect.objectContaining({ title: 'My Project' }),
);
expect(result).toMatchObject({
title: 'My Project',
displayName: 'My Project',
});
mockConnectionState.resolve();
@@ -16437,7 +16913,7 @@ describe('QwenAgent extMethod renameSession routing', () => {
expect(sessionService.forkSession).toHaveBeenCalledWith(
liveSessionId,
expect.any(String),
{ title: 'Source session (Branch)', atRecordId: checkpoint },
{ title: 'Source session(1)', atRecordId: checkpoint },
);
expect(liveBeginHistoryMutation).toHaveBeenCalledOnce();
expect(liveReleaseHistoryMutation).toHaveBeenCalledOnce();
@@ -17469,6 +17945,7 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => {
waitForActiveTurnsToSettle: vi.fn().mockResolvedValue(undefined),
cancelPendingPrompt: vi.fn().mockResolvedValue(undefined),
assertCanStartTurn: vi.fn().mockResolvedValue(undefined),
isTurnIdle: vi.fn().mockReturnValue(true),
sendUpdate: opts.recoveredGoalSendError
? vi.fn().mockRejectedValue(opts.recoveredGoalSendError)
: vi.fn().mockResolvedValue(undefined),
@@ -18593,6 +19070,7 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => {
assertCanStartTurn: vi.fn().mockResolvedValue(undefined),
beginClose: vi.fn().mockReturnValue(vi.fn()),
waitForActiveTurnsToSettle: vi.fn().mockResolvedValue(undefined),
isTurnIdle: vi.fn().mockReturnValue(true),
sendUpdate: vi.fn().mockResolvedValue(undefined),
clearActiveTodoPlanRevision: vi.fn(),
};
@@ -19605,6 +20083,7 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => {
waitForCloseGateToRelease: vi.fn().mockResolvedValue(undefined),
cancelPendingPrompt: vi.fn().mockResolvedValue(undefined),
waitForActiveTurnsToSettle: vi.fn().mockResolvedValue(undefined),
isTurnIdle: vi.fn().mockReturnValue(true),
dispose: replacementDispose,
} as unknown as InstanceType<typeof Session>;
const sessions = (
@@ -19920,6 +20399,7 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => {
});
expect(replayOptions).toEqual({
finalizeDangling: true,
goalBootstrap: {
goalStatus: {
kind: 'set',
@@ -20005,7 +20485,7 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => {
},
});
expect(replayOptions).toBeUndefined();
expect(replayOptions).toEqual({ finalizeDangling: true });
mockConnectionState.resolve();
await agentPromise;
@@ -20077,7 +20557,7 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => {
},
});
expect(replayOptions).toBeUndefined();
expect(replayOptions).toEqual({ finalizeDangling: true });
mockConnectionState.resolve();
await agentPromise;
+120 -20
View File
@@ -78,6 +78,7 @@ import {
subagentGenerator,
redactUrlCredentials,
computeUniqueBranchTitle,
normalizeDerivedBranchTitle,
BranchPointInvalidError,
parseGoalSnapshotV2,
parseGoalStateCause,
@@ -373,6 +374,8 @@ import {
ACTIVE_WORK_HEARTBEAT_VERSION,
ACTIVE_WORK_HOLD_CATEGORIES,
ACTIVE_WORK_LEGACY_HOLD_CATEGORIES,
CHANNEL_LIVENESS_META_KEY,
CHANNEL_LIVENESS_VERSION,
clampActiveWorkIntervalMs,
type ActiveWorkHoldV1,
CHANNEL_STARTUP_PROFILE_META_KEY,
@@ -1141,19 +1144,10 @@ function getLoadReplayPageSize(params: LoadSessionRequest): number | undefined {
return value as number;
}
function deriveForkBaseName(
name: unknown,
recording: { getCurrentCustomTitle(): string | undefined } | undefined,
sessionId: string,
): string {
if (typeof name === 'string' && name.trim().length > 0) {
return name.trim();
}
const existingTitle = recording?.getCurrentCustomTitle();
const stripped = existingTitle
?.replace(/\s*\(Branch(?:\s+\d+)?\)\s*$/, '')
.trim();
return stripped && stripped.length > 0 ? stripped : sessionId.slice(0, 8);
function normalizeRequestedBranchName(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const normalized = value.trim();
return normalized || undefined;
}
function createHiddenWorkspaceMemoryConfig(config: Config): Config {
return new Proxy(config, {
@@ -3182,8 +3176,15 @@ interface ActivePromptCall {
}
function isOwnerOnlyDirectory(stats: Stats): boolean {
if (process.platform === 'win32') return false;
if (stats.isSymbolicLink() || !stats.isDirectory()) return false;
if (process.platform === 'win32') {
// Node's fs.Stats exposes no ownership or permission bits on Windows, so
// the POSIX mode/uid check has no equivalent here. Containment then rests
// on the structural checks around this predicate — symlink rejection and
// dev/ino identity across the realpath round trip — the same trade-off
// serve/live/discovery.ts already makes on this platform.
return true;
}
if (typeof process.getuid === 'function' && stats.uid !== process.getuid()) {
return false;
}
@@ -4229,6 +4230,33 @@ class QwenAgent implements Agent {
return runWithAcpRuntimeOutputDir(settings, cwd, operation);
}
/**
* Whether an ungated restore replay (qwen/session/loadUpdates) may
* finalize dangling tool calls. A session with an active turn — a client
* prompt or an autonomous goal/cron/notification turn — may still owe the
* trailing call's result, so the replay keeps it pending and lets the
* live stream deliver it (#9704). Samples the turn state before the
* transcript read and again at replay time so a turn that starts or
* settles inside the read window is seen. Not for the live loadSession
* path: that restore runs under the close gate, which drains active
* turns, blocks new ones, and reports closing=true — so isTurnIdle()
* there is structurally false and would keep genuinely abandoned calls
* pending forever.
*/
private finalizeDanglingForRestore(
session: Session | undefined,
turnIdleBeforeRead: boolean,
): boolean {
const idleAtReplay = session?.isTurnIdle() ?? true;
const finalize = turnIdleBeforeRead && idleAtReplay;
// Template literal, not printf-style placeholders: createDebugLogger's
// formatArgs does no util.format substitution, it space-joins the args.
debugLogger.debug(
`[ACP] restore replay finalizeDangling=${finalize} (idleBeforeRead=${turnIdleBeforeRead}, idleAtReplay=${idleAtReplay}) session=${session?.getId() ?? '(non-live)'}`,
);
return finalize;
}
private async assertLiveSessionScope(
config: Config,
settings: LoadedSettings,
@@ -4401,6 +4429,13 @@ class QwenAgent implements Agent {
)
: ACTIVE_WORK_LEGACY_HOLD_CATEGORIES
: undefined;
const requestedChannelLiveness = args._meta?.[CHANNEL_LIVENESS_META_KEY];
const channelLivenessRequested =
requestedChannelLiveness !== null &&
typeof requestedChannelLiveness === 'object' &&
!Array.isArray(requestedChannelLiveness) &&
(requestedChannelLiveness as Record<string, unknown>)['v'] ===
CHANNEL_LIVENESS_VERSION;
if (activeWorkIntervalMs !== undefined) {
this.activeWorkReporter?.dispose();
this.activeWorkReporter = new ActiveWorkReporter(
@@ -4430,6 +4465,13 @@ class QwenAgent implements Agent {
},
}
: {}),
...(channelLivenessRequested
? {
[CHANNEL_LIVENESS_META_KEY]: {
v: CHANNEL_LIVENESS_VERSION,
},
}
: {}),
};
return Object.keys(responseMeta).length > 0
? { ...response, _meta: responseMeta }
@@ -4655,6 +4697,13 @@ class QwenAgent implements Agent {
replayState: replayPage.replay,
goalBootstrap: replayGoalBootstrap(projection),
suppressRestoreAskUserQuestion,
// The restore gate already drained active turns and blocks
// new ones (and a drain timeout rejects before replay), so
// a trailing unmatched call here is genuinely abandoned —
// finalize it. The turn-activity guard cannot be sampled
// under the gate: isTurnIdle() is structurally false while
// the close gate is held (#9704).
finalizeDangling: true,
...(restoreOptions.replay.kind === 'recent'
? {
limits: {
@@ -7649,6 +7698,21 @@ class QwenAgent implements Agent {
const SESSION_ID_RE = /^[0-9a-fA-F-]{32,36}$/;
switch (method) {
case SERVE_STATUS_EXT_METHODS.channelPing: {
const nonce = params['nonce'];
if (
params['v'] !== CHANNEL_LIVENESS_VERSION ||
typeof nonce !== 'number' ||
!Number.isSafeInteger(nonce) ||
nonce < 0
) {
throw RequestError.invalidParams(
undefined,
'Invalid channel liveness ping',
);
}
return { v: CHANNEL_LIVENESS_VERSION, nonce };
}
case PROMPT_CANCEL_METHOD: {
const sessionId = params['sessionId'];
if (typeof sessionId !== 'string' || sessionId.length === 0) {
@@ -10936,6 +11000,7 @@ class QwenAgent implements Agent {
}
const liveSession = this.sessions.get(sessionId);
const turnIdleBeforeRead = liveSession?.isTurnIdle() ?? true;
let replayConfig = this.config;
let sessionData: ResumedSessionData | undefined;
if (liveSession) {
@@ -10977,6 +11042,13 @@ class QwenAgent implements Agent {
// Read-only history dump never re-hangs the question. Skip
// finalize only on load/resume that will actually restore.
suppressRestoreAskUserQuestion: true,
// Ungated read: unlike the live loadSession restore (whose gate
// drains turns), a turn may still be running here, so guard on
// turn activity instead of finalizing unconditionally (#9704).
finalizeDangling: this.finalizeDanglingForRestore(
liveSession,
turnIdleBeforeRead,
),
});
return {
@@ -11078,11 +11150,31 @@ class QwenAgent implements Agent {
const recording = sourceConfig.getChatRecordingService();
const sessionService = sourceConfig.getSessionService();
const baseName = deriveForkBaseName(
name,
recording,
sessionId,
);
const requestedName = normalizeRequestedBranchName(name);
const sourceCustomTitle =
requestedName === undefined
? recording?.getCurrentCustomTitle()
: undefined;
const persistedDisplayName =
requestedName === undefined &&
sourceCustomTitle === undefined
? await sessionService.getSessionDisplayName(sessionId)
: undefined;
const sourceDisplayName =
sourceCustomTitle ?? persistedDisplayName;
const derivedBaseName = sourceCustomTitle
? normalizeDerivedBranchTitle(sourceCustomTitle)
: sourceDisplayName;
// A base that is empty, whitespace-only, or exactly a
// legacy `(Branch)`/`(Branch N)` token falls back to the
// session-id prefix here, while CLI /branch falls back to
// the first prompt. Deliberate: no picker name survives to
// anchor the family to, and one shared fallback would need
// a prompt-only display-name read on this route.
const baseName =
requestedName ??
(derivedBaseName?.trim() || undefined) ??
sessionId.slice(0, 8);
const title = await computeUniqueBranchTitle(
baseName,
@@ -11120,7 +11212,15 @@ class QwenAgent implements Agent {
const recording = sourceConfig.getChatRecordingService();
if (recording) await recording.flush();
const sessionService = sourceConfig.getSessionService();
const title = deriveForkBaseName(name, recording, sessionId);
const requestedName = normalizeRequestedBranchName(name);
let title = requestedName;
if (title === undefined) {
const sourceCustomTitle = recording?.getCurrentCustomTitle();
title = sourceCustomTitle
? (normalizeDerivedBranchTitle(sourceCustomTitle) ??
sessionId.slice(0, 8))
: sessionId.slice(0, 8);
}
const newSessionId = randomUUID();
const fork = () =>
sessionService.forkSession(sessionId, newSessionId, {
@@ -73,25 +73,30 @@ describe('CaptureScreenContextTool', () => {
await expect(readFile(file.path)).rejects.toThrow();
});
it('rejects a symlink and deletes only the Host-provided link', async () => {
const target = await captureFile();
const link = join(target.directory, 'linked.png');
await symlink(target.path, link);
const tool = new CaptureScreenContextTool(
async () => ({
appName: 'Finder',
accessibilityText: '',
screenshotPath: link,
}),
target.directory,
);
// The rejection relies on O_NOFOLLOW, which libuv ignores on win32; the
// tool is macOS-scoped, matching the symlink-test skips elsewhere.
it.skipIf(process.platform === 'win32')(
'rejects a symlink and deletes only the Host-provided link',
async () => {
const target = await captureFile();
const link = join(target.directory, 'linked.png');
await symlink(target.path, link);
const tool = new CaptureScreenContextTool(
async () => ({
appName: 'Finder',
accessibilityText: '',
screenshotPath: link,
}),
target.directory,
);
const result = await tool.build({}).execute(new AbortController().signal);
const result = await tool.build({}).execute(new AbortController().signal);
expect(result.error?.message).toBeTruthy();
await expect(readFile(target.path)).resolves.toEqual(PNG);
await expect(readFile(link)).rejects.toThrow();
});
expect(result.error?.message).toBeTruthy();
await expect(readFile(target.path)).resolves.toEqual(PNG);
await expect(readFile(link)).rejects.toThrow();
},
);
it('rejects a screenshot outside the Host private directory', async () => {
const outside = await captureFile();
@@ -8773,7 +8773,8 @@ describe('Session', () => {
status: 'skipped',
convertedCount: 0,
omittedCount: 0,
modelId: 'qwen3.7-plus',
modelId: 'idealab:qwen3.7-plus',
modelEndpoint: 'idealab.alibaba-inc.com',
egressOccurred: true,
});
mockChat.sendMessageStream = vi
@@ -8797,6 +8798,28 @@ describe('Session', () => {
expect(
textParts(sent).some((t: string) => t.includes('look at this')),
).toBe(true);
expect(mockClient.sessionUpdate).toHaveBeenCalledWith({
sessionId: 'test-session-id',
update: {
sessionUpdate: 'agent_message_chunk',
content: {
type: 'text',
text: 'Vision bridge cancelled. Your image and prompt/context were sent to qwen3.7-plus (idealab.alibaba-inc.com).',
},
_meta: {
source: 'vision_bridge_notice',
qwenDiscreteMessage: true,
visionBridgeNotice: {
status: 'skipped',
convertedCount: 0,
omittedCount: 0,
modelName: 'qwen3.7-plus',
modelEndpoint: 'idealab.alibaba-inc.com',
egressOccurred: true,
},
},
},
});
});
it('preserves oversized inline images for the vision bridge', async () => {
@@ -11928,6 +11951,37 @@ describe('Session', () => {
});
});
it('marks estimated compression counts in the ACP notice (#9309)', async () => {
mockGeminiClient.tryCompressChat.mockResolvedValueOnce({
originalTokenCount: 26600,
newTokenCount: 14500,
originalTokenCountIsEstimated: true,
newTokenCountIsEstimated: false,
compressionStatus: core.CompressionStatus.COMPRESSED,
});
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValue(createEmptyStream());
await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'hello' }],
});
expect(mockClient.sessionUpdate).toHaveBeenCalledWith({
sessionId: 'test-session-id',
update: {
sessionUpdate: 'agent_message_chunk',
content: {
type: 'text',
text:
'IMPORTANT: This conversation approached the input token limit for qwen3-code-plus. ' +
'A compressed context will be sent for future messages (compressed from: ~26600 to 14500 tokens).',
},
},
});
});
it('continues sending when automatic compression fails', async () => {
mockGeminiClient.tryCompressChat.mockRejectedValueOnce(
new Error('compression rate limited'),
@@ -6762,11 +6762,20 @@ export class Session implements SessionContext {
const warningSuffix = compressed.warning
? `\n⚠️ ${compressed.warning}`
: '';
// Estimated counts (#9309) get a '~' prefix so the notice doesn't
// read as an API-reported figure on a different scale than a later
// banner.
const formatCount = (count?: number, isEstimated?: boolean) =>
count === undefined
? 'unknown'
: isEstimated
? `~${count}`
: String(count);
compressionDiagnostic =
`IMPORTANT: This conversation ${reasonClause}. ` +
`A compressed context will be sent for future messages (compressed from: ` +
`${compressed.originalTokenCount ?? 'unknown'} to ` +
`${compressed.newTokenCount ?? 'unknown'} tokens).` +
`${formatCount(compressed.originalTokenCount, compressed.originalTokenCountIsEstimated)} to ` +
`${formatCount(compressed.newTokenCount, compressed.newTokenCountIsEstimated)} tokens).` +
warningSuffix;
}
} catch (compressionError) {
@@ -12415,8 +12424,9 @@ export class Session implements SessionContext {
if (bridgeResult.status !== 'skipped' || bridgeResult.egressOccurred) {
try {
await this.messageEmitter.emitAgentMessage(
await this.messageEmitter.emitVisionBridgeNotice(
formatVisionBridgeNotice(bridgeResult),
bridgeResult,
);
} catch (error) {
debugLogger.debug(
@@ -17,6 +17,7 @@ import {
type GoalRecord,
type GoalSnapshotV2,
type GoalStateCause,
type VisionBridgeResult,
} from '@qwen-code/qwen-code-core';
import { BaseEmitter } from './base-emitter.js';
import type { SessionUpdate } from '@agentclientprotocol/sdk';
@@ -202,6 +203,34 @@ export class MessageEmitter extends BaseEmitter {
);
}
async emitVisionBridgeNotice(
text: string,
result: VisionBridgeResult,
): Promise<void> {
await this.sendUpdate(
createTranscriptMessageUpdate({
role: 'assistant',
text,
extra: {
source: 'vision_bridge_notice',
qwenDiscreteMessage: true,
visionBridgeNotice: {
status: result.status,
convertedCount: result.convertedCount,
omittedCount: result.omittedCount,
...(result.modelId
? { modelName: result.modelId.replace(/^[^:]+:/, '') }
: {}),
...(result.modelEndpoint
? { modelEndpoint: result.modelEndpoint }
: {}),
egressOccurred: result.egressOccurred === true,
},
},
}),
);
}
async emitSlashCommandOutput(
text: string,
timestamp?: string | number,
@@ -286,6 +286,38 @@ describe('history replay page', () => {
).toBe(false);
});
it('finalizes a dangling tool call as failed by default', async () => {
const result = await collectHistoryReplayUpdates({
sessionId: SESSION_ID,
records: [userRecord(), toolCallRecord()],
cumulativeUsage: createReplayCumulativeUsage(),
});
expect(result.replayError).toBeUndefined();
expect(result.updates).toContainEqual(
expect.objectContaining({
sessionUpdate: 'tool_call_update',
status: 'failed',
}),
);
});
it('keeps a dangling tool call in flight when finalizeDangling is false', async () => {
const result = await collectHistoryReplayUpdates({
sessionId: SESSION_ID,
records: [userRecord(), toolCallRecord()],
cumulativeUsage: createReplayCumulativeUsage(),
finalizeDangling: false,
});
expect(result.replayError).toBeUndefined();
expect(
result.updates.some(
(update) => update.sessionUpdate === 'tool_call_update',
),
).toBe(false);
});
it('bounds textual tool results collected for bulk replay', async () => {
const source = 'x'.repeat(499_999);
const result = await collectHistoryReplayUpdates({
@@ -254,6 +254,7 @@ export async function collectHistoryReplayUpdates({
goalBootstrap,
limits,
suppressRestoreAskUserQuestion,
finalizeDangling,
}: {
sessionId: string;
config?: Config;
@@ -270,6 +271,14 @@ export async function collectHistoryReplayUpdates({
* so the replayed card doesn't spin forever with no restore prompt coming.
*/
suppressRestoreAskUserQuestion?: boolean;
/**
* Ungated live-session loads (qwen/session/loadUpdates) while the
* session still has an active turn must not finalize dangling calls: a
* trailing unmatched call is in-flight, not abandoned, and its result
* arrives through the live stream (#9704). The gated live loadSession
* path and non-live loads pass true.
*/
finalizeDangling?: boolean;
}): Promise<{ updates: SessionUpdate[]; replayError?: string }> {
const updates: SessionUpdate[] = [];
try {
@@ -299,6 +308,7 @@ export async function collectHistoryReplayUpdates({
...(initial.goalCause ? { initialGoalCause: initial.goalCause } : {}),
...(goalBootstrap ? { goalBootstrap } : {}),
...(skipFinalizeCallIds ? { skipFinalizeCallIds } : {}),
...(finalizeDangling === undefined ? {} : { finalizeDangling }),
});
} catch (error) {
if (error instanceof HistoryReplayLimitError) throw error;
@@ -433,6 +433,34 @@ describe('HistoryReplayer', () => {
]);
});
it('keeps a dangling call in flight when finalizeDangling is false', async () => {
const record: ChatRecord = {
...createAssistantRecord(''),
message: {
role: 'model',
parts: [
{
functionCall: {
id: 'call-inflight',
name: 'run_shell_command',
args: { command: 'sleep 10' },
},
},
],
},
};
await replayer.replay([record], undefined, { finalizeDangling: false });
const updates = sentUpdates();
expect(updates.map((update) => update['sessionUpdate'])).toEqual([
'tool_call',
]);
expect(replayer.getPendingToolCalls()).toEqual([
expect.objectContaining({ callId: 'call-inflight' }),
]);
});
it('should carry dangling function calls across replay pages', async () => {
const record: ChatRecord = {
...createAssistantRecord(''),
@@ -91,6 +91,7 @@ export class HistoryReplayer {
initialGoalCause?: GoalStateCause;
goalBootstrap?: HistoryReplayGoalBootstrap;
skipFinalizeCallIds?: ReadonlySet<string>;
finalizeDangling?: boolean;
} = {},
): Promise<void> {
try {
@@ -108,7 +109,7 @@ export class HistoryReplayer {
await this.sendUpdate(update);
}
await this.replayPage(records, {
finalizeDangling: true,
finalizeDangling: options.finalizeDangling ?? true,
gaps,
...(options.skipFinalizeCallIds
? { skipFinalizeCallIds: options.skipFinalizeCallIds }
@@ -4,7 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { Config } from '@qwen-code/qwen-code-core';
import type { TurnContent, MessageRewriteConfig } from './types.js';
@@ -278,4 +281,65 @@ describe('LlmRewriter', () => {
expect(input).not.toContain('上一轮改写结果');
});
});
describe('promptFile', () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'llm-rewriter-promptfile-'));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
function promptOf(rewriter: unknown): string {
return (rewriter as { prompt: string }).prompt;
}
it('loads a custom prompt from a readable file', () => {
const filePath = join(tempDir, 'prompt.md');
writeFileSync(filePath, ' custom rewrite prompt ');
const rewriter = new LlmRewriter(makeConfig(), {
enabled: true,
target: 'all',
promptFile: filePath,
} as MessageRewriteConfig);
expect(promptOf(rewriter)).toBe('custom rewrite prompt');
});
it('falls back to the default prompt when the file is missing', () => {
const rewriter = new LlmRewriter(makeConfig(), {
enabled: true,
target: 'all',
promptFile: join(tempDir, 'does-not-exist.md'),
} as MessageRewriteConfig);
expect(promptOf(rewriter)).toContain('rewrites raw coding-agent output');
});
// Regression for #9752: promptFile pointing at a path that exists but
// cannot be read as a file (a directory) used to throw EISDIR from the
// constructor, crashing ACP session startup.
it('falls back to the default prompt when promptFile is a directory', () => {
expect(
() =>
new LlmRewriter(makeConfig(), {
enabled: true,
target: 'all',
promptFile: tempDir,
} as MessageRewriteConfig),
).not.toThrow();
const rewriter = new LlmRewriter(makeConfig(), {
enabled: true,
target: 'all',
promptFile: tempDir,
} as MessageRewriteConfig);
expect(promptOf(rewriter)).toContain('rewrites raw coding-agent output');
});
});
});
@@ -57,16 +57,29 @@ export class LlmRewriter {
// promptFile takes precedence over inline prompt
if (rewriteConfig.promptFile) {
const filePath = resolve(rewriteConfig.promptFile);
if (existsSync(filePath)) {
this.prompt = readFileSync(filePath, 'utf-8').trim();
debugLogger.info(
`Loaded rewrite prompt from file: ${filePath} (${this.prompt.length} chars)`,
);
} else {
if (!existsSync(filePath)) {
debugLogger.warn(
`Rewrite prompt file not found: ${filePath}, using default`,
);
this.prompt = DEFAULT_REWRITE_PROMPT;
} else {
// existsSync passes for directories and says nothing about
// readability, so the read itself can still fail (EISDIR, EACCES,
// ...). Degrade like the missing-file case instead of throwing,
// which would crash ACP session startup (#9752).
try {
this.prompt = readFileSync(filePath, 'utf-8').trim();
debugLogger.info(
`Loaded rewrite prompt from file: ${filePath} (${this.prompt.length} chars)`,
);
} catch (error) {
debugLogger.warn(
`Rewrite prompt file could not be read: ${filePath} (${
error instanceof Error ? error.message : String(error)
}), using default`,
);
this.prompt = DEFAULT_REWRITE_PROMPT;
}
}
} else {
this.prompt = rewriteConfig.prompt || DEFAULT_REWRITE_PROMPT;
@@ -112,6 +112,47 @@ describe('MessageRewriteMiddleware', () => {
},
);
it('does not rewrite vision bridge notices or carry their metadata', async () => {
const { middleware, mockSendUpdate } = createMiddleware('message');
const notice = {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'Vision bridge cancelled.' },
_meta: {
source: 'vision_bridge_notice',
qwenDiscreteMessage: true,
visionBridgeNotice: { status: 'skipped' },
},
} as unknown as SessionUpdate;
await middleware.interceptUpdate(notice);
await middleware.interceptUpdate({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'Normal model response.' },
} as unknown as SessionUpdate);
await middleware.flushTurn();
await middleware.waitForPendingRewrites();
expect(mockSendUpdate).toHaveBeenNthCalledWith(1, notice);
expect(mockSendUpdate).toHaveBeenNthCalledWith(3, {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'rewritten text' },
_meta: { rewritten: true, turnIndex: 1 },
});
const { LlmRewriter } = await import('./LlmRewriter.js');
const rewriter = vi.mocked(LlmRewriter).mock.results[0]?.value as {
rewrite: ReturnType<typeof vi.fn>;
};
expect(rewriter.rewrite).toHaveBeenCalledWith(
{
thoughts: [],
messages: ['Normal model response.'],
hasToolCalls: false,
},
expect.any(AbortSignal),
);
});
it('should accumulate messages when target is "message"', async () => {
const { middleware, mockSendUpdate } = createMiddleware('message');
@@ -88,11 +88,12 @@ export class MessageRewriteMiddleware {
// Always send original message as-is
await this.sendUpdate(update);
const source = (
updateRecord['_meta'] as Record<string, unknown> | undefined
)?.['source'];
if (
updateType === 'agent_message_chunk' &&
(updateRecord['_meta'] as Record<string, unknown> | undefined)?.[
'source'
] === 'slash_command'
(source === 'slash_command' || source === 'vision_bridge_notice')
) {
return;
}
@@ -33,3 +33,6 @@ Add to `settings.json`:
```
`timeoutMs` sets the per-rewrite LLM call timeout in milliseconds. Defaults to 30000.
If `promptFile` is missing or cannot be read, rewriting falls back to the
built-in default prompt. Set `QWEN_DEBUG_LOG_FILE` to capture the fallback
warning.
Loaded 100 of 2109 files, more files were not shown because too many files have changed in this diff. Show more