Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f6f74a2
feat(web-shell): expose channel access policies
qqqys Aug 10, 2026
c197181
test(cli): cover shared Channel management fields
qqqys Aug 10, 2026
aed48dc
feat(web-shell): clarify channel policy controls
qqqys Aug 10, 2026
3807cb9
feat(web-shell): select channel workspace
qqqys Aug 10, 2026
111e17e
feat(web-shell): redesign channel management
qqqys Aug 10, 2026
2333a05
fix(web-shell): align channel manager with shell tabs
qqqys Aug 10, 2026
f7adc65
fix(web-shell): prioritize conversation settings
qqqys Aug 10, 2026
f8ac419
fix(web-shell): preserve legacy channel defaults
qqqys Aug 10, 2026
907b46a
fix(channels): address management review blockers
qqqys Aug 10, 2026
26849ba
fix(channels): address editor review blockers
qqqys Aug 10, 2026
b6f26d0
fix(channels): preserve workspace action and route state
qqqys Aug 11, 2026
49f7a25
fix(web-shell): prevent stale channel editor state
qqqys Aug 11, 2026
844d833
fix(channels): preserve stored group settings
qqqys Aug 11, 2026
410543b
fix(channels): preserve group behavior settings
qqqys Aug 11, 2026
7c5c125
fix(web-shell): reset channel workspace UI state
qqqys Aug 12, 2026
a811c93
fix(web-shell): resolve channel management conflicts
qqqys Aug 12, 2026
013b31e
test(web-shell): assert restored channel scope
qqqys Aug 12, 2026
3a7b0f0
Merge branch 'main' into codex/issue-8845-channel-access-policies
qqqys Aug 13, 2026
ec7f42f
fix(web-shell): preserve legacy channel scope
qqqys Aug 13, 2026
6c532fe
fix(web-shell): preserve inherited channel defaults
qqqys Aug 13, 2026
fd6241b
fix(channels): preserve compatible legacy settings
qqqys Aug 13, 2026
1bd4a48
fix(web-shell): keep workspace navigation available
qqqys Aug 13, 2026
ad365b8
fix(channels): default new channels to pairing
qqqys Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions docs/developers/daemon/15-channel-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ There are two current host modes:
- `qwen channel start [name]` is the standalone ACP-backed channel service. It passes adapters an `AcpBridge` implementation of `ChannelAgentBridge`.
- `qwen serve --channel <name>` and `qwen serve --channel all` are experimental daemon-managed modes. Named selections are grouped by owning workspace and `qwen serve` starts one out-of-process worker per owning runtime; each worker connects to the daemon through the SDK and adapters receive a `DaemonChannelBridge`-backed `ChannelAgentBridge` facade. `--channel all` remains a primary-only selection.

In daemon-managed mode, each channel maps inbound chat traffic to daemon sessions under a configurable `SessionScope` (`user`, `thread`, or `single`). The adapter delegates to `DaemonChannelBridge`, which delegates to the SDK's `DaemonSessionClient` (see [`13-sdk-daemon-client.md`](./13-sdk-daemon-client.md)). Every named channel must resolve to one registered, trusted workspace. The worker uses that runtime's canonical cwd, `QWEN_DAEMON_WORKSPACE`, and environment overlay; ownership resolution never falls back to primary.
In daemon-managed mode, each channel maps inbound chat traffic to daemon sessions under a configurable `SessionScope` (`user`, `chat_thread`, or `single`). The legacy Channel value `thread` remains readable and editable for existing configurations, but new Web Shell configurations do not offer it; this is separate from the daemon bridge's own `single`/`thread` session creation knob. The adapter delegates to `DaemonChannelBridge`, which delegates to the SDK's `DaemonSessionClient` (see [`13-sdk-daemon-client.md`](./13-sdk-daemon-client.md)). Every named channel must resolve to one registered, trusted workspace. The worker uses that runtime's canonical cwd, `QWEN_DAEMON_WORKSPACE`, and environment overlay; ownership resolution never falls back to primary.

### Webhook-triggered channel tasks

Expand Down Expand Up @@ -194,14 +194,14 @@ Adapter `connect()` failures are reported separately from worker lifecycle error

`ChannelConfig` (from `packages/channels/base/src/types.ts`):

| Knob | Effect |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `sessionScope` | `'user'` (sender + chat), `'thread'` (thread id or chat), `'chat_thread'` (channel + chatId + threadId, for polling adapters), or `'single'` (one shared session per channel). |
| `approvalMode` | `'auto'` (auto-respond) / `'prompt'` (render UI). |
| `allowlist?: string[]` | Sender ids allowed; missing = open. |
| `denylist?: string[]` | Sender ids denied. |
| `chunkSize`, `chunkIntervalMs` | Outbound block streaming settings. |
| `daemon: { baseUrl, token?, clientId? }` | Forwarded to `DaemonChannelSessionFactory`. |
| Knob | Effect |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `sessionScope` | `'user'` (sender + chat), `'chat_thread'` (channel + chatId + threadId), or `'single'` (one shared session per channel). Legacy `'thread'` is preserved when already configured but is not offered for new Web Shell configurations. |
| `approvalMode` | `'auto'` (auto-respond) / `'prompt'` (render UI). |
| `allowlist?: string[]` | Sender ids allowed; missing = open. |
| `denylist?: string[]` | Sender ids denied. |
| `chunkSize`, `chunkIntervalMs` | Outbound block streaming settings. |
| `daemon: { baseUrl, token?, clientId? }` | Forwarded to `DaemonChannelSessionFactory`. |

Channel-specific keys layer on top (DingTalk: `streamCredentials`; WeChat: `ilinkUrl`, `botId`; Telegram: `botToken`; Feishu: `clientId` (appId), `clientSecret` (appSecret), `verificationToken`, `encryptKey` (webhook mode)).

Expand Down
2 changes: 1 addition & 1 deletion docs/users/features/channels/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ Channels are configured under the `channels` key in `settings.json`. Each channe
| `model` | No | Model to use for this channel (e.g., `qwen3.5-plus`). Overrides the default model. Useful for multimodal models that support image input |
| `senderPolicy` | No | Who can talk to the bot: `allowlist` (default), `open`, or `pairing` |
| `allowedUsers` | No | List of user IDs allowed to use the bot (used by `allowlist` and `pairing` policies) |
| `sessionScope` | No | How sessions are scoped: `user` (default), `thread`, or `single` |
| `sessionScope` | No | How sessions are scoped: `user` (default), `chat_thread`, or `single`. Legacy `thread` remains compatible when already configured but is not offered for new Web Shell configurations |
| `cwd` | No | Working directory for the agent. Defaults to the current directory |
| `approvalMode` | No | Tool approval mode for channel sessions. Unattended webhook tasks require `yolo`; the setting applies to every session on the channel |
| `instructions` | No | Custom instructions prepended to the first message of each session |
Expand Down
22 changes: 11 additions & 11 deletions docs/users/features/channels/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,17 +46,17 @@ The `type` must match a channel type registered by an installed extension. Check

All standard channel options work with custom channels:

| Option | Description |
| -------------- | ---------------------------------------------- |
| `senderPolicy` | `allowlist`, `pairing`, or `open` |
| `allowedUsers` | Static allowlist of sender IDs |
| `sessionScope` | `user`, `thread`, or `single` |
| `cwd` | Working directory for the agent |
| `instructions` | Prepended to the first message of each session |
| `model` | Model override for the channel |
| `groupPolicy` | `disabled`, `allowlist`, `pairing`, or `open` |
| `dmPolicy` | `open` or `disabled` |
| `groups` | Per-group settings |
| Option | Description |
| -------------- | -------------------------------------------------------------------------------------------------- |
| `senderPolicy` | `allowlist`, `pairing`, or `open` |
| `allowedUsers` | Static allowlist of sender IDs |
| `sessionScope` | `user`, `chat_thread`, or `single`; legacy `thread` remains compatible for existing configurations |
| `cwd` | Working directory for the agent |
| `instructions` | Prepended to the first message of each session |
| `model` | Model override for the channel |
| `groupPolicy` | `disabled`, `allowlist`, `pairing`, or `open` |
| `dmPolicy` | `open` or `disabled` |
| `groups` | Per-group settings |

See [Overview](./overview) for details on each option.

Expand Down
2 changes: 1 addition & 1 deletion packages/channels/base/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ Everything between `handleInbound()` and `sendMessage()` is handled by the base
| `Envelope` | Normalized inbound message format |
| `SenderPolicy` | `'allowlist' \| 'pairing' \| 'open'` |
| `GroupPolicy` | `'disabled' \| 'allowlist' \| 'pairing' \| 'open'` |
| `SessionScope` | `'user' \| 'thread' \| 'single'` |
| `SessionScope` | `'user' \| 'chat_thread' \| 'single'`; legacy `'thread'` is deprecated |

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] This row announces chat_thread, but the "Routing keys by scope" table further down this same README (~lines 244-250) still lists only user/thread/single — omitting chat_thread and leaving deprecated thread unmarked. That table is the only documentation of the key shapes (repo-wide grep), and this PR exists precisely because a routing-key shape was hand-duplicated out of sync. Add a chat_thread row (channel:chatId:threadId, or channel:chatId without threadId — one session per chat thread, shared across senders) and mark the thread row deprecated.

中文说明

这一行宣告了 chat_thread,但同一 README 下方的 "Routing keys by scope" 表(约 244-250 行)仍只列出 user/thread/single——遗漏了 chat_thread,也未标注 thread 已废弃。该表是键形状的唯一文档(全仓库 grep 确认),而本 PR 正是因为路由键形状被手工复制后失同步才产生的。请补一行 chat_threadchannel:chatId:threadId,无 threadId 时为 channel:chatId——每个 chat thread 一个会话、跨发送者共享),并把 thread 行标注为废弃。

— qwen3.8-max via Qwen Code /review (v0.21.8)

| `GroupConfig` | Per-group settings (e.g. `requireMention`) |
| `SessionTarget` | Maps a session back to its channel/sender/chat |
| `ToolCallEvent` | Agent tool-call event delivered to adapters |
Expand Down
6 changes: 5 additions & 1 deletion packages/channels/base/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export interface ChannelConfig {
clientSecret?: string;
senderPolicy: SenderPolicy;
allowedUsers: string[];
/** Channel routing scope. `thread` is retained for existing configurations only. */
sessionScope: SessionScope;
cwd: string;
approvalMode?: string;
Expand Down Expand Up @@ -516,7 +517,10 @@ export interface ChannelPlugin {
/** Serializable metadata for safe configuration management. */
management?: ChannelManagementDescriptor;

/** Default session scope for this channel type (applied when config omits sessionScope). */
/**
* Default Channel routing scope (applied when config omits sessionScope).
* `thread` is retained for existing configurations only.
*/
defaultSessionScope?: SessionScope;

/** Create a channel adapter instance. */
Expand Down
24 changes: 24 additions & 0 deletions packages/channels/wecom/src/WeComAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,30 @@ describe('WeComChannel', () => {
rmSync(join(tmpdir(), 'channel-files'), { recursive: true, force: true });
});

it('shares attachment routing across senders in chat_thread scope', () => {
const channel = new WeComChannel(
'bot',
makeConfig({ sessionScope: 'chat_thread' }),
makeBridge(),
);
const routeKey = (
channel as unknown as {
attachmentRouteKey(
senderId: string,
chatId: string,
threadId?: string,
): string;
}
).attachmentRouteKey.bind(channel);

expect(routeKey('alice', 'chat-1', 'topic-1')).toBe(
routeKey('bob', 'chat-1', 'topic-1'),
);
expect(routeKey('alice', 'chat-1', 'topic-1')).not.toBe(
routeKey('alice', 'chat-2', 'topic-1'),
);
Comment on lines +449 to +454

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] This test never varies or omits threadId, so it does not pin the thread component of the chat_thread key. Probe: inverting the ternary in attachmentRouteKey keeps it green — all threads of one chat could collapse into one attachment bucket (a file uploaded in thread A attached to thread B's session) without any test failing. Add assertions that vary the thread id: expect(routeKey('alice', 'chat-1', 'topic-1')).not.toBe(routeKey('alice', 'chat-1', 'topic-2')), expect(routeKey('alice', 'chat-1')).toBe('bot:chat-1'), and/or assert one exact key string.

中文说明

该测试从不改变或省略 threadId,因此没有钉住 chat_thread 键中的 thread 分量。探针:反转 attachmentRouteKey 中的三元表达式后测试仍为绿——同一聊天的所有 thread 可能塌缩进同一个附件桶(thread A 上传的文件挂到 thread B 的会话)而没有任何测试失败。请补充改变 thread id 的断言:expect(routeKey('alice', 'chat-1', 'topic-1')).not.toBe(routeKey('alice', 'chat-1', 'topic-2'))expect(routeKey('alice', 'chat-1')).toBe('bot:chat-1'),和/或断言一个精确键字符串。

— qwen3.8-max via Qwen Code /review (v0.21.8)

});

it('requires botId and secret', () => {
expect(
() => new WeComChannel('bot', makeConfig({ botId: '' }), makeBridge()),
Expand Down
4 changes: 4 additions & 0 deletions packages/channels/wecom/src/WeComAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -770,6 +770,10 @@ export class WeComChannel extends ChannelBase {
switch (this.config.sessionScope) {
case 'thread':
return `${this.name}:${threadId || chatId}`;
case 'chat_thread':
return threadId
? `${this.name}:${chatId}:${threadId}`
: `${this.name}:${chatId}`;
Comment on lines +773 to +776

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] This added switch is now a second full copy of SessionRouter.routingKey (base/src/SessionRouter.ts) — the two must stay byte-for-byte in sync by hand, and this duplication is precisely how the defect this PR fixes arose (the missing chat_thread case misrouted attachments). Any future scope addition or key-format change silently diverges unless both switches are edited together; the compiler can't help because both have default branches. — Concrete cost: the next scope change repeats this bug class. Consider exposing the router's key computation (or extracting a shared sessionScopeRouteKey(name, scope, senderId, chatId, threadId) in channel-base) and reusing it here.

中文说明

新增的这段 switch 现在是 SessionRouter.routingKey(base/src/SessionRouter.ts)的第二份完整拷贝——两者必须靠手工保持逐字节同步,而这种重复正是本 PR 修复的缺陷的成因(缺失 chat_thread 分支导致附件误路由)。未来任何 scope 新增或键格式变更,除非同时修改两处,否则会悄悄分叉;由于两者都有 default 分支,编译器无法帮忙。— 具体代价:下一次 scope 变更会重演这类 bug。建议暴露 router 的键计算(或在 channel-base 提取共享的 sessionScopeRouteKey(name, scope, senderId, chatId, threadId))并在此复用。

— qwen3.8-max via Qwen Code /review (v0.21.8)

Comment on lines 771 to +776

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] Routing key format change for 'chat_thread' orphans existing sessions with thread IDs on migration — Failure scenario: a channel with sessionScope: 'thread' had an active session routed as name:topic-1. After upgrade, parseChannelConfig normalizes the config to 'chat_thread', so SessionRouter.routingKey produces name:chat-1:topic-1. The old session is never found, and old attachment directories are orphaned.

中文说明

迁移后 'chat_thread' 的路由键格式变化会使现有含有 thread ID 的会话孤立。— 失败场景:sessionScope: 'thread' 的频道有一个活跃会话,路由键为 name:topic-1。升级后 parseChannelConfig 将配置归一化为 'chat_thread'SessionRouter.routingKey 产生 name:chat-1:topic-1。旧会话无法找到,旧附件目录也被孤立。建议添加路由键迁移兼容层。

— deepseek-v4-flash via Qwen Code /review (v0.21.8)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

已修复 + 验证证据:b6f26d0dee;config-utils.test.ts 50/50、channel-editor-state.test.ts 31/31 通过,legacy thread 在加载和编辑保存两条路径保持原路由与授权语义。

case 'single':
return `${this.name}:__single__`;
case 'user':
Expand Down
43 changes: 17 additions & 26 deletions packages/cli/src/commands/channel/channel-registry-builtins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,35 +108,26 @@ describe('built-in channel registry', () => {
const entry = (await supportedChannelCatalog()).find(
(candidate) => candidate.type === 'valid-nested-type-key',
);
expect(entry).toEqual({
expect(entry).toMatchObject({
type: 'valid-nested-type-key',
displayName: 'valid-nested-type-key',
manageable: true,
fields: [
{
key: 'settings',
label: 'Settings',
kind: 'object',
properties: [{ key: 'type', label: 'Type', kind: 'string' }],
},
// supportedChannelCatalog() injects the session-scope descriptor into
// every manageable entry that does not declare its own.
{
key: 'sessionScope',
label: 'Session scope',
kind: 'enum',
required: true,
default: 'user',
description:
'Controls which incoming conversations share one agent session.',
options: [
{ value: 'user', label: 'Per user and chat' },
{ value: 'thread', label: 'Per thread' },
{ value: 'chat_thread', label: 'Per chat and thread' },
{ value: 'single', label: 'One shared session' },
],
},
],
});
expect(entry?.fields[0]).toEqual({
key: 'settings',
label: 'Settings',
kind: 'object',
properties: [{ key: 'type', label: 'Type', kind: 'string' }],
});
expect(entry?.fields.map((field) => field.key)).toEqual([
'settings',
'senderPolicy',
'allowedUsers',
'groupPolicy',
'sessionScope',
]);
expect(
entry?.fields.find((field) => field.key === 'senderPolicy'),
).toMatchObject({ default: 'pairing' });
});
});
67 changes: 60 additions & 7 deletions packages/cli/src/commands/channel/channel-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,7 @@ describe('channel registry', () => {
const plugin: ChannelPlugin = {
channelType: 'valid-optional-required-object',
displayName: 'valid-optional-required-object',
defaultSessionScope: 'thread',
management: {
fields: [
{
Expand Down Expand Up @@ -738,6 +739,17 @@ describe('channel registry', () => {
(candidate) => candidate.type === 'valid-optional-required-object',
);
expect(entry?.manageable).toBe(true);
expect(
entry?.fields.find((field) => field.key === 'sessionScope'),
).toMatchObject({
default: 'thread',
options: [
{ value: 'user' },
{ value: 'thread' },
{ value: 'chat_thread' },
{ value: 'single' },
],
});
});

it('only marks the manually configurable built-in types as manageable', async () => {
Expand Down Expand Up @@ -770,18 +782,49 @@ describe('channel registry', () => {
required: true,
}),
);
expect(
catalog.find((entry) => entry.type === 'dingtalk')?.fields,
).toContainEqual(
expect.objectContaining({
key: 'sessionScope',
for (const type of ['dingtalk', 'wecom', 'feishu'] as const) {
const fields = catalog.find((entry) => entry.type === type)?.fields;
expect(
fields
?.find((field) => field.key === 'senderPolicy')
?.options?.map((option) => option.value),
).toEqual(['pairing', 'allowlist', 'open']);
expect(
fields?.find((field) => field.key === 'senderPolicy'),
).toMatchObject({ default: 'pairing' });
expect(fields).toContainEqual(
expect.objectContaining({
key: 'allowedUsers',
kind: 'string-list',
}),
);
expect(
fields
?.find((field) => field.key === 'groupPolicy')
?.options?.map((option) => option.value),
).toEqual(['disabled', 'pairing', 'allowlist', 'open']);
expect(
fields?.find((field) => field.key === 'sessionScope'),
).toMatchObject({
kind: 'enum',
required: true,
default: 'user',
}),
);
options: [
{ value: 'user' },
{ value: 'thread' },
{ value: 'chat_thread' },
{ value: 'single' },
],
});
}
for (const type of ['github', 'gitlab'] as const) {
const fields = catalog.find((entry) => entry.type === type)?.fields;
expect(
fields?.filter((field) => field.key === 'senderPolicy'),
).toHaveLength(1);
expect(
fields?.filter((field) => field.key === 'groupPolicy'),
).toHaveLength(1);
expect(fields).toContainEqual(
expect.objectContaining({
key: 'groupPolicy',
Expand All @@ -807,6 +850,16 @@ describe('channel registry', () => {
kind: 'string-list',
}),
);
expect(
fields?.filter((field) => field.key === 'sessionScope'),
).toHaveLength(1);
expect(
fields?.find((field) => field.key === 'sessionScope'),
).toMatchObject({
kind: 'enum',
required: true,
default: 'chat_thread',
});
}
expect(
catalog.find((entry) => entry.type === 'github')?.fields,
Expand Down
Loading
Loading