Skip to content

feat(serve): persist workspace channel configuration - #7514

Merged
wenshao merged 5 commits into
QwenLM:mainfrom
qqqys:codex/issue-7209-channel-config-store
Jul 23, 2026
Merged

feat(serve): persist workspace channel configuration#7514
wenshao merged 5 commits into
QwenLM:mainfrom
qqqys:codex/issue-7209-channel-config-store

Conversation

@qqqys

@qqqys qqqys commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

This is the first part of the channel-management work for #7209. It adds serializable management metadata for DingTalk, WeCom, and Feishu, and introduces a workspace-scoped settings store that can safely list, create, update, remove, and select channel configurations for qwen serve.

Secret values use explicit preserve, replace, or clear operations, and mutations use revision checks to prevent stale browser state from overwriting newer workspace settings. Existing comments, unrelated settings, and unmanaged legacy fields are preserved.

Why it's needed

The WebShell channel-management UI needs a small, validated persistence contract before runtime controls and HTTP routes can be added. Keeping this foundation separate makes the multi-workspace ownership boundary and secret-update semantics reviewable without the UI and worker-lifecycle changes from the original large PR.

Only DingTalk, WeCom, and Feishu are exposed as manageable channel types in this phase. QR-code authentication and all other channel types are out of scope.

Reviewer Test Plan

How to verify

Confirm that the channel catalog marks only DingTalk, WeCom, and Feishu as manageable and returns serializable field metadata. Confirm that workspace mutations preserve comments and unrelated settings, reject stale revisions and invalid values, require explicit secret operations, and persist startup selection separately under serve.channels.

Local verification completed with the two focused test files (30 tests), repository type checking, focused ESLint, and a full repository build during dependency installation.

Evidence (Before & After)

N/A — this PR only adds the configuration contract and persistence layer; it has no user-visible UI changes.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

Node.js 22 workspace install on macOS.

Risk & Scope

  • Main risk or tradeoff: This foundational PR is intentionally not wired to daemon routes yet; the follow-up runtime and route PRs will consume the store.
  • Not validated / out of scope: WebShell UI, daemon runtime start/stop/reload behavior, pairing approval, QR-code authentication, and channel types other than DingTalk, WeCom, and Feishu.
  • Breaking changes / migration notes: None. Existing channel settings remain readable, and unmanaged legacy fields can only be retained unchanged through this store.

Linked Issues

Part of #7209.

中文说明

本 PR 做了什么

这是 #7209 频道管理工作的第一部分。它为钉钉、企业微信和飞书增加可序列化的管理元数据,并新增工作区级设置存储,用于安全地列出、新建、更新、删除频道配置,以及持久化 qwen serve 的频道启动选择。

密钥值通过明确的保留、替换或清空操作更新;配置变更通过版本校验避免浏览器中的旧状态覆盖较新的工作区设置。已有注释、无关设置和不可管理的历史字段都会被保留。

为什么需要

WebShell 频道管理 UI 在接入运行时控制和 HTTP 路由之前,需要一个小而且经过校验的持久化契约。把基础层单独拆出,可以独立评审多工作区归属边界和密钥更新语义,避免与原大 PR 中的 UI 和 worker 生命周期改动混在一起。

本阶段只开放钉钉、企业微信和飞书三种可管理频道。扫码认证和其他频道类型均不在范围内。

Reviewer 测试计划

如何验证

确认频道目录只把钉钉、企业微信和飞书标记为可管理,并返回可序列化的字段元数据。确认工作区配置变更能够保留注释和无关设置,拒绝过期版本与非法字段,要求显式的密钥操作,并把启动选择独立持久化到 serve.channels

本地已完成两个聚焦测试文件(30 个测试)、仓库类型检查、聚焦 ESLint,以及安装依赖期间触发的全仓构建。

证据(Before & After)

不适用——本 PR 只增加配置契约和持久化层,没有用户可见的 UI 改动。

测试平台

OS 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

环境(可选)

macOS 上的 Node.js 22 工作区安装。

风险与范围

  • 主要风险或取舍:这个基础 PR 暂时不接入 daemon 路由;后续运行时和路由 PR 会消费该存储层。
  • 未验证或范围外:WebShell UI、daemon 启停/重载、配对审批、扫码认证,以及钉钉、企业微信和飞书之外的频道类型。
  • 破坏性变更或迁移说明:无。已有频道设置仍可读取,不可管理的历史字段只能原样保留。

关联 Issue

#7209 的一部分。

@qqqys
qqqys marked this pull request as ready for review July 22, 2026 14:43
@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

Built the PR base vs this PR head 6d020c0, drove a fixed endpoint set against each, and diffed the JSON responses. Only fields that changed are shown.

No response changes against the PR base across 4 scenario(s).

Qwen Code · serve A/B

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: This is a feat PR linked to #7209 — the WebShell channel-management UI needs a validated persistence contract before runtime controls and HTTP routes can be added. The feature need is established in the linked issue; no reproduction required for a feature addition.

Direction: Aligned. Channel management is part of the qwen serve story, and splitting the persistence layer from the UI/runtime work is the right sequencing. CHANGELOG has no direct reference but the serve area is actively evolving (e.g. #7552 landed workspace-level generation).

Size: 576 production lines / 700 test lines / 5 schema lines across 6 packages. Touches packages/cli/src/config/settingsSchema.ts (core path packages/*/src/config/**). Since this is a feat at 500+ production lines, maintainer awareness was flagged on the initial triage — @wenshao has since reviewed, requested changes (fixed in 2348189, f704a4c, 6d020c0), and verified locally.

Approach: Scope feels right — types in channel-base, metadata on the 3 plugins, a catalog function, and a self-contained store with revision checking and explicit secret operations. Every edit in the diff serves the stated goal; no drive-by changes. The store isn't wired to routes yet, which is intentional and keeps this reviewable.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:这是一个关联 #7209feat PR——WebShell 频道管理 UI 在接入运行时控制和 HTTP 路由之前,需要一个经过校验的持久化契约。功能需求已在关联 issue 中确立;功能新增无需复现。

方向:对齐。频道管理是 qwen serve 的一部分,将持久化层与 UI/运行时工作拆分是正确的排序。CHANGELOG 无直接引用,但 serve 领域正在活跃演进(如 #7552 已合入工作区级生成)。

规模:576 生产行 / 700 测试行 / 5 schema 行,跨 6 个包。触及 packages/cli/src/config/settingsSchema.ts(核心路径 packages/*/src/config/**)。由于是 500+ 生产行的 feat,初次 triage 已标记维护者关注——@wenshao 随后完成了审查、提出修改意见(已在 2348189f704a4c6d020c0 中修复),并进行了本地验证。

方案:范围合理——channel-base 中的类型、3 个插件上的元数据、目录函数、以及带版本校验和显式密钥操作的自包含存储。diff 中每一处改动都服务于既定目标;无顺手改动。存储层尚未接入路由,这是有意为之,保持可审查性。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

Reviewed at 6d020c09826509e44b6214bc6c6de908bbc39ba2 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal: For a workspace-scoped channel settings store with secret management, I would: (1) add field-descriptor types to channel-base, (2) annotate the 3 manageable plugins, (3) expose a catalog function, (4) build a store class with snapshot/upsert/remove/setStartupNames + revision checking, (5) add the serve schema key. The PR matches this almost exactly.

Findings: No critical blockers. The implementation is straightforward and well-structured:

  • Secret handling is sound — secrets must go through explicit preserve/replace/clear operations; passing them directly in config is rejected. validateSecretUpdate is strict about object shape (exactly operation for preserve/clear, operation + non-empty value for replace).
  • Prototype pollution is guarded on both channel names and secret keys via UNSAFE_OBJECT_KEYS. The "all" sentinel is reserved on upsert to prevent confusion with the startup selector.
  • Revision checking (SHA-256 of serialized state) prevents stale browser state from overwriting newer settings. The conflict error is clear.
  • assertManagedConfig validates against descriptor fields + shared fields, and allows legacy fields only when preserved unchanged (isDeepStrictEqual). This is the right balance — strict on new values, permissive on existing ones.
  • The remove method correctly handles the "all" sentinel: keeps it when other channels remain, clears it when none do. The whitespace-canonicalization (allall) is a nice touch.
  • assertSharedField hardcodes the shared channel config vocabulary (senderPolicy, dmPolicy, etc.). This couples the store to the shared field definitions, but for a validation layer that's acceptable — the alternative (deriving from types at runtime) would be over-engineering at this stage.

One minor observation (non-blocking): upsert calls loadSettings twice — once in assertRevisionsnapshot() and once to get the workspace file handle for saveSettings. This is a small redundancy but ensures the write uses the latest file state, so it's a reasonable tradeoff.

Real-Scenario Testing

This PR adds a persistence layer with no user-visible CLI surface (not wired to routes yet). Unit tests are the appropriate verification:

$ cd packages/cli && npx vitest run src/serve/channel-settings-store.test.ts src/commands/channel/channel-registry.test.ts

 RUN  v3.2.4

 ✓ src/commands/channel/channel-registry.test.ts (1 test) 481ms
   ✓ channel registry > only marks the manually configurable built-in types as manageable  480ms
 ✓ src/serve/channel-settings-store.test.ts (33 tests) 263ms

 Test Files  2 passed (2)
      Tests  34 passed (34)
   Duration  9.19s

ESLint on all 10 changed source files: clean (no output).

Typecheck (tsc --noEmit): no errors in PR-changed files. Pre-existing errors in scripts/check-i18n.ts and scripts/desktop-openwork-sync.ts are unrelated (present on the merge base, not introduced by this PR).

中文说明

代码审查

独立方案: 对于带密钥管理的工作区级频道设置存储,我的方案是:(1) 在 channel-base 添加字段描述符类型,(2) 标注 3 个可管理插件,(3) 暴露目录函数,(4) 构建带 snapshot/upsert/remove/setStartupNames + 版本校验的存储类,(5) 添加 serve schema 键。PR 与我的方案几乎完全一致。

发现: 无关键阻塞问题。实现简洁、结构清晰:

  • 密钥处理合理——密钥必须通过显式的 preserve/replace/clear 操作;直接在 config 中传递会被拒绝。validateSecretUpdate 对对象形状严格校验。
  • 原型污染在频道名和密钥键上均有防护。"all" 哨兵在 upsert 时被保留,防止与启动选择器混淆。
  • 版本校验(SHA-256)防止浏览器旧状态覆盖较新设置。
  • assertManagedConfig 对描述符字段和共享字段进行校验,仅在旧字段未更改时允许保留。
  • remove 方法正确处理 "all" 哨兵:有其他频道时保留,无频道时清除。
  • assertSharedField 硬编码了共享频道配置词汇表——对验证层来说可以接受。

次要观察(非阻塞):upsert 调用了两次 loadSettings——一次在 assertRevisionsnapshot() 中,一次获取工作区文件句柄用于 saveSettings。这是合理的权衡。

实际场景测试

本 PR 添加的持久化层没有用户可见的 CLI 界面(尚未接入路由)。单元测试是合适的验证方式:34 个测试全部通过。ESLint 干净。类型检查在 PR 变更文件中无错误。

Qwen Code · qwen3.8-max-preview

Reviewed at 6d020c09826509e44b6214bc6c6de908bbc39ba2 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — clean review across every stage; the initial triage deferred for maintainer awareness on the cross-package core-path scope, and @wenshao has since reviewed, requested changes (all fixed), and verified locally.

This is a well-executed foundational PR. The approach matches what I'd propose independently — types in channel-base, metadata on the 3 plugins, a catalog function, and a self-contained store with revision checking and explicit secret operations. The code is straightforward, the tests are thorough (34 tests covering edge cases like prototype pollution, stale revisions, legacy field preservation, the "all" sentinel, and whitespace canonicalization), and everything passes (typecheck, lint, unit tests).

The three fix commits since the initial review (2348189 harden snapshots, f704a4c validate startup names, 6d020c0 reserve "all" name) addressed wenshao's review feedback cleanly. The store isn't wired to daemon routes yet — that's intentional and keeps this PR reviewable in isolation.

No blocking concerns. The maintainer awareness flag from the initial triage is satisfied by wenshao's active review and local verification.

中文说明

置信度:4/5 — 各阶段审查均干净;初次 triage 因跨包核心路径范围转交维护者关注,@wenshao 随后完成了审查、提出修改意见(均已修复),并进行了本地验证。

这是一个执行良好的基础性 PR。方案与我的独立提议一致——channel-base 中的类型、3 个插件上的元数据、目录函数、以及带版本校验和显式密钥操作的自包含存储。代码简洁,测试全面(34 个测试覆盖原型污染、过期版本、旧字段保留、"all" 哨兵、空格规范化等边界情况),全部通过(类型检查、lint、单元测试)。

初次审查后的三个修复提交(2348189 加固快照、f704a4c 校验启动名称、6d020c0 保留 "all" 名称)干净地处理了 wenshao 的审查反馈。存储层尚未接入 daemon 路由——这是有意为之,保持 PR 可独立审查。

无阻塞问题。初次 triage 的维护者关注标记已由 wenshao 的主动审查和本地验证满足。

Qwen Code · qwen3.8-max-preview

Reviewed at 6d020c09826509e44b6214bc6c6de908bbc39ba2 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

⏸️ Deferring to @yiliang114 @wenshao — this feat PR touches core paths (packages/*/src/config/**) and spans 6 packages with 553 production logic lines. The review is clean (30/30 tests, typecheck, lint, runtime exercise all pass), but the cross-package core-module scope needs a maintainer's sign-off before merge. Needs a human call on this one.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline.

— qwen3.7-max via Qwen Code /review

Comment on lines +293 to +298
const channels =
typeof settings.channels === 'object' &&
settings.channels !== null &&
!Array.isArray(settings.channels)
? (settings.channels as Record<string, Record<string, unknown>>)
: {};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The runtime guard checks only that settings.channels is a non-null, non-array object, but the cast asserts every value is itself a Record<string, unknown> — a property the check does not verify. — Concrete cost: a settings file containing "channels": {"ding": null} or {"ding": 42} passes the outer guard. snapshot() returns the value typed as Record<string, Record<string, unknown>>. A future consumer calling Object.entries(snapshot.channels["ding"]) on a null value crashes with TypeError. The upsert method has a guard (?? {}) that snapshot() lacks.

Suggested change
const channels =
typeof settings.channels === 'object' &&
settings.channels !== null &&
!Array.isArray(settings.channels)
? (settings.channels as Record<string, Record<string, unknown>>)
: {};
const raw =
typeof settings.channels === 'object' &&
settings.channels !== null &&
!Array.isArray(settings.channels)
? settings.channels
: {};
const channels: Record<string, Record<string, unknown>> = {};
for (const [name, value] of Object.entries(raw)) {
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
channels[name] = value as Record<string, unknown>;
}
}

— qwen3.7-max via Qwen Code /review

Comment on lines +353 to +354
const current = this.assertRevision(options.expectedRevision);
const storedPrevious = current.channels[name] ?? {};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] upsert and remove accept a channel name parameter without validating it against UNSAFE_OBJECT_KEYS (__proto__, constructor, prototype), even though this file validates secret-update keys against that same set. — Failure scenario: upsert('__proto__', ...) passes all validation, but saveSettingsapplyUpdates silently skips the __proto__ key. The caller gets a successful response with a snapshot whose revision matches the input (nothing changed on disk) — a silent no-op that looks like success. Adding an early guard here would match the existing assertValidChannelSecretUpdates pattern.

Suggested change
const current = this.assertRevision(options.expectedRevision);
const storedPrevious = current.channels[name] ?? {};
if (UNSAFE_OBJECT_KEYS.has(name)) {
throw channelSettingsError(
'channel_settings_invalid_name',
`Channel name ${JSON.stringify(name)} is not allowed.`,
);
}
const current = this.assertRevision(options.expectedRevision);
const storedPrevious = current.channels[name] ?? {};

— qwen3.7-max via Qwen Code /review

@qqqys

qqqys commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

已修复。

验证:cd packages/cli && npx vitest run src/serve/channel-settings-store.test.ts(31 passed);npm run typecheck 通过;npm run build 通过。

@wenshao

wenshao commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Code Review — feat(serve): persist workspace channel configuration (#7514)

Verdict: No blocking correctness issues found. This is a well-scoped, carefully validated persistence layer with strong negative-path test coverage (30 tests). A few maintainability/precision notes below.

Overview

Adds serializable management descriptors to the DingTalk/WeCom/Feishu plugins, a supportedChannelCatalog() helper that exposes only manageable types (stripping non-serializable fields like createChannel), a new top-level serve settings key, and WorkspaceChannelSettingsStore — an optimistic-concurrency store to list/upsert/remove channels and persist startup selection under serve.channels.

Strengths

  • Secret model is excellent. Explicit preserve/replace/clear; secrets can't be smuggled in as plain config keys; blank replacements rejected; secret keys must be plugin-declared; __proto__/constructor/prototype rejected in both channel names and secret maps.
  • skipLoadEnvironment: true correctly keeps $VAR references literal rather than resolving real secret values into the persisted file — the right call for a config store.
  • Deterministic revisioning over {channels, startupNames}, stable across unchanged reads (covered by a test).
  • Defense in depth: proto-pollution is blocked both in the store's validators and again at the write layer (applyUpdates skips dangerous keys). "Rejects without writing" is asserted via before/after file-byte comparison throughout.
  • Settings-schema gate satisfied: the committed settings.schema.json serve entry is byte-for-byte what scripts/generate-settings-schema.ts produces for an object-typed setting with no properties (description + type: object + additionalProperties: true), in the correct position.
  • all-sentinel canonicalization (name.trim() === 'all') is consistent with normalizeServeChannelSelection's trimming.

Suggestions

1. [Maintainability] Hardcoded shared-field schema duplicates canonical ChannelConfig with no compile-time link.
assertSharedField re-encodes the policy enums and shared-field allowlist as inline literals (senderPolicy: new Set(['allowlist','pairing','open']), model/cwd/approvalMode/..., identity, memoryScope, etc.). These match SenderPolicy/DmPolicy/GroupPolicy/SessionScope/DispatchMode in packages/channels/base/src/types.ts today, but nothing ties them together. Adding a new policy value or a new shared field to ChannelConfig will make this validator silently reject valid config as "not manageable," with no failing typecheck/test to catch the drift. Consider deriving these from the canonical types (or a satisfies Record<SenderPolicy, …> guard / shared constant) so the two stay locked together.

2. [Low] setStartupNames performs no validation.
Unlike upsert/remove, it writes names verbatim into serve.channels. It doesn't dedupe/trim, reject empty names, enforce the all-exclusivity rule, or check the names exist. Since the eventual consumer normalizeServeChannelSelection throws on ['all', <name>], the store can persist a selection that fails at startup. Even though a future HTTP route may validate, mirroring normalizeServeChannelSelection's rules here would be good defense-in-depth.

3. [Low] upsert/remove replace the whole channels subtree (replacePath: ['channels']).
Two side effects worth noting against the PR's "existing comments are preserved" claim:

  • Malformed sibling entries (null/scalar/array) that snapshot() filters out are dropped from disk on any unrelated upsert/remove. (Harmless — they're already broken — but it's a silent mutation of unrelated config.)
  • Inline comments inside a channel entry are lost, because the subtree is rebuilt from plain objects. Comments outside channels survive, and setStartupNames preserves the channels node intact — but there's no test asserting an inline channel comment survives an upsert. The claim holds for setStartupNames, not fully for upsert/remove.

4. [Low/Informational] TOCTOU window. assertRevision() reads, then saveSettings() re-reads and writes; the revision guard catches stale callers but not two truly-concurrent writers (last write wins). writeWithBackupSync guarantees no torn file, so this is acceptable and matches the stated "prevent stale browser state" goal — just noting it isn't a hard mutex.

5. [Nit] upsert calls loadSettings three times (assertRevision→snapshot, workspaceFile, final snapshot), each re-parsing from disk. Fine for an infrequent write path; could reuse the loaded SettingsFile.

6. [Nit] channel-registry.test.ts asserts the exact builtin catalog, while channel-settings-store.test.ts registers a test plugin into the module-level registry — safe under vitest's default per-file isolation, but would collide under --no-isolate.

Risk

Low. Foundation-only, not wired to daemon routes; nothing currently reads settings.serve.channels, so absent-vs-empty semantics don't yet affect runtime (and [] normalizes to "no selection" via normalizeServeChannelSelection). Style is clean and matches repo conventions (license header, .js import extensions, ChannelSettingsError code, Object.hasOwn).

中文说明

结论: 未发现阻断性正确性问题。这是一个范围清晰、校验严谨的持久化层,负向路径测试覆盖很强(30 个测试)。以下为可维护性/精确性方面的建议。

概述: 为钉钉/企业微信/飞书插件增加可序列化的 management 描述符;新增 supportedChannelCatalog()(仅暴露可管理类型并剥离 createChannel 等不可序列化字段);新增顶层 serve 设置项;以及 WorkspaceChannelSettingsStore——基于乐观并发的存储,用于列出/新增/更新/删除频道,并把启动选择持久化到 serve.channels

优点: 密钥模型很好(显式 preserve/replace/clear,密钥不能作为普通字段夹带,空值替换被拒绝,密钥键必须由插件声明,频道名和密钥映射都拦截 __proto__ 等);skipLoadEnvironment: true 正确保留 $VAR 字面量而不解析真实密钥;版本号哈希确定且稳定;写入层与校验层双重防护原型污染;schema 生成门禁已满足。

建议:

  1. [可维护性] assertSharedFieldChannelConfig 的枚举与共享字段白名单硬编码为内联字面量,目前与 channels/base 的规范类型一致,但两者没有编译期关联。将来给 ChannelConfig 增加新枚举值/新字段时,这里会静默把合法配置判为“不可管理”,且没有测试/类型检查能发现。建议从规范类型派生(或用 satisfies 守卫)。
  2. [低] setStartupNames 完全没有校验,会把 names 原样写入,可能持久化 ['all', <name>] 这种 normalizeServeChannelSelection 启动时会抛错的组合。建议在写入时对齐同样的规则。
  3. [低] upsert/removereplacePath: ['channels'] 整体替换 channels 子树:会丢弃快照过滤掉的畸形兄弟项,也会丢失频道条目内部的行内注释;"保留已有注释"仅对 setStartupNames 完全成立,缺少针对 upsert 保留行内注释的测试。
  4. [低/说明] assertRevision 读取与 saveSettings 写入之间存在 TOCTOU 窗口;版本校验只拦截过期调用方,两个并发写入仍是后写覆盖。有原子写保证不产生半写文件,符合“防止浏览器旧状态覆盖”的既定目标。
  5. [细节] upsert 三次调用 loadSettings;可复用已加载的 SettingsFile
  6. [细节] 注册表测试断言精确目录,而 store 测试向模块级注册表注册测试插件——在 vitest 默认按文件隔离下安全,--no-isolate 下会冲突。

风险: 低。仅为基础层,尚未接入 daemon 路由;当前没有代码读取 settings.serve.channels,且 [] 会被 normalizeServeChannelSelection 规范化为“无选择”。代码风格整洁,符合仓库约定。

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline.

— qwen3.7-max via Qwen Code /review

Comment on lines +412 to +416
async setStartupNames(
names: readonly string[],
options: ChannelSettingsMutationOptions,
): Promise<ChannelSettingsSnapshot> {
this.assertRevision(options.expectedRevision);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] setStartupNames does not validate names entries against assertSafeChannelName, while both sibling methods (upsert and remove) do. — Concrete cost: the class's own safety contract — "no unsafe prototype-chain names in the settings store" — has a gap at this entry point. A caller passing ['__proto__'] or ['constructor'] would persist those values into serve.channels without rejection, and any downstream consumer that converts the startupNames array into an object-keyed structure would hit prototype pollution.

Suggested change
async setStartupNames(
names: readonly string[],
options: ChannelSettingsMutationOptions,
): Promise<ChannelSettingsSnapshot> {
this.assertRevision(options.expectedRevision);
async setStartupNames(
names: readonly string[],
options: ChannelSettingsMutationOptions,
): Promise<ChannelSettingsSnapshot> {
for (const name of names) {
assertSafeChannelName(name);
}
this.assertRevision(options.expectedRevision);

— qwen3.7-max via Qwen Code /review

@qqqys

qqqys commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

已修复。

验证:cd packages/cli && npx vitest run src/serve/channel-settings-store.test.ts(32 passed);npx eslint packages/cli/src/serve/channel-settings-store.ts packages/cli/src/serve/channel-settings-store.test.ts 通过;npm run build 通过。

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline.

— qwen3.7-max via Qwen Code /review

Comment on lines +132 to +139
const enumValues: Record<string, ReadonlySet<string>> = {
senderPolicy: new Set(['allowlist', 'pairing', 'open']),
dmPolicy: new Set(['open', 'disabled']),
groupPolicy: new Set(['disabled', 'allowlist', 'open']),
sessionScope: new Set(['user', 'thread', 'single']),
dispatchMode: new Set(['steer', 'followup', 'collect']),
blockStreaming: new Set(['on', 'off']),
};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Shared-field enum values are hardcoded copies of the union types in packages/channels/base/src/types.ts with no shared source of truth, creating a silent-drift maintenance risk.

Failure scenario: A contributor adds a new member to a channel-base union type (e.g., SenderPolicy = 'allowlist' | 'pairing' | 'open' | 'blocklist') without realizing assertSharedField duplicates the value set. The new value passes TypeScript type-checking but the runtime validator rejects it with channel_settings_invalid_config, making a legitimate config change impossible through the management API until the duplication is noticed.

Suggested fix: Export const arrays from packages/channels/base/src/types.ts (e.g., export const SENDER_POLICIES = ['allowlist', 'pairing', 'open'] as const) and derive both the string-literal union types and these runtime Sets from those arrays.

— qwen3.7-max via Qwen Code /review

Comment on lines +76 to +78
function assertSafeChannelName(name: string): void {
if (UNSAFE_OBJECT_KEYS.has(name)) {
throw new ChannelSettingsError(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] assertSafeChannelName does not reserve the name "all" (or whitespace-trimmed variants), so upsert can create a channel whose key collides with the "all" sentinel that remove uses in startup-name maintenance.

Failure scenario: A channel named "all" exists (created via upsert). When a different channel (e.g., "bot") is removed, Object.keys(channels).some(channelName => !isAllStartupName(channelName)) evaluates to false because the remaining "all" channel is filtered out by isAllStartupName, so startupNames is cleared to [] — even though a channel still exists and the sentinel should be preserved.

Suggested fix: Extend assertSafeChannelName to also block names where name.trim() === 'all', matching the predicate in isAllStartupName.

— qwen3.7-max via Qwen Code /review

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed — no blockers. Suggestions are inline.

— qwen3.7-max via Qwen Code /review

Comment on lines +115 to +121
await store.upsert('bot', {
expectedRevision: first.revision,
config: {
type: 'management-validation-test',
clientId: 'client-id',
senderPolicy: 'pairing',
},

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] All successful upsert tests modify the pre-existing bot channel — no test verifies end-to-end creation of a brand-new channel name that doesn't already exist in settings. — Failure scenario: the upsert code path for a new channel differs from modification (storedPrevious becomes {}, previous becomes {} since type doesn't match, legacy field tolerance is disabled). If a bug exists in new-channel creation, no test would catch it.

Consider adding a test that writes settings without a bot channel, then calls store.upsert('new-bot', {...}) with valid config and verifies the channel appears in the output.

— qwen3.7-max via Qwen Code /review

Comment on lines +11 to +19
management: {
fields: [
{
key: 'clientId',
label: 'App ID',
kind: 'string',
required: true,
envResolvable: true,
},

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Only DingTalk's management descriptor is explicitly tested in channel-registry.test.ts (via catalog.find(e => e.type === 'dingtalk')?.fields). Feishu and WeCom descriptors have distinct labels and WeCom has an additional wsUrl field — a wrong kind on any of these fields would go undetected. — Concrete cost: if clientSecret in Feishu were accidentally typed as kind: 'string' instead of 'secret', the store would not enforce secret-handling protocols for that field.

Consider adding field-level assertions for all three manageable channel types in channel-registry.test.ts.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

✅ Local verification — merge reference

Verified the final head ac157a533 in an isolated worktree with a real npm ci + full workspace build (Node v22.23.1 / macOS). Not a symlink shortcut on purpose: channel-registry dynamically imports the channel packages by workspace name, so the built @qwen-code/channel-* dist must actually be this PR's source — confirmed the management descriptor is present in the built channels/dingtalk/dist.

Results

Check Command Result
Focused tests vitest run channel-settings-store.test.ts channel-registry.test.ts 33 passed (32 store + 1 registry)
CLI typecheck tsc --noEmit ✅ exit 0
ESLint 10 changed files ✅ exit 0
Prettier --check ✅ all files use Prettier style
Settings-schema gate npm run generate:settings-schema settings.schema.json in sync (no diff)
Full build npm ciprepare (tsc --build all) ✅ exit 0

The description says "30 tests" — at the current head it's 33 (the it.each rejection matrix expands to 10 cases). Nothing missing, just an accurate count.

verification summary

Real-flow E2E (beyond the unit tests)

Drove the actual WorkspaceChannelSettingsStore over an on-disk workspace settings.json through the real loadSettings/saveSettings stack and the real channel registry — full lifecycle: create → secret preserve → secret replacesetStartupNames → 5 fail-closed rejections → remove. 24/24 assertions passed. Highlights:

  • The $ENV secret is kept across preserve, and rotated only on an explicit replace; serve.port and the // Ops workspace comment survive every mutation.
  • serve.channels (startup selection) is persisted separately from channels; remove clears both together.
  • Every rejection (_conflict / _invalid_name / _invalid_secret / _unmanageable / _invalid_config) leaves the file byte-identical — genuinely fail-closed.

real-flow e2e

A/B — are the new management descriptors load-bearing?

Overlaid the base (main) dingtalk/wecom/feishu plugins (no descriptor), rebuilt, re-ran the registry test → FAIL (expected [] to deeply equal [ 'dingtalk', 'wecom', 'feishu' ]). Restored PR source → PASS. Confirms the tests pin the actual change rather than pre-existing behavior.

Observations (non-blocking)

  • assertSharedField (~80 lines) hardcodes the shared-field schema, duplicating knowledge from the channel-base types (as the review bot also noted). Fine for this foundation PR; worth centralizing when the shared-field set next changes.
  • The head is a merge of an older main (merge-base 32c491f); origin/main has since advanced to e07ebdc. GitHub reports MERGEABLE (no conflicts), so a routine rebase/re-merge before landing is all that's needed.

Verdict: from a build/test standpoint this is merge-ready — 33/33 tests, all quality gates green, schema in sync, the full persistence lifecycle verified end-to-end on the real settings stack, and the A/B proves the change is load-bearing. 👍

中文版本

✅ 本地验证 —— 合并参考

隔离的 worktree 中对最终 head ac157a533 进行了验证:真实 npm ci + 全量工作区构建(Node v22.23.1 / macOS)。刻意没有用 symlink 走捷径:channel-registry 会按工作区包名动态 import 各频道包,因此构建出的 @qwen-code/channel-* dist 必须确实是本 PR 的源码——已确认 management 描述符出现在构建后的 channels/dingtalk/dist 中。

结果

检查项 命令 结果
聚焦测试 vitest run channel-settings-store.test.ts channel-registry.test.ts 33 通过(32 store + 1 registry)
CLI 类型检查 tsc --noEmit ✅ exit 0
ESLint 10 个改动文件 ✅ exit 0
Prettier --check ✅ 全部符合 Prettier 风格
Settings schema 门禁 npm run generate:settings-schema settings.schema.json 已同步(无 diff)
全量构建 npm cipreparetsc --build 全部) ✅ exit 0

描述里写的是「30 个测试」,当前 head 实际是 33 个it.each 拒绝用例矩阵展开为 10 个)。没有遗漏,只是准确计数。

真实链路 E2E(超出单元测试)

用真实的 loadSettings/saveSettings 栈和真实的频道注册表,让真正的 WorkspaceChannelSettingsStore 在磁盘上的 settings.json 上跑完整生命周期:新建 → 密钥 preserve → 密钥 replacesetStartupNames → 5 个 fail-closed 拒绝 → remove24/24 断言全部通过。 要点:

  • $ENV 形式的密钥在 preserve 时被保留,只有显式 replace 才会轮换;serve.port// Ops workspace 注释在每次变更后都保留。
  • 启动选择 serve.channelschannels 分开持久化;remove 会把两者一起清理。
  • 每个拒绝路径(_conflict / _invalid_name / _invalid_secret / _unmanageable / _invalid_config)都让文件逐字节保持不变——确实是 fail-closed。

A/B —— 新增的 management 描述符是否是关键因素?

把 dingtalk/wecom/feishu 插件覆盖为 base(main) 版本(无描述符),重新构建后再跑 registry 测试 → 失败expected [] to deeply equal [ 'dingtalk', 'wecom', 'feishu' ])。恢复 PR 源码 → 通过。证明测试锁定的是本次改动本身,而非既有行为。

观察(非阻塞)

  • assertSharedField(约 80 行)把共享字段的 schema 硬编码了一遍,与 channel-base 类型中的知识存在重复(评审 bot 也提到过)。作为基础 PR 没问题;等下次共享字段集变化时可以考虑集中管理。
  • 该 head 是合并了较旧 main 的结果(merge-base 32c491f),而 origin/main 之后已推进到 e07ebdc。GitHub 显示 MERGEABLE(无冲突),落地前做一次常规 rebase/重新合并即可。

结论: 从构建/测试角度看可以合并——33/33 测试全过、所有质量门禁绿灯、schema 已同步、完整持久化生命周期已在真实 settings 栈上端到端验证,A/B 也证明了改动是关键因素。👍

@wenshao

wenshao commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

Comment on lines +103 to +105
function isEnvironmentReference(value: string): boolean {
return /^\$[A-Za-z_][A-Za-z0-9_]*$/.test(value);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] isEnvironmentReference regex only matches $VAR syntax, but resolveEnvVarsInObject (used during loadSettings) resolves both $VAR and ${VAR}. A non-envResolvable field set to ${MY_SECRET} bypasses this guard and gets resolved at runtime.

Failure scenario: a user stores ${MY_SECRET} for a field with envResolvable: false. isEnvironmentReference('${MY_SECRET}') returns false (regex requires $ followed by [A-Za-z_], not {), so the value passes assertDescriptorValue and is persisted. On the next loadSettings, resolveEnvVarsInObject resolves ${MY_SECRET} from process.env — exactly what envResolvable: false was meant to prevent.

Suggested change
function isEnvironmentReference(value: string): boolean {
return /^\$[A-Za-z_][A-Za-z0-9_]*$/.test(value);
}
function isEnvironmentReference(value: string): boolean {
return /^\$(?:[A-Za-z_][A-Za-z0-9_]*|\{[A-Za-z_][A-Za-z0-9_]*\})$/.test(value);
}

— qwen3.7-max via Qwen Code /review

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@wenshao
wenshao added this pull request to the merge queue Jul 23, 2026
Merged via the queue into QwenLM:main with commit 0a16b89 Jul 23, 2026
69 checks passed
chiga0 pushed a commit that referenced this pull request Jul 23, 2026
* feat(serve): persist workspace channel configuration

* fix(serve): harden channel settings snapshots

* fix(serve): validate startup channel names

* fix(serve): reserve all channel name

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
yiliang114 added a commit to he-yufeng/qwen-code that referenced this pull request Jul 23, 2026
)

* fix(cli): correct queued message display style and ordering

Mid-turn steer messages (user input queued while the model is
responding) had two display bugs:

1. They rendered with notification styling (● icon) instead of
   user-input styling (> prefix) because accept() added them to
   UI history as MessageType.NOTIFICATION.

2. They appeared below the model's reply because accept() was
   only called in the finally block after the entire response
   stream completed, appending the user message after all model
   response items.

Fix: use MessageType.USER with sentToModel: true for steer
messages, and settle the steer input on the first stream event
(after the user-content push lands but before model-response
events are committed to UI history). Pass steer inputs through
to recursive sendMessageStream calls so all takeSteerInput paths
benefit from early settlement. Add a WeakSet guard to
settleSteerInput for idempotency across recursive invocations.

* test(core): add ordering test for early steer settlement

Verify that accept() is called after the first stream event is
pulled but before subsequent events reach the consumer, pinning
the settle-before-content timing that ensures queued user
messages render above the model's reply.

* fix(cli): use sentToModel: false for steer messages, address review

- Use sentToModel: false instead of true: steer messages are injected
  into an existing tool-result turn, not standalone user turns.
  sentToModel: true would make isRealUserTurn() count them as real
  turns, inflating the rewind turn index.
- Remove unnecessary as HistoryItemWithoutId cast.
- Add post-cleanup assertion in ordering test to verify the WeakSet
  guard prevents double-settlement.

* fix(cli): align resumed mid-turn steer display with live session (#7381)

Resume path now renders mid_turn_user_message as MessageType.USER with
sentToModel: false, matching the live-session styling. Add a comment
documenting the intentional sentToModel: false choice.

* fix(cli): exclude steer messages from user-turn filters (#7381)

Steer messages (sentToModel: false) were counted as real user turns by
five downstream consumers that filter on type === 'user' without checking
sentToModel, breaking cancel auto-restore, telemetry turn count, prompt
recall, away-recap thresholds, and resume collapse boundaries.

Add sentToModel !== false guards at each site.

* test(cli): add coverage for sentToModel !== false guards (#7381)

* test(cli): add coverage for sentToModel !== false guard in input-history filter (#7381)

* test(cli): add coverage for sentToModel !== false guard in YOLO turn-count telemetry (#7381)

* fix(cli): restore corrupted docs and classify steer items as synthetic (#7381)

* fix(docs): restore corrupted autogenerated input names in GitHub Action docs (#7381)

* fix(cli): deduplicate findLastUserItemIndex and add steerInput forwarding test (#7381)

* fix(cli): keep code-block copy numbering continuous across steer items (#7381)

* test(core): add Hook continuation steerInput forwarding test

Verify that steerInput is forwarded through the Stop-hook
continuation path and settled early on the first content event
of the continuation turn, matching the existing Steer
continuation coverage.

* fix(cli): sync selection test fixtures with ink FrameCell/ReadonlyFrame types (#7381)

* fix(core): align cron day wildcard semantics (#7464)

Co-authored-by: destire-mio <248462155+destire-mio@users.noreply.github.com>

* feat(core): keep completed background agents resident (#7426)

* feat(core): keep background agents resident

* fix(core): harden background continuation boundaries

* docs(core): move per-spawn cleanup comment to subagentDispose

The comment describing the per-spawn cleanup (which stays undefined on
the fork-resume path) had drifted above the launchModel declaration,
where it no longer applied and could mislead readers. Relocate it to the
subagentDispose assignment in the non-fork branch it actually documents.

* fix(core): close finishing window and release resident on error in background GOAL path

- Non-worktree GOAL completion drained the message queue but never called
  registry.beginFinishing(), unlike the worktree path. A send_message racing
  the terminal transition could be accepted (status still running,
  finishingAgents empty) and then orphaned by complete(). Call beginFinishing()
  after the empty drain to reject the racing message instead.
- The completion catch block never reset keepResident, so a throw from
  patchAgentMeta/registry.complete left the runtime resident but finalized as
  failed — a zombie that cleanupRuntime never reclaimed. Reset keepResident in
  the catch so the finally block disposes it.

---------

Co-authored-by: Claude <noreply@anthropic.com>

* ci(autofix): continue environment-specific fixes (#7444)

* ci(autofix): continue environment-specific fixes

* docs(autofix): align verification wording

* docs(autofix): require bundle before integration tests

* docs(autofix): scope surrogate verification rules

* docs(autofix): require focused tests before integration checks

* docs(autofix): clarify review verification guidance

* fix(acp-bridge): close prompt-terminal follow-ups from the PR #7400 self-review (#7453)

* fix(acp-bridge): close prompt-terminal follow-ups from PR #7400 self-review

Keep a removed RUNNING prompt visible to the teardown flush via a removed flag so its terminal still publishes when the session closes before the agent cooperates; gate broadcastTurnError's session turn-state mutation to running prompts; propagate the typed PromptDeadlineExceededError from the pre-dispatch abort check; document the deadline FIFO-release overlap trade-off, the trailing prompt_cancelled after flush, and the result.then/finally ordering invariant; route the dedup log to the debug channel; drop the prompt-deadline re-export that pulled the bridge into a leaf module.

Fixes #7451

* test(acp-bridge): cover promote-then-remove-then-settle duplicate completed guard (#7453)

---------

Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>

* fix(core): strip Qwen-internal daemon secrets from agent-spawned child env (#7256)

* fix(core): strip Qwen-internal daemon secrets from agent-spawned child env

Shell subprocesses (and the monitor tool and stdio MCP servers) inherited
the full daemon process.env, including QWEN_SERVER_TOKEN (the serve-daemon
bearer credential), so an agent-run command like printenv QWEN_SERVER_TOKEN
could read an internal secret. Add a shared sanitizeChildEnv() that removes
Qwen-internal daemon/server tokens (QWEN_SERVER_TOKEN, QWEN_DAEMON_TOKEN)
before spawning, and apply it at the shell child_process + PTY paths,
monitor.ts, and the mcp-client stdio transport.

The denylist is deliberately narrow: it does NOT strip third-party
credentials (GH_TOKEN, AWS_*, NPM_TOKEN, ...) that real shell workflows
legitimately inherit -- only Qwen-internal secrets. Exported from the
package root so the desktop denylists can consolidate onto it later.

Fixes #6601.

* test(core): cover daemon-secret stripping on monitor and mcp-client spawn sites

* test(core): replace process.env instead of mutating in shell sanitization tests

The file restores process.env by reference in afterEach, so in-place key
mutations leaked into later tests. Use the replacement pattern already used
by setupConflictingPathEnv.

* docs(core): align JSDoc @param names with actual function signatures (#7492)

Fix 6 instances where JSDoc @param tags had drifted from their
corresponding function signatures — parameters were renamed, removed,
or undocumented over time but the doc blocks were not updated.

Closes #7446

* feat(serve): support forced MCP reconnects (#7488)

* feat(serve): support forced MCP reconnects

* test(serve): cover forced MCP reconnect options

---------

Co-authored-by: 克竟 <dingbingzhi.dbz@alibaba-inc.com>

* fix(cli): insert newline on Shift+Enter and stop streaming thinking-block flicker (#7397)

* fix(cli): re-push Kitty keyboard flags onto the alternate screen in VP mode

In VP mode the app renders on the alternate screen (`alternateScreen: true`),
but the Kitty keyboard progressive-enhancement flags were pushed only once at
startup on the main screen. The Kitty spec tracks these flags per screen
buffer, so the alternate screen's stack stays empty and the terminal never
reports modifiers: Shift+Enter arrives as a bare Enter (submit) or, when the
terminal emits an ESC-prefixed variant, as an orphaned Escape that trips the
empty-buffer double-Esc rewind prompt — so Shift+Enter can never insert a
newline in VP mode even on Kitty-capable terminals (e.g. cmux).

Re-push the flags onto the alternate screen right after Ink enters it (Ink
writes the enter-alt-screen sequence synchronously inside render(), so the
push is correctly ordered). Ink discards the alternate screen and its flag
stack on unmount, leaving the startup main-screen push balanced by the
existing disableKittyProtocol() on cleanup.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): stabilize streaming thinking block height to stop flicker

The pending "Thinking…" block renders the tail of the reasoning stream in a
content-sized box. As the model emits paragraph separators, a blank line
enters and leaves the tail window (and `trimEnd` drops trailing blanks), so the
visible line count oscillates and the block flickers 2→3→5 rows during
streaming.

Track the tallest height the block has reached for the current thought and
never render fewer rows than that (capped at the streaming window size),
padding at the top so the newest line stays pinned to the bottom. The tracker
resets when streaming ends or when the buffer shrinks (a new thought replaced
it), so height is monotonic within a thought without leaking across thoughts.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): decode xterm modifyOtherKeys Shift/Ctrl/Alt+Enter so it inserts a newline

Terminals such as Ghostty report Shift+Enter as the xterm modifyOtherKeys
sequence `ESC [ 27 ; <mods> ; <key> ~` (e.g. `ESC [ 27 ; 2 ; 13 ~`) when the
Kitty keyboard protocol is not negotiated — which is the default, since Kitty
detection does not always succeed. Two bugs kept this from inserting a newline:

1. The CSI-u parser read the leading `27` marker as the key code (matching the
   Escape key code 27) instead of the real key code in the third parameter, so
   with Kitty enabled Shift+Enter was mistaken for Escape and tripped the
   double-Esc rewind prompt.
2. The reassembly path that stitches readline's shredded CSI fragments back
   together was gated behind `kittyProtocolEnabled`, so with Kitty disabled the
   `ESC [ 27 ; 2 ;` head plus the stray `13~` tail leaked into the composer as
   literal text and no newline was inserted.

Decode the third parameter as the real key code for the `27;…~` form, and route
those sequences through the reassembly buffer even when Kitty is off (only the
`ESC [ 27` marker opts in, so keys readline already parses cleanly are
untouched). Shift/Ctrl/Alt+Enter now insert a newline in both VP and non-VP
mode regardless of Kitty negotiation.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): anchor VP viewport to the top until a conversation turn exists

On a fresh VP-mode session the virtualized list holds the banner plus startup
notices (tips / MOTD / info), so it is longer than one item. Keying the initial
scroll anchor off list length alone selected scroll-to-end, which pinned the
banner to the bottom of the full-height viewport and left the top half of the
screen blank.

Anchor to the top until there is an actual conversation turn (a user/user_shell
history item or a pending response), then resume scroll-to-end so the latest
output stays in view. Startup notices no longer count as content that forces
bottom alignment.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): stabilize streaming thinking window against availableTerminalHeight drift

The grow-only streaming thinking window still flickered because its line cap was
derived from availableTerminalHeight. While a thought streams the terminal keeps
constrainHeight on, so availableTerminalHeight (and the derived maxLines) drifts
up and down as sibling pending content grows, and the grow-only clamp
`min(maxLines, …)` shrank the block whenever it dipped.

Use a constant window height (MAX_STREAMING_THINKING_VISUAL_LINES) for the
pending window instead. The window is only a few lines, so a fixed cap cannot
meaningfully overflow (VP scrolls anyway), and the height stays stable while
still growing monotonically within a thought.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* Revert "fix(cli): anchor VP viewport to the top until a conversation turn exists"

This reverts commit fbe86a9e159b75ea1f5b689cc327599c9dc91090.

* fix(cli): guard modifyOtherKeys detection against keypresses without a sequence

The modifyOtherKeys prefix check ran on every keypress, but some synthetic
keypresses (and the useKeypress test harness) emit a key with no `sequence`,
so `key.sequence.startsWith(...)` threw an unhandled rejection. Use optional
chaining so a missing sequence is simply not a modifyOtherKeys start.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(cli): mock pushKittyProtocolFlags in gemini.test.tsx kitty mock

The kittyProtocolDetector mock omitted the newly added pushKittyProtocolFlags
export. Add it so the mock stays in sync with the real module and a VP-mode
startup path exercised through this suite cannot hit an undefined call.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(web-shell): open singleton subagent details (#7495)

Co-authored-by: ytahdn <ytahdn@gmail.com>

* fix(web-shell): avoid redundant git status requests (#7496)

Co-authored-by: ytahdn <ytahdn@gmail.com>

* fix(agent): ignore empty working_dir placeholders (#7343)

* fix(agent): ignore empty working_dir placeholders

* test(agent): align empty working_dir expectations

* feat(prompts): allow overriding core identity via QWEN_SYSTEM_IDENTITY_MD (#7478)

* feat(prompts): update prompts.ts for QWEN_SYSTEM_IDENTITY_MD

* feat(prompts): update prompts.test.ts for QWEN_SYSTEM_IDENTITY_MD

* fix(prompts): address CR on QWEN_SYSTEM_IDENTITY_MD

Keep getDefaultCoreIdentitySentence private, fail loud on path
resolution errors, use trimEnd, and resolve identity only on the
default-prompt branch.

* test(prompts): align identity override tests with CR feedback

Sample default identity from live prompt, cover trimEnd trailing
whitespace, and assert homedir resolution failures throw.

---------

Co-authored-by: 易良 <1204183885@qq.com>

* fix(cli): yield to single-slot background agents (#7258)

Co-authored-by: hogeheer <267467744+hogeheer499-commits@users.noreply.github.com>

* docs(autofix): require evidenced pre-commit verification, not a bare "verified" (#7486)

* docs(autofix): require evidenced pre-commit verification, not a bare "verified"

The skill already said to run build/typecheck/lint/Vitest before
committing, but softly — and #7408 committed a fix with a TS error the
gate then rejected while its summary claimed "verified all 3 commits".
A self-assessment the gate contradicts wastes a whole round.

Strengthens the address-review contract from "run the checks" to:
- actually run them, do not assert them from reading the diff;
- if typecheck or a touched-package test fails, do NOT commit — treat
  the feedback as unresolved (failure.md);
- end address-summary.md with a `## Verification` section listing each
  command run and its result; a bare "verified" is not acceptable.

The framing is structural, not etiquette: the deterministic gate re-runs
the same commands and discards the round on any failure, so skipping them
only moves the rejection later. Pinned by a test so it cannot soften back.

This is the checkable half of "audit before committing" — the
undirected/reverse-audit-until-clean practice does not transfer to an
unsupervised agent (no verifiable stopping condition, and it worsens the
timeouts seen on large PRs), but "run the gate's own checks first and
show the evidence" does.

* fix(autofix): clarify Verification section precedes collapsed Chinese translation (#7486)

---------

Co-authored-by: wenshao <wenshao@example.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>

* feat(autofix): stop a PR that fails to push for N rounds in a row (#7482)

* feat(autofix): stop a PR that fails to push for N rounds in a row

Under takeover the round cap is 100, which is right for a PR that needs
many PRODUCTIVE rounds. It is wrong for one that fails every round: #6723
ran 7 consecutive failed rounds (3 agent timeouts at 50 min, 4 gate
rejections whose fix broke tests) over 8 hours, heading for round 100,
because it is a 5700-line, 47-file, 5-day-old PR racing a fast-moving
main — every round re-resolves a conflict it cannot finish or that fails
the gate. Retrying at the same per-round budget will not converge; a
human has to rebase or split it.

Adds CONSECUTIVE_FAILURE_CAP (5), distinct from the total round cap. The
handoff step already runs only when a round did NOT push, so it counts
the unbroken run of prior failure markers — stopping at the first push
("Addressed the latest review feedback") or legitimate no-op ("no
changes needed"), either of which proves progress and resets the streak.
At the cap it forces the terminal round even under takeover, with a
handoff that names the real fix (rebase/split, then /retry). Cause-
agnostic: a timeout and a gate rejection both count.

* fix(autofix): address review feedback on consecutive-failure circuit breaker (#7482)

- Fix misleading comment: the walk is oldest-first (API order) with
  reset-on-success, not newest-first with early stop
- Prefer the already-fetched ic.json over a redundant gh api call,
  falling back to the API only when the file is missing
- Filter eval markers by re-arm window (win=) so pre-re-arm failures
  do not immediately re-terminate a re-armed PR
- Add test coverage for the MARK_ROUND == MAX_ROUNDS guard and for
  window-scoped streak counting

* fix(autofix): exempt transient model errors from consecutive-failure breaker (#7482)

---------

Co-authored-by: wenshao <wenshao@example.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>

* feat(core): restore background agent roster (#7459)

* feat(core): restore background agent roster

* fix(web-shell): add list_agents to TOOL_DISPLAY_NAMES

The new list_agents core wire tool was added to core's ToolNames but not
to the web-shell TOOL_DISPLAY_NAMES map, causing toolFormatting.drift.test.ts
to fail (expected ['list_agents'] to deeply equal []). Add the missing
'ListAgents' display-name entry so the browser panel shows a friendly name
instead of the raw wire name and the drift guard passes.

* fix(cli): reload old-session background agents on failed resume rollback

When /resume fails after core has swapped but before the UI swap, the catch
block rolls core back to the old session via startNewSession(oldSessionId).
However the forward path already called resetBackgroundStateForSessionSwitch,
which cleared the old session's in-memory background agents. The rollback did
not reload them, so list_agents returned empty for the old session (whose
sidecars are still on disk) until the next process start or successful resume.

Reload the old session's paused background agents after rolling core back, so
the restored roster matches on-disk state. Placed after startNewSession so the
loadPausedBackgroundAgents current-session guard is satisfied; best-effort via
.catch so it never blocks the rollback path.

* fix(web-shell): add zh translation for list_agents tool name

The toolFormatting test 'has a zh translation for every tool in the
display-name map' failed with expected ['list_agents'] to deeply equal []
because list_agents was added to TOOL_DISPLAY_NAMES without a matching
toolName.list_agents zh-CN entry. Add the translation to restore parity.

* fix(cli): resolve CI failures for background-agent roster restore

- Add toolDisplayName.ListAgents translations (en, zh, zh-TW, ca) so the
  new list_agents tool has a zh entry; fixes i18n/index.test.ts.
- Add loadPausedBackgroundAgents and consumePendingRecoveredAgentsNotice
  to the acpAgent worktree test config mock, which loadSession now calls
  via #restoreBackgroundAgentsOnResume; fixes acpAgent.worktree.test.ts.

* refactor(core): extract incompatible-isolation blocked reason to a const

Move the incompatible-isolation blocked-reason string out of an inline
literal into a module-level INCOMPATIBLE_ISOLATION_BLOCKED_REASON const,
matching its four sibling reasons so the text is discoverable by
constant-name grep and edited alongside the others.

* fix(core): preserve retained activity state on failed agent revive

Address review feedback on the background-agent roster restore:

- On a failed completed-agent revive, restore UI state with a non-empty
  guard instead of `??`. Because `restorePausedEntry` resets the paused
  entry's `recentActivities` to `[]`, the previous `failedEntry?.field ??
  completedEntry.field` kept that empty array and dropped the pre-revive
  snapshot (the UI Progress section rendered empty). Applied consistently
  to pendingMessages, recentActivities, and pendingApprovals.

Add regression coverage for previously untested paths:

- failed revive preserves pre-revive recentActivities
- terminal-agent cap admits only the newest MAX_RETAINED_TERMINAL_AGENTS
  completed sidecars on restore
- /resume rollback reloads the old session's background agents
- headless resume prepends the recovered-agents notice to the prompt

* test(cli): cover interrupted-turn continuation not consuming recovered-agents notice

Add ACP and headless regression tests asserting an interrupted-turn
continuation does not consume the one-shot recovered-agents notice
(the !isContinue / !continueInterrupted guards), so it is delivered on
the user's next ordinary prompt. Mirrors the existing slash-command
coverage.

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat(cli): support custom skill directories via settings (#7395)

* feat(cli): support custom skill directories via settings (#7394)

Add skills.directories setting that accepts an array of additional
directory paths to scan for skills (SKILL.md files). Paths support
~ expansion. Directories are scanned recursively at user level,
after the default ~/.qwen/skills/ directory.

Example settings.json:
{
  "skills": {
    "directories": ["~/.agent/skills", "~/.claude/skills"]
  }
}

Changes:
- settingsSchema.ts: add skills.directories array setting
- core Config: add customSkillDirs param and getCustomSkillDirs()
- SkillManager: append custom dirs to user-level skill base dirs
- CLI config: read skills.directories and pass to core Config

* fix(cli): regenerate settings schema for skills.directories (#7394)

* fix(core): address review feedback for custom skill directories (#7395)

- Use optional chaining for getCustomSkillDirs() to prevent TypeError
  on partial Config mocks (workspace-skill-management, workspace-skills-status)
- Reuse expandHomeDir utility instead of inline tilde expansion
- Fix inaccurate 'scanned recursively' wording to 'one level deep'
- Correct JSDoc: paths are raw, expansion happens in SkillManager
- Trim whitespace from custom dir entries in CLI layer
- Add tests for custom dir expansion, dedup, and partial config safety

* fix(core): address review feedback for custom skill directories (#7395)

* fix(core): address review feedback for custom skill directories (#7395)

* test(core): add relative path resolution test for custom skill dirs (#7395)

* fix(cli): add Array.isArray guard for skills.directories and safe mode test (#7395)

* fix(skills): address review feedback on custom skill directories (#7395)

- Add bare mode test for skills.directories guard
- Include resolved absolute path in relative directory warning
- Clarify that dedup applies to default user dirs, not bundled skills
- Regenerate settings schema

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>
Co-authored-by: Qwen Code Autofix <qwen-code-autofix@users.noreply.github.com>

* fix(core): add image modality support for qwen3.8-max and kimi-k3 models (#7491)

* fix(core): add image modality support for qwen3.8-max models

qwen3.8-max-preview supports image input but was falling through to the
catch-all text-only rule because no pattern matched it. This caused the
vision bridge to unnecessarily transcribe images via a secondary model
instead of sending them directly to the primary model.

* fix(core): also add image modality for kimi-k3

Kimi K3 officially supports image + video input but was falling through
to the catch-all text-only rule, same issue as qwen3.8-max.

* fix(dingtalk): preserve non-bot mention context (#7473)

* fix(dingtalk): preserve non-bot mention context

* test(dingtalk): cover plural mentions, staffId fallback, and edge cases (#7473)

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>

* fix(core): harden the usage salvage around session deletion (#7425)

Post-merge review follow-ups on #7391 (three findings):

- Salvage the archived transcript in the active-branch deletion too:
  when both copies co-exist (an interrupted archive) and the fresh
  active transcript carries no telemetry, the archived copy holds the
  session's usage history and was deleted unsalvaged. The dedup guard
  makes the extra call a no-op whenever the active copy already wrote.
- Enforce the "never blocks deletion" contract at the call site: a
  salvageUsageBestEffort wrapper catches and warns, so the guarantee is
  structural rather than an implementation detail of
  persistUsageBeforeTranscriptDeletion. The new failure-tolerance test
  (salvage rejects -> deletion still succeeds) fails without the
  wrapper — the bare await let the rejection escape through
  removeSessionFiles' rethrowing catch.
- Clear the salvage module mock in beforeEach so the wiring test's
  invocationCallOrder assertions can never read stale calls.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(core): make fork subagents discoverable (#7460)

* test(core): cover Shell truncation without an artifact (#7470)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(ci): autofix route checks existing labels on non-trigger label events (#7481)

* fix(ci): autofix route checks existing labels on non-trigger label events

When triage adds multiple labels in sequence, per-issue concurrency
cancels earlier runs. If the last label is not a trigger label
(e.g. scope/build-system), the surviving run skips the issue phase
even though the issue already has autofix/approved +
status/ready-for-agent.

Before ignoring a non-trigger label event, check ISSUE_LABELS_JSON
for both required labels. If present and the issue is open, proceed
with the issue phase. Trust was already established when the trigger
labels were applied (both require triage+ permission).

* fix(ci): require trusted sender for label fallback

* feat(cli): preserve semantic text when copying VP selections (#7286)

* docs(cli): define semantic copy fidelity scope

* docs(cli): address semantic frame review gaps

* docs(cli): preserve soft-wrap source separators

* feat(cli): preserve semantic selection copy

* fix(cli): address semantic copy review findings

* fix(cli): preserve clipped semantic boundaries

* fix(cli): limit separator carrier joiner to visible width in wrap metadata

The greedy /\s+/ match in wrapTextWithMetadata could capture more
source whitespace than the separator carrier row actually consumed
(e.g. a tab following a space), causing duplicated whitespace in
semantic copy. Limit the match to visibleLine.length characters and
add a mixed space/tab regression test.

---------

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>

* test(core): stub the registry methods agent.ts actually calls (#7538)

The shared stubRegistry in agent.test.ts was missing six methods that
agent.ts reaches: bridgeApprovalEvents, getQueuedCount,
registerResidentAgent, restartCompletedAgent, unregisterResidentAgent and
waitForMessages.

That is not a benign omission. The background body wraps its work in a
try/catch that routes any throw into registry.fail(), so a missing method
never surfaces as 'not a function' — it silently converts a successful
run into a failed one. On the GOAL completion path
unregisterResidentAgent is called immediately before complete(), so the
TypeError replaced the completion entirely:

  registry.fail('fork-...', 'registry2.unregisterResidentAgent is not a
  function', ...)

That is what broke 'runs a non-interactive fork through the background
registry' on main. #7460 added the registry.complete assertion, which
exposed the incomplete stub — before it, nothing checked whether the
background body finished successfully and the TypeError was swallowed.

Stub all six with their real return shapes (unregisterResidentAgent
returns boolean, bridgeApprovalEvents returns the unsubscribe callback
agent.ts later invokes, waitForMessages resolves to a list) and assert
registry.fail was not called before asserting completion, so a future
gap reports the actual error instead of 'complete: 0 calls'.

* perf(startup): lazy-load Google GenAI SDK on first use (#7512)

* perf(startup): lazy-load Google GenAI SDK on first use

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#7512)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* codex: address PR review feedback (#7512)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(vscode): use file picker image paths for vision input (#7493)

* fix(vscode): use image paths from file picker

* fix(vscode): keep image picker paths raw

* fix(vscode): resolve image picker paths on submit

* fix(vscode): send picked images as vision context

* fix(vscode): encode prompt image file URIs

* fix(vscode): address image path review comments

* test(vscode): cover image file reference edge cases

* fix(cli): open the actual serve fallback port (#7501)

* fix(cli): open actual serve fallback port

* test(cli): match serve URL to fallback listener

* docs(cli): clarify serve listen error handling

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>

* fix(ci): don't let one failing scenario sink the whole visual preview (#7511)

The web-shell visuals render runs every screenshot and flow in a single
`test:e2e:visuals`, and that step had no `continue-on-error`, while the compose
and upload steps had no `if: always()`. So one failing or timing-out scenario
failed the job, the artifact was never uploaded, and the publish workflow had
nothing to post — the entire preview vanished even when every other scenario
passed and its PNG was already on disk. A flow (a long multi-click sequence) is
the most fragile scenario kind, so the fragile one silently takes down the
deterministic screenshots. PR #7498 hit exactly this: 29 scenarios passed, one
new channel-management flow timed out, and the PR got no preview and no comment
at all.

Make the after-capture step `continue-on-error` so the passing captures survive
and the later steps still compose and upload them. The publish job only runs on
a `success` conclusion, so the job must stay green — but a masked failure must
not read as a clean preview. Ship the step's real `.outcome` (which
continue-on-error does NOT mask, unlike `.conclusion`) to the publisher as
`render-status.txt`, and have the comment builder use it: an empty preview whose
render failed says "one or more scenarios failed to render" and is explicitly
NOT the reassuring green check or the coverage-gap prompt (both imply the render
ran); a partial preview is labelled partial above the shots that did render. A
missing status file (older run) defaults to complete, so this only ever adds a
warning, never suppresses a real preview.

The failing scenario still needs fixing — it's now surfaced in the comment
rather than by silently deleting everyone else's preview.

Co-authored-by: wenshao <wenshao@example.com>

* feat(web-shell): add selective shadow DOM isolation (#7551)

Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>

* feat(web-shell): add renderChatHeader slot for custom session header (#7553)

* fix(cli): say review coverage gaps in the author's units, not chunk ids (#7550)

The posted review body rendered coverage disclosures with the run's own
bookkeeping as subjects: bare chunk ids, unsorted, one per subject. On a
run that certified nothing (PR #7268) the body enumerated all 49 chunk ids
across two sentences while opening with "Reviewed. Suggestions are
inline." — the opener certified the exact thing every following sentence
took back, and nothing on the PR page maps a chunk id to code.

Three changes, all render-time — the structural entries, the caps, the
caller-echo dedup and the stderr remediation still key on chunk ids, which
is where the id is the selector a reader can act on:

- Coverage now returns the plan's chunk→files table (DiffChunk.files was
  already in the plan JSON; the coverage type slice dropped it).
- compose-review renders chunk gaps through describeChunkGap: every
  planned chunk collapses to "the entire diff", a narrow gap with known
  files names the files, and anything wider is counted against the plan's
  total. Applied to the receipt sentence, the uncoverable sentence (bare
  CLI entries only — caller-authored entries render verbatim) and the
  grouped per-cause sentences.
- The COMMENT opener may no longer say "Reviewed." over a disclosure set
  that denies it: when no chunk is both covered and undisclosed — or no
  chunk universe could be read at all — it opens with a zero-certified
  warning instead. A rewritten launch demonstrably read its chunk, so
  coverage alone is not the test; certified is covered with no disclosure
  against it.

Co-authored-by: verify <verify@local>

* fix(autofix): retry a skipped-Prepare instead of stranding the PR terminal (#7490)

* fix(autofix): retry a skipped-Prepare instead of stranding the PR terminal

A base/infra failure BEFORE the agent runs was misread as an agent crash
and terminated the PR forever. When an early step fails — installing or
building the trusted base, checkout, node setup — the `Prepare branch and
feedback` step is skipped, so NEWEST is empty, and the report step's
"crashed before reading feedback" branch fired: MARK_ROUND=MAX_ROUNDS,
terminal, scan skips it on every future tick.

Observed: a web-shell TypeScript break on `main` failed `Install
dependencies and build` (which builds the trusted base) across a whole
scan batch, and SIX healthy PRs were stranded terminal at round=100 in
one run — including ones at round 9 and 11 that had nothing to do with
the break. `round=100` there is a terminal sentinel, not 100 attempts.

NEWEST-empty now splits on steps.prepare.outcome:
- 'skipped' (an earlier step failed, the agent never ran) is infra/base
  and transient: retry with a sentinel ts so the feedback stays live,
  incrementing the round so a PERSISTENTLY broken base is still bounded
  and stops at the cap (recoverable with /retry).
- 'success'/'failure' (Prepare ran, no feedback produced) is a genuine
  pre-read agent crash: unchanged terminal behaviour.

This is the reverse of the asymmetry #7482 addresses: that bounds a
crash AFTER reading that retried forever; this stops a transient failure
BEFORE reading from going terminal after one.

* docs(autofix): note a pre-Prepare cancel also retries intentionally (#7490)

* fix(autofix): also retry a cancelled/empty prepare outcome, not just skipped

A previous review comment on this PR noted that a job cancelled before
Prepare should retry too. It was right about the intent but the code did
not do it: `steps.prepare.outcome` is 'cancelled' for a cancel and '' for
a job that stopped before Prepare entered the step context — both DISTINCT
from 'skipped', so `== 'skipped'` sent them to the terminal branch, the
same over-termination this PR exists to fix.

Match on "not a real Prepare run" (`!= 'success' && != 'failure'`)
instead, so skipped, cancelled, and empty all retry; only a Prepare that
actually ran to a verdict (success/failure) with no feedback stays
terminal — the genuine pre-read agent crash. Test extended to drive the
cancelled and empty cases (retry) and both real-run outcomes (terminal);
mutation-verified that reverting to `== 'skipped'` reddens the cancelled
case.

* test(autofix): update the pre-read-crash case for the broadened retry

The prior commit broadened NEWEST-empty retry to skipped/cancelled/empty
but left the older 'replays the handoff decision' test asserting the old
terminal behaviour for an unset PREPARE_OUTCOME (which now retries). That
test's terminal cases now set PREPARE_OUTCOME=success/failure explicitly —
the only outcomes that still terminate — so it exercises the genuine
pre-read agent crash rather than the infra/cancel path.

* test(autofix): anchor the skipped-Prepare extraction past the CONSEC block

CI reddened `retries a skipped-Prepare` after main's consecutive-failure
cap (#7482) merged into this branch: that block was inserted between this
decision block and the report `{`, and it calls `gh api`. The test's
`{`-anchored regex over-captured through it, so the extracted script ran
the unstubbed `gh api` and failed. Anchor the end on the same
`# Consecutive-failure` comment the sibling gate-crash test already uses,
so the extraction stops at this decision block's own closing `fi`.

* fix(autofix): exempt skipped-Prepare from the consecutive-failure breaker

A broken base build skips Prepare, producing no API error file — so the
consecutive-failure breaker ran on the new retry path and, after 5
scans, re-introduced the exact mass-stranding this PR exists to prevent.
Exempt pre-agent infra failures (skipped/cancelled/empty outcome) from
the breaker, mirroring the transient 429/5xx exemption: same failure
class (not the PR's fault, self-heals, hits the whole batch). The round
cap + sentinel-ts /retry recovery already bounds a persistently broken
base.

Also trim "checkout" from the retry headlines (checkout failures do not
land in this branch) and hoist the duplicated MARK_TS assignment.

* fix(autofix): reset the consecutive-failure streak on prior infra-failure markers

The streak walker counted prior infra-failure headlines ("AutoFix could
not start —…") as failures, inflating the consecutive-failure count on
subsequent rounds.  A PR with 3 real agent failures, then 3 rounds of
base-build infra failures, then 1 more real failure would trip the
cap-5 breaker even though only 4 rounds were the PR's fault.

Add the two infra-failure headline patterns as reset strings in the
streak walker, alongside the existing push and no-op resets.  The
genuine agent-crash headline ("AutoFix could not start evaluation —…")
is deliberately excluded — it is a real failure and must still count.

* fix(autofix): clarify infra-failure headlines and else-branch comment (#7490)

Address review nits: the retry headline now mentions cancelled runs,
the cap headline says 'reached the round cap' instead of overstating
'could not start for N rounds', the else-branch comment says 'prepare
itself crashed' instead of 'agent crash', and the streak-reset pattern
is simplified now that both infra headlines share the same prefix.

---------

Co-authored-by: wenshao <wenshao@example.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>

* fix(cli): keep role codenames and brief paths out of the posted review body (#7560)

The posted body still carried two operator registers #7550 left in place:
roster role subjects rendered their internal codenames ("Agent 1c:
Cross-file tracer", "Test coverage matrix (whole-diff)"), and an unread
brief's disclosure interpolated its filesystem path. And when verify and
the reverse audit failed the same way, the body said it twice, in two
near-identical sentences.

- Every Brief now carries a publicLabel — the dimension said as what it
  checks ("the cross-file consistency pass") — and coverage's structural
  disclosures carry it as publicSubject beside the internal subject, plus
  a path-free publicReason for unread briefs. The internal label and the
  path stay on stderr, where they are the selector an operator acts on;
  every dedup and certification check still keys on the internal subject.
- compose-review renders the public fields and groups by the reason the
  body PRINTS, so two unread briefs share one path-free sentence instead
  of repeating it per role.
- verificationGaps merges verify and reverse-audit failures of the same
  delivery shape into one sentence with both subjects and both
  consequences; mixed shapes keep their precise per-role texts, and the
  per-role rebuild commands stay on stderr either way.

Co-authored-by: verify <verify@local>

* fix(autofix): retry an agent timeout instead of advancing past its feedback (#7563)

A timeout evaluated NOTHING — the agent ran out of budget before finishing,
so nothing was committed and the feedback is unaddressed. It was treated as
an evaluated verdict (real ts, watermark advances), which strands that
feedback: the next scan sees "nothing new" and never retries. Observed on
#7471 (round 13/100), a heavily-reviewed 1871-line PR: rounds 11 and 13
timed out, but round 12 pushed — so a timeout is transient far more often
than not, and advancing past it left the round-13 feedback unhandled.

run-agent.mjs now drops an `agent-timeout` signal on result.timedOut, and
the handoff routes it like a pre-verdict crash: sentinel ts (feedback stays
live) and a retry, with a headline that names the real fix at the cap
(split the PR or raise the budget). A PR that PERSISTENTLY times out is
bounded by the round cap and the consecutive-failure cap, so this cannot
loop forever — it just stops treating a one-off budget blip as a verdict.

The loop guard stays terminal (a tool-call loop is a real defect, not a
budget blip). An API error still routes to its own model-key handoff; the
timeout signal is written only when NOT an API error.

Co-authored-by: wenshao <wenshao@example.com>

* feat(serve): add workspace-level generation (#7552)

* feat(serve): add workspace-level generation

* docs(serve): document workspace generation capability

* fix(serve): align workspace generation contracts

---------

Co-authored-by: ytahdn <ytahdn@gmail.com>

* ci: matrix ECS runner update + sudo install + repository_dispatch trigger (#7513)

* ci: matrix ECS runner update with sudo install

- Use matrix strategy (ecs-update-sg, ecs-update-64c) to update both
  physical ECS hosts in parallel (fail-fast: false).
- Always use sudo npm install -g so the package lands in /usr/local
  (system-wide PATH) instead of the runner user's home directory.
- Move concurrency to job level (matrix context not available at
  workflow level per actionlint).
- Add repository_dispatch trigger for release-driven updates.
- Register new runner labels in actionlint.yaml.

* fix(ci): use dispatch version for runner update

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>

* fix(web-shell): include managed id in artifact open requests (#7570)

Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>

* feat(serve): persist workspace channel configuration (#7514)

* feat(serve): persist workspace channel configuration

* fix(serve): harden channel settings snapshots

* fix(serve): validate startup channel names

* fix(serve): reserve all channel name

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>

* fix(sdk-python): require canonical form in validate_session_id (#7532)

uuid.UUID() accepts several non-canonical spellings — braced
{...}, urn:uuid:..., and dash-less hex — so validate_session_id let them
through after the RFC 4122 variant check. The value is then forwarded to
the CLI verbatim as --session-id/--resume, producing a malformed session
id downstream rather than a clear error at the SDK boundary.

Reject anything whose canonical form differs from the input. Case is
deliberately not part of the comparison: UUID() lowercases, and an
all-uppercase spelling is still valid canonical input.

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>

* fix(web-shell): sync background agent status (#7561)

* fix(web-shell): sync background agent status

* fix(web-shell): harden background agent reconciliation

---------

Co-authored-by: ytahdn <ytahdn@gmail.com>

* feat(core): propagate trusted daemon invocation context (#7279)

* feat(core): propagate trusted daemon invocation context

* test(cli): update ACP startup expectation

* refactor(core): centralize ACP capability env key

* test(cli): update worktree ACP core mock

* test(integration): run daemon context smoke on PRs

* test(ci): update no-AK smoke expectation

* test(core): cover invocation context isolation

* fix(cli): compare ACP capability safely

* fix(docs): restore GitHub action input names

* fix(core): sanitize private ACP capability from child env

* fix(core): reuse private ACP capability env constant

* test(cli): cover malformed trusted invocation context

* test(acp-bridge): assert exact child environment

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: 易良 <1204183885@qq.com>

* fix(feishu): await stream cancels in media download teardown (#7465)

* fix(feishu): await stream cancels in media download teardown

downloadMedia left two reject paths' stream teardown unawaited:

- the oversize-stream path called reader.cancel() without awaiting, so a
  cancel error during teardown became an unhandled rejection (fatal under
  Node's default --unhandled-rejections=throw);
- the Content-Length reject path returned without cancelling resp.body,
  leaving the connection pinned until GC.

Both were already fixed for the sibling DingTalk downloader in #7361 (which
was itself modelled on this Feishu code), so this brings Feishu to parity.
Adds a regression test that pins the reader.cancel() await via a rejecting
cancel, plus an assertion that the Content-Length path releases the body.

* test(feishu): cover a rejecting body.cancel() on the Content-Length path

Mirrors the existing reader.cancel() teardown test for the other reject
path, per review feedback. Removing the await on resp.body?.cancel()
flips execution onto the 'rejected: size ... exceeds' branch and the
test fails.

* fix(autofix): make the review-address report wrapper lines bilingual (#7569)

The agent's address-summary.md / no-action.md already ends with a
collapsed Chinese translation, but the workflow-appended wrapper lines
around it — the "Addressed/Reviewed the latest feedback" lead-in, the
"Base-conflict check" line, and the "Re-review when you have a moment"
footer — were English-only and sat outside that block. So the posted
comment was only half translated, unlike the takeover-ack comments
(full collapsed Chinese block) and the "model/模型" sign-off in this
same report (already inline-bilingual).

Give each wrapper line an inline Chinese translation, matching the
model/模型 idiom. The English halves are preserved verbatim — the
streak-reset detector globs on "Addressed the latest review feedback"
and "no changes needed", and a test extracts these lines — so behaviour
is unchanged and old English-only comments still match. A new test pins
each English-Chinese pair so a future reword that drops the Chinese
fails. The terminal handoff/failure comment is left English-only for
now (SKILL.md keeps it so by design); that is a separate change.

Co-authored-by: wenshao <wenshao@example.com>

* feat(cli): post the review body bilingually when the PR description is Chinese (#7564)

When the PR author writes Chinese, the posted /review body was
English-only. fetch-pr now records whether the PR description contains
Han characters (prDescriptionHasHan, detected from the same gh pr view
call and stamped into the plan report), and compose-review renders the
body bilingually off that flag: the English body leads, the complete
Chinese version rides collapsed in a <details><summary>中文说明</summary>
block, and the model footer stays outside the fold. The signal is the
CLI's own — the caller cannot toggle the register of a certified body —
and a local plan has no field, so nothing changes for terminal-only
reviews.

Every deterministic body fragment carries an en/zh pair end to end:
compose-review's clause templates and describeChunkGap phrases, the
coverage disclosures (reasons, publicLabel role subjects via a new
publicLabelZh, the path-free unread-brief reason) and the Step 4/5 gap
texts including the combined same-shape sentence. Fragments with no
deterministic translation — model-written findings, caller echoes,
interpolated errors — ride verbatim in both halves. verificationGaps now
returns structural {subject, reason, subjectZh, reasonZh} entries, which
also removes compose-review's last recover-the-boundary-from-prose parse.

SKILL.md instructs the same format for the model-authored inline
comments: English finding first (marker and suggestion block stay in the
English half — tooling filters on them), full Chinese translation
collapsed beneath, footer last.

Co-authored-by: verify <verify@local>

* feat(autofix): auto-rerun a check that died on infrastructure, once (#7562)

* feat(autofix): auto-rerun a check that died on infrastructure, once

A failed check can be red because the machine died, not the code — a
self-hosted runner losing the server, the disk filling. #7490's E2E
failed with "runner lost communication with the server" and went green
on a rerun. The scan now reruns such a check's failed jobs automatically.

Detection is a conservative annotation whitelist (INFRA_FAILURE_SIGNATURES)
— only unambiguous machine failures, never a test-level timeout, which
could be a real regression. The one-shot guard is run_attempt, not a
marker: a run already retried to attempt 2 and still infra-failing is
persistent, so it is left for a human; after a rerun the attempt
increments, so the next scan will not rerun it. Every step is fail-safe
(any API error → no rerun), it runs only when the PR actually has a
failed check, and the gate carries the same review-address carve-out as
the other check selectors so the loop never reruns its own runs.

This is the transient-infra sibling of #7554 (stale-base): that merges
current main when a check is base-inherited; this reruns when a check
died on the runner. Neither touches a check that is a genuine failure.

Note: rerun-failed-jobs needs the PAT to hold `actions: write`.

* fix(autofix): use POSIX ERE groups in infra-failure regex, cover all signatures in tests (#7562)

* fix(autofix): also treat a git fetch/clone transport death as infra

#6506's checkout died mid-transfer — "fetch-pack: invalid index-pack
output" and "RPC failed; curl 92 ... CANCEL" — which then hung the job
into the 20m limit. That is infra, not the PR (it only touches a doc),
and a re-run made it green. But the infra-signature whitelist did not
cover it, so the auto-rerun did not fire and it waited on a human.

Add `invalid index-pack output` and `RPC failed` — the two canonical
git-transport-death phrases — to INFRA_FAILURE_SIGNATURES. A co-present
job-timeout line does not block the match (one matching line classifies
the run), and a BARE timeout with no transport signature is still left
alone, since it can be a real regression. Both new signatures are pinned
in the test's per-signature loop, plus a case on #6506's real composite
annotation and a bare-timeout-is-not-rerun guard.

* fix(autofix): paginate annotations and filter Autofix runs in infra-rerun loop (#7562)

---------

Co-authored-by: wenshao <wenshao@example.com>
Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>

* fix(serve): detect stale SSE cursors across daemon restarts via epoch token; preserve turn attribution and surface compaction failures in replay (#7458)

* fix(daemon): epoch-token restart detection, compaction attribution, and degraded-snapshot signaling (DAEMON-001/007/008)

* fix(acp-bridge): field-level turn attribution merge and replayDegraded bridge test (#7458)

* fix(serve): skip bus epoch lookup for virtual subagent SSE streams (#7458)

The REST SSE route looked up the bus epoch for every session id, but
virtual subagent sessions ride their own bus and their compound ids are
not in the bridge's byId map, so the lookup threw and aborted the
subscription — breaking subagent event streams. Skip the lookup for the
virtual path and degrade a torn-down real session to a headerless stream
(mirrors the /acp route). Also bumps the daemon browser SDK bundle budget
(167KB -> 168KB) for the epoch fields and declares eventEpoch on
DaemonSession so the create/attach path drops its inline type cast.

* fix(serve): stamp eventEpoch on accepted continuations and surface replayDegraded in the SDK (#7458)

Address three review suggestions:
- POST /session/:id/continue now returns eventEpoch alongside lastEventId,
  mirroring the prompt 202 envelope so continuation-seeded SSE cursors
  detect daemon restarts (DAEMON-001)
- DaemonSessionClient exposes replayDegraded from the load response so SDK
  consumers can prefer the full transcript over a degraded snapshot
- add /acp dispatch-level regression test for the degraded-snapshot stderr
  breadcrumb (fires only when snapshot.degraded is set)

* test(cli): fix load-reply race in the degraded-breadcrumb transport test

Await each session/load reply frame before opening the session stream so
the GET cannot race conn.ownSession() into a 403; addresses the review
Critical on the deg-0 arm.

* fix(serve): allow and expose X-Qwen-Event-Epoch in CORS headers

Cross-origin SSE clients must send the epoch header through preflight and
read it from the response, or stale-cursor detection (DAEMON-001) is
silently disabled for every CORS client.

---------

Co-authored-by: qwen-code-bot <qwen-code-bot@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Qwen Autofix <qwen-autofix[bot]@users.noreply.github.com>

* feat(core): Align GenAI telemetry with ARMS (#7536)

* feat(core): align GenAI telemetry with ARMS

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(core): remove estimated token usage splits

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(core): address GenAI telemetry review feedback

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>

* fix(serve): avoid TOCTOU race dropping live sessions from list response (#7556)

* Initial plan

* fix(serve): avoid TOCTOU race dropping live sessions from list response

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: 易良 <1204183885@qq.com>

* fix(cli): prevent monitor turns after task_stop (#7573)

---------

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
Co-authored-by: Qwen Code Autofix <qwen-code-autofix@users.noreply.github.com>
Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: Qwen Code Autofix <qwen-code-autofix[bot]@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: destire-mio <qppque@gmail.com>
Co-authored-by: destire-mio <248462155+destire-mio@users.noreply.github.com>
Co-authored-by: Dragon <52599892+DragonnZhang@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: 易良 <1204183885@qq.com>
Co-authored-by: jinye <djy1989418@126.com>
Co-authored-by: chinesepowered <nlai@rediffmail.com>
Co-authored-by: ovochouovo <18212194+ovochouovo@users.noreply.github.com>
Co-authored-by: Edenman <67549719+BZ-D@users.noreply.github.com>
Co-authored-by: 克竟 <dingbingzhi.dbz@alibaba-inc.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: ytahdn <1294726970@qq.com>
Co-authored-by: ytahdn <ytahdn@gmail.com>
Co-authored-by: Truraly <94105924+Truraly@users.noreply.github.com>
Co-authored-by: zjgzx1988 <zjgzx1988@hotmail.com>
Co-authored-by: hogeheer499-commits <hogeheer499@gmail.com>
Co-authored-by: hogeheer <267467744+hogeheer499-commits@users.noreply.github.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: wenshao <wenshao@example.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: Nothing Chan <chenliu.cl@alibaba-inc.com>
Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>
Co-authored-by: yuanyuanAli <135116774+yuanyuanAli@users.noreply.github.com>
Co-authored-by: verify <verify@local>
Co-authored-by: qqqys <qys177@gmail.com>
Co-authored-by: callmeYe <512217680@qq.com>
Co-authored-by: Qwen Autofix <qwen-autofix[bot]@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants