Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
e36c361
feat(web-shell): show channel sessions in sidebar
Aug 3, 2026
b71723b
feat(web-shell): configure channel session scope
Aug 3, 2026
c587b58
fix(web-shell): refresh channel session transcripts
Aug 3, 2026
2ad35da
feat(web-shell): group channel sessions by platform
Aug 3, 2026
2902406
Merge branch 'main' into feat/web-shell-channel-sessions
wenshao Aug 3, 2026
f7085af
fix(channels): address session review feedback
Aug 4, 2026
7b8cb27
test(web-shell): harden channel session coverage
Aug 4, 2026
9a89c60
test: pin remaining channel session review findings
Aug 4, 2026
75131e2
Merge branch 'main' into feat/web-shell-channel-sessions
qwen-code-dev-bot Aug 4, 2026
b642c44
Merge branch 'main' into feat/web-shell-channel-sessions
qwen-code-dev-bot Aug 4, 2026
f65f88e
fix: harden channel session sidebar and display projections (#8457)
qwen-code-dev-bot Aug 4, 2026
3548a83
Merge branch 'main' into feat/web-shell-channel-sessions
qwen-code-dev-bot Aug 4, 2026
455da46
Merge branch 'main' into feat/web-shell-channel-sessions
qwen-code-dev-bot Aug 5, 2026
cce359a
chore: merge origin/main into feat/web-shell-channel-sessions
qwen-code-dev-bot Aug 7, 2026
dcf1443
Merge origin/main into feat/web-shell-channel-sessions
Aug 9, 2026
3ebb5a3
fix(channels): address session review feedback
Aug 9, 2026
06aed4c
Merge remote-tracking branch 'origin/main' into feat/web-shell-channe…
Aug 9, 2026
022a8ee
fix(channels): address channel session review feedback
Aug 10, 2026
9fdfc0e
Merge branch 'main' into feat/web-shell-channel-sessions
qwen-code-dev-bot Aug 10, 2026
3757f98
Merge branch 'main' into feat/web-shell-channel-sessions
qwen-code-dev-bot Aug 10, 2026
602f484
Merge branch 'main' into feat/web-shell-channel-sessions
qwen-code-dev-bot Aug 10, 2026
a749747
Merge branch 'main' into feat/web-shell-channel-sessions
qwen-code-dev-bot Aug 10, 2026
2a06b7e
Merge branch 'main' into feat/web-shell-channel-sessions
qwen-code-dev-bot Aug 11, 2026
6fcc12d
Merge remote-tracking branch 'origin/main' into feat/web-shell-channe…
Aug 11, 2026
ad78844
fix(channels): harden channel session review round-7 criticals (#8457)
Aug 11, 2026
146fb6a
Merge remote-tracking branch 'origin/main' into feat/web-shell-channe…
Aug 11, 2026
7745971
fix(web-shell): harden first-visit session section latches (#8457)
Aug 11, 2026
92c02d2
fix(channels): address review round-8 findings (#8457)
Aug 11, 2026
84db931
test(web-shell): pin round-8 sidebar behavior fixes (#8457)
Aug 11, 2026
ad02a53
Merge branch 'main' into feat/web-shell-channel-sessions
qwen-code-dev-bot Aug 12, 2026
13b771d
fix(channels): address review round-9 findings (#8457)
Aug 12, 2026
0aea248
Merge remote-tracking branch 'origin/main' into feat/web-shell-channe…
Aug 12, 2026
06336a2
fix(web-shell): pin Other channels group after platform sections (#8457)
Aug 12, 2026
e961edd
Merge remote-tracking branch 'origin/main' into feat/web-shell-channe…
Aug 12, 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
40 changes: 40 additions & 0 deletions docs/design/2026-08-03-web-shell-channel-session-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Web Shell channel session scope

## Motivation

Channel runtimes already route incoming messages according to `sessionScope`,
but the Web Shell channel editor only renders platform-specific management
fields. Users can therefore configure credentials and access policy, but cannot
choose which conversations share an agent session.

## Design

Add the existing shared `sessionScope` setting to every manageable channel type
in the daemon channel catalog. Preserve a plugin-provided field when one exists;
otherwise advertise an enum with the runtime-supported values and the plugin's
default scope.

Render the field in a dedicated session section of the channel editor. New and
legacy configurations show their effective default, and saving writes the
selected value through the existing channel upsert request.

The available scopes are:

- `user`: one session per sender and chat.
- `thread`: one session per routing thread, falling back to the chat.
- `chat_thread`: one session per chat and nested thread.
- `single`: one session shared by the entire channel instance.

## Compatibility

The runtime router and persisted configuration format are unchanged. Existing
configurations without `sessionScope` retain their current plugin default until
they are edited and saved. Unmanageable channel types do not advertise the
field.

## Verification

- Assert catalog defaults for DingTalk and GitHub.
- Assert the management store accepts every runtime-supported scope.
- Assert new and legacy editor drafts use the effective default.
- Verify the Web Shell can select and persist a non-default scope end to end.
68 changes: 68 additions & 0 deletions docs/design/2026-08-03-web-shell-channel-session-sidebar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Web Shell channel sessions in the sidebar

## Motivation

Daemon-managed channels create ordinary workspace sessions with
`sourceType: "channel"`, but the Web Shell sidebar intentionally requests only
the `default` session catalog. A session started from DingTalk, Feishu, or
another channel therefore cannot be opened from the sidebar even though it is
stored in the selected workspace.

## Design

Add a two-option source switch above the sidebar's project session list:

- **Tasks** lists `sourceType: "default"` and remains the initial selection.
- **Channels** lists `sourceType: "channel"`.

The switch is shown only when the daemon advertises
`session_source_metadata`. Older daemons keep the current unfiltered request
and do not show a control they cannot support.

The selected source is applied consistently to active, pinned, archived, and
secondary-workspace session requests. Existing session rows, workspace
sections, grouping, search, polling, and open-session actions are reused.
Because channel sessions can be created by external messages without a Web
Shell mutation event, the expanded Channels list uses the active-session poll
interval instead of the 30-second idle interval.

When the selected workspace also advertises `channel_management`, the Channels
catalog joins each session's immutable channel instance name (`sourceId`) to
the current channel configuration and groups sessions by `config.type`. The
type catalog supplies the platform label, so multiple instances of the same
platform share one collapsible section. Sessions whose instance no longer
exists remain visible under Other channels. If the catalog is unavailable, the
list keeps its existing fallback instead of hiding sessions. Channel type grouping
overrides user-defined session groups in the Channels view; Tasks keeps its
existing organization behavior. Secondary workspaces resolve their own
workspace-scoped channel catalog.

Channel adapters still prepend their model-facing instructions and contextual
history. The daemon prompt carries the user-authored text separately as
transcript display metadata, so live and replayed Web Shell messages do not
expose that hidden context and channel session titles derive from the same
visible text.

## Boundaries

- Channel configuration and runtime management are unchanged.
- Session source metadata and daemon list APIs are unchanged.
- Session Overview and Split View keep their existing default-session scope.
- The switch is in-memory UI state and resets to Tasks on page reload.
- Channel type classification reflects the current workspace configuration;
sessions do not persist a historical platform type.

## Verification

- Assert the source switch is gated by `session_source_metadata`.
- Assert Tasks is initially selected and requests `sourceType: "default"`.
- Assert selecting Channels requests `sourceType: "channel"` for primary and
workspace-qualified lists.
- Assert the Channels list polls on the active-session interval.
- Assert multiple instances of one platform share a collapsible type section,
other platform sessions remain separate, pinned sessions stay in their type
section, and unmatched sessions remain under Other channels.
- Assert channel prompts preserve full model context while recording only the
user-authored text for transcript display.
- Run the sidebar and workspace-section unit tests, Web Shell build, and
TypeScript typecheck.
77 changes: 75 additions & 2 deletions packages/acp-bridge/src/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5717,6 +5717,23 @@ describe('createAcpSessionBridge', () => {
await bridge.shutdown();
});

it('strips channel display metadata from non-channel sessions', async () => {
const handle = makeChannel();
const bridge = makeBridge({ channelFactory: async () => handle.channel });
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });

await bridge.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'model text' }],
_meta: { 'qwen.daemon.promptDisplayText': 'hidden transcript text' },
} as PromptRequest);

expect(
handle.agent.promptCalls[0]?._meta?.['qwen.daemon.promptDisplayText'],
Comment on lines +8996 to +8997

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.

Deferred this round: this round ran under a time-budget warning, so it fixed only the failed check and the Critical findings; this suggestion is recorded here and stays open for a follow-up round. Assert the session-bus echo for a forged display key on a non-channel session.

中文说明

本轮暂缓:本轮在时间预算告警下运行,仅修复了失败的检查项与 Critical 发现;此建议已在此记录,保留至后续轮次处理。为非 channel 会话上伪造的 display key 补充 session-bus echo 断言。

).toBeUndefined();
await bridge.shutdown();
});

it('strips spoofed delivery metadata and injects only trusted context', async () => {
const handle = makeChannel();
const bridge = makeBridge({ channelFactory: async () => handle.channel });
Expand Down Expand Up @@ -6183,6 +6200,53 @@ describe('createAcpSessionBridge', () => {
await bridge.shutdown();
});

it('echoes only channel display text while forwarding full model context', async () => {
const handle = makeChannel({
promptImpl: () => ({ stopReason: 'end_turn' }),
});
const bridge = makeBridge({ channelFactory: async () => handle.channel });
const session = await bridge.spawnOrAttach({
workspaceCwd: WS_A,
sourceType: 'channel',
});
const abort = new AbortController();
const events = bridge.subscribeEvents(session.sessionId, {
signal: abort.signal,
});
const userChunk = (async () => {
for await (const event of events) {
if (event.type !== 'session_update') continue;
const update = (
event.data as {
update?: { sessionUpdate?: string; content?: unknown };
}
).update;
if (update?.sessionUpdate === 'user_message_chunk') return update;
}
throw new Error('no user_message_chunk observed');
})();

await bridge.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [
{ type: 'text', text: 'internal channel instructions\n\nhello' },
],
_meta: { 'qwen.daemon.promptDisplayText': 'hello' },
} as PromptRequest);

await expect(userChunk).resolves.toMatchObject({
content: { type: 'text', text: 'hello' },
});
expect(handle.agent.promptCalls[0]).toMatchObject({
prompt: [
{ type: 'text', text: 'internal channel instructions\n\nhello' },
],
_meta: { 'qwen.daemon.promptDisplayText': 'hello' },
});
abort.abort();
await bridge.shutdown();
});

it('echoes one user_message_chunk per content block (multi-modal)', async () => {
const factory: ChannelFactory = async () =>
makeChannel({ promptImpl: () => ({ stopReason: 'end_turn' }) }).channel;
Expand Down Expand Up @@ -7173,7 +7237,10 @@ describe('createAcpSessionBridge', () => {
const bridge = makeBridge({
channelFactory: async () => handle.channel,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const session = await bridge.spawnOrAttach({
workspaceCwd: WS_A,
sourceType: 'channel',
});
const sub = (async () => {
for await (const ev of bridge.subscribeEvents(session.sessionId)) {
events.push(ev);
Expand All @@ -7193,7 +7260,10 @@ describe('createAcpSessionBridge', () => {
session.sessionId,
{
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'queued behind first' }],
prompt: [
{ type: 'text', text: 'hidden context\n\nqueued behind first' },
],
_meta: { 'qwen.daemon.promptDisplayText': 'queued behind first' },
},
undefined,
{ promptId: 'prompt-second' },
Expand Down Expand Up @@ -7239,6 +7309,9 @@ describe('createAcpSessionBridge', () => {
(startedEvents[0] as BridgeEvent & { data: { text: string } }).data
.text,
).toBe('queued behind first');
expect(handle.agent.promptCalls[1]?.prompt).toEqual([
{ type: 'text', text: 'hidden context\n\nqueued behind first' },
]);
const completedEvents = events.filter(
(e) => e.type === 'pending_prompt_completed',
);
Expand Down
47 changes: 41 additions & 6 deletions packages/acp-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ import {
CHANNEL_STARTUP_PROFILE_META_KEY,
CHANNEL_STARTUP_PROFILE_VERSION,
DAEMON_CHANNEL_DELIVERY_META_KEY,
DAEMON_PROMPT_DISPLAY_TEXT_META_KEY,
LOAD_REPLAY_BULK_MODE,
LOAD_REPLAY_HIDE_INHERITED_META_KEY,
LOAD_REPLAY_META_KEY,
Expand Down Expand Up @@ -234,6 +235,23 @@ function sessionSourceRequestMeta(
: {};
}

/**
* The daemon's channel adapter carries the user-facing display projection in
* prompt meta; only channel sessions may use it — every other source echoes
* its prompt content verbatim. Single source of truth for the echo, the
* pending-entry text, and the re-arm strip so their `''`/missing-meta
* semantics cannot drift apart.
*/
function getChannelPromptDisplayText(
entry: Pick<SessionEntry, 'sourceType'>,
meta: Record<string, unknown> | null | undefined,
): string | undefined {
const displayText = meta?.[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY];

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] This trusts caller-controlled qwen.daemon.promptDisplayText whenever the target session is a channel session. An ACP/Web/API caller can send one instruction to the model while persisting a different visible transcript, hiding the action from echo, replay, queue text, and titles. Strip this key from untrusted metadata and carry it only through a trusted internal channel-delivery context.

中文: 这里会在目标为 channel 会话时信任调用方可控的 qwen.daemon.promptDisplayText。ACP/Web/API 调用方可以给模型发送一条指令,却持久化另一段可见文本,从而在实时回显、重放、队列文本和标题中隐藏真实操作。请从不可信元数据中移除该字段,只通过可信的内部频道投递上下文传递它。

— qwen3.7-max 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.

Fixed in 022a8ee. Caller metadata is now stripped at the daemon route. A process-local, workspace-bound channel-worker authorization promotes displayText into trusted bridge context only for the owning worker; forged ACP/Web/API metadata is ignored. Added route and bridge regression coverage for trusted and forged projections.

return entry.sourceType === 'channel' && typeof displayText === 'string'

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.

Deferred this round: this round ran under a time-budget warning, so it fixed only the failed check and the Critical findings; this suggestion is recorded here and stays open for a follow-up round. Add a test that the memory classifier's prompts carry displayText ''.

中文说明

本轮暂缓:本轮在时间预算告警下运行,仅修复了失败的检查项与 Critical 发现;此建议已在此记录,保留至后续轮次处理。补充测试验证 memory classifier 的 prompt 携带 displayText ''。

? displayText
: undefined;
}

function isDefinitiveAcpRequestError(error: unknown): boolean {
if (error instanceof RequestError) return true;
if (!isRecord(error)) return false;
Expand Down Expand Up @@ -930,15 +948,21 @@ function echoPromptToSessionBus(
// contract — cheaper than a thrown `TypeError` mid-echo.
const prompt = req.prompt;
if (!Array.isArray(prompt) || prompt.length === 0) return;
const displayText = getChannelPromptDisplayText(entry, req._meta);
let displayTextPublished = false;
const serverTimestamp = Date.now();
const blockCount = Math.min(prompt.length, MAX_ECHO_CONTENT_BLOCKS);

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] The echo traversal is capped before finding the projected text block. With 256 non-text blocks followed by text, the full prompt reaches the agent but live transcript never receives the display projection, diverging from persisted replay. Publish projection independently or reserve a slot for the first text block.

中文: 回显遍历在找到投影文本块之前就受数量上限限制。若前 256 个都是非文本块、之后才是文本块,完整提示会到达 agent,但实时会话记录收不到展示文本,与持久化重放不一致。请独立发布投影,或为首个文本块预留回显位置。

— qwen3.7-max 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.

Fixed in 022a8ee. The capped echo now reserves a slot for the first text block found after the 256-block boundary when a display projection exists. Added coverage for 256 non-text blocks followed by hidden text and verified that the visible projection is emitted.

for (let i = 0; i < blockCount; i += 1) {
const part = prompt[i];
if (!part || typeof part !== 'object' || Array.isArray(part)) continue;
// Every `ContentBlock` variant (text, image, audio, resource) is
// published to the bus verbatim. The SDK's `normalizeDaemonEvent`
// accepts any `content` shape; rich rendering of non-text blocks is
// the consumer's responsibility.
let displayPart = part;
if (displayText !== undefined && part.type === 'text') {

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] Please add tests for an intentionally empty displayText and for multiple text blocks. Without them, removing the empty guard or displayTextPublished can publish hidden later text while the current suite remains green.

中文: 建议补充 displayText 为空字符串以及包含多个文本块的测试。否则即使误删空值保护或 displayTextPublished,隐藏的后续文本被发布时现有测试仍可能全部通过。

— qwen3.7-max 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.

Addressed in 022a8ee. Added coverage for an intentionally empty displayText with multiple hidden text blocks, verifying that no hidden text chunk is published while non-text content remains visible.

if (displayTextPublished) continue;
displayTextPublished = true;
Comment on lines +1403 to +1405

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.

Deferred this round: this round ran under a time-budget warning, so it fixed only the failed check and the Critical findings; this suggestion is recorded here and stays open for a follow-up round. Add a direct-path multi-block test pinning that the display projection replaces only the first text block.

中文说明

本轮暂缓:本轮在时间预算告警下运行,仅修复了失败的检查项与 Critical 发现;此建议已在此记录,保留至后续轮次处理。补充直接路径多块测试,固定 display projection 仅替换第一个 text block。

displayPart = { ...part, text: displayText };
}
// Non-text blocks are published verbatim. Channel text uses the display
// projection so hidden model context never reaches transcript consumers.
try {
entry.events.publish({
type: 'session_update',
Expand All @@ -947,7 +971,7 @@ function echoPromptToSessionBus(
sessionId: req.sessionId,
update: {
sessionUpdate: 'user_message_chunk',
content: part,
content: displayPart,
// `_meta` lives inside the `update` object rather than at
// envelope level. `_meta` is a standard JSON-RPC/MCP extension
// field permitted alongside spec fields, the SDK normalizer
Expand Down Expand Up @@ -5471,7 +5495,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
promptId,
queuedAt,
...(originatorClientId !== undefined ? { originatorClientId } : {}),
text: extractPromptText(req.prompt),
text:
getChannelPromptDisplayText(entry, req._meta) ??
extractPromptText(req.prompt),
abortController: pendingAbort,
state: isQueued ? 'queued' : 'running',
};
Expand Down Expand Up @@ -5655,6 +5681,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
copy._meta && typeof copy._meta === 'object'
? { ...copy._meta }
: {};
const promptDisplayText = getChannelPromptDisplayText(
entry,
meta,
);
delete meta[DAEMON_RETRY_META_KEY];
delete meta[INVOCATION_CONTEXT_META_KEY];
delete meta[PRIVATE_PARENT_CAPABILITY_META_KEY];
Expand All @@ -5663,6 +5693,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
// below) re-arms it after this strip.
delete meta[DAEMON_CONTINUE_META_KEY];
delete meta[DAEMON_CHANNEL_DELIVERY_META_KEY];
delete meta[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY];
if (isRetry) {
meta[DAEMON_RETRY_META_KEY] = true;
}
Expand All @@ -5673,6 +5704,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
meta[DAEMON_CHANNEL_DELIVERY_META_KEY] =
context.channelDelivery;
}
if (promptDisplayText !== undefined) {
meta[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY] =
promptDisplayText;
}
Comment on lines +7439 to +7442

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.

Thanks. I am leaving this mutation-only negative assertion out of this PR: the conditional spread keeps the metadata property absent when no projection is supplied, and the production behavior in scope is covered by the defined-projection and downstream fall-through tests.

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.

Re-checked this round and keeping the earlier disposition: the conditional spread keeps the metadata property absent when no projection is supplied, and over the JSON-RPC wire an undefined meta value serializes away, so the mutated shape is behavior-neutral. The defined-projection echo/recording tests plus strips channel display metadata from non-channel sessions cover the production behavior in scope.

中文说明

本轮复查后维持此前结论:条件展开保证未提供投影时 metadata 属性缺失,且经 JSON-RPC 传输时 undefined 的 meta 值会被序列化丢弃,因此该突变形态在行为上是中性的。已定义投影的回显/记录测试以及 strips channel display metadata from non-channel sessions 已覆盖范围内的生产行为。

Comment on lines +7439 to +7442

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.

Deferred this round: this round ran under a time-budget warning, so it fixed only the failed check and the Critical findings; this suggestion is recorded here and stays open for a follow-up round. Add a re-arm test proving the display key is restored from the trusted context, not caller meta.

中文说明

本轮暂缓:本轮在时间预算告警下运行,仅修复了失败的检查项与 Critical 发现;此建议已在此记录,保留至后续轮次处理。补充 re-arm 测试,证明 display key 从可信上下文恢复而非调用方 meta。

meta[INVOCATION_CONTEXT_META_KEY] = invocationContext;
if (Object.keys(meta).length > 0) {
copy._meta = meta;
Expand Down
2 changes: 2 additions & 0 deletions packages/acp-bridge/src/bridgeTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,8 @@ export interface BridgeClientRequestContext {
}

export const DAEMON_CHANNEL_DELIVERY_META_KEY = 'qwen.daemon.channelDelivery';
export const DAEMON_PROMPT_DISPLAY_TEXT_META_KEY =
'qwen.daemon.promptDisplayText';

/**
* Returned from `recordHeartbeat`. `lastSeenAt` is the server-side
Expand Down
Loading
Loading