diff --git a/docs/design/2026-08-03-web-shell-channel-session-scope.md b/docs/design/2026-08-03-web-shell-channel-session-scope.md new file mode 100644 index 00000000000..e3f5b7642dd --- /dev/null +++ b/docs/design/2026-08-03-web-shell-channel-session-scope.md @@ -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. diff --git a/docs/design/2026-08-03-web-shell-channel-session-sidebar.md b/docs/design/2026-08-03-web-shell-channel-session-sidebar.md new file mode 100644 index 00000000000..e79ae6186e3 --- /dev/null +++ b/docs/design/2026-08-03-web-shell-channel-session-sidebar.md @@ -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. diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 8715e79537d..be0de381cb7 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -8982,6 +8982,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'], + ).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 }); @@ -9452,6 +9469,296 @@ 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': 'forged' }, + } as PromptRequest, + undefined, + { promptDisplayText: 'hello' }, + ); + + 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('reserves an echo slot for channel display text after the block cap', 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 textChunk = (async () => { + for await (const event of events) { + const update = ( + event.data as { + update?: { content?: { type?: string; text?: string } }; + } + ).update; + if (update?.content?.type === 'text') return update.content.text; + } + throw new Error('no text echo observed'); + })(); + const resources = Array.from({ length: 256 }, (_, index) => ({ + type: 'resource_link' as const, + uri: `file:///resource-${index}`, + name: `resource-${index}`, + })); + + await bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [...resources, { type: 'text', text: 'hidden text' }], + }, + undefined, + { promptDisplayText: 'visible text' }, + ); + + await expect(textChunk).resolves.toBe('visible text'); + abort.abort(); + await bridge.shutdown(); + }); + + it('does not sacrifice an echo block for an empty display projection after the cap', 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 userChunks: unknown[] = []; + const drain = (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') continue; + userChunks.push(update.content); + if (userChunks.length === 256) break; + } + })(); + const resources = Array.from({ length: 256 }, (_, index) => ({ + type: 'resource_link' as const, + uri: `file:///resource-${index}`, + name: `resource-${index}`, + })); + + await bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [...resources, { type: 'text', text: 'hidden text' }], + }, + undefined, + { promptDisplayText: '' }, + ); + + // An empty projection publishes no visible slot, so reserving one would + // only overwrite a resource block that the echo must keep. + await vi.waitFor(() => expect(userChunks).toHaveLength(256)); + expect(userChunks).toEqual(resources); + abort.abort(); + await drain.catch(() => {}); + await bridge.shutdown(); + }); + + it('ignores promptDisplayText for non-channel sessions', async () => { + const promptGate = deferred(); + const handle = makeChannel({ + promptImpl: () => promptGate.promise, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + 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'); + })(); + + const promptPromise = bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'verbatim user text' }], + }, + undefined, + { promptDisplayText: 'hidden' }, + ); + + await vi.waitFor(() => { + expect(bridge.getPendingPrompts(session.sessionId)).toMatchObject([ + { text: 'verbatim user text', state: 'running' }, + ]); + }); + await vi.waitFor(() => expect(handle.agent.promptCalls).toHaveLength(1)); + await expect(userChunk).resolves.toMatchObject({ + content: { type: 'text', text: 'verbatim user text' }, + }); + expect(handle.agent.promptCalls[0]?._meta).not.toHaveProperty( + 'qwen.daemon.promptDisplayText', + ); + + promptGate.resolve({ stopReason: 'end_turn' }); + await promptPromise; + abort.abort(); + await bridge.shutdown(); + }); + + it('hides every text block for an intentionally empty display projection', 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 userChunks: unknown[] = []; + const drain = (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') continue; + userChunks.push(update.content); + if ( + typeof update.content === 'object' && + update.content !== null && + (update.content as { type?: unknown }).type === 'resource_link' + ) { + break; + } + } + })(); + + await bridge.sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [ + { type: 'text', text: 'hidden first' }, + { type: 'text', text: 'hidden second' }, + { type: 'resource_link', uri: 'file:///visible', name: 'visible' }, + ], + }, + undefined, + { promptDisplayText: '' }, + ); + + await drain; + expect(userChunks).toEqual([ + { type: 'resource_link', uri: 'file:///visible', name: 'visible' }, + ]); + abort.abort(); + await bridge.shutdown(); + }); + + it('does not leak a prompt slot for a malformed null block', 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 malformedResult = await bridge + .sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [null] as never, + }, + undefined, + { promptDisplayText: '' }, + ) + .catch((error: unknown) => error); + expect(malformedResult).not.toBeInstanceOf(TypeError); + expect(bridge.getPendingPrompts(session.sessionId)).toHaveLength(0); + await expect( + bridge.sendPrompt(session.sessionId, { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'next' }], + }), + ).resolves.toMatchObject({ stopReason: 'end_turn' }); + 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; @@ -10475,7 +10782,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); @@ -10495,10 +10805,14 @@ describe('createAcpSessionBridge', () => { session.sessionId, { sessionId: session.sessionId, - prompt: [{ type: 'text', text: 'queued behind first' }], + prompt: [ + { type: 'image', data: 'aW1hZ2U=', mimeType: 'image/png' }, + { type: 'text', text: 'hidden channel instructions' }, + ], + _meta: { 'qwen.daemon.promptDisplayText': '' }, }, undefined, - { promptId: 'prompt-second' }, + { promptId: 'prompt-second', promptDisplayText: '' }, ); await new Promise((r) => setTimeout(r, 20)); @@ -10509,7 +10823,7 @@ describe('createAcpSessionBridge', () => { expect(addedEvents[0]?.promptId).toBe('prompt-second'); expect( (addedEvents[0] as BridgeEvent & { data: { text: string } }).data.text, - ).toBe('queued behind first'); + ).toBe('[image]'); const pending = bridge.getPendingPrompts(session.sessionId); expect(pending).toHaveLength(2); @@ -10540,7 +10854,11 @@ describe('createAcpSessionBridge', () => { expect( (startedEvents[0] as BridgeEvent & { data: { text: string } }).data .text, - ).toBe('queued behind first'); + ).toBe('[image]'); + expect(handle.agent.promptCalls[1]?.prompt).toEqual([ + { type: 'image', data: 'aW1hZ2U=', mimeType: 'image/png' }, + { type: 'text', text: 'hidden channel instructions' }, + ]); const completedEvents = events.filter( (e) => e.type === 'pending_prompt_completed', ); diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index f10d29069b2..6a715418b40 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -128,13 +128,14 @@ import { CHANNEL_STARTUP_PROFILE_VERSION, DAEMON_CHANNEL_DELIVERY_META_KEY, DAEMON_MODEL_PROMPT_META_KEY, - MID_TURN_RECONCILIATION_RING_SIZE, + DAEMON_PROMPT_DISPLAY_TEXT_META_KEY, LOAD_REPLAY_BULK_MODE, LOAD_REPLAY_HIDE_INHERITED_META_KEY, LOAD_REPLAY_META_KEY, LOAD_REPLAY_MODE_META_KEY, LOAD_REPLAY_PAGE_SIZE_META_KEY, LOAD_REPLAY_VERSION, + MID_TURN_RECONCILIATION_RING_SIZE, PROMPT_CANCEL_METHOD, REQUESTED_SESSION_ID_META_KEY, TODO_STOP_GUARD_QUEUE_RELEASE_METHOD, @@ -273,6 +274,21 @@ function sessionSourceRequestMeta( : {}; } +/** + * Only the daemon's authenticated channel-worker path can populate the + * user-facing display projection. Every other source echoes its prompt content + * verbatim. Single source of truth for the echo, pending-entry text, and child + * metadata so their `''`/undefined semantics cannot drift apart. + */ +function getChannelPromptDisplayText( + entry: Pick, + displayText: string | undefined, +): string | undefined { + return entry.sourceType === 'channel' && typeof displayText === 'string' + ? displayText + : undefined; +} + function isDefinitiveAcpRequestError(error: unknown): boolean { if (error instanceof RequestError) return true; if (!isRecord(error)) return false; @@ -1355,6 +1371,7 @@ function echoPromptToSessionBus( req: PromptRequest, promptId: string, originatorClientId: string | undefined, + displayText: string | undefined, ): void { // `PromptRequest.prompt` is a non-optional `ContentBlock[]` per the // ACP type contract — read it directly so a future SDK bump that @@ -1366,15 +1383,31 @@ function echoPromptToSessionBus( // contract — cheaper than a thrown `TypeError` mid-echo. const prompt = req.prompt; if (!Array.isArray(prompt) || prompt.length === 0) return; + let displayTextPublished = false; const serverTimestamp = Date.now(); - const blockCount = Math.min(prompt.length, MAX_ECHO_CONTENT_BLOCKS); + const echoPrompt = prompt.slice(0, MAX_ECHO_CONTENT_BLOCKS); + if ( + displayText && + !echoPrompt.some((part) => isRecord(part) && part['type'] === 'text') + ) { + const textPart = prompt + .slice(MAX_ECHO_CONTENT_BLOCKS) + .find((part) => isRecord(part) && part['type'] === 'text'); + if (textPart) echoPrompt[echoPrompt.length - 1] = textPart; + } + const blockCount = echoPrompt.length; for (let i = 0; i < blockCount; i += 1) { - const part = prompt[i]; + const part = echoPrompt[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') { + if (displayTextPublished) continue; + displayTextPublished = true; + if (!displayText) continue; + 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', @@ -1383,7 +1416,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 @@ -7166,12 +7199,25 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ); } } + const channelDisplayText = getChannelPromptDisplayText( + entry, + context?.promptDisplayText, + ); + const pendingText = + channelDisplayText === undefined + ? extractPromptText(req.prompt) + : channelDisplayText || + (req.prompt.some( + (block) => isRecord(block) && block['type'] === 'image', + ) + ? '[image]' + : ''); const pendingEntry: PendingPromptEntry = { promptId, queuedAt, ...(originatorClientId !== undefined ? { originatorClientId } : {}), ...(isPromotedMidTurn ? { promotedMidTurn: true } : {}), - text: extractPromptText(req.prompt), + text: pendingText, abortController: pendingAbort, state: isQueued ? 'queued' : 'running', }; @@ -7369,6 +7415,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { copy._meta && typeof copy._meta === 'object' ? { ...copy._meta } : {}; + const promptDisplayText = channelDisplayText; delete meta[DAEMON_RETRY_META_KEY]; delete meta[INVOCATION_CONTEXT_META_KEY]; delete meta[PRIVATE_PARENT_CAPABILITY_META_KEY]; @@ -7377,6 +7424,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]; delete meta[DAEMON_MODEL_PROMPT_META_KEY]; if (isRetry) { meta[DAEMON_RETRY_META_KEY] = true; @@ -7388,6 +7436,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; + } if (modelPrompt !== undefined) { meta[DAEMON_MODEL_PROMPT_META_KEY] = modelPrompt; } @@ -7441,6 +7493,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { promptRequest, pendingEntry.promptId, originatorClientId, + channelDisplayText, ); } } catch (echoErr) { diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 0745f7901e2..a1b41316bc7 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -683,6 +683,8 @@ export interface BridgeClientRequestContext { * unchanged. HTTP routes never populate this from request input. */ modelPrompt?: string; + /** User-facing projection supplied by an authenticated channel worker. */ + promptDisplayText?: string; /** Trusted Channel delivery correlation injected by the daemon prompt * route. Never populated from caller-controlled ACP metadata. */ channelDelivery?: { @@ -722,6 +724,8 @@ export function isValidTrustedModelPrompt(value: unknown): value is string { } 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 diff --git a/packages/channels/base/src/AcpBridge.test.ts b/packages/channels/base/src/AcpBridge.test.ts index 94ea4475e2d..4387a551a84 100644 --- a/packages/channels/base/src/AcpBridge.test.ts +++ b/packages/channels/base/src/AcpBridge.test.ts @@ -8,6 +8,8 @@ import { } from './AcpBridge.js'; import { CHANNEL_LOOP_MCP_SERVER_NAME } from './ChannelLoopTools.js'; import { + ACP_PRIVATE_PARENT_CAPABILITY_ENV, + ACP_PRIVATE_PARENT_CAPABILITY_META_KEY, CHANNEL_PROMPT_META_KEY, type ChannelLoopToolHandler, } from './ChannelAgentBridge.js'; @@ -168,6 +170,27 @@ describe('AcpBridge', () => { } }); + it('performs the private-parent capability handshake with the spawned child', async () => { + const bridge = new AcpBridge({ + cliEntryPath: '/tmp/qwen', + cwd: '/tmp', + }); + + await bridge.start(); + + const spawnOptions = child.spawn.mock.calls[0]![2] as { + env?: Record; + }; + const capability = spawnOptions.env?.[ACP_PRIVATE_PARENT_CAPABILITY_ENV]; + expect(typeof capability).toBe('string'); + expect(capability!.length).toBeGreaterThan(0); + const initializeParams = child.connections[0]!.initialize.mock + .calls[0]![0] as { _meta?: Record }; + expect( + initializeParams._meta?.[ACP_PRIVATE_PARENT_CAPABILITY_META_KEY], + ).toBe(capability); + }); + it('registers the channel loop MCP server once across concurrent calls', async () => { const pending: Array<() => void> = []; const extMethod = vi.fn( @@ -440,6 +463,29 @@ describe('AcpBridge', () => { }); }); + it('forwards the user-facing prompt projection to the daemon', async () => { + const bridge = new AcpBridge({ + cliEntryPath: '/tmp/qwen', + cwd: '/tmp', + }) as unknown as TestableAcpBridge; + const prompt = vi.fn().mockResolvedValue({}); + bridge.child = { killed: false, exitCode: null }; + bridge.connection = { extMethod: vi.fn(), prompt }; + + await bridge.prompt('s-1', 'hidden context\n\nhello', { + displayText: 'hello', + }); + + expect(prompt).toHaveBeenCalledWith({ + sessionId: 's-1', + prompt: [{ type: 'text', text: 'hidden context\n\nhello' }], + _meta: { + [CHANNEL_PROMPT_META_KEY]: true, + 'qwen.daemon.promptDisplayText': 'hello', + }, + }); + }); + it('excludes nested subagent text from the final response', async () => { const bridge = new AcpBridge({ cliEntryPath: '/tmp/qwen', diff --git a/packages/channels/base/src/AcpBridge.ts b/packages/channels/base/src/AcpBridge.ts index 4abb896233a..3eccc018f20 100644 --- a/packages/channels/base/src/AcpBridge.ts +++ b/packages/channels/base/src/AcpBridge.ts @@ -1,6 +1,6 @@ import { spawn } from 'node:child_process'; import type { ChildProcess } from 'node:child_process'; -import { randomUUID } from 'node:crypto'; +import { randomBytes, randomUUID } from 'node:crypto'; import { Readable, Writable } from 'node:stream'; import { EventEmitter } from 'node:events'; import { @@ -15,9 +15,13 @@ import type { RequestPermissionResponse, } from '@agentclientprotocol/sdk'; import { + ACP_PRIVATE_PARENT_CAPABILITY_ENV, + ACP_PRIVATE_PARENT_CAPABILITY_META_KEY, + CHANNEL_PROMPT_DISPLAY_TEXT_META_KEY, CHANNEL_PROMPT_META_KEY, type AvailableCommand, type ChannelAgentBridge, + type ChannelAgentBridgePromptOptions, type ChannelAgentBridgeSessionOptions, type ChannelLoopToolHandler, type ToolCallEvent, @@ -106,6 +110,10 @@ export class AcpBridge extends EventEmitter implements ChannelAgentBridge { async start(): Promise { const { cliEntryPath, cwd } = this.options; + // Private-parent capability: marks this bridge as a trusted ACP parent of + // the spawned child so trusted prompt metadata (e.g. the classifier's + // display projection) survives the child's untrusted-caller strip. + const privateParentCapability = randomBytes(32).toString('base64url'); const args = [ ...process.execArgv.filter((a) => !/^--inspect(-brk)?($|=)/.test(a)), @@ -119,7 +127,11 @@ export class AcpBridge extends EventEmitter implements ChannelAgentBridge { this.child = spawn(process.execPath, args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env, QWEN_CODE_DISABLE_CRON: '1' }, + env: { + ...process.env, + QWEN_CODE_DISABLE_CRON: '1', + [ACP_PRIVATE_PARENT_CAPABILITY_ENV]: privateParentCapability, + }, shell: false, }); @@ -185,6 +197,9 @@ export class AcpBridge extends EventEmitter implements ChannelAgentBridge { this.connection.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {}, + _meta: { + [ACP_PRIVATE_PARENT_CAPABILITY_META_KEY]: privateParentCapability, + }, }), ACP_START_TIMEOUT_MS, `ACP initialization timed out after ${ACP_START_TIMEOUT_MS}ms`, @@ -245,7 +260,7 @@ export class AcpBridge extends EventEmitter implements ChannelAgentBridge { async prompt( sessionId: string, text: string, - options?: { imageBase64?: string; imageMimeType?: string }, + options?: ChannelAgentBridgePromptOptions, ): Promise { const conn = this.ensureConnection(); @@ -281,7 +296,14 @@ export class AcpBridge extends EventEmitter implements ChannelAgentBridge { await conn.prompt({ sessionId, prompt: prompt as Array<{ type: 'text'; text: string }>, - _meta: { [CHANNEL_PROMPT_META_KEY]: true }, + _meta: { + [CHANNEL_PROMPT_META_KEY]: true, + ...(options?.displayText !== undefined + ? { + [CHANNEL_PROMPT_DISPLAY_TEXT_META_KEY]: options.displayText, + } + : {}), + }, }); } finally { this.off('textChunk', onChunk); diff --git a/packages/channels/base/src/ChannelAgentBridge.ts b/packages/channels/base/src/ChannelAgentBridge.ts index 53bf9609351..dfa33635cd4 100644 --- a/packages/channels/base/src/ChannelAgentBridge.ts +++ b/packages/channels/base/src/ChannelAgentBridge.ts @@ -3,8 +3,20 @@ import type { RequestPermissionResponse, } from '@agentclientprotocol/sdk'; +export const CHANNEL_PROMPT_DISPLAY_TEXT_META_KEY = + 'qwen.daemon.promptDisplayText'; +export const CHANNEL_PROMPT_AUTHORIZATION_META_KEY = + 'qwen.daemon.channelPromptAuthorization'; // Client-supplied routing hint only; never use it as an authorization boundary. export const CHANNEL_PROMPT_META_KEY = 'qwen.channel.prompt'; +// Private-parent capability handshake with the spawned `qwen --acp` child +// (packages/core/src/utils/invocation-context.ts owns the same constants). +// channel-base keeps a minimal dependency footprint, so the wire contract is +// pinned by value in a cross-package test instead of imported. +export const ACP_PRIVATE_PARENT_CAPABILITY_META_KEY = + 'qwen-code/private-parent-capability'; +export const ACP_PRIVATE_PARENT_CAPABILITY_ENV = + 'QWEN_CODE_PRIVATE_ACP_CAPABILITY'; export interface AvailableCommand { name: string; @@ -93,6 +105,14 @@ export interface ChannelAgentBridgeSessionOptions { sourceId?: string; } +export interface ChannelAgentBridgePromptOptions { + imageBase64?: string; + imageMimeType?: string; + /** User-authored text shown in transcripts when `text` includes hidden context. + * `''` means no user-visible text and must not be treated as unset. */ + displayText?: string; +} + export interface ChannelAgentBridge { readonly availableCommands: AvailableCommand[]; getAvailableCommands?(sessionId: string): AvailableCommand[]; @@ -118,7 +138,7 @@ export interface ChannelAgentBridge { prompt( sessionId: string, text: string, - options?: { imageBase64?: string; imageMimeType?: string }, + options?: ChannelAgentBridgePromptOptions, ): Promise; cancelSession(sessionId: string): Promise; /** Release a bridge-owned session that will not be routed to a caller. */ @@ -126,6 +146,11 @@ export interface ChannelAgentBridge { sessionId: string, expectedBindingToken?: object, ): Promise; + /** + * Daemon-mode hook for permanently removing an internal session's data. + * Standalone bridges may omit it and fall back to discardSession. + */ + deleteSessionData?(sessionId: string): Promise; respondToPermission?( requestId: string, response: RequestPermissionResponse, diff --git a/packages/channels/base/src/ChannelBase.test.ts b/packages/channels/base/src/ChannelBase.test.ts index c474057f35f..e2c3e829266 100644 --- a/packages/channels/base/src/ChannelBase.test.ts +++ b/packages/channels/base/src/ChannelBase.test.ts @@ -22,6 +22,7 @@ import { ChannelBase, CLEAR_CANCEL_TIMEOUT_MS } from './ChannelBase.js'; import type { ChannelBaseOptions } from './ChannelBase.js'; import type { ChannelLoop, ChannelLoopInput } from './ChannelLoopStore.js'; import { + buildChannelWebhookDisplayText, buildChannelWebhookPrompt, resolveChannelWebhookTarget, } from './ChannelWebhookTask.js'; @@ -8050,7 +8051,11 @@ describe('ChannelBase', () => { await ch.handleInbound(envelope({ text: '/schedule list' })); - expect(bridge.prompt).toHaveBeenCalledWith('s-1', '/schedule list', {}); + expect(bridge.prompt).toHaveBeenCalledWith('s-1', '/schedule list', { + displayText: '/schedule list', + imageBase64: undefined, + imageMimeType: undefined, + }); expect(ch.sent).toEqual([{ chatId: 'chat1', text: 'agent response' }]); }); @@ -10232,6 +10237,63 @@ describe('ChannelBase', () => { expect(secondPrompt).not.toContain('Be concise.'); }); + it('keeps all model-only context out of the user-facing prompt text', async () => { + const ch = createChannel({ + instructions: 'Be concise.', + sessionScope: 'thread', + groupPolicy: 'open', + }); + + await ch.handleInbound( + envelope({ + text: 'hello', + isGroup: true, + isMentioned: true, + referencedText: 'earlier message', + metadata: 'Issue: hidden metadata', + attachments: [ + { + type: 'file', + filePath: '/tmp/hidden.txt', + mimeType: 'text/plain', + }, + ], + }), + ); + + const [sessionId, modelText, options] = ( + bridge.prompt as ReturnType + ).mock.calls[0]!; + expect(sessionId).toEqual(expect.any(String)); + expect(modelText).toContain('Be concise.'); + expect(modelText).toContain('[User 1]'); + expect(modelText).toContain('earlier message'); + expect(modelText).toContain('/tmp/hidden.txt'); + expect(modelText).toContain('Issue: hidden metadata'); + expect(options).toMatchObject({ displayText: 'hello' }); + }); + + it('neutralizes display-unsafe controls in the raw-text display fallback', async () => { + const ch = createChannel(); + const rlo = String.fromCharCode(0x202e); // bidi override (trojan-source) + const bel = String.fromCharCode(0x07); // C0 control + // Adapters that never set displayText fall back to the raw text; the + // projection must neutralize it before it reaches the session bus, + // transcript, and session previews. + await ch.handleInbound( + envelope({ text: `line1${rlo}${bel}\nline2${'A'.repeat(9000)}` }), + ); + + const [, , options] = (bridge.prompt as ReturnType).mock + .calls[0]!; + const displayText = (options as { displayText: string }).displayText; + // Controls are replaced, the real newline survives, and the projection + // is capped by code point. + expect(displayText.startsWith('line1 \nline2')).toBe(true); + expect(displayText).not.toContain(rlo); + expect(Array.from(displayText)).toHaveLength(8000); + }); + it('prepends channel boundary metadata after custom instructions once per session', async () => { const ch = createChannel({ instructions: 'Be concise.', @@ -14326,6 +14388,7 @@ describe('ChannelBase', () => { await ch.handleInbound(envelope({ text: '!echo hello' })); expect(bridge.prompt).toHaveBeenCalledWith('s-1', '!echo hello', { + displayText: '!echo hello', imageBase64: undefined, imageMimeType: undefined, }); @@ -14364,7 +14427,10 @@ describe('ChannelBase', () => { return Promise.resolve('coalesced response'); }); - const ch = createChannel({ dispatchMode: 'collect' }); + const ch = createChannel({ + dispatchMode: 'collect', + groupPolicy: 'open', + }); // Send first message — starts processing const p1 = ch.handleInbound(envelope({ text: 'first' })); @@ -14374,10 +14440,24 @@ describe('ChannelBase', () => { // Send two more messages while first is busy — these should buffer const p2 = ch.handleInbound( - envelope({ text: 'second', messageId: 'msg-2' }), + envelope({ + text: 'second', + senderName: 'Alice', + isGroup: true, + isMentioned: true, + messageId: 'msg-2', + metadata: 'hidden policy second', + }), ); const p3 = ch.handleInbound( - envelope({ text: 'third', messageId: 'msg-3' }), + envelope({ + text: 'third', + senderName: 'Bob', + isGroup: true, + isMentioned: true, + messageId: 'msg-3', + metadata: 'hidden policy third', + }), ); // p2 and p3 should resolve immediately (buffered, not queued) @@ -14414,6 +14494,13 @@ describe('ChannelBase', () => { .calls[1][1] as string; expect(secondCallText).toContain('second'); expect(secondCallText).toContain('third'); + // Metadata stays model-facing; the coalesced projection carries only + // the raw user-authored texts. + expect(secondCallText).toContain('hidden policy second'); + expect(secondCallText).toContain('hidden policy third'); + expect( + (bridge.prompt as ReturnType).mock.calls[1][2], + ).toMatchObject({ displayText: '[Alice] second\n\n[Bob] third' }); // Both responses should have been sent expect(ch.sent).toEqual( @@ -16348,6 +16435,43 @@ describe('ChannelBase', () => { expect(prompt).toContain('Event:'); expect(prompt).toContain('payload-survives'); }); + + it('caps and sanitizes the webhook display text like the model prompt', () => { + const task: ChannelWebhookTask = { + channelName: 'dingtalk-main', + source: 'github-ci', + eventType: 'ci_failed', + targetRef: 'default', + title: `[forged] ${'T'.repeat(20_000)}\u202e`, + summary: `S\u0007${'S'.repeat(20_000)}`, + payload: {}, + }; + + const displayText = buildChannelWebhookDisplayText(task); + const [title, summary] = displayText.split('\n\n'); + + // Same per-field caps as the model prompt path (500/1000 code points). + expect(Array.from(title!).length).toBeLessThanOrEqual(500); + expect(Array.from(summary!).length).toBeLessThanOrEqual(1000); + // sanitizePromptText strips the [tag] forgery prefix, bidi overrides, + // and C0 controls on both projections. + expect(displayText).not.toContain('[forged]'); + expect(displayText).not.toContain('\u202e'); + expect(displayText).not.toContain('\u0007'); + }); + + it('omits absent webhook summary from the display text', () => { + const task: ChannelWebhookTask = { + channelName: 'dingtalk-main', + source: 'github-ci', + eventType: 'ci_failed', + targetRef: 'default', + title: 'CI failed on main', + payload: {}, + }; + + expect(buildChannelWebhookDisplayText(task)).toBe('CI failed on main'); + }); }); describe('runWebhookTask', () => { @@ -16391,7 +16515,7 @@ describe('ChannelBase', () => { expect.stringContaining( '[External event "ci_failed" from github-ci]', ), - {}, + { displayText: 'CI failed' }, ); expect(ch.proactive).toEqual([ { chatId: 'group-1', text: 'CI failed because lint broke.' }, @@ -16997,6 +17121,11 @@ describe('ChannelBase', () => { const collectedPrompt = (bridge.prompt as ReturnType).mock .calls[1][1] as string; expect(collectedPrompt).toContain('follow-up while webhook runs'); + expect( + (bridge.prompt as ReturnType).mock.calls[1][2], + ).toMatchObject({ + displayText: '[Webhook] follow-up while webhook runs', + }); }); it('waits for bridge recovery before resolving a webhook session', async () => { @@ -17328,7 +17457,7 @@ describe('ChannelBase', () => { expect(bridge.prompt).toHaveBeenLastCalledWith( expect.any(String), '[Loop "daily summary" created by Alice] Scheduled task running unattended: no one is present to answer questions, and your final response is delivered to this chat automatically — do whatever work the task requires, then put the result in your final response instead of trying to deliver it to this chat yourself.\n\npost summary', - {}, + { displayText: 'post summary' }, ); expect(ch.proactive).toEqual([ { chatId: 'group-1', text: 'loop response' }, @@ -18647,7 +18776,7 @@ describe('ChannelBase', () => { expect(bridge.prompt).toHaveBeenLastCalledWith( 's-1', '[Loop "daily summary" created by Alice] Scheduled task running unattended: no one is present to answer questions, and your final response is delivered to this chat automatically — do whatever work the task requires, then put the result in your final response instead of trying to deliver it to this chat yourself.\n\npost again', - {}, + { displayText: 'post again' }, ); expect(ch.proactive).toEqual([ { chatId: 'chat1', text: 'second response' }, @@ -19478,7 +19607,7 @@ describe('ChannelBase', () => { expect(bridge.prompt).toHaveBeenLastCalledWith( expect.any(String), 'while loop runs', - expect.any(Object), + { displayText: 'while loop runs' }, ); }); diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index d09c15131f3..c47dc5b0a75 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -47,6 +47,7 @@ import { sanitizePromptText, sanitizePromptPath, sanitizeLogText, + sanitizeDisplayText, truncateCodePoints, PROMPT_UNSAFE_INVISIBLES, } from './sanitize.js'; @@ -63,6 +64,7 @@ import type { import type { ChannelLoop, ChannelLoopInput } from './ChannelLoopStore.js'; import { ChannelLoopSkippedError } from './ChannelLoopScheduler.js'; import { + buildChannelWebhookDisplayText, buildChannelWebhookPrompt, resolveChannelWebhookTarget, } from './ChannelWebhookTask.js'; @@ -273,7 +275,11 @@ type PendingPermissionLookup = | { kind: 'found'; pending: PendingPermission } | { kind: 'none'; explicit: boolean } | { kind: 'ambiguous'; requestIds: string[] }; -type CollectBufferEntry = { text: string; envelope: Envelope }; +type CollectBufferEntry = { + text: string; + displayText: string; + envelope: Envelope; +}; type ActivePrompt = { runId: string; owner?: ChannelPromptOwner; @@ -326,6 +332,7 @@ const COMMAND_TOKEN_RE = new RegExp(`^[${COMMAND_TOKEN_CHARS}]+(?:@\\S+)?$`); const LOOP_ADD_RE = /^"([^"]+)"\s+(.+)$/su; const MAX_LOOP_JOBS_PER_TARGET = 10; const MAX_LOOP_PROMPT_CHARS = 4000; +const MAX_DISPLAY_PROJECTION_CHARS = 8000; /** * The command-providing surface of a bridge. AcpBridge runs a single agent and @@ -1420,11 +1427,13 @@ export abstract class ChannelBase { this.collectBuffers.delete(sessionId); const lost = buffer.length; const coalesced = buffer.map((b) => b.text).join('\n\n'); + const coalescedDisplayText = buffer.map((b) => b.displayText).join('\n\n'); const lastEnvelope = buffer[buffer.length - 1]!.envelope; this.notifyPromptBufferDrained(lastEnvelope.chatId, sessionId, buffer); const syntheticEnvelope: Envelope = { ...lastEnvelope, text: coalesced, + displayText: coalescedDisplayText, alreadyPrefixed: true, referencedText: undefined, mentionedMemberIds: undefined, @@ -1648,6 +1657,7 @@ export abstract class ChannelBase { promptBridge, sessionId, promptToSend, + job.prompt, promptState, job.id, options.timeoutMs, @@ -1819,6 +1829,7 @@ export abstract class ChannelBase { }, ); const promptText = buildChannelWebhookPrompt(task, target); + const displayText = buildChannelWebhookDisplayText(task); const taskId = `webhook:${task.source}:${task.eventType}`; const safeTaskId = sanitizeLogText(taskId, 64); const safeChannel = sanitizeLogText(this.name, 64); @@ -1944,6 +1955,7 @@ export abstract class ChannelBase { promptBridge, sessionId, promptToSend, + displayText, promptState, taskId, options.timeoutMs, @@ -2048,11 +2060,12 @@ export abstract class ChannelBase { promptBridge: ChannelAgentBridge, sessionId: string, promptText: string, + displayText: string, promptState: ActivePrompt, jobId: string, timeoutMs: number | undefined, ): Promise { - const prompt = promptBridge.prompt(sessionId, promptText, {}); + const prompt = promptBridge.prompt(sessionId, promptText, { displayText }); prompt.catch(() => {}); if (timeoutMs === undefined) { return prompt; @@ -5028,6 +5041,14 @@ export abstract class ChannelBase { await this.recordObservedContact(envelope); this.onObservedContact(envelope); } + // Adapters that never set `displayText` fall back to the raw message + // text; sanitize at this boundary so attacker-controlled bidi/zero-width/ + // control chars cannot reach the session-bus echo, recorded transcript, + // or session previews. + const displayText = sanitizeDisplayText( + envelope.displayText ?? envelope.text, + MAX_DISPLAY_PROJECTION_CHARS, + ); let memoryIntent: ResolvedChannelMemoryIntent | null = parseChannelMemoryIntent(envelope.text); @@ -5293,7 +5314,17 @@ export abstract class ChannelBase { buffer = []; this.collectBuffers.set(sessionId, buffer); } - buffer.push({ text: promptText, envelope }); + const bufferedDisplayText = + (envelope.isGroup || this.config.sessionScope === 'single') && + !envelope.alreadyPrefixed && + !recognizedSlashCommand + ? `[${sanitizeSenderName(envelope.senderName || envelope.senderId || 'unknown')}] ${sanitizePromptText(displayText)}` + : displayText; + buffer.push({ + text: promptText, + displayText: bufferedDisplayText, + envelope, + }); try { this.onPromptBuffered( envelope.chatId, @@ -5626,6 +5657,7 @@ export abstract class ChannelBase { const response = await promptBridge.prompt(sessionId, promptToSend, { imageBase64, imageMimeType, + displayText, }); await this.settleCancelRequested(promptState); @@ -5776,6 +5808,9 @@ export abstract class ChannelBase { this.collectBuffers.delete(sessionId); const lost = buffer.length; const coalesced = buffer.map((b) => b.text).join('\n\n'); + const coalescedDisplayText = buffer + .map((b) => b.displayText) + .join('\n\n'); const lastEnvelope = buffer[buffer.length - 1]!.envelope; this.notifyPromptBufferDrained( lastEnvelope.chatId, @@ -5786,6 +5821,7 @@ export abstract class ChannelBase { const syntheticEnvelope: Envelope = { ...lastEnvelope, text: coalesced, + displayText: coalescedDisplayText, // Coalesced text already carries each message's [sender] prefix. alreadyPrefixed: true, // Clear attachments/references — already resolved in original text diff --git a/packages/channels/base/src/ChannelWebhookTask.ts b/packages/channels/base/src/ChannelWebhookTask.ts index d305d765812..33f4e434464 100644 --- a/packages/channels/base/src/ChannelWebhookTask.ts +++ b/packages/channels/base/src/ChannelWebhookTask.ts @@ -108,6 +108,32 @@ export function buildChannelWebhookPrompt( return truncateCodePoints(lines.join('\n'), MAX_WEBHOOK_PROMPT_CHARS); } +/** + * User-visible projection of a webhook task (session-bus displayText, + * transcript). Mirrors the model-prompt treatment in + * buildChannelWebhookPrompt — same per-field caps and sanitizePromptText — + * so an oversized or crafted title/summary cannot reach the transcript + * uncapped while the model side stays bounded and sanitized. + */ +export function buildChannelWebhookDisplayText( + task: ChannelWebhookTask, +): string { + const title = truncateCodePoints( + sanitizePromptText(task.title), + MAX_WEBHOOK_TITLE_CHARS, + ); + const summary = + task.summary === undefined + ? undefined + : truncateCodePoints( + sanitizePromptText(task.summary), + MAX_WEBHOOK_SUMMARY_CHARS, + ); + return [title, summary] + .filter((part): part is string => Boolean(part)) + .join('\n\n'); +} + function truncateCodePoints(text: string, maxChars: number): string { const chars = Array.from(text); return chars.length > maxChars ? chars.slice(0, maxChars).join('') : text; diff --git a/packages/channels/base/src/DaemonChannelBridge.test.ts b/packages/channels/base/src/DaemonChannelBridge.test.ts index 8335baf17c5..d32e4e89e26 100644 --- a/packages/channels/base/src/DaemonChannelBridge.test.ts +++ b/packages/channels/base/src/DaemonChannelBridge.test.ts @@ -9,7 +9,10 @@ import { type DaemonChannelLoopMcpHost, type DaemonChannelSessionClient, } from './DaemonChannelBridge.js'; -import { CHANNEL_PROMPT_META_KEY } from './ChannelAgentBridge.js'; +import { + CHANNEL_PROMPT_AUTHORIZATION_META_KEY, + CHANNEL_PROMPT_META_KEY, +} from './ChannelAgentBridge.js'; class EventQueue implements AsyncGenerator { private events: DaemonChannelEvent[] = []; @@ -132,6 +135,73 @@ function turnCompleteEvent(sessionId = 'session-1'): DaemonChannelEvent { } describe('DaemonChannelBridge', () => { + it('deletes an internal session through its owning workspace', async () => { + const events = new EventQueue(); + const session = createFakeSession(events); + const deleteSessionData = vi.fn().mockResolvedValue(undefined); + const bridge = new DaemonChannelBridge({ + cwd: '/repo', + sessionFactory: vi.fn().mockResolvedValue(session), + deleteSessionData, + }); + + await bridge.start(); + await bridge.newSession('/repo'); + await bridge.deleteSessionData?.('session-1'); + + expect(deleteSessionData).toHaveBeenCalledWith('session-1'); + expect(bridge.listSessions()).toEqual([]); + events.close(); + bridge.stop(); + }); + + it('deletes session data after the live binding has already died', async () => { + const events = new EventQueue(); + const session = createFakeSession(events); + const deleteSessionData = vi.fn().mockResolvedValue(undefined); + const bridge = new DaemonChannelBridge({ + cwd: '/repo', + sessionFactory: vi.fn().mockResolvedValue(session), + deleteSessionData, + }); + + await bridge.start(); + await bridge.newSession('/repo'); + events.push({ + v: 1, + type: 'session_died', + data: { sessionId: 'session-1', reason: 'child_exit' }, + }); + await waitFor(() => expect(bridge.listSessions()).toEqual([])); + + await bridge.deleteSessionData?.('session-1'); + + expect(deleteSessionData).toHaveBeenCalledWith('session-1'); + events.close(); + bridge.stop(); + }); + + it('keeps the live binding when permanent deletion fails', async () => { + const events = new EventQueue(); + const session = createFakeSession(events); + const deleteSessionData = vi.fn().mockRejectedValue(new Error('locked')); + const bridge = new DaemonChannelBridge({ + cwd: '/repo', + sessionFactory: vi.fn().mockResolvedValue(session), + deleteSessionData, + }); + + await bridge.start(); + await bridge.newSession('/repo'); + await expect(bridge.deleteSessionData?.('session-1')).rejects.toThrow( + 'locked', + ); + + expect(bridge.listSessions()).toHaveLength(1); + events.close(); + bridge.stop(); + }); + it('registers the loop MCP server for the exact daemon session', async () => { const events = new EventQueue(); const session = createFakeSession(events); @@ -1868,6 +1938,44 @@ describe('DaemonChannelBridge', () => { bridge.stop(); }); + it('forwards a distinct user-facing prompt text in daemon metadata', async () => { + const events = new EventQueue(); + const session = createFakeSession(events); + const bridge = new DaemonChannelBridge({ + cwd: '/repo', + sessionFactory: vi.fn().mockResolvedValue(session), + promptAuthorization: 'worker-token', + }); + + await bridge.start(); + await bridge.newSession('/repo'); + + const promptPromise = bridge.prompt( + 'session-1', + 'internal context\n\nhello', + { + displayText: 'hello', + }, + ); + await waitFor(() => expect(session.prompt).toHaveBeenCalledOnce()); + expect(session.prompt).toHaveBeenCalledWith( + { + prompt: [{ type: 'text', text: 'internal context\n\nhello' }], + _meta: { + [CHANNEL_PROMPT_META_KEY]: true, + [CHANNEL_PROMPT_AUTHORIZATION_META_KEY]: 'worker-token', + 'qwen.daemon.promptDisplayText': 'hello', + }, + }, + expect.any(AbortSignal), + ); + + events.push(turnCompleteEvent()); + await promptPromise; + events.close(); + bridge.stop(); + }); + it('aborts in-flight prompts when the bridge stops', async () => { const events = new EventQueue(); const session = createFakeSession(events); diff --git a/packages/channels/base/src/DaemonChannelBridge.ts b/packages/channels/base/src/DaemonChannelBridge.ts index 806165fad99..b036b533f10 100644 --- a/packages/channels/base/src/DaemonChannelBridge.ts +++ b/packages/channels/base/src/DaemonChannelBridge.ts @@ -4,10 +4,13 @@ import type { RequestPermissionResponse, } from '@agentclientprotocol/sdk'; import { + CHANNEL_PROMPT_AUTHORIZATION_META_KEY, + CHANNEL_PROMPT_DISPLAY_TEXT_META_KEY, CHANNEL_PROMPT_META_KEY, type AvailableCommand, type BridgeSessionInfo, type ChannelAgentBridge, + type ChannelAgentBridgePromptOptions, type ChannelAgentBridgeSessionOptions, type ChannelLoopToolHandler, type ToolCallEvent, @@ -86,6 +89,8 @@ export interface DaemonChannelBridgeOptions { modelServiceId?: string; sessionScope?: SessionScope; channelLoopMcpHost?: DaemonChannelLoopMcpHost; + deleteSessionData?: (sessionId: string) => Promise; + promptAuthorization?: string; } export interface DaemonPermissionRequestEvent { @@ -233,10 +238,18 @@ export class DaemonChannelBridge private lifecycleGeneration = 0; private latestAvailableCommandsSessionId: string | undefined; private lastError: unknown; + readonly deleteSessionData?: (sessionId: string) => Promise; constructor(options: DaemonChannelBridgeOptions) { super(); this.options = options; + const deleteSessionData = options.deleteSessionData; + if (deleteSessionData) { + this.deleteSessionData = async (sessionId) => { + await deleteSessionData(sessionId); + this.removeSessionBinding(sessionId); + }; + } this.on('error', (error) => { this.lastError = error; }); @@ -348,7 +361,7 @@ export class DaemonChannelBridge async prompt( sessionId: string, text: string, - options?: { imageBase64?: string; imageMimeType?: string }, + options?: ChannelAgentBridgePromptOptions, ): Promise { const session = this.ensureSession(sessionId); if (this.activePrompts.has(sessionId)) { @@ -404,12 +417,28 @@ export class DaemonChannelBridge }); } prompt.push({ type: 'text', text }); + const promptAuthorization = + options?.displayText !== undefined + ? this.options.promptAuthorization + : undefined; try { const result = await session.prompt( { prompt, - _meta: { [CHANNEL_PROMPT_META_KEY]: true }, + _meta: { + [CHANNEL_PROMPT_META_KEY]: true, + ...(promptAuthorization + ? { + [CHANNEL_PROMPT_AUTHORIZATION_META_KEY]: promptAuthorization, + } + : {}), + ...(options?.displayText !== undefined + ? { + [CHANNEL_PROMPT_DISPLAY_TEXT_META_KEY]: options.displayText, + } + : {}), + }, }, controller.signal, ); diff --git a/packages/channels/base/src/index.ts b/packages/channels/base/src/index.ts index 035078e8f0a..8bdf47f9bd5 100644 --- a/packages/channels/base/src/index.ts +++ b/packages/channels/base/src/index.ts @@ -5,6 +5,11 @@ export { } from './paths.js'; export { PollingChannelBase } from './PollingChannelBase.js'; export { ACP_EVENT_LOOP_STALL_RESTART_MS, AcpBridge } from './AcpBridge.js'; +export { + ACP_PRIVATE_PARENT_CAPABILITY_ENV, + ACP_PRIVATE_PARENT_CAPABILITY_META_KEY, + CHANNEL_PROMPT_DISPLAY_TEXT_META_KEY, +} from './ChannelAgentBridge.js'; export type { AvailableCommand, BridgeSessionInfo, @@ -88,6 +93,7 @@ export { SessionRouter } from './SessionRouter.js'; export { sanitizeSenderName, sanitizePromptText, + sanitizeDisplayText, sanitizeLogText, truncateCodePoints, } from './sanitize.js'; diff --git a/packages/channels/base/src/sanitize.test.ts b/packages/channels/base/src/sanitize.test.ts index bbd35ed2277..d230f98ec68 100644 --- a/packages/channels/base/src/sanitize.test.ts +++ b/packages/channels/base/src/sanitize.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { sanitizeSenderName, sanitizePromptText, + sanitizeDisplayText, sanitizeQuotedText, sanitizePromptPath, sanitizeLogText, @@ -295,3 +296,24 @@ describe('sanitizeLogText', () => { expect(isHighSurrogate(out.charCodeAt(out.length - 1))).toBe(false); }); }); + +describe('sanitizeDisplayText', () => { + it('neutralizes bidi, zero-width, and C0 controls but keeps newlines', () => { + const out = sanitizeDisplayText( + `a${RLO}b${ZWSP}c${NEL}d\u0007e\rf\ng[h]`, + 100, + ); + expect(out).toBe('a b c d e f\ng[h]'); + }); + + it('keeps brackets and multi-line structure that sanitizePromptText folds', () => { + const out = sanitizeDisplayText('[BUG] title:\n- one\n- two', 100); + expect(out).toBe('[BUG] title:\n- one\n- two'); + }); + + it('caps to maxLen code points without splitting a surrogate pair', () => { + const out = sanitizeDisplayText('a'.repeat(399) + EMOJI + 'tail', 400); + expect(out).toBe('a'.repeat(399) + EMOJI); + expect(isHighSurrogate(out.charCodeAt(out.length - 1))).toBe(false); + }); +}); diff --git a/packages/channels/base/src/sanitize.ts b/packages/channels/base/src/sanitize.ts index 3bd82b780c7..c8512b544e7 100644 --- a/packages/channels/base/src/sanitize.ts +++ b/packages/channels/base/src/sanitize.ts @@ -80,6 +80,24 @@ export function sanitizePromptText(text: string): string { ); } +/** + * Neutralize attacker-controlled text that is surfaced VERBATIM to users + * (session-bus display projections, transcripts, session-list previews): + * strip the Unicode line/bidi/zero-width controls that can reorder or hide + * rendered text, plus C0/DEL controls EXCEPT newline — multi-line user text + * keeps its line structure in the transcript. Capped by CODE POINT so a cap + * landing mid-surrogate-pair cannot leave a lone surrogate. Unlike + * sanitizePromptText it preserves newlines and brackets: display text is + * rendered to a human, not parsed as prompt structure. + */ +export function sanitizeDisplayText(text: string, maxLen: number): string { + const cleaned = text + .replace(PROMPT_UNSAFE_INVISIBLES, ' ') + // eslint-disable-next-line no-control-regex + .replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, ' '); + return truncateCodePoints(cleaned, maxLen); +} + /** * Neutralize an attacker-influenced filesystem path before rendering it on * its own line in a prompt (`... saved to: `). Unlike diff --git a/packages/channels/base/src/types.ts b/packages/channels/base/src/types.ts index 5da16a1934f..e2537ce0f54 100644 --- a/packages/channels/base/src/types.ts +++ b/packages/channels/base/src/types.ts @@ -106,6 +106,8 @@ export interface Envelope { chatId: string; chatName?: string; text: string; + /** User-authored text to display when `text` contains model-only context. */ + displayText?: string; threadId?: string; /** Platform-specific message ID for response correlation. */ messageId?: string; diff --git a/packages/channels/github/src/GithubAdapter.test.ts b/packages/channels/github/src/GithubAdapter.test.ts index 4816a497404..2033daeec93 100644 --- a/packages/channels/github/src/GithubAdapter.test.ts +++ b/packages/channels/github/src/GithubAdapter.test.ts @@ -1519,6 +1519,9 @@ describe('GithubChannel', () => { expect(channel.inboundEnvelopes[0]!.text).toBe( 'Return a formal review summary with verified actionable findings, or a concise no-blocker result.', ); + expect(channel.inboundEnvelopes[0]!.displayText).toBe( + 'Review requested: feat: divide', + ); expect(channel.inboundEnvelopes[0]!.metadata).toContain( 'For review_requested, return a formal review summary', ); @@ -1654,6 +1657,7 @@ describe('GithubChannel', () => { senderId: 'maintainer', isMentioned: true, text: 'Triage this issue and respond with the next action.', + displayText: 'Issue assigned: broken build', }); expect(channel.inboundEnvelopes[1]).toMatchObject({ senderId: 'bob', @@ -1702,6 +1706,9 @@ describe('GithubChannel', () => { ); expect(channel.inboundEnvelopes[0]!.text).toContain('@alice: first'); expect(channel.inboundEnvelopes[0]!.text).toContain('@bob: second'); + expect(channel.inboundEnvelopes[0]!.displayText).toBe( + '- @alice: first\n- @bob: second', + ); }, ); @@ -2049,6 +2056,60 @@ describe('GithubChannel', () => { expect(channel.inboundEnvelopes[0]!.text).toContain('latest'); }); + it('sanitizes crafted comment bodies in the aggregate display projection', async () => { + await initWithoutLoop(); + mockOctokit.paginate + .mockResolvedValueOnce([ + makeNotification({ + reason: 'comment', + last_read_at: '2026-07-01T12:00:00.000Z', + }), + ]) + .mockResolvedValueOnce([ + makeComment({ + body: 'line one\u202e hidden\u200b\u0007\r\nline two [BUG] kept', + }), + ]); + + await pollOnce(); + + const displayText = channel.inboundEnvelopes[0]!.displayText!; + // eslint-disable-next-line no-control-regex + const craftedChars = /[\u202a-\u202e\u2066-\u2069\u200b\u0007\r]/; + expect(displayText).not.toMatch(craftedChars); + // Newlines and brackets are display content and must survive. + expect(displayText).toContain('line one'); + expect(displayText).toContain('\nline two [BUG] kept'); + expect(channel.inboundEnvelopes[0]!.text).toContain( + displayText.slice('- @alice: '.length), + ); + }); + + it('truncates aggregated comments on code-point boundaries', async () => { + await initWithoutLoop(); + mockOctokit.paginate + .mockResolvedValueOnce([ + makeNotification({ + reason: 'comment', + last_read_at: '2026-07-01T12:00:00.000Z', + }), + ]) + .mockResolvedValueOnce([ + // 399 ASCII + one 2-unit emoji + tail: a UTF-16 slice(0, 400) would + // land mid-surrogate-pair and leave a lone surrogate behind. + makeComment({ body: 'a'.repeat(399) + '\ud83c\udf89' + 'tail' }), + ]); + + await pollOnce(); + + const displayText = channel.inboundEnvelopes[0]!.displayText!; + expect(displayText).toContain('a'.repeat(399) + '\ud83c\udf89'); + expect(displayText).not.toContain('tail'); + expect(displayText).not.toMatch( + /[\ud800-\udbff](?![\udc00-\udfff])|(? { await initWithoutLoop(); const comments = Array.from({ length: 25 }, (_, index) => diff --git a/packages/channels/github/src/GithubAdapter.ts b/packages/channels/github/src/GithubAdapter.ts index 869559dce63..f9a9c817c4a 100644 --- a/packages/channels/github/src/GithubAdapter.ts +++ b/packages/channels/github/src/GithubAdapter.ts @@ -25,7 +25,10 @@ import { getGlobalQwenDir, getWorkspaceScopeDirName, PollingChannelBase, + sanitizeDisplayText, sanitizeLogText, + sanitizePromptText, + truncateCodePoints, } from '@qwen-code/channel-base'; import { testBotMention, stripBotMention } from './mention.js'; @@ -1384,6 +1387,7 @@ export class GithubChannel extends PollingChannelBase { ? await this.fetchPrMeta(ctx) : await this.fetchIssueMeta(ctx); const title = meta.title || ctx.subjectTitle; + const displayTitle = truncateCodePoints(sanitizePromptText(title), 500); const details = reason === 'review_requested' ? `Author: ${meta.user?.login || 'unknown'} | State: ${meta.state || 'unknown'} | Draft: ${meta.draft ? 'true' : 'false'} | Branch: ${meta.head?.ref || 'unknown'} → ${meta.base?.ref || 'unknown'}` @@ -1401,6 +1405,10 @@ export class GithubChannel extends PollingChannelBase { reason === 'review_requested' ? 'Return a formal review summary with verified actionable findings, or a concise no-blocker result.' : 'Triage this issue and respond with the next action.', + displayText: + reason === 'review_requested' + ? `Review requested: ${displayTitle}` + : `Issue assigned: ${displayTitle}`, isGroup: true, isMentioned: true, isReplyToBot: false, @@ -1439,7 +1447,7 @@ export class GithubChannel extends PollingChannelBase { const summary = comments .map( (comment) => - `- @${comment.user?.login || 'unknown'}: ${(comment.body || '').trim().slice(0, MAX_AGGREGATE_COMMENT_CHARS)}`, + `- @${comment.user?.login || 'unknown'}: ${sanitizeDisplayText((comment.body || '').trim(), MAX_AGGREGATE_COMMENT_CHARS)}`, ) .join('\n'); const envelope: Envelope = { @@ -1450,6 +1458,7 @@ export class GithubChannel extends PollingChannelBase { threadId: ctx.threadId, messageId: String(first.id), text: `Review these new comments and output exactly ${NO_REPLY_SENTINEL} if no public reply is needed:\n${summary}`, + displayText: summary, isGroup: true, isMentioned: true, isReplyToBot: false, diff --git a/packages/channels/qqbot/src/QQChannel.ts b/packages/channels/qqbot/src/QQChannel.ts index bf3428a8cb5..d7f896ea676 100644 --- a/packages/channels/qqbot/src/QQChannel.ts +++ b/packages/channels/qqbot/src/QQChannel.ts @@ -2292,6 +2292,7 @@ export class QQChannel extends ChannelBase { safeName: string; cleanText: string; text: string; + displayText: string; senderName: string; } | null { // Keep identity values out of the display-name position. In particular, @@ -2305,6 +2306,12 @@ export class QQChannel extends ChannelBase { const content = (event.content || '').trim(); const cleanText = content.replace(/<@[^>]{1,64}>/g, '').trim(); + let mentionIndex = 0; + const displayContent = content + .replace(/<@[^>]{1,64}>/g, (mention) => + event.mentions?.[mentionIndex++]?.is_you ? '' : mention, + ) + .trim(); // Strip trusted tags that could be forged by users const safeContent = content .replace(/\[atMention=[^\]]*]/g, '') @@ -2315,6 +2322,11 @@ export class QQChannel extends ChannelBase { .replace(/\[botOpenId:[^\]]*]/g, '') .replace(/\[bot]/g, '') .trim(); + const safeDisplayText = displayContent + .replace(/\[atMention=[^\]]*]/g, '') + .replace(/\[botOpenId:[^\]]*]/g, '') + .replace(/\[bot]/g, '') + .trim(); const isAtBot = event.mentions?.some((m) => m.is_you) ?? false; // Extract bot's own OPENID from mentions (per-group) — must come @@ -2397,6 +2409,7 @@ export class QQChannel extends ChannelBase { const text = isSlash ? sanitizePromptText(safeCleanText) : `[atMention=${effectiveIsAtBot}]${openIdSuffix} [${safeName}${senderTag}]: ${sanitizePromptText(this.qqConfig.allowMention !== false ? safeContent : safeCleanText)}${suffixFromBotOpenId}`; + const displayText = sanitizePromptText(safeDisplayText); return { isAtBot: effectiveIsAtBot, @@ -2404,6 +2417,7 @@ export class QQChannel extends ChannelBase { safeName, cleanText, text, + displayText, senderName, }; } @@ -2454,6 +2468,7 @@ export class QQChannel extends ChannelBase { senderName, chatId, text, + displayText: sanitizePromptText(safeContent), messageId: event.id, isGroup: false, isMentioned: true, @@ -2506,7 +2521,8 @@ export class QQChannel extends ChannelBase { forceAtMention: true, }); if (!result) return; - const { isSlash, text, senderName, safeName, cleanText } = result; + const { isSlash, text, displayText, senderName, safeName, cleanText } = + result; // Deduplicate before handleInbound — prepareGroupMessage already ran // so side effects (extractBotOpenId) are applied regardless of dedup. @@ -2540,6 +2556,7 @@ export class QQChannel extends ChannelBase { senderName, chatId, text, + displayText, messageId: event.id, isGroup: true, isMentioned: true, @@ -2585,7 +2602,15 @@ export class QQChannel extends ChannelBase { const result = this.prepareGroupMessage(event, chatId); if (!result) return; - const { isSlash, text, senderName, isAtBot, safeName, cleanText } = result; + const { + isSlash, + text, + displayText, + senderName, + isAtBot, + safeName, + cleanText, + } = result; // @-bot messages always pass through (passive reply). // Non-@-bot messages are subject to active-message and keyword policies. @@ -2683,6 +2708,7 @@ export class QQChannel extends ChannelBase { channelName: this.name, chatId, text, + displayText, senderId, senderName, messageId: event.id, diff --git a/packages/channels/qqbot/src/events.test.ts b/packages/channels/qqbot/src/events.test.ts index e0ef472e860..2cd103f8e65 100644 --- a/packages/channels/qqbot/src/events.test.ts +++ b/packages/channels/qqbot/src/events.test.ts @@ -290,6 +290,7 @@ describe('handleC2C', () => { expect(env['senderId']).toBe('user-openid-1'); expect(env['chatId']).toBe('user-openid-1'); expect(env['text']).toBe('[atMention=true] [Alice]: 你好,帮我查一下天气'); + expect(env['displayText']).toBe('你好,帮我查一下天气'); }); it('斜杠命令不包装 atMention', async () => { @@ -414,6 +415,25 @@ describe('handleGroup', () => { expect(env['text']).toBe( '[atMention=true] [Bob(ABCDEF0123456789ABCDEF0123456789)]: <@OPENID_BOT> 你好', ); + expect(env['displayText']).toBe('你好'); + }); + + it('可见文本只移除机器人 mention', async () => { + const ch = makeChannel(); + const pvt = ch as unknown as QQChannelRaw; + pvt['handleGroup']( + makeGroupEvent({ + content: '<@OPENID_BOT> ask <@OPENID_ALICE> now', + mentions: [ + { member_openid: 'bot-openid', is_you: true }, + { member_openid: 'alice-openid', is_you: false }, + ], + }), + ); + await vi.advanceTimersByTimeAsync(600); + + const env = mockHandleInbound.mock.calls[0][0] as Record; + expect(env['displayText']).toBe('ask <@OPENID_ALICE> now'); }); it('allowMention=false 时清理 <@OPENID> 标签', async () => { @@ -504,6 +524,24 @@ describe('handleGroup', () => { expect(env['text']).toBe('/status'); }); + it('其他成员 mention 后的斜杠命令仍被识别', async () => { + const ch = makeChannel(); + const pvt = ch as unknown as QQChannelRaw; + pvt['handleGroup']( + makeGroupEvent({ + content: '<@OPENID_BOT> <@OPENID_ALICE> /schedule list', + mentions: [ + { member_openid: 'bot-openid', is_you: true }, + { member_openid: 'alice-openid', is_you: false }, + ], + }), + ); + await vi.advanceTimersByTimeAsync(600); + const env = mockHandleInbound.mock.calls[0][0] as Record; + expect(env['text']).toBe('/schedule list'); + expect(env['displayText']).toBe('<@OPENID_ALICE> /schedule list'); + }); + it('重复消息不触发', async () => { const ch = makeChannel(); const pvt = ch as unknown as QQChannelRaw; @@ -1068,6 +1106,7 @@ describe('handleGroupAll', () => { const env = mockHandleInbound.mock.calls[0][0] as Record; expect(env['isGroup']).toBe(true); expect(env['text']).toContain('[atMention=false]'); + expect(env['displayText']).toBe('hello world'); }); it('policy=keyword 时只有匹配关键词才触发', async () => { diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index f62b75f5892..c7f5e80e201 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -2145,6 +2145,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { 'qwen-code/invocation': invocation, 'qwen-code/private-parent-capability': 'must-not-propagate', 'qwen.daemon.modelPrompt': 'trusted model-only prompt', + 'qwen.daemon.promptDisplayText': 'trusted display text', }, }); @@ -2152,7 +2153,10 @@ describe('QwenAgent MCP SSE/HTTP support', () => { { sessionId: 'trusted-session', prompt: [{ type: 'text', text: 'hello' }], - _meta: { keep: true }, + _meta: { + keep: true, + 'qwen.daemon.promptDisplayText': 'trusted display text', + }, }, invocation, expect.any(AbortSignal), @@ -2507,6 +2511,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { }, 'qwen-code/private-parent-capability': 'forged-capability', 'qwen.daemon.modelPrompt': 'forged model-only prompt', + 'qwen.daemon.promptDisplayText': 'forged display text', }, }); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index cc839a84090..b56d9e9b883 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -331,6 +331,7 @@ import { CHANNEL_STARTUP_PROFILE_VERSION, CLIENT_MCP_OVER_WS_CONFIG_FLAG, DAEMON_MODEL_PROMPT_META_KEY, + DAEMON_PROMPT_DISPLAY_TEXT_META_KEY, LOAD_REPLAY_BULK_MODE, LOAD_REPLAY_HIDE_INHERITED_META_KEY, LOAD_REPLAY_META_KEY, @@ -5451,9 +5452,20 @@ class QwenAgent implements Agent { : {}; const suppliedContext = meta[INVOCATION_CONTEXT_META_KEY]; const suppliedModelPrompt = meta[DAEMON_MODEL_PROMPT_META_KEY]; + const suppliedPromptDisplayText = meta[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY]; delete meta[INVOCATION_CONTEXT_META_KEY]; delete meta[DAEMON_MODEL_PROMPT_META_KEY]; delete meta[PRIVATE_PARENT_CAPABILITY_META_KEY]; + delete meta[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY]; + // The user-facing display projection is caller-controlled metadata; honor + // it only for trusted parents (the daemon bridge re-injects the trusted + // channel-worker value here). A plain delete would drop that re-injection. + if ( + this.privateParentState === 'trusted' && + typeof suppliedPromptDisplayText === 'string' + ) { + meta[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY] = suppliedPromptDisplayText; + } if (Object.keys(meta).length > 0) { sanitizedParams._meta = meta; } else { diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index f219d3c0f3b..45106c16344 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -5344,6 +5344,26 @@ describe('Session', () => { ); }); + it('records daemon prompt display text separately from model context', async () => { + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValue(createEmptyStream()); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [ + { type: 'text', text: 'internal channel instructions\n\nhello' }, + ], + _meta: { 'qwen.daemon.promptDisplayText': 'hello' }, + }); + + expect(mockChatRecordingService.recordUserMessage).toHaveBeenCalledWith( + 'internal channel instructions\n\nhello', + undefined, + { displayText: 'hello', hookContext: '' }, + ); + }); + it('degrades an oversized inline image to a text placeholder before sending to the model', async () => { const ENV_KEY = 'QWEN_CODE_MAX_INLINE_MEDIA_BYTES'; const original = process.env[ENV_KEY]; diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 873e7c6cdcf..f068c355e6d 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -189,6 +189,7 @@ import { ENV_ACP_REPEATED_TOOL_FAILURE_GUARD } from '../../config/shared-env-key import { type ActiveWorkHoldV1, DAEMON_CHANNEL_DELIVERY_META_KEY, + DAEMON_PROMPT_DISPLAY_TEXT_META_KEY, MID_TURN_QUEUE_DRAIN_METHOD, isValidTrustedModelPrompt, TODO_STOP_GUARD_CONTINUATION_CLAIM_METHOD, @@ -3949,6 +3950,11 @@ export class Session implements SessionContext { .filter((block) => block.type === 'text') .map((block) => (block.type === 'text' ? block.text : '')) .join(' '); + const promptDisplayText = + typeof promptMetadata?.[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY] === + 'string' + ? promptMetadata[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY] + : undefined; const modelPromptBlocks: PromptRequest['prompt'] = modelPrompt === undefined ? params.prompt @@ -4048,7 +4054,12 @@ export class Session implements SessionContext { } else { // record user message for session management const recorder = this.config.getChatRecordingService(); - if (goalTurn) { + if (promptDisplayText !== undefined) { + recorder?.recordUserMessage(promptText, goalTurn?.permit, { + displayText: promptDisplayText, + hookContext: '', + }); + } else if (goalTurn) { recorder?.recordUserMessage(promptText, goalTurn.permit); } else { recorder?.recordUserMessage(promptText); diff --git a/packages/cli/src/commands/channel/channel-registry-builtins.test.ts b/packages/cli/src/commands/channel/channel-registry-builtins.test.ts index 4417c960287..4a1678fc951 100644 --- a/packages/cli/src/commands/channel/channel-registry-builtins.test.ts +++ b/packages/cli/src/commands/channel/channel-registry-builtins.test.ts @@ -119,6 +119,23 @@ describe('built-in channel registry', () => { 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' }, + ], + }, ], }); }); diff --git a/packages/cli/src/commands/channel/channel-registry.test.ts b/packages/cli/src/commands/channel/channel-registry.test.ts index 84de79466c3..c625cfc13f2 100644 --- a/packages/cli/src/commands/channel/channel-registry.test.ts +++ b/packages/cli/src/commands/channel/channel-registry.test.ts @@ -21,6 +21,58 @@ function invalidPlugin( } describe('channel registry', () => { + it('publishes a plugin session-scope descriptor once with its runtime default', async () => { + registerPlugin({ + channelType: 'valid-custom-session-scope', + displayName: 'Custom scope', + defaultSessionScope: 'thread', + management: { + fields: [ + { + key: 'sessionScope', + label: 'Conversation scope', + kind: 'enum', + options: [ + { value: 'user', label: 'User' }, + { value: 'thread', label: 'Thread' }, + ], + }, + ], + }, + createChannel() { + throw new Error('not used'); + }, + }); + + const descriptor = (await supportedChannelCatalog()).find( + (entry) => entry.type === 'valid-custom-session-scope', + ); + const scopeFields = descriptor?.fields.filter( + (field) => field.key === 'sessionScope', + ); + expect(scopeFields).toHaveLength(1); + expect(scopeFields?.[0]).toMatchObject({ + label: 'Conversation scope', + default: 'thread', + }); + }); + + it('strips management for an invalid runtime session-scope default', async () => { + registerPlugin({ + channelType: 'invalid-session-scope-default', + displayName: 'Invalid scope', + defaultSessionScope: 'workspace' as never, + management: { fields: [] }, + createChannel() { + throw new Error('not used'); + }, + }); + + await expect(getPlugin('invalid-session-scope-default')).resolves.toEqual( + expect.objectContaining({ management: undefined }), + ); + }); + it.each([ { type: 'invalid-nested-secret', @@ -718,6 +770,16 @@ describe('channel registry', () => { required: true, }), ); + expect( + catalog.find((entry) => entry.type === 'dingtalk')?.fields, + ).toContainEqual( + expect.objectContaining({ + key: 'sessionScope', + kind: 'enum', + required: true, + default: 'user', + }), + ); for (const type of ['github', 'gitlab'] as const) { const fields = catalog.find((entry) => entry.type === type)?.fields; expect(fields).toContainEqual( @@ -746,6 +808,17 @@ describe('channel registry', () => { }), ); } + expect( + catalog.find((entry) => entry.type === 'github')?.fields, + ).toContainEqual( + expect.objectContaining({ + key: 'sessionScope', + default: 'chat_thread', + }), + ); + expect( + catalog.find((entry) => entry.type === 'telegram')?.fields, + ).not.toContainEqual(expect.objectContaining({ key: 'sessionScope' })); expect( catalog.find((entry) => entry.type === 'dingtalk')?.fields, ).toContainEqual( diff --git a/packages/cli/src/commands/channel/channel-registry.ts b/packages/cli/src/commands/channel/channel-registry.ts index 784559f8d3b..302c7f3badd 100644 --- a/packages/cli/src/commands/channel/channel-registry.ts +++ b/packages/cli/src/commands/channel/channel-registry.ts @@ -2,6 +2,7 @@ import type { ChannelConfigFieldDescriptor, ChannelConfigFieldKind, ChannelPlugin, + SessionScope, } from '@qwen-code/channel-base'; export interface ChannelTypeDescriptor { @@ -164,6 +165,14 @@ function assertManagementField( function assertManagementDescriptor(plugin: ChannelPlugin): void { const management = plugin.management; if (management === undefined) return; + const defaultSessionScope: unknown = plugin.defaultSessionScope ?? 'user'; + if ( + !SESSION_SCOPE_OPTIONS.some( + (option) => option.value === defaultSessionScope, + ) + ) { + throw new Error('Channel defaultSessionScope is invalid.'); + } if ( management.validateConfig !== undefined && (typeof management.validateConfig !== 'function' || @@ -177,8 +186,35 @@ function assertManagementDescriptor(plugin: ChannelPlugin): void { throw new Error('Channel management metadata must declare a fields array.'); } assertManagementFields(management.fields); + const sessionScopeField = management.fields.find( + (field) => field.key === 'sessionScope', + ); + if (sessionScopeField) { + if (sessionScopeField.kind !== 'enum') { + throw new Error('Channel field "sessionScope" must be an enum.'); + } + if ( + !sessionScopeField.options?.some( + (option: { value: string }) => option.value === defaultSessionScope, + ) + ) { + throw new Error( + 'Channel field "sessionScope" must include the channel defaultSessionScope.', + ); + } + } } +const SESSION_SCOPE_OPTIONS: ReadonlyArray<{ + value: SessionScope; + label: string; +}> = [ + { 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' }, +]; + function ensureBuiltins(): Promise { if (!builtinsPromise) { builtinsPromise = (async () => { @@ -272,12 +308,35 @@ export async function supportedChannelCatalog(): Promise< ChannelTypeDescriptor[] > { await ensureBuiltins(); - return [...registry.values()].map( - ({ channelType, displayName, management }) => ({ + return [...registry.values()].map((plugin) => { + const { channelType, displayName, management } = plugin; + const fields = management?.fields ?? []; + const defaultSessionScope = plugin.defaultSessionScope ?? 'user'; + const normalizedFields = fields.map((field) => + field.key === 'sessionScope' && field.default === undefined + ? { ...field, default: defaultSessionScope } + : field, + ); + return { type: channelType, displayName, manageable: management !== undefined, - fields: management?.fields ?? [], - }), - ); + fields: + management && !fields.some((field) => field.key === 'sessionScope') + ? [ + ...normalizedFields, + { + key: 'sessionScope', + label: 'Session scope', + kind: 'enum', + required: true, + default: defaultSessionScope, + description: + 'Controls which incoming conversations share one agent session.', + options: SESSION_SCOPE_OPTIONS, + }, + ] + : normalizedFields, + }; + }); } diff --git a/packages/cli/src/commands/channel/daemon-worker.test.ts b/packages/cli/src/commands/channel/daemon-worker.test.ts index e3438898408..6709044463f 100644 --- a/packages/cli/src/commands/channel/daemon-worker.test.ts +++ b/packages/cli/src/commands/channel/daemon-worker.test.ts @@ -158,7 +158,7 @@ const mockChannelLoopScheduler = vi.hoisted(() => })), ); const mockDaemonChannelBridge = vi.hoisted(() => - vi.fn(() => ({ + vi.fn((_options?: unknown) => ({ get availableCommands() { return []; }, @@ -311,6 +311,11 @@ const deliveryRequest = { }; function createSdk() { + const deleteSessionsData = vi.fn().mockResolvedValue({ + removed: ['classifier-session'], + notFound: [], + errors: [], + }); const client = { capabilities: vi.fn().mockResolvedValue({ v: 1, @@ -319,6 +324,7 @@ function createSdk() { modelServices: [], workspaceCwd: '/workspace', }), + workspaceByCwd: vi.fn(() => ({ deleteSessionsData })), }; const DaemonClient = vi.fn(() => client); const DaemonSessionClient = { @@ -341,7 +347,7 @@ function createSdk() { respondToPermission: vi.fn(), }), }; - return { client, DaemonClient, DaemonSessionClient }; + return { client, DaemonClient, DaemonSessionClient, deleteSessionsData }; } beforeEach(() => { @@ -650,6 +656,27 @@ describe('createDaemonChannelBridgeFacade', () => { expect(respondToPermission).toHaveBeenCalledWith('req-1', response); }); + it('forwards permanent internal-session deletion when present', async () => { + const deleteSessionData = vi.fn().mockResolvedValue(undefined); + const bridge = { + availableCommands: [], + on: mockBridgeOn, + off: mockBridgeOff, + newSession: mockBridgeNewSession, + loadSession: mockBridgeLoadSession, + prompt: mockBridgePrompt, + cancelSession: mockBridgeCancelSession, + deleteSessionData, + }; + const facade = createDaemonChannelBridgeFacade(bridge, { + exposeShellCommand: false, + }); + + await facade.deleteSessionData?.('classifier-session'); + + expect(deleteSessionData).toHaveBeenCalledWith('classifier-session'); + }); + it('omits permission responses when absent on bridge', () => { const bridge = { availableCommands: [], @@ -667,6 +694,7 @@ describe('createDaemonChannelBridgeFacade', () => { expect('respondToPermission' in facade).toBe(false); expect('discardSession' in facade).toBe(false); + expect('deleteSessionData' in facade).toBe(false); }); it('omits listSessions when absent on bridge', () => { @@ -716,6 +744,94 @@ describe('createDaemonChannelBridgeFacade', () => { }); describe('runChannelDaemonWorker', () => { + it('wires permanent classifier-session deletion to the worker workspace', async () => { + const sdk = createSdk(); + const handle = await runChannelDaemonWorker({ + daemonUrl: 'http://127.0.0.1:4170', + workspace: '/workspace', + selection: { mode: 'names', names: ['telegram'] }, + loadDaemonSdk: async () => sdk, + }); + const options = mockDaemonChannelBridge.mock.calls.at(-1)?.[0] as { + deleteSessionData?: (sessionId: string) => Promise; + }; + + await options.deleteSessionData?.('classifier-session'); + + expect(sdk.client.workspaceByCwd).toHaveBeenCalledWith('/workspace'); + expect(sdk.deleteSessionsData).toHaveBeenCalledWith(['classifier-session']); + await handle.close(); + }); + + it('treats an already-deleted classifier session as deletion success', async () => { + const sdk = createSdk(); + sdk.deleteSessionsData.mockResolvedValue({ + removed: [], + notFound: ['classifier-session'], + errors: [], + }); + const handle = await runChannelDaemonWorker({ + daemonUrl: 'http://127.0.0.1:4170', + workspace: '/workspace', + selection: { mode: 'names', names: ['telegram'] }, + loadDaemonSdk: async () => sdk, + }); + const options = mockDaemonChannelBridge.mock.calls.at(-1)?.[0] as { + deleteSessionData?: (sessionId: string) => Promise; + }; + + await expect( + options.deleteSessionData?.('classifier-session'), + ).resolves.toBeUndefined(); + await handle.close(); + }); + + it('propagates per-session daemon deletion errors as a rejection', async () => { + const sdk = createSdk(); + sdk.deleteSessionsData.mockResolvedValue({ + removed: [], + notFound: [], + errors: [{ sessionId: 'classifier-session', error: 'storage locked' }], + }); + const handle = await runChannelDaemonWorker({ + daemonUrl: 'http://127.0.0.1:4170', + workspace: '/workspace', + selection: { mode: 'names', names: ['telegram'] }, + loadDaemonSdk: async () => sdk, + }); + const options = mockDaemonChannelBridge.mock.calls.at(-1)?.[0] as { + deleteSessionData?: (sessionId: string) => Promise; + }; + + await expect( + options.deleteSessionData?.('classifier-session'), + ).rejects.toThrow('storage locked'); + await handle.close(); + }); + + it('rejects when the deletion result omits the session entirely', async () => { + const sdk = createSdk(); + sdk.deleteSessionsData.mockResolvedValue({ + removed: [], + notFound: [], + errors: [], + }); + const handle = await runChannelDaemonWorker({ + daemonUrl: 'http://127.0.0.1:4170', + workspace: '/workspace', + selection: { mode: 'names', names: ['telegram'] }, + loadDaemonSdk: async () => sdk, + }); + const options = mockDaemonChannelBridge.mock.calls.at(-1)?.[0] as { + deleteSessionData?: (sessionId: string) => Promise; + }; + + await expect( + options.deleteSessionData?.('classifier-session'), + ).rejects.toThrow('Session classifier-session was not deleted.'); + await handle.close(); + }); + it('forwards router discard through the daemon bridge facade', async () => { const sdk = createSdk(); const handle = await runChannelDaemonWorker({ @@ -755,6 +871,7 @@ describe('runChannelDaemonWorker', () => { const handle = await runChannelDaemonWorker({ daemonUrl: 'http://127.0.0.1:4170', daemonToken: 'secret-token', + promptAuthorization: 'worker-prompt-token', workspace: '/workspace', selection: { mode: 'names', names: ['telegram'] }, loadDaemonSdk: async () => sdk, @@ -775,6 +892,7 @@ describe('runChannelDaemonWorker', () => { expect.objectContaining({ cwd: '/workspace', modelServiceId: 'qwen-plus', + promptAuthorization: 'worker-prompt-token', }), ); const bridgeFacade = mockSessionRouter.mock.calls[0]![0] as { diff --git a/packages/cli/src/commands/channel/daemon-worker.ts b/packages/cli/src/commands/channel/daemon-worker.ts index 62495c2a427..d23a71cc4f8 100644 --- a/packages/cli/src/commands/channel/daemon-worker.ts +++ b/packages/cli/src/commands/channel/daemon-worker.ts @@ -115,6 +115,13 @@ interface DaemonCapabilitiesLike { interface DaemonClientLike { capabilities(): Promise; + workspaceByCwd?(cwd: string): { + deleteSessionsData(sessionIds: string[]): Promise<{ + removed: string[]; + notFound: string[]; + errors: Array<{ sessionId: string; error: string }>; + }>; + }; } interface DaemonSessionClientStaticLike { @@ -178,6 +185,7 @@ export interface RunChannelDaemonWorkerOptions { reportStartup?: (message: ChannelStartupReportMessage) => Promise; startupSignal?: AbortSignal; channelLoopMcpHost?: DaemonChannelLoopMcpHost; + promptAuthorization?: string; } export function createDaemonSessionFactory({ @@ -250,6 +258,10 @@ export function createDaemonChannelBridgeFacade( facade.discardSession = bridge.discardSession.bind(bridge); } + if (bridge.deleteSessionData) { + facade.deleteSessionData = bridge.deleteSessionData.bind(bridge); + } + if (bridge.getAvailableCommands) { facade.getAvailableCommands = bridge.getAvailableCommands.bind(bridge); } @@ -477,6 +489,25 @@ export async function runChannelDaemonWorker( DaemonSessionClient: sdk.DaemonSessionClient, clientId: `qwen-channel-worker:${process.pid}`, }), + ...(opts.promptAuthorization + ? { promptAuthorization: opts.promptAuthorization } + : {}), + deleteSessionData: async (sessionId) => { + const workspaceClient = client.workspaceByCwd?.(daemonWorkspace); + if (!workspaceClient) { + throw new Error('Daemon SDK does not support session data deletion.'); + } + const result = await workspaceClient.deleteSessionsData([sessionId]); + if ( + !result.removed.includes(sessionId) && + !result.notFound.includes(sessionId) + ) { + const detail = result.errors.find( + (entry) => entry.sessionId === sessionId, + )?.error; + throw new Error(detail ?? `Session ${sessionId} was not deleted.`); + } + }, ...(modelServiceId ? { modelServiceId } : {}), ...(opts.channelLoopMcpHost ? { channelLoopMcpHost: opts.channelLoopMcpHost } @@ -767,6 +798,7 @@ function scrubDaemonWorkerEnv(): void { function readDaemonWorkerEnv(): { daemonToken: string | undefined; daemonUrl: string; + promptAuthorization: string; workspace: string; } { const daemonToken = process.env[QWEN_DAEMON_TOKEN_ENV]; @@ -774,6 +806,7 @@ function readDaemonWorkerEnv(): { return { daemonToken, daemonUrl: readRequiredEnv(QWEN_DAEMON_URL_ENV), + promptAuthorization: readRequiredEnv(CHANNEL_DAEMON_WORKER_SENTINEL), workspace: readRequiredEnv(QWEN_DAEMON_WORKSPACE_ENV), }; } finally { @@ -902,7 +935,8 @@ export const daemonWorkerCommand: CommandModule = { try { assertInternalDaemonWorkerInvocation(); - const { daemonToken, daemonUrl, workspace } = readDaemonWorkerEnv(); + const { daemonToken, daemonUrl, promptAuthorization, workspace } = + readDaemonWorkerEnv(); // Mirror the ACP-child self-scrub: in dev mode the supervisor spawns // this worker with the daemon's loader-carrying base env (the harness // tsx loader must reach this .ts entry), but nothing the worker spawns @@ -931,6 +965,7 @@ export const daemonWorkerCommand: CommandModule = { const handle = await runChannelDaemonWorker({ daemonUrl, daemonToken, + promptAuthorization, workspace, selection, startupSignal: startupAbortController.signal, diff --git a/packages/cli/src/commands/channel/display-text-wire-key.test.ts b/packages/cli/src/commands/channel/display-text-wire-key.test.ts new file mode 100644 index 00000000000..41038fc0158 --- /dev/null +++ b/packages/cli/src/commands/channel/display-text-wire-key.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { CHANNEL_PROMPT_DISPLAY_TEXT_META_KEY } from '@qwen-code/channel-base'; +import { DAEMON_PROMPT_DISPLAY_TEXT_META_KEY } from '@qwen-code/acp-bridge/bridgeTypes'; + +// The channel bridges write the display projection under the channel-base key +// and the daemon-side Session reads it under the acp-bridge key; the packages +// have no dependency path between them, so pin the wire contract here where +// both packages are importable. +describe('channel prompt display text wire key', () => { + it('is identical across channel-base and acp-bridge', () => { + expect(CHANNEL_PROMPT_DISPLAY_TEXT_META_KEY).toBe( + DAEMON_PROMPT_DISPLAY_TEXT_META_KEY, + ); + }); +}); diff --git a/packages/cli/src/commands/channel/memory-intent-classifier.test.ts b/packages/cli/src/commands/channel/memory-intent-classifier.test.ts index ab00ab43a16..0115021a76d 100644 --- a/packages/cli/src/commands/channel/memory-intent-classifier.test.ts +++ b/packages/cli/src/commands/channel/memory-intent-classifier.test.ts @@ -56,11 +56,35 @@ describe('BridgeChannelMemoryIntentClassifier', () => { expect(bridge.prompt).toHaveBeenCalledWith( 'classifier-session', expect.stringContaining('"你记一下以后回复前说 1122"'), - {}, + { displayText: '' }, ); expect(bridge.cancelSession).toHaveBeenCalledWith('classifier-session'); }); + it('discards the internal classifier session when supported', async () => { + const bridge = bridgeWithResponse('{"intent":"none","confidence":0.9}'); + bridge.discardSession = vi.fn(); + const classifier = new BridgeChannelMemoryIntentClassifier(bridge, '/tmp'); + + await classifier.classifyChannelMemoryIntent('memory architecture'); + + expect(bridge.discardSession).toHaveBeenCalledWith('classifier-session'); + expect(bridge.cancelSession).not.toHaveBeenCalled(); + }); + + it('permanently deletes the internal classifier session when supported', async () => { + const bridge = bridgeWithResponse('{"intent":"none","confidence":0.9}'); + bridge.discardSession = vi.fn(); + bridge.deleteSessionData = vi.fn(); + const classifier = new BridgeChannelMemoryIntentClassifier(bridge, '/tmp'); + + await classifier.classifyChannelMemoryIntent('memory architecture'); + + expect(bridge.deleteSessionData).toHaveBeenCalledWith('classifier-session'); + expect(bridge.discardSession).not.toHaveBeenCalled(); + expect(bridge.cancelSession).not.toHaveBeenCalled(); + }); + it('canonicalizes plural facts and asks the model to split independent durable facts', async () => { const { bridge, classifier } = classifierFor( '{"intent":"remember","memories":["默认使用 staging","回复使用中文"],"confidence":0.93}', @@ -402,7 +426,7 @@ describe('BridgeChannelMemoryIntentClassifier', () => { confidence: 0.93, }); expect(stderrSpy).toHaveBeenCalledWith( - '[classifier] cancelSession failed: transport closed\n', + '[classifier] session cleanup failed: transport closed\n', ); stderrSpy.mockRestore(); }); diff --git a/packages/cli/src/commands/channel/memory-intent-classifier.ts b/packages/cli/src/commands/channel/memory-intent-classifier.ts index bb630b039ee..37010039110 100644 --- a/packages/cli/src/commands/channel/memory-intent-classifier.ts +++ b/packages/cli/src/commands/channel/memory-intent-classifier.ts @@ -223,7 +223,7 @@ export class BridgeChannelMemoryIntentClassifier const response = await bridge.prompt( sessionId, `${CLASSIFIER_PROMPT}${JSON.stringify(text)}${buildMemoryManifest(entries)}`, - {}, + { displayText: '' }, ); try { return normalizeClassifierResult(extractJsonObject(response), entries); @@ -234,11 +234,17 @@ export class BridgeChannelMemoryIntentClassifier } } finally { try { - await bridge.cancelSession(sessionId); + if (bridge.deleteSessionData) { + await bridge.deleteSessionData(sessionId); + } else if (bridge.discardSession) { + await bridge.discardSession(sessionId); + } else { + await bridge.cancelSession(sessionId); + } } catch (error) { // session cleanup must not mask a successful classification process.stderr.write( - `[classifier] cancelSession failed: ${sanitizeLogText( + `[classifier] session cleanup failed: ${sanitizeLogText( error instanceof Error ? error.message : String(error), 200, )}\n`, diff --git a/packages/cli/src/commands/channel/private-parent-capability-wire-key.test.ts b/packages/cli/src/commands/channel/private-parent-capability-wire-key.test.ts new file mode 100644 index 00000000000..395a37e610e --- /dev/null +++ b/packages/cli/src/commands/channel/private-parent-capability-wire-key.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { + ACP_PRIVATE_PARENT_CAPABILITY_ENV, + ACP_PRIVATE_PARENT_CAPABILITY_META_KEY, +} from '@qwen-code/channel-base'; +import { + PRIVATE_ACP_CAPABILITY_ENV, + PRIVATE_PARENT_CAPABILITY_META_KEY, +} from '@qwen-code/qwen-code-core'; + +// The standalone channel bridge performs the private-parent capability +// handshake under the channel-base constants and the ACP child validates it +// under the core constants; the packages have no dependency path between +// them, so pin the wire contract here where both packages are importable. +describe('private parent capability wire keys', () => { + it('are identical across channel-base and core', () => { + expect(ACP_PRIVATE_PARENT_CAPABILITY_META_KEY).toBe( + PRIVATE_PARENT_CAPABILITY_META_KEY, + ); + expect(ACP_PRIVATE_PARENT_CAPABILITY_ENV).toBe(PRIVATE_ACP_CAPABILITY_ENV); + }); +}); diff --git a/packages/cli/src/serve/channel-settings-store.test.ts b/packages/cli/src/serve/channel-settings-store.test.ts index f0888e08b37..32764641e17 100644 --- a/packages/cli/src/serve/channel-settings-store.test.ts +++ b/packages/cli/src/serve/channel-settings-store.test.ts @@ -189,6 +189,28 @@ describe('WorkspaceChannelSettingsStore', () => { ).toBe('$BOT_TOKEN'); }); + it('accepts chat-and-thread session scope', async () => { + const store = new WorkspaceChannelSettingsStore(workspace); + + await store.upsert('bot', { + expectedRevision: store.snapshot().revision, + config: { + type: 'management-validation-test', + clientId: 'client-id', + sessionScope: 'chat_thread', + }, + }); + + expect( + ( + readWorkspaceSettings()['channels'] as Record< + string, + Record + > + )['bot']?.['sessionScope'], + ).toBe('chat_thread'); + }); + it('replaces and clears secrets only through explicit operations', async () => { writeWorkspaceSettings(`{ "$version": 4, diff --git a/packages/cli/src/serve/channel-settings-store.ts b/packages/cli/src/serve/channel-settings-store.ts index a87a7075047..6d80ea1afe2 100644 --- a/packages/cli/src/serve/channel-settings-store.ts +++ b/packages/cli/src/serve/channel-settings-store.ts @@ -144,7 +144,7 @@ function assertSharedField(key: string, value: unknown): boolean { senderPolicy: new Set(['allowlist', 'pairing', 'open']), dmPolicy: new Set(['open', 'disabled']), groupPolicy: new Set(['disabled', 'allowlist', 'pairing', 'open']), - sessionScope: new Set(['user', 'thread', 'single']), + sessionScope: new Set(['user', 'thread', 'chat_thread', 'single']), dispatchMode: new Set(['steer', 'followup', 'collect']), blockStreaming: new Set(['on', 'off']), }; diff --git a/packages/cli/src/serve/channel-worker-prompt-authorization.ts b/packages/cli/src/serve/channel-worker-prompt-authorization.ts new file mode 100644 index 00000000000..c4eb8ba059f --- /dev/null +++ b/packages/cli/src/serve/channel-worker-prompt-authorization.ts @@ -0,0 +1,24 @@ +const workspacesByToken = new Map(); + +export const CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY = + 'qwen.daemon.channelPromptAuthorization'; + +export function registerChannelWorkerPromptAuthorization( + token: string, + workspaceCwd: string, +): void { + workspacesByToken.set(token, workspaceCwd); +} + +export function revokeChannelWorkerPromptAuthorization(token: string): void { + workspacesByToken.delete(token); +} + +export function isChannelWorkerPromptAuthorized( + token: unknown, + workspaceCwd: string, +): boolean { + return ( + typeof token === 'string' && workspacesByToken.get(token) === workspaceCwd + ); +} diff --git a/packages/cli/src/serve/channel-worker-supervisor.test.ts b/packages/cli/src/serve/channel-worker-supervisor.test.ts index 220576fd0d8..918660ed58d 100644 --- a/packages/cli/src/serve/channel-worker-supervisor.test.ts +++ b/packages/cli/src/serve/channel-worker-supervisor.test.ts @@ -6,6 +6,7 @@ import { createChannelWorkerSupervisor, type ChannelWorkerChild, } from './channel-worker-supervisor.js'; +import { isChannelWorkerPromptAuthorized } from './channel-worker-prompt-authorization.js'; import { CHANNEL_WORKER_HEARTBEAT_INTERVAL_MS } from './channel-worker-env.js'; import { MAX_CHANNEL_STARTUP_FAILURES } from './channel-worker-startup-ipc.js'; import { @@ -254,6 +255,13 @@ describe('createChannelWorkerSupervisor', () => { expect(env).toHaveProperty('TELEGRAM_BOT_TOKEN', 'telegram-secret'); expect(env).toHaveProperty('HTTPS_PROXY', 'http://proxy.example.com:8080'); expect(env['QWEN_CHANNEL_DAEMON_WORKER']).not.toBe('1'); + const promptAuthorization = env['QWEN_CHANNEL_DAEMON_WORKER']!; + expect( + isChannelWorkerPromptAuthorized(promptAuthorization, '/workspace'), + ).toBe(true); + expect(isChannelWorkerPromptAuthorized(promptAuthorization, '/other')).toBe( + false, + ); const argv = spawnWorker.mock.calls[0]![1]; expect(argv).not.toContain('secret-token'); expect(supervisor.snapshot()).toMatchObject({ @@ -263,6 +271,10 @@ describe('createChannelWorkerSupervisor', () => { channels: ['telegram', 'feishu'], requestedChannels: ['telegram', 'feishu'], }); + supervisor.killAllSync(); + expect( + isChannelWorkerPromptAuthorized(promptAuthorization, '/workspace'), + ).toBe(false); }); it('ignores non-ready IPC messages before the ready message', async () => { @@ -945,6 +957,61 @@ describe('createChannelWorkerSupervisor', () => { ); }); + it('revokes the worker prompt authorization when the worker exits naturally', async () => { + const child = new FakeChild(); + const spawnWorker = vi.fn( + (_execPath: string, _argv: string[], _options: unknown) => child, + ); + const supervisor = createChannelWorkerSupervisor({ + cliEntryPath: '/repo/dist/index.js', + daemonUrl: 'http://127.0.0.1:4170', + workspace: '/workspace', + selection: { mode: 'names', names: ['telegram'] }, + spawnWorker, + }); + + const started = supervisor.start(); + child.emit('message', { type: 'ready', channels: ['telegram'] }); + await started; + + const env = (spawnWorker.mock.calls[0]![2] as { env: NodeJS.ProcessEnv }) + .env; + const promptAuthorization = env['QWEN_CHANNEL_DAEMON_WORKER']!; + expect( + isChannelWorkerPromptAuthorized(promptAuthorization, '/workspace'), + ).toBe(true); + + child.emit('exit', 1, null); + + expect( + isChannelWorkerPromptAuthorized(promptAuthorization, '/workspace'), + ).toBe(false); + }); + + it('revokes the worker prompt authorization when spawn throws', async () => { + let capturedEnv: NodeJS.ProcessEnv | undefined; + const supervisor = createChannelWorkerSupervisor({ + cliEntryPath: '/repo/dist/index.js', + daemonUrl: 'http://127.0.0.1:4170', + workspace: '/workspace', + selection: { mode: 'names', names: ['telegram'] }, + spawnWorker: vi.fn( + (_execPath: string, _argv: string[], options: unknown) => { + capturedEnv = (options as { env: NodeJS.ProcessEnv }).env; + throw new Error('spawn ENOENT'); + }, + ), + }); + + await expect(supervisor.start()).rejects.toThrow('spawn ENOENT'); + + const promptAuthorization = capturedEnv?.['QWEN_CHANNEL_DAEMON_WORKER']; + expect(promptAuthorization).toBeDefined(); + expect( + isChannelWorkerPromptAuthorized(promptAuthorization!, '/workspace'), + ).toBe(false); + }); + it('restarts a ready worker after unexpected exit within budget', async () => { vi.useFakeTimers(); const firstChild = new FakeChild(false); diff --git a/packages/cli/src/serve/channel-worker-supervisor.ts b/packages/cli/src/serve/channel-worker-supervisor.ts index f1432cddf4f..1de48a5a3c5 100644 --- a/packages/cli/src/serve/channel-worker-supervisor.ts +++ b/packages/cli/src/serve/channel-worker-supervisor.ts @@ -54,6 +54,10 @@ import { type ChannelAdapterSnapshot, type ChannelStartupFailure, } from './channel-worker-startup-ipc.js'; +import { + registerChannelWorkerPromptAuthorization, + revokeChannelWorkerPromptAuthorization, +} from './channel-worker-prompt-authorization.js'; import { CHANNEL_LOOP_MCP_IPC_TIMEOUT_MS, createChannelLoopMcpRequest, @@ -492,6 +496,7 @@ export function createChannelWorkerSupervisor( ); } let child: ChannelWorkerChild | undefined; + let activePromptAuthorization: string | undefined; let snapshot: ChannelWorkerSnapshot = { enabled: true, state: 'disabled', @@ -787,6 +792,18 @@ export function createChannelWorkerSupervisor( ...(opts.daemonToken ? { daemonToken: opts.daemonToken } : {}), ...(opts.workerBaseEnv ? { baseEnv: opts.workerBaseEnv } : {}), }); + const promptAuthorization = env[CHANNEL_DAEMON_WORKER_SENTINEL]!; + registerChannelWorkerPromptAuthorization( + promptAuthorization, + opts.workspace, + ); + activePromptAuthorization = promptAuthorization; + const revokePromptAuthorization = () => { + revokeChannelWorkerPromptAuthorization(promptAuthorization); + if (activePromptAuthorization === promptAuthorization) { + activePromptAuthorization = undefined; + } + }; const redaction = workerLogRedactionOptions(opts.daemonToken, env); const requestedChannels = requestedChannelNames(opts.selection); const startedAt = new Date().toISOString(); @@ -832,6 +849,7 @@ export function createChannelWorkerSupervisor( stdio: ['ignore', 'pipe', 'pipe', 'ipc'], }); } catch (err) { + revokePromptAuthorization(); const message = err instanceof Error ? err.message : String(err); const error = sanitizeWorkerError(message, redaction); if (kind === 'initial') { @@ -1247,6 +1265,7 @@ export function createChannelWorkerSupervisor( } function settleExit(code: number | null, signal: NodeJS.Signals | null) { if (child !== startedChild) return; + revokePromptAuthorization(); exitObserved = true; cleanupLaunch(); const state = ready ? 'exited' : 'failed'; @@ -1436,6 +1455,10 @@ export function createChannelWorkerSupervisor( clearRestartTimer(); clearStaleHeartbeatTimer(); stopping = true; + if (activePromptAuthorization) { + revokeChannelWorkerPromptAuthorization(activePromptAuthorization); + activePromptAuthorization = undefined; + } child.kill('SIGKILL'); child = undefined; if (!preserveFailure) { diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 83d1ce9b671..7eaaba8f25b 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -31,6 +31,7 @@ import { type SessionArchiveState, } from '@qwen-code/qwen-code-core'; import type { SessionArtifactInput } from '@qwen-code/acp-bridge/sessionArtifacts'; +import { DAEMON_PROMPT_DISPLAY_TEXT_META_KEY } from '@qwen-code/acp-bridge/bridgeTypes'; import { parseSessionSource } from '@qwen-code/acp-bridge'; import { isReservedLiveSessionSource, @@ -127,6 +128,10 @@ import { runWithWorkspaceRuntimeStorage, } from '../workspace-runtime-storage.js'; import type { ChannelDeliveryAuthorizationStore } from '../channel-delivery-authorization.js'; +import { + CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY, + isChannelWorkerPromptAuthorized, +} from '../channel-worker-prompt-authorization.js'; import { createRequestedSessionIdAdmission, RequestedSessionIdAdmissionError, @@ -3352,6 +3357,33 @@ export function registerSessionRoutes( const forwardedBody = { ...body }; delete forwardedBody['deadlineMs']; delete forwardedBody['delivery']; + const forwardedMeta = + typeof forwardedBody['_meta'] === 'object' && + forwardedBody['_meta'] !== null && + !Array.isArray(forwardedBody['_meta']) + ? { ...(forwardedBody['_meta'] as Record) } + : undefined; + const promptAuthorization = + forwardedMeta?.[CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY]; + const promptDisplayText = + forwardedMeta?.[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY]; + if (forwardedMeta) { + delete forwardedMeta[CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY]; + delete forwardedMeta[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY]; + if (Object.keys(forwardedMeta).length > 0) { + forwardedBody['_meta'] = forwardedMeta; + } else { + delete forwardedBody['_meta']; + } + } + const trustedPromptDisplayText = + typeof promptDisplayText === 'string' && + isChannelWorkerPromptAuthorized( + promptAuthorization, + runtime.workspaceCwd, + ) + ? promptDisplayText + : undefined; const lastEventId = ownerBridge.getSessionLastEventId(sessionId); // Epoch token paired with the cursor above: a client that seeds its @@ -3398,6 +3430,9 @@ export function registerSessionRoutes( ...(effectiveDeadlineMs !== undefined ? { deadlineMs: effectiveDeadlineMs } : {}), + ...(trustedPromptDisplayText !== undefined + ? { promptDisplayText: trustedPromptDisplayText } + : {}), ...(delivery !== undefined ? { channelDelivery: { diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 44288471a9e..e845e773045 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -39,6 +39,12 @@ import { } from './channel-worker-manager.js'; import { runQwenServe, type RunHandle } from './run-qwen-serve.js'; import { ChannelDeliveryAuthorizationStore } from './channel-delivery-authorization.js'; +import { + CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY, + registerChannelWorkerPromptAuthorization, + revokeChannelWorkerPromptAuthorization, +} from './channel-worker-prompt-authorization.js'; +import { CHANNEL_PROMPT_DISPLAY_TEXT_META_KEY } from '@qwen-code/channel-base'; import { resolveWebShellDir, isDocumentNavigation, @@ -12460,6 +12466,55 @@ describe('createServeApp', () => { expect(bridge.promptCalls[0]?.context?.promptId).toBe(res.body.promptId); }); + it('accepts channel display text only from the workspace worker', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const workspace = realpathSync(process.cwd()); + const token = 'channel-worker-prompt-token'; + registerChannelWorkerPromptAuthorization(token, workspace); + try { + const forged = await request(app) + .post('/session/session-A/prompt') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ + prompt: [{ type: 'text', text: 'model text' }], + _meta: { + [CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY]: 'forged', + [CHANNEL_PROMPT_DISPLAY_TEXT_META_KEY]: 'forged display', + }, + }); + const trusted = await request(app) + .post('/session/session-A/prompt') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ + prompt: [{ type: 'text', text: 'model text' }], + _meta: { + [CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY]: token, + [CHANNEL_PROMPT_DISPLAY_TEXT_META_KEY]: 'visible text', + }, + }); + + expect(forged.status).toBe(202); + expect(trusted.status).toBe(202); + expect( + bridge.promptCalls[0]?.context?.promptDisplayText, + ).toBeUndefined(); + expect(bridge.promptCalls[1]?.context?.promptDisplayText).toBe( + 'visible text', + ); + for (const call of bridge.promptCalls) { + expect(call.req._meta ?? {}).not.toHaveProperty( + CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY, + ); + expect(call.req._meta ?? {}).not.toHaveProperty( + CHANNEL_PROMPT_DISPLAY_TEXT_META_KEY, + ); + } + } finally { + revokeChannelWorkerPromptAuthorization(token); + } + }); + it('validates delivery and forwards it only through trusted prompt context', async () => { const bridge = fakeBridge(); const channelDeliveryAuthorizations = diff --git a/packages/cli/src/ui/commands/renameCommand.test.ts b/packages/cli/src/ui/commands/renameCommand.test.ts index 5b2699032ae..91a6a26291f 100644 --- a/packages/cli/src/ui/commands/renameCommand.test.ts +++ b/packages/cli/src/ui/commands/renameCommand.test.ts @@ -121,6 +121,30 @@ describe('renameCommand', () => { expect(tryGenerateSessionTitleMock).toHaveBeenCalledOnce(); }); + it('passes channel display projections to automatic title generation', async () => { + tryGenerateSessionTitleMock.mockResolvedValue({ + ok: false, + reason: 'empty_history', + }); + const displayTexts = ['你好', '/rename --auto']; + const mockConfig = { + getChatRecordingService: vi.fn().mockReturnValue({ + getUserDisplayTextsForTitle: vi.fn().mockReturnValue(displayTexts), + }), + }; + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + await renameCommand.action!(mockContext, '--auto'); + + expect(tryGenerateSessionTitleMock).toHaveBeenCalledWith( + mockConfig, + expect.any(AbortSignal), + ['你好'], + ); + }); + it('should return error when only whitespace is provided and auto-generate fails', async () => { tryGenerateSessionTitleMock.mockResolvedValue({ ok: false, diff --git a/packages/cli/src/ui/commands/renameCommand.ts b/packages/cli/src/ui/commands/renameCommand.ts index c26014920bc..eb7bb841f79 100644 --- a/packages/cli/src/ui/commands/renameCommand.ts +++ b/packages/cli/src/ui/commands/renameCommand.ts @@ -180,9 +180,19 @@ export const renameCommand: SlashCommand = { // future regressions don't leak an interval timer). let outcome: Awaited>; try { + const userDisplayTexts = + config.getChatRecordingService()?.getUserDisplayTextsForTitle?.() ?? + []; + const lastDisplayText = userDisplayTexts.at(-1)?.trim(); + const titleDisplayTexts = lastDisplayText?.match( + /^\/(?:rename|tag)(?:\s+--auto)?$/i, + ) + ? userDisplayTexts.slice(0, -1) + : userDisplayTexts; outcome = await tryGenerateSessionTitle( config, context.abortSignal ?? new AbortController().signal, + titleDisplayTexts, ); } finally { clearInterval(timer); diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts index c8966f5cc15..ed1c7297547 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts @@ -394,6 +394,15 @@ describe('resumeHistoryUtils', () => { expect(items).toEqual([{ id: 1_001, type: 'user', text: 'my prompt' }]); }); + it('does not fall back to hidden text when displayText is empty', () => { + const items = buildUserItems({ + type: 'user', + message: { parts: [{ text: 'internal channel instructions' }] }, + systemPayload: { displayText: '', hookContext: '' }, + }); + expect(items).toEqual([]); + }); + it('prefers displayText over the tag-strip fallback', () => { // Fixture where the two branches disagree: without displayText the // tag-strip path would expose the middle "expanded extra" part. diff --git a/packages/core/src/services/chatRecordingService.autoTitle.test.ts b/packages/core/src/services/chatRecordingService.autoTitle.test.ts index 08c51889b08..d90931cbba8 100644 --- a/packages/core/src/services/chatRecordingService.autoTitle.test.ts +++ b/packages/core/src/services/chatRecordingService.autoTitle.test.ts @@ -373,6 +373,111 @@ describe('ChatRecordingService - auto-title trigger', () => { ); }); + it('passes the latest user display projection to auto-title generation', async () => { + mockOk('Answer greeting'); + chatRecordingService.recordUserMessage( + [{ text: 'hidden channel instructions' }], + undefined, + { displayText: '你好', hookContext: '' }, + ); + + chatRecordingService.recordAssistantTurn({ + model: 'qwen-plus', + message: [{ text: 'reply' }], + }); + await flushMicrotasks(); + + expect(tryGenerateSessionTitleMock).toHaveBeenCalledWith( + mockConfig, + expect.any(AbortSignal), + ['你好'], + ); + }); + + it('restores channel display projections for automatic rename and retries', async () => { + const messages: ChatRecord[] = [ + { + uuid: 'user-1', + parentUuid: null, + sessionId: 'test-session-id', + timestamp: '2026-01-01T00:00:00.000Z', + type: 'user', + provenance: 'real_user', + cwd: '/test/project/root', + version: '1.0.0', + message: { role: 'user', parts: [{ text: 'hidden first prompt' }] }, + systemPayload: { displayText: '你好', hookContext: '' }, + }, + { + uuid: 'assistant-1', + parentUuid: 'user-1', + sessionId: 'test-session-id', + timestamp: '2026-01-01T00:00:01.000Z', + type: 'assistant', + provenance: 'assistant_output', + cwd: '/test/project/root', + version: '1.0.0', + message: { role: 'model', parts: [{ text: 'First reply' }] }, + }, + { + uuid: 'user-2', + parentUuid: 'assistant-1', + sessionId: 'test-session-id', + timestamp: '2026-01-01T00:00:02.000Z', + type: 'user', + provenance: 'real_user', + cwd: '/test/project/root', + version: '1.0.0', + message: { role: 'user', parts: [{ text: 'hidden second prompt' }] }, + systemPayload: { displayText: '再见', hookContext: '' }, + }, + ]; + const resumedConfig = { + ...mockConfig, + getResumedSessionData: vi.fn().mockReturnValue({ + conversation: { messages }, + lastCompletedUuid: 'user-2', + }), + } as unknown as Config; + const service = activateRecording( + new ChatRecordingService(resumedConfig, undefined, true), + resumedConfig, + ); + + expect(service.getUserDisplayTextsForTitle()).toEqual(['你好', '再见']); + + mockOk('Answer greetings'); + service.recordAssistantTurn({ + model: 'qwen-plus', + message: [{ text: 'reply' }], + }); + await flushMicrotasks(); + + expect(tryGenerateSessionTitleMock).toHaveBeenCalledWith( + resumedConfig, + expect.any(AbortSignal), + ['你好', '再见'], + ); + }); + + it('retains only display projections relevant to recent title history', () => { + for (let index = 0; index < 21; index++) { + chatRecordingService.recordUserMessage( + [{ text: `hidden ${index}` }], + undefined, + { + displayText: `visible ${index}`, + hookContext: '', + }, + ); + } + + expect(chatRecordingService.getUserDisplayTextsForTitle()).toHaveLength(20); + expect(chatRecordingService.getUserDisplayTextsForTitle()[0]).toBe( + 'visible 1', + ); + }); + it('does not trigger in headless CLI mode (non-interactive, non-ACP)', async () => { vi.mocked(mockConfig.isInteractive).mockReturnValue(false); vi.mocked(mockConfig.getExperimentalZedIntegration).mockReturnValue(false); diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts index 0d03f83e5c3..26f8d7d63ec 100644 --- a/packages/core/src/services/chatRecordingService.test.ts +++ b/packages/core/src/services/chatRecordingService.test.ts @@ -647,6 +647,70 @@ describe('ChatRecordingService', () => { }); describe('rewindRecording', () => { + it('drops display projections from rewound user turns', async () => { + chatRecordingService.recordUserMessage( + [{ text: 'hidden A' }], + undefined, + { + displayText: 'A', + hookContext: '', + }, + ); + chatRecordingService.recordUserMessage( + [{ text: 'hidden B' }], + undefined, + { + displayText: 'B', + hookContext: '', + }, + ); + + chatRecordingService.rewindRecording(1, { truncatedCount: 1 }); + chatRecordingService.recordUserMessage( + [{ text: 'hidden C' }], + undefined, + { + displayText: 'C', + hookContext: '', + }, + ); + + expect(chatRecordingService.getUserDisplayTextsForTitle()).toEqual([ + 'A', + 'C', + ]); + await chatRecordingService.flush(); + vi.mocked(jsonl.writeLine).mockClear(); + }); + + it('compensates the rewind splice for the display-text cap window', async () => { + // 25 turns, but the projection buffer retains only the last 20, so the + // rewind splice must offset by the 5 turns that fell out of the window. + for (let index = 0; index < 25; index += 1) { + chatRecordingService.recordUserMessage( + [{ text: `hidden ${index}` }], + undefined, + { + displayText: `visible ${index}`, + hookContext: '', + }, + ); + } + expect(chatRecordingService.getUserDisplayTextsForTitle()).toHaveLength( + 20, + ); + + // Rewind to turn 22 keeps turns 0..21; the retained window covers turns + // 5..24, so projections for turns 5..21 (entries 0..16) must survive. + chatRecordingService.rewindRecording(22, { truncatedCount: 3 }); + + expect(chatRecordingService.getUserDisplayTextsForTitle()).toEqual( + Array.from({ length: 17 }, (_, index) => `visible ${index + 5}`), + ); + await chatRecordingService.flush(); + vi.mocked(jsonl.writeLine).mockClear(); + }); + it('preserves a resumed user turn parent when rebuilding rewind boundaries', async () => { vi.mocked(mockConfig.getResumedSessionData).mockReturnValue({ lastCompletedUuid: 'assistant-1', diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 24bb49550f8..5a7ae16efb1 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -57,6 +57,7 @@ const debugLogger = createDebugLogger('CHAT_RECORDING'); * retrying across turns. */ const AUTO_TITLE_ATTEMPT_CAP = 3; +const MAX_TITLE_USER_DISPLAY_TEXTS = 20; const SESSION_FILE_DIFF_AGGREGATE_CHAR_LIMIT = 100_000; const SESSION_FILE_DIFF_CHAR_LIMIT = 50_000; const SESSION_FILE_CONTENT_CHAR_LIMIT = 16_000; @@ -643,6 +644,7 @@ export class ChatRecordingService { /** Immutable creator attribution once recorded. */ private currentSourceType: string | undefined; private currentSourceId: string | undefined; + private readonly userDisplayTextsForTitle: Array = []; /** * How many auto-title attempts have been made this process. * @@ -809,9 +811,16 @@ export class ChatRecordingService { this.currentParentSessionId = undefined; this.currentSourceType = undefined; this.currentSourceId = undefined; + this.userDisplayTextsForTitle.length = 0; if (!sessionData) return; this.rebuildTurnBoundaries(sessionData.conversation.messages); for (const record of sessionData.conversation.messages) { + if (record.type === 'user' && record.subtype === undefined) { + this.trackUserDisplayTextForTitle( + (record.systemPayload as UserPromptRecordPayload | undefined) + ?.displayText, + ); + } if (record.type !== 'system') continue; if (record.subtype === 'custom_title') { const payload = record.systemPayload as @@ -840,6 +849,13 @@ export class ChatRecordingService { } } + private trackUserDisplayTextForTitle(displayText: string | undefined): void { + this.userDisplayTextsForTitle.push(displayText); + if (this.userDisplayTextsForTitle.length > MAX_TITLE_USER_DISPLAY_TEXTS) { + this.userDisplayTextsForTitle.shift(); + } + } + activate( lease: SessionWriterLease, sessionData?: { @@ -1293,6 +1309,7 @@ export class ChatRecordingService { promptPayload?: UserPromptRecordPayload, ): void { try { + this.trackUserDisplayTextForTitle(promptPayload?.displayText); this.turnParentUuids.push(this.lastRecordUuid); const record: ChatRecord = { ...this.createBaseRecord('user'), @@ -1306,6 +1323,10 @@ export class ChatRecordingService { } } + getUserDisplayTextsForTitle(): ReadonlyArray { + return this.userDisplayTextsForTitle; + } + recordGoalRuntimeMessage( message: PartListUnion, goalContext: GoalTurnPermit, @@ -1565,6 +1586,7 @@ export class ChatRecordingService { const outcome = await tryGenerateSessionTitle( this.config, controller.signal, + this.userDisplayTextsForTitle, ); if (!outcome.ok) return; if (controller.signal.aborted) return; @@ -1748,6 +1770,13 @@ export class ChatRecordingService { try { // Re-root: point back to the record just before the target user turn. this.lastRecordUuid = this.turnParentUuids[targetTurnIndex] ?? null; + const projectionStart = Math.max( + 0, + this.turnParentUuids.length - this.userDisplayTextsForTitle.length, + ); + this.userDisplayTextsForTitle.splice( + Math.max(0, targetTurnIndex - projectionStart), + ); // Trim future boundaries — they no longer exist in the active branch. this.turnParentUuids = this.turnParentUuids.slice(0, targetTurnIndex); // The previous attribution snapshot now sits on the abandoned diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index 403c30110fd..4f8cc5144fc 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -465,6 +465,114 @@ describe('SessionService', () => { expect(result.items[0].gitBranch).toBe('main'); }); + it('should use recorded display text for the session list prompt', async () => { + readdirSyncSpy.mockReturnValue([ + `${sessionIdA}.jsonl`, + ] as unknown as Array>); + statSyncSpy.mockReturnValue({ + mtimeMs: Date.now(), + isFile: () => true, + } as fs.Stats); + vi.mocked(jsonl.readLines).mockResolvedValue([ + { + ...recordA1, + message: { + role: 'user', + parts: [{ text: 'internal channel instructions\n\nhello' }], + }, + systemPayload: { displayText: 'hello', hookContext: '' }, + }, + ]); + + const result = await sessionService.listSessions(); + + expect(result.items[0].prompt).toBe('hello'); + }); + + it('should keep an intentionally empty display prompt empty', async () => { + readdirSyncSpy.mockReturnValue([ + `${sessionIdA}.jsonl`, + ] as unknown as Array>); + statSyncSpy.mockReturnValue({ + mtimeMs: Date.now(), + isFile: () => true, + } as fs.Stats); + vi.mocked(jsonl.readLines).mockResolvedValue([ + { + ...recordA1, + message: { + role: 'user', + parts: [{ text: 'internal channel instructions' }], + }, + systemPayload: { displayText: '', hookContext: '' }, + }, + ]); + + const result = await sessionService.listSessions(); + + expect(result.items[0].prompt).toBe(''); + }); + + it('should use a later prompt after an empty display prompt', async () => { + readdirSyncSpy.mockReturnValue([ + `${sessionIdA}.jsonl`, + ] as unknown as Array>); + statSyncSpy.mockReturnValue({ + mtimeMs: Date.now(), + isFile: () => true, + } as fs.Stats); + vi.mocked(jsonl.readLines).mockResolvedValue([ + { + ...recordA1, + message: { + role: 'user', + parts: [{ text: 'internal channel instructions' }], + }, + systemPayload: { displayText: '', hookContext: '' }, + }, + { + ...recordA1, + uuid: 'later-user', + message: { role: 'user', parts: [{ text: 'later prompt' }] }, + }, + ]); + + const result = await sessionService.listSessions(); + + expect(result.items[0].prompt).toBe('later prompt'); + }); + + it('should skip internal user-subtype records after an empty projection', async () => { + readdirSyncSpy.mockReturnValue([ + `${sessionIdA}.jsonl`, + ] as unknown as Array>); + statSyncSpy.mockReturnValue({ + mtimeMs: Date.now(), + isFile: () => true, + } as fs.Stats); + vi.mocked(jsonl.readLines).mockResolvedValue([ + { + ...recordA1, + systemPayload: { displayText: '', hookContext: '' }, + }, + { + ...recordA1, + uuid: 'cron', + subtype: 'cron', + message: { role: 'user', parts: [{ text: 'internal cron prompt' }] }, + }, + { + ...recordA1, + uuid: 'later-user', + message: { role: 'user', parts: [{ text: 'later prompt' }] }, + }, + ]); + + const result = await sessionService.listSessions(); + + expect(result.items[0].prompt).toBe('later prompt'); + }); + it('should NOT populate messageCount during listing', async () => { // Listing must avoid the full-file readline that counting requires // — message counts are now lazy and provided by @@ -509,6 +617,28 @@ describe('SessionService', () => { expect(result.items[0].prompt.endsWith('...')).toBe(true); }); + it('should truncate long prompts on code-point boundaries', async () => { + const longPrompt = '😀'.repeat(300); + readdirSyncSpy.mockReturnValue([ + `${sessionIdA}.jsonl`, + ] as unknown as Array>); + statSyncSpy.mockReturnValue({ + mtimeMs: Date.now(), + isFile: () => true, + } as fs.Stats); + vi.mocked(jsonl.readLines).mockResolvedValue([ + { + ...recordA1, + message: { role: 'user', parts: [{ text: longPrompt }] }, + }, + ]); + + const result = await sessionService.listSessions(); + + expect(Array.from(result.items[0].prompt)).toHaveLength(203); + expect(result.items[0].prompt).toBe(`${'😀'.repeat(200)}...`); + }); + it('should paginate with size parameter', async () => { const now = Date.now(); diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index 5c37e50c880..0e0cd0e8cba 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -21,6 +21,7 @@ import type { FileHistorySnapshotRecordPayload, TitleSource, UiTelemetryRecordPayload, + UserPromptRecordPayload, } from './chatRecordingService.js'; import type { FileHistorySnapshot } from './fileHistoryService.js'; import { @@ -863,8 +864,7 @@ export class SessionService { if ('text' in part) { const textPart = part as { text: string }; const text = textPart.text; - // Truncate long prompts for display - return text.length > 200 ? `${text.slice(0, 200)}...` : text; + return this.truncatePromptForDisplay(text); } } return ''; @@ -876,13 +876,36 @@ export class SessionService { */ private extractFirstPromptFromRecords(records: ChatRecord[]): string { for (const record of records) { - if (record.type !== 'user') continue; + if (record.type !== 'user' || record.subtype !== undefined) continue; + const payload = record.systemPayload as + | UserPromptRecordPayload + | undefined; + if (payload?.displayText !== undefined) { + const displayText = payload.displayText; + if (displayText) { + return this.truncatePromptForDisplay(displayText); + } + continue; + } const prompt = this.extractPromptText(record.message); - if (prompt) return prompt; + if (prompt) { + return prompt; + } } return ''; } + private truncatePromptForDisplay(text: string): string { + const codePoints: string[] = []; + for (const codePoint of text) { + if (codePoints.length === 200) { + return `${codePoints.join('')}...`; + } + codePoints.push(codePoint); + } + return text; + } + /** * Counts unique user/assistant message UUIDs in a session file by * streaming the JSONL line-by-line. Each physical line is routed diff --git a/packages/core/src/services/sessionTitle.test.ts b/packages/core/src/services/sessionTitle.test.ts index e6edc6376e9..b6aae3095f4 100644 --- a/packages/core/src/services/sessionTitle.test.ts +++ b/packages/core/src/services/sessionTitle.test.ts @@ -157,6 +157,129 @@ describe('tryGenerateSessionTitle', () => { expect(callOpts.maxAttempts).toBe(1); }); + it('uses the user-facing projection instead of hidden prompt context', async () => { + const { config, generateJson } = makeConfig({ + fastModel: 'qwen-turbo', + history: [ + { role: 'user', parts: [{ text: 'hidden channel instructions' }] }, + { role: 'model', parts: [{ text: 'Hello!' }] }, + ], + generateJsonResult: { title: 'Answer greeting' }, + }); + + await tryGenerateSessionTitle(config, new AbortController().signal, [ + '你好', + ]); + + const call = generateJson.mock.calls[0][0] as { + contents: Content[]; + }; + const prompt = call.contents[0]?.parts?.[0]?.text; + expect(prompt).toContain('你好'); + expect(prompt).not.toContain('hidden channel instructions'); + }); + + it('projects every recorded user turn when retrying title generation', async () => { + const { config, generateJson } = makeConfig({ + fastModel: 'qwen-turbo', + history: [ + { role: 'user', parts: [{ text: 'hidden first instructions' }] }, + { role: 'model', parts: [{ text: 'First reply' }] }, + { role: 'user', parts: [{ text: 'hidden second instructions' }] }, + { role: 'model', parts: [{ text: 'Second reply' }] }, + ], + generateJsonResult: { title: 'Answer greetings' }, + }); + + await tryGenerateSessionTitle(config, new AbortController().signal, [ + '你好', + '再见', + ]); + + const call = generateJson.mock.calls[0][0] as { contents: Content[] }; + const prompt = call.contents[0]?.parts?.[0]?.text; + expect(prompt).toContain('你好'); + expect(prompt).toContain('再见'); + expect(prompt).not.toContain('hidden first instructions'); + expect(prompt).not.toContain('hidden second instructions'); + }); + + it('treats an all-empty display projection as intentionally empty', async () => { + const { config, generateJson } = makeConfig({ + fastModel: 'qwen-turbo', + history: [ + { role: 'user', parts: [{ text: 'hidden channel instructions' }] }, + { role: 'model', parts: [{ text: 'Hello!' }] }, + ], + generateJsonResult: { title: 'Should never be used' }, + }); + + const outcome = await tryGenerateSessionTitle( + config, + new AbortController().signal, + ['', ''], + ); + + // `''` entries mean "projection recorded, user-authored text empty" — + // stay in projection mode (`empty_history`) instead of falling back to + // the raw history, which carries the hidden model context. + expect(outcome).toEqual({ ok: false, reason: 'empty_history' }); + expect(generateJson).not.toHaveBeenCalled(); + }); + + it('does not align a display projection onto an intervening system turn', async () => { + const { config, generateJson } = makeConfig({ + fastModel: 'qwen-turbo', + history: [ + { role: 'user', parts: [{ text: 'hidden channel instructions' }] }, + { role: 'model', parts: [{ text: 'First reply' }] }, + { role: 'user', parts: [{ text: 'internal cron prompt' }] }, + { role: 'model', parts: [{ text: 'Cron reply' }] }, + ], + generateJsonResult: { title: 'Answer greeting' }, + }); + + await tryGenerateSessionTitle(config, new AbortController().signal, [ + 'visible channel message', + ]); + + const call = generateJson.mock.calls[0][0] as { contents: Content[] }; + const prompt = call.contents[0]?.parts?.[0]?.text; + expect(prompt).toContain('visible channel message'); + expect(prompt).not.toContain('hidden channel instructions'); + expect(prompt).not.toContain('internal cron prompt'); + }); + + it('omits unprojected older user turns from resumed channel history', async () => { + const { config, generateJson } = makeConfig({ + fastModel: 'qwen-turbo', + history: [ + { role: 'user', parts: [{ text: 'oldest hidden instructions' }] }, + { role: 'model', parts: [{ text: 'Oldest reply' }] }, + { role: 'user', parts: [{ text: 'older hidden instructions' }] }, + { role: 'model', parts: [{ text: 'Older reply' }] }, + { role: 'user', parts: [{ text: 'current hidden instructions' }] }, + { role: 'model', parts: [{ text: 'Current reply' }] }, + ], + generateJsonResult: { title: 'Answer greeting' }, + }); + + // Resumed sessions replay `undefined` for every user turn recorded before + // display-projection tracking existed; only the newest turn projects. + await tryGenerateSessionTitle(config, new AbortController().signal, [ + undefined, + undefined, + '当前消息', + ]); + + const call = generateJson.mock.calls[0][0] as { contents: Content[] }; + const prompt = call.contents[0]?.parts?.[0]?.text; + expect(prompt).toContain('当前消息'); + expect(prompt).not.toContain('undefined'); + expect(prompt).not.toContain('older hidden instructions'); + expect(prompt).not.toContain('current hidden instructions'); + }); + it('sanitizes residual markdown and trailing punctuation from the model result', async () => { const { config } = makeConfig({ fastModel: 'qwen-turbo', diff --git a/packages/core/src/services/sessionTitle.ts b/packages/core/src/services/sessionTitle.ts index a1e5807a594..1981f3dedd2 100644 --- a/packages/core/src/services/sessionTitle.ts +++ b/packages/core/src/services/sessionTitle.ts @@ -109,6 +109,7 @@ export type SessionTitleOutcome = export async function tryGenerateSessionTitle( config: Config, abortSignal: AbortSignal, + userDisplayTexts: ReadonlyArray = [], ): Promise { try { const model = config.getFastModel(); @@ -120,7 +121,14 @@ export async function tryGenerateSessionTitle( const fullHistory = geminiClient.getHistoryShallow(); if (fullHistory.length < 2) return { ok: false, reason: 'empty_history' }; - const dialog = filterToDialog(fullHistory); + const hasDisplayProjection = userDisplayTexts.some( + (displayText) => displayText !== undefined, + ); + const dialog = hasDisplayProjection + ? userDisplayTexts.flatMap((displayText): Content[] => + displayText ? [{ role: 'user', parts: [{ text: displayText }] }] : [], + ) + : filterToDialog(fullHistory); const recentHistory = takeRecentDialog(dialog, RECENT_MESSAGE_WINDOW); if (recentHistory.length === 0) { return { ok: false, reason: 'empty_history' }; diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx index aeee30855b1..a578adefd20 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx @@ -33,6 +33,22 @@ const DINGTALK: DaemonChannelTypeDescriptor = { kind: 'secret', required: true, }, + { + key: 'sessionScope', + // Descriptor labels intentionally differ from the i18n values so a + // missing i18n key surfaces the untranslated fallback instead of + // passing the copy assertions below. + label: 'Session scope (descriptor)', + kind: 'enum', + required: true, + default: 'user', + options: [ + { value: 'user', label: 'Descriptor per user' }, + { value: 'thread', label: 'Descriptor per thread' }, + { value: 'chat_thread', label: 'Descriptor per chat' }, + { value: 'single', label: 'Descriptor shared' }, + ], + }, { key: 'interactiveCards', label: 'Interactive Cards', @@ -217,6 +233,14 @@ describe('ChannelEditorDialog', () => { expect(clear).toBeDefined(); }); + it('shows the effective session scope in its own section', async () => { + await renderDialog({ instance: INSTANCE }); + + expect(document.body.textContent).toContain('Session'); + expect(document.body.textContent).toContain('Session scope'); + expect(document.body.textContent).toContain('Per user and chat'); + }); + it('submits a new instance with typed fields and the current revision', async () => { const onSave = vi.fn().mockResolvedValue(undefined); await renderDialog({ onSave }); @@ -246,6 +270,7 @@ describe('ChannelEditorDialog', () => { config: { type: 'dingtalk', clientId: 'ding-client-id', + sessionScope: 'user', senderPolicy: 'pairing', }, secrets: { @@ -390,6 +415,7 @@ describe('ChannelEditorDialog', () => { type: 'dingtalk', clientId: 'stored-id', senderPolicy: 'open', + sessionScope: 'user', interactiveCards: { enabled: true, statusCard: { enabled: true } }, }, secrets: { clientSecret: { operation: 'preserve' } }, diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx index a1c386fa022..10961af8c3f 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx @@ -93,6 +93,10 @@ const FIELD_LABEL_KEYS: Record> = { }, }; +const COMMON_FIELD_LABEL_KEYS: Record = { + sessionScope: 'channels.editor.field.sessionScope', +}; + export interface ChannelEditorDialogProps { open: boolean; descriptor: DaemonChannelTypeDescriptor; @@ -206,13 +210,17 @@ export function ChannelEditorDialog({ setSubmitError(undefined); }, [descriptor, instance, open]); + const fieldLabelKey = (field: DaemonChannelConfigFieldDescriptor) => + FIELD_LABEL_KEYS[descriptor.type]?.[field.key] ?? + COMMON_FIELD_LABEL_KEYS[field.key]; + const fieldLabel = (field: DaemonChannelConfigFieldDescriptor) => { - const key = FIELD_LABEL_KEYS[descriptor.type]?.[field.key]; + const key = fieldLabelKey(field); return key ? t(key) : field.label; }; const fieldDescription = (field: DaemonChannelConfigFieldDescriptor) => { - const labelKey = FIELD_LABEL_KEYS[descriptor.type]?.[field.key]; + const labelKey = fieldLabelKey(field); if (labelKey) { const descKey = `${labelKey}.description`; const translated = t(descKey); @@ -221,6 +229,19 @@ export function ChannelEditorDialog({ return field.description; }; + const fieldOptionLabel = ( + field: DaemonChannelConfigFieldDescriptor, + option: { value: string; label: string }, + ) => { + const labelKey = fieldLabelKey(field); + if (labelKey) { + const optionKey = `${labelKey}.option.${option.value}`; + const translated = t(optionKey); + if (translated !== optionKey) return translated; + } + return option.label; + }; + const validationMessage = ( field: DaemonChannelConfigFieldDescriptor | undefined, code: ChannelEditorValidationCode, @@ -443,7 +464,7 @@ export function ChannelEditorDialog({ {field.options?.map((option) => ( - {option.label} + {fieldOptionLabel(field, option)} ))} @@ -530,6 +551,13 @@ export function ChannelEditorDialog({ ); }; + const sessionScopeField = descriptor.fields.find( + (field) => field.key === 'sessionScope', + ); + const platformFields = descriptor.fields.filter( + (field) => field.key !== 'sessionScope' && field.kind !== 'object', + ); + return ( @@ -607,12 +635,23 @@ export function ChannelEditorDialog({ -
-

- {t('channels.editor.section.credentials')} -

- {descriptor.fields.map(renderField)} -
+ {platformFields.length > 0 ? ( +
+

+ {t('channels.editor.section.credentials')} +

+ {platformFields.map(renderField)} +
+ ) : null} + + {sessionScopeField ? ( +
+

+ {t('channels.editor.section.session')} +

+ {renderField(sessionScopeField)} +
+ ) : null} {(() => { const descriptorPolicy = hasDescriptorSenderPolicy(descriptor); diff --git a/packages/web-shell/client/components/channels/channel-editor-state.test.ts b/packages/web-shell/client/components/channels/channel-editor-state.test.ts index 76335ff3efa..1632e0b35d7 100644 --- a/packages/web-shell/client/components/channels/channel-editor-state.test.ts +++ b/packages/web-shell/client/components/channels/channel-editor-state.test.ts @@ -34,6 +34,19 @@ const DINGTALK: DaemonChannelTypeDescriptor = { required: true, envResolvable: true, }, + { + key: 'sessionScope', + label: 'Session scope', + kind: 'enum', + required: true, + default: 'user', + 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' }, + ], + }, { key: 'interactiveCards', label: 'Interactive Cards', @@ -80,6 +93,7 @@ describe('Channel editor state', () => { config: { type: 'dingtalk', clientId: 'ding-client-id', + sessionScope: 'user', senderPolicy: 'pairing', }, secrets: { @@ -117,6 +131,15 @@ describe('Channel editor state', () => { }); }); + it('shows the effective scope default for a legacy instance', () => { + const instance = configuredInstance(); + delete instance.config.sessionScope; + + const draft = createChannelEditorDraft(DINGTALK, instance); + + expect(draft.values.sessionScope).toBe('user'); + }); + it('supports explicitly clearing a stored secret', () => { const instance = configuredInstance(); const draft = createChannelEditorDraft(DINGTALK, instance); diff --git a/packages/web-shell/client/components/channels/channel-editor-state.ts b/packages/web-shell/client/components/channels/channel-editor-state.ts index 5ae74bf1fd6..a8b1e58ef8e 100644 --- a/packages/web-shell/client/components/channels/channel-editor-state.ts +++ b/packages/web-shell/client/components/channels/channel-editor-state.ts @@ -75,7 +75,9 @@ function initialFieldValue( } if (field.kind === 'enum') { if (typeof value === 'string' && value) return value; - return instance ? '' : (field.default ?? field.options?.[0]?.value ?? ''); + return instance && field.key !== 'sessionScope' + ? '' + : (field.default ?? field.options?.[0]?.value ?? ''); } return typeof value === 'string' ? value : ''; } diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.collapse-persist.test.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.collapse-persist.test.tsx index d8c9ee32f3d..3ec777b0c85 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.collapse-persist.test.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.collapse-persist.test.tsx @@ -85,6 +85,7 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ useActions: () => ({ renameSession: vi.fn() }), useWorkspace: () => workspace, useWorkspaceActions: () => workspaceActions, + useChannels: () => ({ data: undefined, catalog: [], channels: {} }), useSessions: (options?: { archiveState?: string; group?: string }) => { if (options?.archiveState === 'archived') return archived; if (options?.group === 'pinned') return pinned; diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx index d6749a74073..78c006c374a 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx @@ -12,6 +12,7 @@ import { } from 'react'; import { useActions, + useChannels, useConnection, useWorkspace, useWorkspaceActions, @@ -34,6 +35,8 @@ import { ChevronRightIcon, Columns2Icon, LayoutGridIcon, + ListTodoIcon, + MessageCircleIcon, EllipsisVerticalIcon, ArchiveIcon, ArchiveRestoreIcon, @@ -59,6 +62,7 @@ import { WebShellThemeId, type WebShellTheme } from '../../themeContext'; import { useI18n } from '../../i18n'; import { Input } from '../ui/input'; import { Button } from '../ui/button'; +import { Tabs, TabsList, TabsTrigger } from '../ui/tabs'; import { Field, FieldGroup, FieldLabel } from '../ui/field'; import { Select, @@ -80,6 +84,7 @@ import { DialogShell } from '../dialogs/DialogShell'; import { WorkspaceSection } from './WorkspaceSection'; import { SessionGroupSection } from './SessionGroupSection'; import { SessionDetailsSubmenu } from './SessionDetailsSubmenu'; +import { groupSessionsByChannelType } from './channelSessionGroups'; import { resolveSessionDetailsCollisionBoundary } from './sessionDetailsCollisionBoundary'; import { isPrimaryCollapsedSectionId, @@ -89,7 +94,6 @@ import { import { SESSION_LIST_PAGE_SIZE, SESSION_ORGANIZATION_FEATURE, - WEB_SHELL_SESSION_SOURCE_TYPE, } from '../../constants/sessions'; import styles from './WebShellSidebar.module.css'; import { @@ -120,6 +124,19 @@ const GROUP_MENU_MARGIN = 8; const CUSTOM_GROUP_COLOR_OPTION = '__custom__'; const DEFAULT_CUSTOM_GROUP_COLOR: DaemonSessionGroupHexColor = '#416ef5'; +type SidebarSessionSource = 'default' | 'channel'; + +function matchesSessionSource( + session: DaemonSessionSummary, + source: SidebarSessionSource | undefined, +): boolean { + if (source === 'channel') return session.sourceType === 'channel'; + if (source === 'default') { + return session.sourceType === undefined || session.sourceType === 'default'; + } + return true; +} + function getSessionIdentity( sessionId: string, workspaceCwd: string | undefined, @@ -578,6 +595,25 @@ export function WebShellSidebar({ const sourceMetadataEnabled = Boolean( connection.capabilities?.features?.includes('session_source_metadata'), ); + const [sessionSource, setSessionSource] = + useState('default'); + const selectedSessionSource = sourceMetadataEnabled + ? sessionSource + : undefined; + const channelGroupingEnabled = Boolean( + selectedSessionSource === 'channel' && + workspace.capabilities?.features.includes('channel_management'), + ); + const { + data: channelCatalogData, + catalog: channelTypeCatalog, + channels: channelInstances, + reload: reloadChannelCatalog, + error: channelCatalogError, + } = useChannels({ + autoLoad: channelGroupingEnabled, + enabled: channelGroupingEnabled, + }); const sessionArchiveEnabled = Boolean( connection.capabilities?.features?.includes('session_archive'), ); @@ -620,9 +656,7 @@ export function WebShellSidebar({ enabled: includePrimaryWorkspaceSessions, pageSize: SESSION_LIST_PAGE_SIZE, archiveState: 'active', - ...(sourceMetadataEnabled - ? { sourceType: WEB_SHELL_SESSION_SOURCE_TYPE } - : {}), + ...(selectedSessionSource ? { sourceType: selectedSessionSource } : {}), ...(organizationEnabled ? { view: 'organized' as const, group: 'all' } : {}), @@ -637,14 +671,29 @@ export function WebShellSidebar({ !organizationEnabled || !includePrimaryWorkspaceSessions || sessionsPage !== undefined; + // Which source the settled sessions page belongs to. Switching the source + // changes the catalog query key, whose entry starts without a page + // (undefined), so reconciliation must not run until a page fetched for the + // new source settles — otherwise the other source's sections would be + // consumed as the new source's initial catalog. + const lastSettledSessionsPageRef = useRef(sessionsPage); + const settledSessionsSourceRef = useRef(sessionSource); + useEffect(() => { + // An undefined page is the empty pre-settle snapshot, not a settled fetch. + if (sessionsPage === undefined) return; + if (lastSettledSessionsPageRef.current !== sessionsPage) { + lastSettledSessionsPageRef.current = sessionsPage; + settledSessionsSourceRef.current = sessionSource; + } + }, [sessionsPage, sessionSource]); + const loadPinnedSessions = + organizationEnabled && selectedSessionSource !== 'channel'; const { sessions: primaryPinnedSessions } = useWebShellSessions({ - autoLoad: organizationEnabled, - enabled: organizationEnabled && includePrimaryWorkspaceSessions, + autoLoad: loadPinnedSessions, + enabled: loadPinnedSessions && includePrimaryWorkspaceSessions, pageSize: SESSION_LIST_PAGE_SIZE, archiveState: 'active', - ...(sourceMetadataEnabled - ? { sourceType: WEB_SHELL_SESSION_SOURCE_TYPE } - : {}), + ...(selectedSessionSource ? { sourceType: selectedSessionSource } : {}), view: 'organized', group: 'pinned', }); @@ -666,9 +715,7 @@ export function WebShellSidebar({ includePrimaryWorkspaceSessions, pageSize: SESSION_LIST_PAGE_SIZE, archiveState: 'archived', - ...(sourceMetadataEnabled - ? { sourceType: WEB_SHELL_SESSION_SOURCE_TYPE } - : {}), + ...(selectedSessionSource ? { sourceType: selectedSessionSource } : {}), ...(organizationEnabled ? { view: 'organized' as const, group: 'all' } : {}), @@ -716,11 +763,16 @@ export function WebShellSidebar({ ), ); const knownSessionSectionIdsRef = useRef>(new Set()); - // Dedicated first-sync latch. Cleared only after both groups catalog and - // sessions list have settled (including empty responses). Do not infer this - // from knownSessionSectionIdsRef.size — seeding that set early would make the - // first real sync look mid-session and auto-collapse restored expansions. - const awaitingInitialSessionCatalogRef = useRef(true); + // Dedicated first-sync latch, keyed by session source: each source's first + // settled catalog only registers section ids. Without per-source latches the + // Tasks settle consumes the shared latch and the first Channels visit treats + // every platform section as brand-new, auto-collapsing and persisting them. + // Do not infer this from knownSessionSectionIdsRef.size — seeding that set + // early would make the first real sync look mid-session and auto-collapse + // restored expansions. + const awaitingInitialSessionCatalogBySourceRef = useRef< + Record + >({ default: true, channel: true }); const [groupsCatalogReady, setGroupsCatalogReady] = useState(!organizationEnabled); // organizationEnabled can flip true mid-session (capabilities can land after @@ -776,7 +828,10 @@ export function WebShellSidebar({ const sidebarRef = useRef(null); const groupMenuRef = useRef(null); const sessionMenuPointerDismissRef = useRef(false); - const previousRunningRef = useRef | null>(null); + const previousRunningBySourceRef = useRef< + Record | null> + >({ default: null, channel: null }); + const lastTrackedSessionSourceRef = useRef(sessionSource); const autoOpenedContextRef = useRef(null); const resizeTeardownRef = useRef<((updateState: boolean) => void) | null>( null, @@ -833,19 +888,22 @@ export function WebShellSidebar({ options: { pageSize: SESSION_LIST_PAGE_SIZE, archiveState: 'active', - ...(sourceMetadataEnabled - ? { sourceType: WEB_SHELL_SESSION_SOURCE_TYPE } + ...(selectedSessionSource + ? { sourceType: selectedSessionSource } : {}), view: 'organized', group: 'pinned', }, })), - [secondaryWorkspaceCwds, sourceMetadataEnabled], + [secondaryWorkspaceCwds, selectedSessionSource], ); const secondaryPinnedSnapshots = useSessionCatalogQueries( workspace.client, secondaryPinnedQueries, - { autoLoad: true, enabled: organizationEnabled }, + { + autoLoad: true, + enabled: organizationEnabled && selectedSessionSource !== 'channel', + }, ); const secondaryPinnedSessions = useMemo( () => @@ -866,15 +924,15 @@ export function WebShellSidebar({ options: { pageSize: SESSION_LIST_PAGE_SIZE, archiveState: 'archived', - ...(sourceMetadataEnabled - ? { sourceType: WEB_SHELL_SESSION_SOURCE_TYPE } + ...(selectedSessionSource + ? { sourceType: selectedSessionSource } : {}), ...(organizationEnabled ? { view: 'organized' as const, group: 'all' } : {}), }, })), - [organizationEnabled, secondaryWorkspaceCwds, sourceMetadataEnabled], + [organizationEnabled, secondaryWorkspaceCwds, selectedSessionSource], ); const secondaryArchivedSnapshots = useSessionCatalogQueries( workspace.client, @@ -929,6 +987,7 @@ export function WebShellSidebar({ ...(includePrimaryWorkspaceSessions ? primaryPinnedSessions : []), ...secondaryPinnedSessions, ]) { + if (!matchesSessionSource(session, selectedSessionSource)) continue; byId.set( getSessionIdentity( session.sessionId, @@ -942,6 +1001,7 @@ export function WebShellSidebar({ includePrimaryWorkspaceSessions, primaryWorkspaceCwd, primaryPinnedSessions, + selectedSessionSource, secondaryPinnedSessions, ]); const resolveSessionWorkspaceScope = useCallback( @@ -1161,6 +1221,7 @@ export function WebShellSidebar({ ...(includePrimaryWorkspaceSessions ? archivedSessions : []), ...secondaryArchivedSessions, ]) { + if (!matchesSessionSource(session, selectedSessionSource)) continue; byIdentity.set(getIdentityForSession(session), session); } return [...byIdentity.values()]; @@ -1168,6 +1229,7 @@ export function WebShellSidebar({ archivedSessions, getIdentityForSession, includePrimaryWorkspaceSessions, + selectedSessionSource, secondaryArchivedSessions, ]); const effectiveArchivedLoading = @@ -1393,8 +1455,8 @@ export function WebShellSidebar({ [sessions], ); const sessionPollInterval = - projectExpanded || hasRunningSession - ? hasRunningSession && !error + projectExpanded || hasRunningSession || selectedSessionSource === 'channel' + ? (hasRunningSession || selectedSessionSource === 'channel') && !error ? ACTIVE_SESSION_POLL_INTERVAL_MS : IDLE_SESSION_POLL_INTERVAL_MS : undefined; @@ -1403,16 +1465,53 @@ export function WebShellSidebar({ includePrimaryWorkspaceSessions ? catalogQuery : undefined, sessionPollInterval, ); + // Channel grouping rides the session poll cadence: instances added or + // removed while the channel source is active must reach the grouping logic + // without a source switch. + const channelCatalogPollInFlightRef = useRef(false); + useEffect(() => { + if (!channelGroupingEnabled) return; + // Back off on the channels hook's OWN failures too — a persistently + // failing channels endpoint must not be re-requested every 2s. + const pollInterval = + !error && !channelCatalogError + ? ACTIVE_SESSION_POLL_INTERVAL_MS + : IDLE_SESSION_POLL_INTERVAL_MS; + const intervalId = window.setInterval(() => { + if (document.hidden || channelCatalogPollInFlightRef.current) return; + channelCatalogPollInFlightRef.current = true; + void reloadChannelCatalog().finally(() => { + channelCatalogPollInFlightRef.current = false; + }); + }, pollInterval); + return () => window.clearInterval(intervalId); + }, [ + channelCatalogError, + channelGroupingEnabled, + error, + reloadChannelCatalog, + ]); useEffect(() => { + if (lastTrackedSessionSourceRef.current !== sessionSource) { + lastTrackedSessionSourceRef.current = sessionSource; + return; + } + if (loading || error) return; + const runningBySessionId = new Map( - sessions.map((session) => [ - getIdentityForSession(session), - Boolean(session.hasActivePrompt), - ]), + sessions + .filter((session) => + matchesSessionSource(session, selectedSessionSource), + ) + .map((session) => [ + getIdentityForSession(session), + Boolean(session.hasActivePrompt), + ]), ); - const previousRunningBySessionId = previousRunningRef.current; - previousRunningRef.current = runningBySessionId; + const previousRunningBySessionId = + previousRunningBySourceRef.current[sessionSource]; + previousRunningBySourceRef.current[sessionSource] = runningBySessionId; if (previousRunningBySessionId === null) return; setCompletedUnreadIds((current) => { @@ -1435,8 +1534,9 @@ export function WebShellSidebar({ for (const sessionIdentity of next) { if ( sessionIdentity === currentSessionIdentity || - !runningBySessionId.has(sessionIdentity) || - runningBySessionId.get(sessionIdentity) + (previousRunningBySessionId.has(sessionIdentity) && + (!runningBySessionId.has(sessionIdentity) || + runningBySessionId.get(sessionIdentity))) ) { next.delete(sessionIdentity); changed = true; @@ -1445,7 +1545,15 @@ export function WebShellSidebar({ return changed ? next : current; }); - }, [currentSessionIdentity, getIdentityForSession, sessions]); + }, [ + currentSessionIdentity, + error, + getIdentityForSession, + loading, + selectedSessionSource, + sessionSource, + sessions, + ]); const reconcileRemovedWorkspace = useCallback( async (removed: DaemonWorkspaceCapability) => { @@ -2555,7 +2663,13 @@ export function WebShellSidebar({ const filteredSessions = useMemo(() => { const query = searchQuery.trim().toLowerCase(); - const unpinnedSessions = sessions.filter((session) => !session.isPinned); + const sourceScopedSessions = sessions.filter((session) => + matchesSessionSource(session, selectedSessionSource), + ); + const unpinnedSessions = + selectedSessionSource === 'channel' + ? sourceScopedSessions + : sourceScopedSessions.filter((session) => !session.isPinned); const nextSessions = query ? unpinnedSessions.filter((session) => { const label = getSessionLabel(session).toLowerCase(); @@ -2579,7 +2693,28 @@ export function WebShellSidebar({ (createdTimeById.get(b.sessionId) ?? 0) - (createdTimeById.get(a.sessionId) ?? 0), ); - }, [organizationEnabled, searchQuery, sessions]); + }, [organizationEnabled, searchQuery, selectedSessionSource, sessions]); + + const channelCatalogLoaded = channelCatalogData !== undefined; + const channelSessionSections = useMemo( + () => + selectedSessionSource === 'channel' && channelCatalogLoaded + ? groupSessionsByChannelType( + filteredSessions, + channelTypeCatalog, + channelInstances, + t('sidebar.channelType.other'), + ) + : null, + [ + channelCatalogLoaded, + channelInstances, + channelTypeCatalog, + filteredSessions, + selectedSessionSource, + t, + ], + ); const sessionSections = useMemo(() => { if (!organizationEnabled) return []; @@ -2655,19 +2790,30 @@ export function WebShellSidebar({ }, [filteredSessions, groups, organizationEnabled, searchQuery, t]); useEffect(() => { - if (!organizationEnabled) return; - // Wait for both independent catalog sources. Flipping the latch on the - // first non-empty derived sections would treat later initial recent/color - // ids as brand-new and auto-collapse them; leaving the latch set when the - // first ready catalog is empty would leave the first real section expanded. - if (!groupsCatalogReady || !sessionsCatalogReady) return; - - const unseenIds = sessionSections + const activeSections = channelSessionSections ?? sessionSections; + if (selectedSessionSource === 'channel') { + if (!channelCatalogLoaded) return; + // The refetch for the new source retains the previous source's page + // until it settles; wait for a page fetched for the channel source. + if (settledSessionsSourceRef.current !== 'channel') return; + } else { + if (!organizationEnabled) return; + if (!groupsCatalogReady || !sessionsCatalogReady) return; + } + const unseenIds = activeSections .map((section) => section.id) .filter((id) => !knownSessionSectionIdsRef.current.has(id)); - const isInitialCatalog = awaitingInitialSessionCatalogRef.current; + const isInitialCatalog = + awaitingInitialSessionCatalogBySourceRef.current[sessionSource]; if (isInitialCatalog) { - awaitingInitialSessionCatalogRef.current = false; + // First-sync registration must reflect the full unfiltered catalog: + // sections hidden by an active search would never register and would + // later auto-collapse as mid-session additions. An empty first catalog + // keeps the latch so the first real sections still register as initial + // — channel sessions are externally driven and can arrive while the + // tab is open on an empty settle. + if (searchQuery.trim() || activeSections.length === 0) return; + awaitingInitialSessionCatalogBySourceRef.current[sessionSource] = false; for (const id of unseenIds) knownSessionSectionIdsRef.current.add(id); return; } @@ -2681,8 +2827,13 @@ export function WebShellSidebar({ }); }, [ groupsCatalogReady, + channelCatalogLoaded, + channelSessionSections, organizationEnabled, + searchQuery, + selectedSessionSource, sessionSections, + sessionSource, sessionsCatalogReady, ]); @@ -3424,12 +3575,15 @@ export function WebShellSidebar({ ); const body = useMemo(() => { - if (loading && sessions.length === 0) { + // Gate notices on the resource, not the filtered view: background + // refreshes set loading/error while retaining the settled page, so a + // filter-empty or empty-but-settled view must not flash or swap to retry. + if (loading && sessionsPage === undefined) { return (
{t('sidebar.loadingSessions')}
); } - if (error && sessions.length === 0) { + if (error && sessionsPage === undefined) { return ( - - {pinnedExpanded && ( -
- {pinnedSessions.map((session) => - renderSessionRow(session, { - readOnly: isActiveSessionReadOnly(session), - }), - )} -
- )} - + {!collapsed && sourceMetadataEnabled && ( + + setSessionSource(value as SidebarSessionSource) + } + > + + + + {t('sidebar.sessionSource.tasks')} + + + + {t('sidebar.sessionSource.channels')} + + + )} + {!collapsed && + selectedSessionSource !== 'channel' && + pinnedSessions.length > 0 && ( + <> +
+ +
+ {pinnedExpanded && ( +
+ {pinnedSessions.map((session) => + renderSessionRow(session, { + readOnly: isActiveSessionReadOnly(session), + }), + )} +
+ )} + + )} {!collapsed && liveWorkspaces.map((ws) => ( formatRelativeTime(iso, t)} autoExpandKey={ @@ -4261,7 +4463,8 @@ export function WebShellSidebar({ noSessionsLabel={t('sidebar.noSessions')} loadErrorLabel={t('sidebar.loadFailed')} organizationEnabled={organizationEnabled} - sourceMetadataEnabled={sourceMetadataEnabled} + sourceType={selectedSessionSource} + channelGroupingEnabled={channelGroupingEnabled} ungroupedLabel={t('sidebar.groupUngrouped')} onRenameGroup={ canOrganizeWorkspace(ws.cwd) @@ -4276,7 +4479,7 @@ export function WebShellSidebar({ renameGroupLabel={t('sidebar.groupRename')} deleteGroupLabel={t('sidebar.groupDelete')} groupActionsDisabled={groupBusy} - excludePinned + excludePinned={selectedSessionSource !== 'channel'} onOpenGitDiff={onOpenGitDiff} onOpenCommit={onOpenCommit} formatTime={(iso) => formatRelativeTime(iso, t)} diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx index 1ac43270ef4..50c2329a32f 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx @@ -17,6 +17,7 @@ const { active, archived, useSessions, + useChannels, listWorkspaceSessions, archiveSessionsData, unarchiveSessionsData, @@ -25,14 +26,16 @@ const { exportSession, exportArchivedSession, sessionActions, + channelState, invalidateSessionCatalog, renameSessionCatalog, refreshSessionCatalogQueries, + useSessionCatalogPollingSpy, } = vi.hoisted(() => { const makeSessions = () => ({ sessions: [] as DaemonSessionSummary[], loading: false, - error: null, + error: null as Error | null, reload: vi.fn().mockResolvedValue(undefined), deleteSession: vi.fn().mockResolvedValue(true), archiveSession: vi.fn().mockResolvedValue(true), @@ -62,14 +65,41 @@ const { const active = makeSessions(); const archived = makeSessions(); const useSessions = vi.fn( - (options?: { archiveState?: string; sourceType?: string }) => - options?.archiveState === 'archived' ? archived : active, + (options?: { + archiveState?: string; + sourceType?: string; + group?: string; + }) => (options?.archiveState === 'archived' ? archived : active), ); const exportArchivedSession = vi.fn(); const sessionActions = { renameSession: vi.fn() }; + const channelState = { + error: undefined as Error | undefined, + data: undefined as + | { + catalog: Array<{ + type: string; + displayName: string; + manageable: boolean; + fields: []; + }>; + snapshot: { revision: string; instances: Record }; + } + | undefined, + catalog: [] as Array<{ + type: string; + displayName: string; + manageable: boolean; + fields: []; + }>, + channels: {} as Record, + reload: vi.fn().mockResolvedValue(undefined), + }; + const useChannels = vi.fn(() => channelState); const invalidateSessionCatalog = vi.fn(); const renameSessionCatalog = vi.fn(); const refreshSessionCatalogQueries = vi.fn(); + const useSessionCatalogPollingSpy = vi.fn(); return { connection: { status: 'connected', @@ -97,6 +127,10 @@ const { workspaceByCwd: vi.fn(() => ({ listWorkspaceSessions, listSessionGroups: vi.fn().mockResolvedValue({ groups: [] }), + workspaceChannelTypes: vi.fn().mockResolvedValue([]), + workspaceChannels: vi + .fn() + .mockResolvedValue({ revision: '0', instances: {} }), archiveSessionsData, unarchiveSessionsData, exportArchivedSession, @@ -112,6 +146,7 @@ const { active, archived, useSessions, + useChannels, listWorkspaceSessions, archiveSessionsData, unarchiveSessionsData, @@ -120,9 +155,11 @@ const { exportSession, exportArchivedSession, sessionActions, + channelState, invalidateSessionCatalog, renameSessionCatalog, refreshSessionCatalogQueries, + useSessionCatalogPollingSpy, }; }); @@ -132,6 +169,7 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ useWorkspace: () => workspace, useWorkspaceActions: () => workspaceActions, useSessions, + useChannels, })); vi.mock('../../session-catalog/session-catalog-hooks', () => { @@ -151,9 +189,15 @@ vi.mock('../../session-catalog/session-catalog-hooks', () => { if (options?.enabled === false) { return { ...state, sessions: [], data: undefined, catalogQuery }; } + // A useSessions implementation may model an unsettled catalog page with + // an explicit `data` key (undefined until the fetch settles), matching + // the real store's empty snapshot on a query-key change. return { ...state, - data: state.sessions, + data: + 'data' in state + ? (state as { data?: DaemonSessionSummary[] }).data + : state.sessions, catalogQuery, }; }, @@ -172,7 +216,7 @@ vi.mock('../../session-catalog/session-catalog-hooks', () => { for (const listener of catalogListeners) listener(workspaceCwd); }, }), - useSessionCatalogPolling: () => undefined, + useSessionCatalogPolling: useSessionCatalogPollingSpy, useSessionCatalogQuery: ( client: typeof workspace.client, query: { workspaceCwd: string; options?: Record }, @@ -267,6 +311,9 @@ vi.mock('../../session-catalog/session-catalog-hooks', () => { const { I18nProvider } = await import('../../i18n'); const { WebShellSidebar } = await import('./WebShellSidebar'); +const { COLLAPSED_SESSION_SECTIONS_STORAGE_KEY } = await import( + './collapsedSessionSections' +); globalThis.IS_REACT_ACT_ENVIRONMENT = true; if (!globalThis.PointerEvent) { @@ -459,6 +506,98 @@ async function expandArchived(): Promise { }); } +async function switchSessionSource( + label: 'Tasks' | 'Channels', +): Promise { + const tab = Array.from( + container.querySelectorAll('[role="tab"]'), + ).find((button) => button.textContent?.trim() === label); + expect(tab).toBeDefined(); + await act(async () => { + tab!.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, button: 0 }), + ); + tab!.click(); + await Promise.resolve(); + }); + return tab!; +} + +function enableChannelOrganization(): void { + const channelCapabilities = { + ...capabilities, + features: [ + ...capabilities.features, + 'channel_management', + 'session_organization', + ], + }; + connection.capabilities = channelCapabilities; + workspace.capabilities = channelCapabilities; + workspaceActions.listSessionGroups.mockResolvedValue({ + groups: [], + colorOptions: [], + }); +} + +function setChannelCatalog(): void { + channelState.catalog = [ + { + type: 'dingtalk', + displayName: 'DingTalk', + manageable: true, + fields: [], + }, + { + type: 'feishu', + displayName: 'Feishu', + manageable: true, + fields: [], + }, + ]; + channelState.channels = { + 'ding-one': { + name: 'ding-one', + config: { type: 'dingtalk' }, + secrets: {}, + startsWithServe: false, + runtime: { state: 'connected' }, + }, + 'feishu-one': { + name: 'feishu-one', + config: { type: 'feishu' }, + secrets: {}, + startsWithServe: false, + runtime: { state: 'connected' }, + }, + }; + channelState.data = { + catalog: channelState.catalog, + snapshot: { revision: '1', instances: channelState.channels }, + }; +} + +async function settleGroupsCatalog(): Promise { + await act(async () => { + await workspaceActions.listSessionGroups.mock.results.at(-1)?.value; + await Promise.resolve(); + }); +} + +async function openSessionSearch(): Promise { + const searchButton = Array.from( + container.querySelectorAll('button'), + ).find((button) => button.getAttribute('aria-label') === 'Search sessions'); + expect(searchButton).toBeDefined(); + await act(async () => { + click(searchButton!); + await Promise.resolve(); + }); + const input = container.querySelector('input'); + expect(input).not.toBeNull(); + return input!; +} + function sessionAction(label: string): HTMLButtonElement | undefined { return Array.from( container.querySelectorAll( @@ -562,6 +701,7 @@ function dialogButton(label: string): HTMLButtonElement { } beforeEach(() => { + window.localStorage.clear(); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -603,6 +743,10 @@ beforeEach(() => { workspace.client.workspaceByCwd.mockImplementation(() => ({ listWorkspaceSessions, listSessionGroups: vi.fn().mockResolvedValue({ groups: [] }), + workspaceChannelTypes: vi.fn().mockResolvedValue([]), + workspaceChannels: vi + .fn() + .mockResolvedValue({ revision: '0', instances: {} }), archiveSessionsData, unarchiveSessionsData, deleteSessionsData, @@ -617,6 +761,7 @@ beforeEach(() => { invalidateSessionCatalog.mockReset(); renameSessionCatalog.mockReset(); refreshSessionCatalogQueries.mockReset(); + useSessionCatalogPollingSpy.mockReset(); active.reload.mockReset(); active.reload.mockResolvedValue(undefined); active.deleteSession.mockReset(); @@ -629,8 +774,20 @@ beforeEach(() => { archived.reload.mockReset(); archived.reload.mockResolvedValue(undefined); useSessions.mockClear(); + useSessions.mockImplementation((options?: { archiveState?: string }) => + options?.archiveState === 'archived' ? archived : active, + ); + useChannels.mockClear(); active.sessions.length = 0; + active.loading = false; + active.error = null; archived.sessions.length = 0; + channelState.error = undefined; + channelState.data = undefined; + channelState.catalog = []; + channelState.channels = {}; + channelState.reload.mockReset(); + channelState.reload.mockResolvedValue(undefined); }); afterEach(() => { @@ -3055,6 +3212,938 @@ describe('WebShellSidebar primary workspace header', () => { }); }); +describe('WebShellSidebar session source switch', () => { + it('never renders primary sessions from the inactive source', async () => { + active.sessions.push( + { + sessionId: 'task-session', + displayName: 'Task session', + workspaceCwd: '/tmp/project', + sourceType: 'default', + }, + { + sessionId: 'channel-session', + displayName: 'Channel session', + workspaceCwd: '/tmp/project', + sourceType: 'channel', + }, + ); + renderSidebar(); + await ensureWorkspaceExpanded('project'); + + expect(container.textContent).toContain('Task session'); + expect(container.textContent).not.toContain('Channel session'); + + await switchSessionSource('Channels'); + + expect(container.textContent).not.toContain('Task session'); + expect(container.textContent).toContain('Channel session'); + }); + + it('preserves channel completion state while the tasks source is active', async () => { + const channelSession: DaemonSessionSummary = { + sessionId: 'channel-session', + displayName: 'Channel session', + workspaceCwd: '/tmp/project', + sourceType: 'channel', + hasActivePrompt: true, + }; + const taskSession: DaemonSessionSummary = { + sessionId: 'task-session', + displayName: 'Task session', + workspaceCwd: '/tmp/project', + sourceType: 'default', + }; + let channelResult = { + ...active, + sessions: [channelSession], + loading: false, + }; + let taskResult = { + ...active, + sessions: [taskSession], + loading: false, + }; + useSessions.mockImplementation( + (options?: { archiveState?: string; sourceType?: string }) => { + if (options?.archiveState === 'archived') return archived; + return options?.sourceType === 'channel' ? channelResult : taskResult; + }, + ); + renderSidebar(); + await ensureWorkspaceExpanded('project'); + + await switchSessionSource('Channels'); + channelResult = { ...channelResult, loading: true }; + renderSidebar(); + channelResult = { ...channelResult, loading: false }; + renderSidebar(); + await switchSessionSource('Tasks'); + + channelResult = { + ...channelResult, + sessions: [taskSession], + loading: false, + }; + await switchSessionSource('Channels'); + channelResult = { ...channelResult, loading: true }; + renderSidebar(); + channelResult = { + ...channelResult, + sessions: [{ ...channelSession, hasActivePrompt: false }], + loading: false, + }; + renderSidebar(); + + const row = Array.from( + container.querySelectorAll('[role="button"]'), + ).find((candidate) => candidate.textContent?.includes('Channel session')); + expect(row).toBeDefined(); + expect(row!.querySelector('[class*="sessionStatusDot"]')).not.toBeNull(); + + // A Tasks-source reconcile while the channel marker exists must not wipe + // it: the marker belongs to the inactive source and must survive. + await switchSessionSource('Tasks'); + taskResult = { + ...taskResult, + sessions: [ + taskSession, + { + sessionId: 'second-task-session', + displayName: 'Second task session', + workspaceCwd: '/tmp/project', + sourceType: 'default', + }, + ], + }; + renderSidebar(); + await act(async () => { + await Promise.resolve(); + }); + + await switchSessionSource('Channels'); + channelResult = { ...channelResult, sessions: [...channelResult.sessions] }; + renderSidebar(); + await act(async () => { + await Promise.resolve(); + }); + + const restoredRow = Array.from( + container.querySelectorAll('[role="button"]'), + ).find((candidate) => candidate.textContent?.includes('Channel session')); + expect(restoredRow).toBeDefined(); + expect( + restoredRow!.querySelector('[class*="sessionStatusDot"]'), + ).not.toBeNull(); + }); + + it('applies the source switch to the archived list', async () => { + archived.sessions.push( + { + sessionId: 'archived-task-session', + displayName: 'Archived task session', + workspaceCwd: '/tmp/project', + sourceType: 'default', + isArchived: true, + }, + { + sessionId: 'archived-channel-session', + displayName: 'Archived channel session', + workspaceCwd: '/tmp/project', + sourceType: 'channel', + isArchived: true, + }, + ); + renderSidebar(); + await expandArchived(); + + expect(container.textContent).toContain('Archived task session'); + expect(container.textContent).not.toContain('Archived channel session'); + + await switchSessionSource('Channels'); + + // Both halves of the archived application: the request carries the + // channel sourceType, and the client-side dedupe filter keeps archived + // task sessions out of the Channels tab. + expect( + useSessions.mock.calls.findLast( + ([options]) => options?.archiveState === 'archived', + )?.[0]?.sourceType, + ).toBe('channel'); + expect(container.textContent).toContain('Archived channel session'); + expect(container.textContent).not.toContain('Archived task session'); + }); + + it('switches primary and workspace-qualified lists from tasks to channels', async () => { + renderSidebar(); + + const sourceTabs = Array.from( + container.querySelectorAll('[role="tab"]'), + ); + const tasksTab = sourceTabs.find( + (button) => button.textContent?.trim() === 'Tasks', + ); + const channelsTab = sourceTabs.find( + (button) => button.textContent?.trim() === 'Channels', + ); + expect(tasksTab?.getAttribute('data-state')).toBe('active'); + expect(channelsTab).toBeDefined(); + expect( + useSessions.mock.calls.find( + ([options]) => + options?.archiveState === 'active' && options.group !== 'pinned', + )?.[0]?.sourceType, + ).toBe('default'); + + await switchSessionSource('Channels'); + + expect(channelsTab?.getAttribute('data-state')).toBe('active'); + expect( + useSessions.mock.calls.findLast( + ([options]) => + options?.archiveState === 'active' && options.group !== 'pinned', + )?.[0]?.sourceType, + ).toBe('channel'); + + await expandWorkspace('other'); + expect( + listWorkspaceSessions.mock.calls.some( + ([options]) => options?.sourceType === 'channel', + ), + ).toBe(true); + }); + + it('hides the switch and keeps legacy session requests unfiltered', async () => { + connection.capabilities = { + ...capabilities, + features: capabilities.features.filter( + (feature) => feature !== 'session_source_metadata', + ), + }; + renderSidebar(); + + expect(container.querySelector('[aria-label="Session source"]')).toBeNull(); + expect( + useSessions.mock.calls.every( + ([options]) => options?.sourceType === undefined, + ), + ).toBe(true); + await expandWorkspace('other'); + expect(listWorkspaceSessions).toHaveBeenCalled(); + expect( + listWorkspaceSessions.mock.calls.every( + ([options]) => options?.sourceType === undefined, + ), + ).toBe(true); + }); + + it('polls channel sessions on the active-session interval', async () => { + const channelCapabilities = { + ...capabilities, + features: [...capabilities.features, 'channel_management'], + }; + connection.capabilities = channelCapabilities; + workspace.capabilities = channelCapabilities; + const setIntervalSpy = vi.spyOn(window, 'setInterval'); + const clearIntervalSpy = vi.spyOn(window, 'clearInterval'); + renderSidebar(); + await ensureWorkspaceExpanded('project'); + expect(useSessionCatalogPollingSpy).not.toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 2_000, + ); + expect( + setIntervalSpy.mock.calls.some(([, timeout]) => timeout === 2_000), + ).toBe(false); + const channelsTab = await switchSessionSource('Channels'); + expect(channelsTab?.getAttribute('data-state')).toBe('active'); + // The channel source moves the primary session catalog onto the active + // polling interval through the catalog store... + expect(useSessionCatalogPollingSpy).toHaveBeenCalledWith( + workspace.client, + expect.anything(), + 2_000, + ); + // ...and the channel catalog rides its own interval at the same cadence. + const activePoll = setIntervalSpy.mock.calls.findLast( + ([, timeout]) => timeout === 2_000, + ); + expect(activePoll).toBeDefined(); + const activePollIndex = setIntervalSpy.mock.calls + .map(([, timeout]) => timeout) + .lastIndexOf(2_000); + const activePollId = setIntervalSpy.mock.results[activePollIndex]?.value; + channelState.reload.mockClear(); + + await act(async () => { + const callback = activePoll![0]; + expect(callback).toBeTypeOf('function'); + if (typeof callback === 'function') callback(); + await Promise.resolve(); + }); + + expect(channelState.reload).toHaveBeenCalledOnce(); + + // Leaving the Channels tab tears the interval down; otherwise the sidebar + // keeps reloading the channel catalog while the Tasks tab is active. + channelState.reload.mockClear(); + await switchSessionSource('Tasks'); + expect(clearIntervalSpy).toHaveBeenCalledWith(activePollId); + }); + + it('backs off the channel catalog poll while the channels hook errors', async () => { + const channelCapabilities = { + ...capabilities, + features: [...capabilities.features, 'channel_management'], + }; + connection.capabilities = channelCapabilities; + workspace.capabilities = channelCapabilities; + channelState.error = new Error('channels endpoint down'); + const setIntervalSpy = vi.spyOn(window, 'setInterval'); + + renderSidebar(); + await ensureWorkspaceExpanded('project'); + await switchSessionSource('Channels'); + + // A persistently failing channels endpoint must not be re-requested on + // the 2s active cadence; the poll downshifts like the sibling pollers. + expect( + setIntervalSpy.mock.calls.some(([, timeout]) => timeout === 2_000), + ).toBe(false); + expect( + setIntervalSpy.mock.calls.some(([, timeout]) => timeout === 30_000), + ).toBe(true); + }); + + it('keeps a flat channel list when channel metadata is unavailable', async () => { + active.sessions.push({ + sessionId: 'legacy-channel-session', + displayName: 'Legacy channel', + workspaceCwd: '/tmp/project', + sourceType: 'channel', + sourceId: 'legacy-bot', + }); + renderSidebar(); + await ensureWorkspaceExpanded('project'); + await switchSessionSource('Channels'); + + expect(useChannels).toHaveBeenLastCalledWith({ + autoLoad: false, + enabled: false, + }); + expect(container.textContent).toContain('Legacy channel'); + expect(container.querySelector('section[aria-label]')).toBeNull(); + }); + + it('groups channel sessions by platform type and toggles each group', async () => { + const channelCapabilities = { + ...capabilities, + features: [...capabilities.features, 'channel_management'], + }; + connection.capabilities = channelCapabilities; + workspace.capabilities = channelCapabilities; + channelState.catalog = [ + { + type: 'dingtalk', + displayName: 'DingTalk', + manageable: true, + fields: [], + }, + { + type: 'feishu', + displayName: 'Feishu', + manageable: true, + fields: [], + }, + ]; + channelState.channels = { + 'ding-one': { + name: 'ding-one', + config: { type: 'dingtalk' }, + secrets: {}, + startsWithServe: false, + runtime: { state: 'connected' }, + }, + 'ding-two': { + name: 'ding-two', + config: { type: 'dingtalk' }, + secrets: {}, + startsWithServe: false, + runtime: { state: 'connected' }, + }, + feishu: { + name: 'feishu', + config: { type: 'feishu' }, + secrets: {}, + startsWithServe: false, + runtime: { state: 'connected' }, + }, + }; + channelState.data = { + catalog: channelState.catalog, + snapshot: { revision: '1', instances: channelState.channels }, + }; + active.sessions.push( + { + sessionId: 'ding-one-session', + displayName: 'DingTalk one', + workspaceCwd: '/tmp/project', + sourceType: 'channel', + sourceId: 'ding-one', + }, + { + sessionId: 'feishu-session', + displayName: 'Feishu one', + workspaceCwd: '/tmp/project', + sourceType: 'channel', + sourceId: 'feishu', + }, + { + sessionId: 'ding-two-session', + displayName: 'DingTalk two', + workspaceCwd: '/tmp/project', + sourceType: 'channel', + sourceId: 'ding-two', + isPinned: true, + }, + { + sessionId: 'legacy-channel-session', + displayName: 'Legacy channel', + workspaceCwd: '/tmp/project', + sourceType: 'channel', + }, + ); + + renderSidebar(); + await ensureWorkspaceExpanded('project'); + await switchSessionSource('Channels'); + + expect(useChannels).toHaveBeenLastCalledWith({ + autoLoad: true, + enabled: true, + }); + + const dingTalkGroup = container.querySelector( + 'section[aria-label="DingTalk"]', + ); + expect(dingTalkGroup).not.toBeNull(); + expect(dingTalkGroup!.textContent).toContain('DingTalk one'); + expect(dingTalkGroup?.textContent).toContain('DingTalk two'); + expect(dingTalkGroup?.textContent).not.toContain('Feishu one'); + expect( + container.querySelector('section[aria-label="Feishu"]')?.textContent, + ).toContain('Feishu one'); + expect( + container.querySelector('section[aria-label="Other channels"]') + ?.textContent, + ).toContain('Legacy channel'); + + const toggle = dingTalkGroup?.querySelector( + 'button[aria-expanded="true"]', + ); + await act(async () => click(toggle!)); + expect(toggle?.getAttribute('aria-expanded')).toBe('false'); + expect(dingTalkGroup?.textContent).not.toContain('DingTalk one'); + await act(async () => click(toggle!)); + expect(toggle?.getAttribute('aria-expanded')).toBe('true'); + expect(dingTalkGroup?.textContent).toContain('DingTalk one'); + }); + + it('starts channel sections expanded on the first Channels visit with organization enabled', async () => { + const channelCapabilities = { + ...capabilities, + features: [ + ...capabilities.features, + 'channel_management', + 'session_organization', + ], + }; + connection.capabilities = channelCapabilities; + workspace.capabilities = channelCapabilities; + workspaceActions.listSessionGroups.mockResolvedValue({ + groups: [], + colorOptions: [], + }); + channelState.catalog = [ + { + type: 'dingtalk', + displayName: 'DingTalk', + manageable: true, + fields: [], + }, + ]; + channelState.channels = { + 'ding-one': { + name: 'ding-one', + config: { type: 'dingtalk' }, + secrets: {}, + startsWithServe: false, + runtime: { state: 'connected' }, + }, + }; + channelState.data = { + catalog: channelState.catalog, + snapshot: { revision: '1', instances: channelState.channels }, + }; + const taskSessions: DaemonSessionSummary[] = [ + { + sessionId: 'task-session', + displayName: 'Task session', + workspaceCwd: '/tmp/project', + sourceType: 'default', + }, + ]; + const channelSessions: DaemonSessionSummary[] = [ + { + sessionId: 'ding-one-session', + displayName: 'DingTalk one', + workspaceCwd: '/tmp/project', + sourceType: 'channel', + sourceId: 'ding-one', + }, + ]; + // Serve a distinct settled page per source (distinct identities), as the + // real resource does when the source-switch refetch resolves. + useSessions.mockImplementation( + (options?: { archiveState?: string; sourceType?: string }) => { + if (options?.archiveState === 'archived') { + return { ...archived, data: archived.sessions }; + } + if (options?.sourceType === 'channel') { + return { + ...active, + sessions: channelSessions, + data: channelSessions, + }; + } + return { ...active, sessions: taskSessions, data: taskSessions }; + }, + ); + + renderSidebar(); + await ensureWorkspaceExpanded('project'); + // Settle the groups catalog so the Tasks source consumes its own + // first-sync latch before the Channels visit. + await act(async () => { + await workspaceActions.listSessionGroups.mock.results.at(-1)?.value; + await Promise.resolve(); + }); + expect(container.textContent).toContain('Task session'); + + await switchSessionSource('Channels'); + + const dingTalkGroup = container.querySelector( + 'section[aria-label="DingTalk"]', + ); + expect(dingTalkGroup).not.toBeNull(); + expect(dingTalkGroup!.textContent).toContain('DingTalk one'); + // The first Channels visit consumes the channel-source latch without + // treating the platform sections as brand-new mid-session additions. + expect( + dingTalkGroup!.querySelector('button[aria-expanded="true"]'), + ).not.toBeNull(); + expect( + window.localStorage.getItem(COLLAPSED_SESSION_SECTIONS_STORAGE_KEY) ?? '', + ).not.toContain('channel-type:'); + }); + + it('keeps channel sections expanded when the catalog settles before the sessions page', async () => { + enableChannelOrganization(); + setChannelCatalog(); + const taskSessions: DaemonSessionSummary[] = [ + { + sessionId: 'task-session', + displayName: 'Task session', + workspaceCwd: '/tmp/project', + sourceType: 'default', + }, + ]; + let channelPage: DaemonSessionSummary[] | undefined = undefined; + useSessions.mockImplementation( + (options?: { archiveState?: string; sourceType?: string }) => { + if (options?.archiveState === 'archived') { + return { ...archived, data: archived.sessions }; + } + if (options?.sourceType === 'channel') { + // The new source's catalog entry starts unsettled: no page until + // its fetch resolves, while the channel catalog is already loaded. + return { + ...active, + sessions: channelPage ?? [], + data: channelPage, + }; + } + return { ...active, sessions: taskSessions, data: taskSessions }; + }, + ); + + renderSidebar(); + await ensureWorkspaceExpanded('project'); + await settleGroupsCatalog(); + await switchSessionSource('Channels'); + + // The channel catalog settled before the channel sessions page; no + // section may be registered or persisted yet. + expect( + container.querySelector('section[aria-label="DingTalk"]'), + ).toBeNull(); + expect( + window.localStorage.getItem(COLLAPSED_SESSION_SECTIONS_STORAGE_KEY) ?? '', + ).not.toContain('channel-type:'); + + channelPage = [ + { + sessionId: 'ding-one-session', + displayName: 'DingTalk one', + workspaceCwd: '/tmp/project', + sourceType: 'channel', + sourceId: 'ding-one', + }, + ]; + renderSidebar(); + await act(async () => { + await Promise.resolve(); + }); + + const dingTalkGroup = container.querySelector( + 'section[aria-label="DingTalk"]', + ); + expect(dingTalkGroup).not.toBeNull(); + expect(dingTalkGroup!.textContent).toContain('DingTalk one'); + expect( + dingTalkGroup!.querySelector('button[aria-expanded="true"]'), + ).not.toBeNull(); + expect( + window.localStorage.getItem(COLLAPSED_SESSION_SECTIONS_STORAGE_KEY) ?? '', + ).not.toContain('channel-type:'); + }); + + it('starts the first channel section expanded when it arrives after an empty settle', async () => { + enableChannelOrganization(); + setChannelCatalog(); + const taskSessions: DaemonSessionSummary[] = [ + { + sessionId: 'task-session', + displayName: 'Task session', + workspaceCwd: '/tmp/project', + sourceType: 'default', + }, + ]; + let channelSessions: DaemonSessionSummary[] = []; + useSessions.mockImplementation( + (options?: { archiveState?: string; sourceType?: string }) => { + if (options?.archiveState === 'archived') { + return { ...archived, data: archived.sessions }; + } + if (options?.sourceType === 'channel') { + return { + ...active, + sessions: channelSessions, + data: channelSessions, + }; + } + return { ...active, sessions: taskSessions, data: taskSessions }; + }, + ); + + renderSidebar(); + await ensureWorkspaceExpanded('project'); + await settleGroupsCatalog(); + await switchSessionSource('Channels'); + + // The first Channels visit settles a defined empty catalog; the latch + // must stay set because channel sessions are externally driven. + expect( + container.querySelector('section[aria-label="DingTalk"]'), + ).toBeNull(); + + // The first incoming message creates the first channel session while the + // tab is open (the 2s poll picks it up). + channelSessions = [ + { + sessionId: 'ding-one-session', + displayName: 'DingTalk one', + workspaceCwd: '/tmp/project', + sourceType: 'channel', + sourceId: 'ding-one', + }, + ]; + renderSidebar(); + await act(async () => { + await Promise.resolve(); + }); + + const dingTalkGroup = container.querySelector( + 'section[aria-label="DingTalk"]', + ); + expect(dingTalkGroup).not.toBeNull(); + expect( + dingTalkGroup!.querySelector('button[aria-expanded="true"]'), + ).not.toBeNull(); + expect( + window.localStorage.getItem(COLLAPSED_SESSION_SECTIONS_STORAGE_KEY) ?? '', + ).not.toContain('channel-type:'); + }); + + it('does not register the first channel catalog against a search filter', async () => { + enableChannelOrganization(); + setChannelCatalog(); + const taskSessions: DaemonSessionSummary[] = [ + { + sessionId: 'task-session', + displayName: 'Task session', + workspaceCwd: '/tmp/project', + sourceType: 'default', + }, + ]; + const channelSessions: DaemonSessionSummary[] = [ + { + sessionId: 'ding-one-session', + displayName: 'DingTalk one', + workspaceCwd: '/tmp/project', + sourceType: 'channel', + sourceId: 'ding-one', + }, + { + sessionId: 'feishu-one-session', + displayName: 'Feishu one', + workspaceCwd: '/tmp/project', + sourceType: 'channel', + sourceId: 'feishu-one', + }, + ]; + useSessions.mockImplementation( + (options?: { archiveState?: string; sourceType?: string }) => { + if (options?.archiveState === 'archived') { + return { ...archived, data: archived.sessions }; + } + if (options?.sourceType === 'channel') { + return { + ...active, + sessions: channelSessions, + data: channelSessions, + }; + } + return { ...active, sessions: taskSessions, data: taskSessions }; + }, + ); + + renderSidebar(); + await ensureWorkspaceExpanded('project'); + await settleGroupsCatalog(); + expect(container.textContent).toContain('Task session'); + + const searchInput = await openSessionSearch(); + await act(async () => { + setInputValue(searchInput, 'ding'); + await Promise.resolve(); + }); + await switchSessionSource('Channels'); + + // Only the DingTalk section matches the search; the first-catalog latch + // must wait for an unfiltered settle instead of registering only it. + await act(async () => { + setInputValue(searchInput, ''); + await Promise.resolve(); + }); + + const feishuGroup = container.querySelector( + 'section[aria-label="Feishu"]', + ); + expect(feishuGroup).not.toBeNull(); + expect( + feishuGroup!.querySelector('button[aria-expanded="true"]'), + ).not.toBeNull(); + expect( + window.localStorage.getItem(COLLAPSED_SESSION_SECTIONS_STORAGE_KEY) ?? '', + ).not.toContain('channel-type:'); + }); + it('does not reconcile channel sections against the previous source page', async () => { + enableChannelOrganization(); + setChannelCatalog(); + const taskSession: DaemonSessionSummary = { + sessionId: 'task-session', + displayName: 'Task session', + workspaceCwd: '/tmp/project', + sourceType: 'default', + }; + const dingSession: DaemonSessionSummary = { + sessionId: 'ding-one-session', + displayName: 'DingTalk one', + workspaceCwd: '/tmp/project', + sourceType: 'channel', + sourceId: 'ding-one', + }; + const feishuSession: DaemonSessionSummary = { + sessionId: 'feishu-one-session', + displayName: 'Feishu one', + workspaceCwd: '/tmp/project', + sourceType: 'channel', + sourceId: 'feishu-one', + }; + // The source switch retains the previous page (identity unchanged) until + // the channel fetch settles, and that page still carries a channel row. + const retainedPage = [taskSession, dingSession]; + let channelPage: DaemonSessionSummary[] | undefined = undefined; + useSessions.mockImplementation( + (options?: { archiveState?: string; sourceType?: string }) => { + if (options?.archiveState === 'archived') { + return { ...archived, data: archived.sessions }; + } + if (options?.sourceType === 'channel') { + return channelPage + ? { ...active, sessions: channelPage, data: channelPage } + : { ...active, sessions: retainedPage, data: retainedPage }; + } + return { ...active, sessions: retainedPage, data: retainedPage }; + }, + ); + + renderSidebar(); + await ensureWorkspaceExpanded('project'); + await settleGroupsCatalog(); + expect(container.textContent).toContain('Task session'); + + await switchSessionSource('Channels'); + + // The settled page still belongs to the tasks source, so its DingTalk + // section must not consume the channel initial-catalog latch. + expect(container.querySelector('section[aria-label="Feishu"]')).toBeNull(); + expect( + window.localStorage.getItem(COLLAPSED_SESSION_SECTIONS_STORAGE_KEY) ?? '', + ).not.toContain('channel-type:'); + + channelPage = [dingSession, feishuSession]; + renderSidebar(); + await act(async () => { + await Promise.resolve(); + }); + + // Both sections register against the true channel-source page: expanded, + // and not persisted as mid-session auto-collapses. + for (const label of ['DingTalk', 'Feishu']) { + const group = container.querySelector( + `section[aria-label="${label}"]`, + ); + expect(group).not.toBeNull(); + expect( + group!.querySelector('button[aria-expanded="true"]'), + ).not.toBeNull(); + } + expect( + window.localStorage.getItem(COLLAPSED_SESSION_SECTIONS_STORAGE_KEY) ?? '', + ).not.toContain('channel-type:'); + }); +}); + +describe('WebShellSidebar session list notices', () => { + it('keeps a settled filtered-empty view while a refresh is in flight', async () => { + active.sessions = [ + { + sessionId: 'task-session', + displayName: 'Task session', + workspaceCwd: '/tmp/project', + sourceType: 'default', + }, + ]; + active.loading = true; + + renderSidebar(); + await ensureWorkspaceExpanded('project'); + const searchInput = await openSessionSearch(); + await act(async () => { + setInputValue(searchInput, 'no-match'); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('No matching sessions.'); + expect(container.textContent).not.toContain('Loading sessions...'); + }); + + it('keeps a settled filtered-empty view when a refresh failed', async () => { + active.sessions = [ + { + sessionId: 'task-session', + displayName: 'Task session', + workspaceCwd: '/tmp/project', + sourceType: 'default', + }, + ]; + active.error = new Error('daemon restarted'); + + renderSidebar(); + await ensureWorkspaceExpanded('project'); + const searchInput = await openSessionSearch(); + await act(async () => { + setInputValue(searchInput, 'no-match'); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('No matching sessions.'); + expect(container.textContent).not.toContain('Failed to load sessions'); + }); + + it('shows the loading notice until the first page settles', async () => { + active.sessions = []; + active.loading = true; + useSessions.mockImplementation((options?: { archiveState?: string }) => { + const state = options?.archiveState === 'archived' ? archived : active; + return { ...state, data: undefined }; + }); + + renderSidebar(); + await ensureWorkspaceExpanded('project'); + + expect(container.textContent).toContain('Loading sessions...'); + }); + + it('keeps the settled empty notice while a background refresh is in flight', async () => { + active.sessions = []; + active.loading = true; + + renderSidebar(); + await ensureWorkspaceExpanded('project'); + + expect(container.textContent).toContain('No matching sessions.'); + expect(container.textContent).not.toContain('Loading sessions...'); + }); + + it('keeps the settled empty notice when a background refresh failed', async () => { + active.sessions = []; + active.error = new Error('daemon restarted'); + + renderSidebar(); + await ensureWorkspaceExpanded('project'); + + expect(container.textContent).toContain('No matching sessions.'); + expect(container.textContent).not.toContain('Failed to load sessions'); + }); + + it('offers a retry when the first page load failed', async () => { + active.sessions = []; + active.error = new Error('daemon restarted'); + useSessions.mockImplementation((options?: { archiveState?: string }) => { + const state = options?.archiveState === 'archived' ? archived : active; + return { ...state, data: undefined }; + }); + + renderSidebar(); + await ensureWorkspaceExpanded('project'); + + expect(container.textContent).toContain('Failed to load sessions'); + const retry = Array.from( + container.querySelectorAll('button'), + ).find((button) => button.textContent?.includes('Failed to load sessions')); + expect(retry).toBeDefined(); + await act(async () => { + click(retry!); + await Promise.resolve(); + }); + expect(active.reload).toHaveBeenCalled(); + }); +}); + describe('WebShellSidebar Live group', () => { it('shows Live sessions without exposing the backing Conversations workspace', async () => { const liveWorkspace: DaemonWorkspaceCapability = { diff --git a/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx b/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx index 25d188876fa..75d3915025b 100644 --- a/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx +++ b/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx @@ -123,6 +123,9 @@ function renderSection( client: DaemonClient; reloadToken: number; expanded: boolean; + sourceType: string; + channelGroupingEnabled: boolean; + organizationEnabled: boolean; }> = {}, ): void { act(() => { @@ -138,7 +141,9 @@ function renderSection( trustToOpenLabel="Trust to open" noSessionsLabel="No sessions" loadErrorLabel="Load failed" - organizationEnabled={false} + organizationEnabled={overrides.organizationEnabled ?? false} + sourceType={overrides.sourceType} + channelGroupingEnabled={overrides.channelGroupingEnabled} ungroupedLabel="Ungrouped" formatTime={() => ''} renderSession={(session: DaemonSessionSummary): ReactNode => ( @@ -230,6 +235,439 @@ describe('WorkspaceSection label', () => { container.querySelector('[title="A very long session name"]'), ).not.toBeNull(); }); + + it('does not render sessions loaded for the previous source', async () => { + let resolveChannel: (page: { + sessions: DaemonSessionSummary[]; + }) => void = () => {}; + const channelPage = new Promise<{ sessions: DaemonSessionSummary[] }>( + (resolve) => { + resolveChannel = resolve; + }, + ); + let resolveDefault: (page: { + sessions: DaemonSessionSummary[]; + }) => void = () => {}; + const defaultPage = new Promise<{ sessions: DaemonSessionSummary[] }>( + (resolve) => { + resolveDefault = resolve; + }, + ); + const listWorkspaceSessionsPage = vi.fn( + (options?: { sourceType?: string }) => + options?.sourceType === 'channel' ? channelPage : defaultPage, + ); + const client = { + workspaceByCwd: vi.fn(() => ({ + workspaceGit, + listWorkspaceSessionsPage, + listSessionGroups: vi.fn().mockResolvedValue({ groups: [] }), + })), + } as unknown as DaemonClient; + + // Switch to the channel source while the default request is still in + // flight. The catalog store keeps one snapshot per query, so the sources + // cannot clobber each other. + renderSection({ client, expanded: true, sourceType: 'default' }); + renderSection({ client, expanded: true, sourceType: 'channel' }); + expect(container.textContent).not.toContain('Task session'); + + // The pre-switch default response settles AFTER the switch; it belongs + // to the default source's catalog entry and must not clobber the + // channel list now on screen. + resolveDefault({ + sessions: [ + { + sessionId: 'task-session', + displayName: 'Task session', + sourceType: 'default', + }, + ], + }); + await flush(); + expect(container.textContent).not.toContain('Task session'); + + resolveChannel({ + sessions: [ + { + sessionId: 'channel-session', + displayName: 'Channel session', + sourceType: 'channel', + }, + ], + }); + await flush(); + expect(container.textContent).toContain('Channel session'); + }); + + it('does not carry a load error across a source switch', async () => { + const listWorkspaceSessionsPage = vi.fn( + (options?: { sourceType?: string }) => + options?.sourceType === 'channel' + ? new Promise<{ sessions: DaemonSessionSummary[] }>(() => {}) + : Promise.reject(new Error('tasks unavailable')), + ); + const client = { + workspaceByCwd: vi.fn(() => ({ + workspaceGit, + listWorkspaceSessionsPage, + listSessionGroups: vi.fn().mockResolvedValue({ groups: [] }), + })), + } as unknown as DaemonClient; + + renderSection({ client, expanded: true, sourceType: 'default' }); + await flush(); + expect(container.textContent).toContain('Load failed'); + + renderSection({ client, expanded: true, sourceType: 'channel' }); + await flush(); + expect(container.textContent).not.toContain('Load failed'); + // The switch must actually initiate the new source's fetch, not leave the + // section stuck on the failed tasks load. + expect(listWorkspaceSessionsPage).toHaveBeenCalledWith( + expect.objectContaining({ sourceType: 'channel' }), + ); + expect( + listWorkspaceSessionsPage.mock.calls.filter( + ([options]) => + (options as { sourceType?: string } | undefined)?.sourceType === + 'channel', + ), + ).toHaveLength(1); + }); + + it('does not flash the empty notice while a fresh source settles', async () => { + const client = { + workspaceByCwd: vi.fn(() => ({ + workspaceGit, + listWorkspaceSessionsPage: vi.fn( + () => new Promise<{ sessions: DaemonSessionSummary[] }>(() => {}), + ), + listSessionGroups: vi.fn().mockResolvedValue({ groups: [] }), + })), + } as unknown as DaemonClient; + + renderSection({ client, expanded: true, sourceType: 'channel' }); + await flush(); + + // The new query key's fetch is in flight with no settled page yet, so + // the section renders nothing instead of "No sessions" for the + // round-trip. + expect(container.textContent).not.toContain('No sessions'); + }); + + it('groups a secondary workspace with its own channel catalog', async () => { + const listSessionGroups = vi.fn().mockResolvedValue({ + groups: [ + { + id: 'organization-group', + name: 'Organization group', + color: 'blue', + order: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + ], + }); + const client = { + workspaceByCwd: vi.fn(() => ({ + workspaceGit, + listWorkspaceSessionsPage: vi.fn().mockResolvedValue({ + sessions: [ + { + sessionId: 'ding-session', + displayName: 'DingTalk secondary', + sourceType: 'channel', + sourceId: 'secondary-ding', + groupId: 'organization-group', + }, + { + sessionId: 'feishu-session', + displayName: 'Feishu secondary', + sourceType: 'channel', + sourceId: 'secondary-feishu', + // Channel mode must keep pinned rows inside their platform + // section (excludePinned is off for the channel source). + isPinned: true, + }, + ], + }), + listSessionGroups, + workspaceChannelTypes: vi.fn().mockResolvedValue([ + { + type: 'dingtalk', + displayName: 'DingTalk', + manageable: true, + fields: [], + }, + { + type: 'feishu', + displayName: 'Feishu', + manageable: true, + fields: [], + }, + ]), + workspaceChannels: vi.fn().mockResolvedValue({ + revision: '1', + instances: { + 'secondary-ding': { + name: 'secondary-ding', + config: { type: 'dingtalk' }, + secrets: {}, + startsWithServe: false, + runtime: { state: 'connected' }, + }, + 'secondary-feishu': { + name: 'secondary-feishu', + config: { type: 'feishu' }, + secrets: {}, + startsWithServe: false, + runtime: { state: 'connected' }, + }, + }, + }), + })), + } as unknown as DaemonClient; + + renderSection({ + workspace: { ...trustedWorkspace, primary: false }, + client, + expanded: true, + sourceType: 'channel', + channelGroupingEnabled: true, + organizationEnabled: true, + }); + await flush(); + + expect( + container.querySelector('section[aria-label="DingTalk"]')?.textContent, + ).toContain('DingTalk secondary'); + expect( + container.querySelector('section[aria-label="Feishu"]')?.textContent, + ).toContain('Feishu secondary'); + expect( + container.querySelector('section[aria-label="Organization group"]'), + ).toBeNull(); + // Channel mode discards the organization sections, so the catalog fetch + // must be skipped too, mirroring the sidebar's own org prefetch gates. + expect(listSessionGroups).not.toHaveBeenCalled(); + }); + + it('renders channel sessions flat while the channel catalog failed to load', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const client = { + workspaceByCwd: vi.fn(() => ({ + workspaceGit, + listWorkspaceSessionsPage: vi.fn().mockResolvedValue({ + sessions: [ + { + sessionId: 'ding-session', + displayName: 'DingTalk session', + sourceType: 'channel', + sourceId: 'ding-one', + groupId: 'organization-group', + }, + ], + }), + listSessionGroups: vi.fn().mockResolvedValue({ + groups: [ + { + id: 'organization-group', + name: 'Organization group', + color: 'blue', + order: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + ], + }), + workspaceChannelTypes: vi.fn().mockRejectedValue(new Error('boom')), + workspaceChannels: vi.fn().mockRejectedValue(new Error('boom')), + })), + } as unknown as DaemonClient; + + renderSection({ + client, + expanded: true, + sourceType: 'channel', + channelGroupingEnabled: true, + organizationEnabled: true, + }); + await flush(); + + // Without a catalog the channel list is not groupable yet; it must stay + // flat instead of falling through to organization groups, which would + // invert the "channel grouping overrides user groups" precedence. + expect(container.textContent).toContain('DingTalk session'); + expect( + container.querySelector('section[aria-label="Organization group"]'), + ).toBeNull(); + warn.mockRestore(); + }); + + it('ignores a stale channel catalog response', async () => { + let resolveStale!: (value: { + revision: string; + instances: Record; + }) => void; + const staleSnapshot = new Promise<{ + revision: string; + instances: Record; + }>((resolve) => { + resolveStale = resolve; + }); + const workspaceChannelTypes = vi.fn().mockResolvedValue([ + { + type: 'dingtalk', + displayName: 'DingTalk', + manageable: true, + fields: [], + }, + { + type: 'feishu', + displayName: 'Feishu', + manageable: true, + fields: [], + }, + ]); + const workspaceChannels = vi + .fn() + .mockReturnValueOnce(staleSnapshot) + .mockResolvedValue({ + revision: 'new', + instances: { + instance: { + name: 'instance', + config: { type: 'feishu' }, + secrets: {}, + startsWithServe: false, + }, + }, + }); + const client = { + workspaceByCwd: vi.fn(() => ({ + workspaceGit, + listWorkspaceSessionsPage: vi.fn().mockResolvedValue({ + sessions: [ + { + sessionId: 'channel-session', + displayName: 'Channel session', + sourceType: 'channel', + sourceId: 'instance', + }, + ], + }), + listSessionGroups: vi.fn().mockResolvedValue({ groups: [] }), + workspaceChannelTypes, + workspaceChannels, + })), + } as unknown as DaemonClient; + + renderSection({ + client, + expanded: true, + sourceType: 'channel', + channelGroupingEnabled: true, + reloadToken: 0, + }); + await flush(); + renderSection({ + client, + expanded: true, + sourceType: 'channel', + channelGroupingEnabled: true, + reloadToken: 1, + }); + await flush(); + expect( + container.querySelector('section[aria-label="Feishu"]'), + ).not.toBeNull(); + + resolveStale({ + revision: 'old', + instances: { + instance: { + name: 'instance', + config: { type: 'dingtalk' }, + }, + }, + }); + await flush(); + + expect( + container.querySelector('section[aria-label="Feishu"]'), + ).not.toBeNull(); + expect( + container.querySelector('section[aria-label="DingTalk"]'), + ).toBeNull(); + }); + + it('refreshes the channel catalog on the session poll tick', async () => { + const workspaceChannelTypes = vi.fn().mockResolvedValue([ + { + type: 'dingtalk', + displayName: 'DingTalk', + manageable: true, + fields: [], + }, + ]); + const workspaceChannels = vi.fn().mockResolvedValue({ + revision: '1', + instances: {}, + }); + const client = { + workspaceByCwd: vi.fn(() => ({ + workspaceGit, + listWorkspaceSessionsPage: vi.fn().mockResolvedValue({ sessions: [] }), + listSessionGroups: vi.fn().mockResolvedValue({ groups: [] }), + workspaceChannelTypes, + workspaceChannels, + })), + } as unknown as DaemonClient; + const setIntervalSpy = vi.spyOn(window, 'setInterval'); + + renderSection({ + client, + expanded: true, + sourceType: 'channel', + channelGroupingEnabled: true, + }); + await flush(); + expect(workspaceChannelTypes).toHaveBeenCalledTimes(1); + + const poll = setIntervalSpy.mock.calls.findLast( + ([, timeout]) => timeout === 10_000, + ); + expect(poll).toBeDefined(); + await act(async () => { + const callback = poll![0]; + expect(callback).toBeTypeOf('function'); + if (typeof callback === 'function') callback(); + await Promise.resolve(); + }); + await flush(); + + expect(workspaceChannelTypes).toHaveBeenCalledTimes(2); + + // Background tabs skip the tick entirely, matching the sibling pollers. + const originalVisibility = document.visibilityState; + Object.defineProperty(document, 'visibilityState', { + value: 'hidden', + configurable: true, + }); + await act(async () => { + const callback = poll![0]; + if (typeof callback === 'function') callback(); + await Promise.resolve(); + }); + await flush(); + expect(workspaceChannelTypes).toHaveBeenCalledTimes(2); + Object.defineProperty(document, 'visibilityState', { + value: originalVisibility, + configurable: true, + }); + setIntervalSpy.mockRestore(); + }); }); describe('WorkspaceSection session loading', () => { diff --git a/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx b/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx index c3a312ea851..cd55e3a27e1 100644 --- a/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx +++ b/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx @@ -8,6 +8,8 @@ import { } from 'react'; import type { DaemonClient } from '@qwen-code/sdk/daemon'; import type { + DaemonChannelsSnapshot, + DaemonChannelTypeCatalog, DaemonSessionGroup, DaemonSessionSummary, DaemonWorkspaceCapability, @@ -17,16 +19,14 @@ import { FolderClosedIcon, FolderOpenIcon } from 'lucide-react'; import { GitBranchIndicator } from '../GitBranchIndicator'; import { BranchPickerPopover } from '../BranchPickerPopover'; import { useI18n } from '../../i18n'; -import { - SESSION_LIST_PAGE_SIZE, - WEB_SHELL_SESSION_SOURCE_TYPE, -} from '../../constants/sessions'; +import { SESSION_LIST_PAGE_SIZE } from '../../constants/sessions'; import { readWorkspaceCollapsedGroupIds, writeWorkspaceCollapsedGroupIds, } from './collapsedSessionSections'; import { workspaceLabel } from '../../utils/workspace'; import { SessionGroupSection } from './SessionGroupSection'; +import { groupSessionsByChannelType } from './channelSessionGroups'; import styles from './WorkspaceSection.module.css'; import { useSessionCatalogQuery } from '../../session-catalog/session-catalog-hooks'; import type { SessionCatalogQuery } from '../../session-catalog/session-catalog-store'; @@ -73,7 +73,8 @@ interface WorkspaceSectionProps { noSessionsLabel: string; loadErrorLabel: string; organizationEnabled: boolean; - sourceMetadataEnabled?: boolean; + sourceType?: string; + channelGroupingEnabled?: boolean; ungroupedLabel: string; formatTime: (iso: string) => string; searchQuery?: string; @@ -115,7 +116,8 @@ export function WorkspaceSection({ noSessionsLabel, loadErrorLabel, organizationEnabled, - sourceMetadataEnabled = false, + sourceType, + channelGroupingEnabled = false, ungroupedLabel, formatTime, searchQuery = '', @@ -135,6 +137,10 @@ export function WorkspaceSection({ onOpenCommit, }: WorkspaceSectionProps) { const [groups, setGroups] = useState([]); + const [channelCatalog, setChannelCatalog] = useState<{ + catalog: DaemonChannelTypeCatalog; + snapshot: DaemonChannelsSnapshot; + }>(); const [internalExpanded, setInternalExpanded] = useState(false); const [collapsedGroupIds, setCollapsedGroupIds] = useState>(() => readWorkspaceCollapsedGroupIds(workspace.id), @@ -142,10 +148,12 @@ export function WorkspaceSection({ const [actionsVisible, setActionsVisible] = useState(false); const [gitStatus, setGitStatus] = useState(); const [branchPickerOpen, setBranchPickerOpen] = useState(false); + const channelCatalogLoadRequestId = useRef(0); const { t } = useI18n(); const expanded = controlledExpanded ?? internalExpanded; const readOnly = !workspace.primary && !workspace.trusted; const disabled = workspace.primary && !workspace.trusted; + const searchActive = searchQuery.trim().length > 0; // A workspace always starts collapsed, including the primary workspace. useEffect(() => { @@ -173,15 +181,13 @@ export function WorkspaceSection({ options: { pageSize: SESSION_LIST_PAGE_SIZE, archiveState: 'active', - ...(sourceMetadataEnabled - ? { sourceType: WEB_SHELL_SESSION_SOURCE_TYPE } - : {}), + ...(sourceType ? { sourceType } : {}), ...(organizationEnabled ? { view: 'organized' as const, group: 'all' } : {}), }, }), - [organizationEnabled, sourceMetadataEnabled, workspace.cwd], + [organizationEnabled, sourceType, workspace.cwd], ); const sessionsResult = useSessionCatalogQuery(client, sessionsQuery, { autoLoad: true, @@ -192,6 +198,7 @@ export function WorkspaceSection({ page: sessionsPage, reload: reloadSessions, stale: sessionsStale, + loading: sessionsLoading, } = sessionsResult; const sessionsActive = sessionsEnabled && sessionsVisible; const previousSessionsActiveRef = useRef(sessionsActive); @@ -222,7 +229,12 @@ export function WorkspaceSection({ }, [sessionsResult.error, workspace.cwd]); useEffect(() => { - if (!renderSessions || disabled || !organizationEnabled) { + if ( + !renderSessions || + disabled || + !organizationEnabled || + channelGroupingEnabled + ) { setGroups([]); return; } @@ -240,6 +252,7 @@ export function WorkspaceSection({ cancelled = true; }; }, [ + channelGroupingEnabled, client, disabled, organizationEnabled, @@ -248,6 +261,50 @@ export function WorkspaceSection({ workspace.cwd, ]); + const loadChannelCatalog = useCallback(async () => { + if (disabled || readOnly || !channelGroupingEnabled) return; + const requestId = ++channelCatalogLoadRequestId.current; + try { + const workspaceClient = client.workspaceByCwd(workspace.cwd); + const [catalog, snapshot] = await Promise.all([ + workspaceClient.workspaceChannelTypes(), + workspaceClient.workspaceChannels(), + ]); + if (requestId === channelCatalogLoadRequestId.current) { + setChannelCatalog({ catalog, snapshot }); + } + } catch (err) { + // Keep the last known catalog across a transient failure; the next + // poll tick retries. + console.warn('[WorkspaceSection] channel catalog load failed:', err); + } + }, [channelGroupingEnabled, client, disabled, readOnly, workspace.cwd]); + + useEffect(() => { + if (!renderSessions || disabled || readOnly || !channelGroupingEnabled) { + channelCatalogLoadRequestId.current += 1; + setChannelCatalog(undefined); + return; + } + if (!expanded && !searchActive) return; + void loadChannelCatalog(); + // The catalog rides its own tick so instances added or removed while a + // section is expanded reach the grouping logic without a collapse cycle. + const timer = setInterval(() => { + if (document.visibilityState === 'visible') void loadChannelCatalog(); + }, 10_000); + return () => clearInterval(timer); + }, [ + channelGroupingEnabled, + disabled, + expanded, + loadChannelCatalog, + readOnly, + reloadToken, + renderSessions, + searchActive, + ]); + // Undefined when `cwd` is not a real path (synthetic fallback workspace), so // the poll — which qualifies the route with the cwd — is skipped entirely. const gitPollCwd = isAbsolutePath(workspace.cwd) ? workspace.cwd : undefined; @@ -319,7 +376,8 @@ export function WorkspaceSection({ }, [excludePinned, searchQuery, sessions]); const groupedSessions = useMemo(() => { - if (!organizationEnabled || groups.length === 0) return null; + if (!organizationEnabled || channelGroupingEnabled || groups.length === 0) + return null; const assigned = new Set(); const sections = groups.map((group) => { const items = visibleSessions.filter( @@ -334,7 +392,20 @@ export function WorkspaceSection({ (session) => !assigned.has(session.sessionId), ), }; - }, [groups, organizationEnabled, visibleSessions]); + }, [channelGroupingEnabled, groups, organizationEnabled, visibleSessions]); + + const channelSessionGroups = useMemo( + () => + channelGroupingEnabled && channelCatalog + ? groupSessionsByChannelType( + visibleSessions, + channelCatalog.catalog, + channelCatalog.snapshot.instances, + t('sidebar.channelType.other'), + ) + : null, + [channelCatalog, channelGroupingEnabled, t, visibleSessions], + ); return (
@@ -416,8 +487,35 @@ export function WorkspaceSection({ {loadErrorLabel}
) : visibleSessions.length === 0 ? ( -
{noSessionsLabel}
- ) : groupedSessions ? ( + // A source switch swaps the query key; until the new source's + // page settles there is no data yet, so the "no sessions" notice + // would flash for a whole fetch round-trip. + sessionsLoading && sessionsPage === undefined ? null : ( +
{noSessionsLabel}
+ ) + ) : channelSessionGroups ? ( + <> + {channelSessionGroups.map((group) => ( + { + setCollapsedGroupIds((current) => { + const next = new Set(current); + if (next.has(group.id)) next.delete(group.id); + else next.add(group.id); + return next; + }); + }} + > + {group.sessions.map((session) => renderSession(session))} + + ))} + + ) : groupedSessions && !channelGroupingEnabled ? ( <> {groupedSessions.sections.map(({ group, sessions }) => ( ) { + return groups.map((group) => group.id); +} + +describe('groupSessionsByChannelType', () => { + it('combines channel instances of the same type in first-seen order', () => { + const groups = groupSessionsByChannelType( + [ + session('d1', 'ding-one'), + session('f1', 'feishu'), + session('d2', 'ding-two'), + ], + catalog, + { + 'ding-one': instance('ding-one', 'dingtalk'), + 'ding-two': instance('ding-two', 'dingtalk'), + feishu: instance('feishu', 'feishu'), + }, + 'Other channels', + ); + + expect( + groups.map(({ id, label, sessions }) => ({ + id, + label, + sessions: sessions.map((item) => item.sessionId), + })), + ).toEqual([ + { + id: 'channel-type:dingtalk', + label: 'DingTalk', + sessions: ['d1', 'd2'], + }, + { + id: 'channel-type:feishu', + label: 'Feishu', + sessions: ['f1'], + }, + ]); + }); + + it('treats prototype-member sourceIds as missing instances instead of crashing', () => { + // Instance names are portable path components, so a deleted instance can + // leave orphaned sessions whose sourceId is 'constructor'/'__proto__'. + // A bare index read would resolve through Object.prototype and throw + // inside the sidebar render path; they must land in the fallback group. + const groups = groupSessionsByChannelType( + [ + session('orphan-constructor', 'constructor'), + session('orphan-proto', '__proto__'), + session('d1', 'ding-one'), + ], + catalog, + { 'ding-one': instance('ding-one', 'dingtalk') }, + 'Other channels', + ); + + expect( + groups.map(({ id, label, sessions }) => ({ + id, + label, + sessions: sessions.map((item) => item.sessionId), + })), + ).toEqual([ + { + id: 'channel-type:dingtalk', + label: 'DingTalk', + sessions: ['d1'], + }, + { + id: 'channel-type-fallback', + label: 'Other channels', + sessions: ['orphan-constructor', 'orphan-proto'], + }, + ]); + }); + + it('pins the fallback group after every resolved platform section', () => { + // The fallback section is emitted in session iteration order unless pinned, + // so an orphan seen before any platform session would otherwise render + // between two real platform sections. + const groups = groupSessionsByChannelType( + [ + session('legacy', 'retired-instance'), + session('f1', 'feishu'), + session('d1', 'ding-one'), + ], + catalog, + { + feishu: instance('feishu', 'feishu'), + 'ding-one': instance('ding-one', 'dingtalk'), + }, + 'Other channels', + ); + + expect(groupIds(groups)).toEqual([ + 'channel-type:feishu', + 'channel-type:dingtalk', + 'channel-type-fallback', + ]); + }); + + it('uses the raw type or fallback group when catalog metadata is incomplete', () => { + const groups = groupSessionsByChannelType( + [ + session('github', 'github'), + session('legacy-one'), + session('legacy-two'), + ], + catalog, + { github: instance('github', 'github') }, + 'Other channels', + ); + + expect( + groups.map(({ id, label, sessions }) => ({ + id, + label, + sessions: sessions.map((item) => item.sessionId), + })), + ).toEqual([ + { + id: 'channel-type:github', + label: 'github', + sessions: ['github'], + }, + { + id: 'channel-type-fallback', + label: 'Other channels', + sessions: ['legacy-one', 'legacy-two'], + }, + ]); + }); +}); diff --git a/packages/web-shell/client/components/sidebar/channelSessionGroups.ts b/packages/web-shell/client/components/sidebar/channelSessionGroups.ts new file mode 100644 index 00000000000..9d946da90a5 --- /dev/null +++ b/packages/web-shell/client/components/sidebar/channelSessionGroups.ts @@ -0,0 +1,59 @@ +import type { + DaemonChannelInstanceSnapshot, + DaemonChannelTypeCatalog, + DaemonSessionSummary, +} from '@qwen-code/sdk/daemon'; + +export interface ChannelSessionGroup { + id: string; + label: string; + sessions: DaemonSessionSummary[]; +} + +const FALLBACK_GROUP_ID = 'channel-type-fallback'; + +export function groupSessionsByChannelType( + sessions: readonly DaemonSessionSummary[], + catalog: DaemonChannelTypeCatalog, + instances: Readonly>, + otherLabel: string, +): ChannelSessionGroup[] { + const labels = new Map( + catalog.map((descriptor) => [descriptor.type, descriptor.displayName]), + ); + const groups = new Map(); + + for (const session of sessions) { + // Object.hasOwn: a sourceId like 'constructor' resolves through + // Object.prototype on a plain record, so a bare index read would + // dereference `.config` on the Object function and throw. + const instance = + session.sourceId && Object.hasOwn(instances, session.sourceId) + ? instances[session.sourceId] + : undefined; + const configuredType = instance?.config['type']; + const type = + typeof configuredType === 'string' + ? configuredType.trim() || undefined + : undefined; + const id = type ? `channel-type:${type}` : FALLBACK_GROUP_ID; + const existing = groups.get(id); + if (existing) { + existing.sessions.push(session); + continue; + } + groups.set(id, { + id, + label: type ? (labels.get(type) ?? type) : otherLabel, + sessions: [session], + }); + } + + const ordered = [...groups.values()]; + const fallback = groups.get(FALLBACK_GROUP_ID); + // Keep the unresolved "Other channels" section after every named platform + // instead of letting first-seen session order wedge it between two of them. + return fallback === undefined + ? ordered + : [...ordered.filter((group) => group !== fallback), fallback]; +} diff --git a/packages/web-shell/client/components/sidebar/collapsedSessionSections.ts b/packages/web-shell/client/components/sidebar/collapsedSessionSections.ts index 33c2c744db5..dc88d68ee16 100644 --- a/packages/web-shell/client/components/sidebar/collapsedSessionSections.ts +++ b/packages/web-shell/client/components/sidebar/collapsedSessionSections.ts @@ -4,7 +4,7 @@ * key so preferences survive reload without competing overwrites. * * Id conventions: - * - Primary catalog: `group:`, `recent`, `color:` + * - Primary catalog: `group:`, `recent`, `color:`, `channel-type:` * - Workspace-scoped: `ws:|group:`, `ws:|ungrouped` */ diff --git a/packages/web-shell/client/e2e/utils/mockDaemon.ts b/packages/web-shell/client/e2e/utils/mockDaemon.ts index 3b1b132cd5b..6db21aed4b8 100644 --- a/packages/web-shell/client/e2e/utils/mockDaemon.ts +++ b/packages/web-shell/client/e2e/utils/mockDaemon.ts @@ -541,6 +541,27 @@ function readRequestBody(raw: string | null): unknown { } } +// Mirror production query modes: `group=pinned` is the pinned bucket; +// `group=all` (and missing group) returns the full active list. The UI +// excludes pinned rows from organized sections via `excludePinned`. +function filterScenarioSessions( + scenario: WebShellDaemonScenario, + searchParams: URLSearchParams, +): DaemonSessionSummary[] { + const group = searchParams.get('group'); + const sourceType = searchParams.get('sourceType'); + const sourceSessions = sourceType + ? scenario.sessions.filter( + (session) => + session.sourceType === sourceType || + (sourceType === 'default' && session.sourceType === undefined), + ) + : scenario.sessions; + return group === 'pinned' + ? sourceSessions.filter((session) => Boolean(session.isPinned)) + : sourceSessions; +} + function isDaemonPath(path: string): boolean { return ( path === '/health' || @@ -565,6 +586,7 @@ function isDaemonPath(path: string): boolean { /^\/workspaces\/[^/]+\/channels\/[^/]+\/pairing-approvals\/?$/.test(path) || /^\/workspaces\/[^/]+\/channels\/[^/]+\/?$/.test(path) || /^\/workspace\/.+\/sessions\/?$/.test(path) || + /^\/workspaces\/[^/]+\/sessions\/?$/.test(path) || /^\/workspace\/.+\/session-groups\/?$/.test(path) || /^\/workspaces\/.+\/git\/?$/.test(path) || /^\/workspaces\/.+\/git\/(branches|checkout|branch|push|pull|commit|diff|log)\/?$/.test( @@ -636,7 +658,11 @@ function isDaemonRoute(method: string, path: string): boolean { ) { return true; } - if (method === 'GET' && /^\/workspace\/.+\/sessions\/?$/.test(path)) { + if ( + method === 'GET' && + (/^\/workspace\/.+\/sessions\/?$/.test(path) || + /^\/workspaces\/[^/]+\/sessions\/?$/.test(path)) + ) { return true; } if (method === 'GET' && /^\/workspace\/.+\/session-groups\/?$/.test(path)) { @@ -847,16 +873,14 @@ async function handleDaemonRoute( await json(route, workspaceMcpResources(scenario, serverName)); return; } - if (method === 'GET' && /^\/workspace\/.+\/sessions\/?$/.test(path)) { - // Mirror production query modes: `group=pinned` is the pinned bucket; - // `group=all` (and missing group) returns the full active list. The UI - // excludes pinned rows from organized sections via `excludePinned`. - const group = searchParams.get('group'); - const sessions = - group === 'pinned' - ? scenario.sessions.filter((session) => Boolean(session.isPinned)) - : scenario.sessions; - await json(route, { sessions }); + if ( + method === 'GET' && + (/^\/workspace\/.+\/sessions\/?$/.test(path) || + /^\/workspaces\/[^/]+\/sessions\/?$/.test(path)) + ) { + await json(route, { + sessions: filterScenarioSessions(scenario, searchParams), + }); return; } if (method === 'GET' && /^\/workspace\/.+\/session-groups\/?$/.test(path)) { diff --git a/packages/web-shell/client/e2e/web-shell.channels.spec.ts b/packages/web-shell/client/e2e/web-shell.channels.spec.ts index b37d300fea7..783ecc7cb89 100644 --- a/packages/web-shell/client/e2e/web-shell.channels.spec.ts +++ b/packages/web-shell/client/e2e/web-shell.channels.spec.ts @@ -11,6 +11,156 @@ import { replayCompleteEvent, } from './utils/mockDaemon'; +test('shows channel sessions in the sidebar channel catalog', async ({ + page, +}, testInfo) => { + const workspaceCwd = '/tmp/qwen-web-shell-e2e'; + // DaemonSessionSummary requires workspaceCwd; keep a shared base so every + // fixture matches the shape the real daemon returns. + const baseSession = { workspaceCwd }; + const scenario = createWebShellDaemonScenario({ + workspaceCwd, + capabilities: { + features: [ + 'session_events', + 'permission_vote', + 'session_permission_vote', + 'session_scope_override', + 'session_source_metadata', + 'channel_management', + ], + }, + channelTypes: [ + { + type: 'dingtalk', + displayName: 'DingTalk', + manageable: true, + fields: [], + }, + { + type: 'feishu', + displayName: 'Feishu', + manageable: true, + fields: [], + }, + ], + channels: { + revision: '1', + instances: { + 'release-bot': { + name: 'release-bot', + config: { type: 'dingtalk' }, + secrets: {}, + startsWithServe: false, + runtime: { state: 'connected' }, + }, + 'ops-bot': { + name: 'ops-bot', + config: { type: 'dingtalk' }, + secrets: {}, + startsWithServe: false, + runtime: { state: 'connected' }, + }, + 'feishu-main': { + name: 'feishu-main', + config: { type: 'feishu' }, + secrets: {}, + startsWithServe: false, + runtime: { state: 'connected' }, + }, + }, + }, + sessions: [ + { + ...baseSession, + sessionId: 'task-session', + displayName: 'Web Shell task', + sourceType: 'default', + }, + { + ...baseSession, + sessionId: 'dingtalk-session', + displayName: 'DingTalk conversation', + sourceType: 'channel', + sourceId: 'release-bot', + }, + { + ...baseSession, + sessionId: 'dingtalk-ops-session', + displayName: 'DingTalk ops conversation', + sourceType: 'channel', + sourceId: 'ops-bot', + isPinned: true, + }, + { + ...baseSession, + sessionId: 'feishu-session', + displayName: 'Feishu conversation', + sourceType: 'channel', + sourceId: 'feishu-main', + }, + { + ...baseSession, + sessionId: 'legacy-channel-session', + displayName: 'Legacy channel conversation', + sourceType: 'channel', + }, + ], + }); + const daemon = await installMockDaemon(page, scenario, { + baseURL: String(testInfo.project.use.baseURL), + }); + + await page.goto(`/session/${encodeURIComponent(scenario.sessionId)}`); + await expect(page.locator('[data-web-shell-root]')).toBeVisible(); + const connection = await daemon.sse.waitForConnection(scenario.sessionId); + await daemon.sendEvent( + replayCompleteEvent({ sessionId: connection.sessionId }), + ); + await expect(page.getByText('Loading...')).toHaveCount(0); + + await expect(page.getByText('Web Shell task', { exact: true })).toBeVisible(); + await expect(page.getByText('DingTalk conversation')).toHaveCount(0); + await expect(page.getByText('Legacy channel conversation')).toHaveCount(0); + await page.getByRole('tab', { name: 'Channels' }).click(); + await expect( + page.getByText('DingTalk conversation', { exact: true }), + ).toBeVisible(); + await expect(page.getByText('Web Shell task')).toHaveCount(0); + const dingTalkGroup = page.getByRole('region', { name: 'DingTalk' }); + await expect(dingTalkGroup).toContainText('DingTalk conversation'); + await expect(dingTalkGroup).toContainText('DingTalk ops conversation'); + await expect(dingTalkGroup).not.toContainText('Feishu conversation'); + await expect(page.getByRole('region', { name: 'Feishu' })).toContainText( + 'Feishu conversation', + ); + await expect( + page.getByRole('region', { name: 'Other channels' }), + ).toContainText('Legacy channel conversation'); + + const dingTalkToggle = dingTalkGroup.getByRole('button').first(); + await expect(dingTalkToggle).toHaveAttribute('aria-expanded', 'true'); + await dingTalkToggle.click(); + await expect(dingTalkToggle).toHaveAttribute('aria-expanded', 'false'); + await expect( + page.getByText('DingTalk conversation', { exact: true }), + ).toHaveCount(0); + await dingTalkToggle.click(); + await expect(dingTalkToggle).toHaveAttribute('aria-expanded', 'true'); + + scenario.sessions.push({ + ...baseSession, + sessionId: 'new-dingtalk-session', + displayName: 'New DingTalk conversation', + sourceType: 'channel', + sourceId: 'release-bot', + }); + await expect( + page.getByText('New DingTalk conversation', { exact: true }), + ).toBeVisible({ timeout: 5_000 }); + await expect(dingTalkGroup).toContainText('New DingTalk conversation'); +}); + test('creates and deletes a typed Channel configuration', async ({ page, }, testInfo) => { @@ -47,6 +197,19 @@ test('creates and deletes a typed Channel configuration', async ({ required: true, envResolvable: true, }, + { + key: 'sessionScope', + label: 'Session scope', + kind: 'enum', + required: true, + default: 'user', + 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' }, + ], + }, ], }, { @@ -103,6 +266,8 @@ test('creates and deletes a typed Channel configuration', async ({ await page.getByLabel('Instance name').fill('release-bot'); await page.getByLabel('Client ID (AppKey)').fill('ding-client-id'); await page.getByLabel('Client Secret (AppSecret)').fill('ding-client-secret'); + await page.getByLabel('Session scope').click(); + await page.getByRole('option', { name: 'Per thread' }).click(); await page.getByRole('button', { name: 'Save' }).click(); await expect( @@ -124,6 +289,7 @@ test('creates and deletes a typed Channel configuration', async ({ config: { type: 'dingtalk', clientId: 'ding-client-id', + sessionScope: 'thread', senderPolicy: 'pairing', }, secrets: { @@ -140,6 +306,7 @@ test('creates and deletes a typed Channel configuration', async ({ await expect( page.getByRole('heading', { name: 'Edit DingTalk' }), ).toBeVisible(); + await expect(page.getByLabel('Session scope')).toHaveText('Per thread'); await expect(page.getByText('Ada', { exact: true })).toBeVisible(); await expect(page.getByText('ABCD1234', { exact: true })).toBeVisible(); await page diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index cc27169d03d..825f01aee60 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -1214,6 +1214,10 @@ const EN: Messages = { 'sidebar.newWorktreeTask': 'New worktree task', 'sidebar.plugins': 'Plugins', 'sidebar.channels': 'Channels', + 'sidebar.sessionSource': 'Session source', + 'sidebar.sessionSource.tasks': 'Tasks', + 'sidebar.sessionSource.channels': 'Channels', + 'sidebar.channelType.other': 'Other channels', 'sidebar.live': 'Live', 'sidebar.project': 'Project', 'sidebar.pinnedSessions': 'Pinned', @@ -2602,10 +2606,19 @@ const EN: Messages = { 'Update public settings or explicitly change stored credentials.', 'channels.editor.section.identity': 'Identity', 'channels.editor.section.credentials': 'Credentials', + 'channels.editor.section.session': 'Session', 'channels.editor.section.access': 'Access policy', 'channels.editor.instanceName': 'Instance name', 'channels.editor.instanceNamePlaceholder': 'e.g. release-bot', 'channels.editor.environmentReference': '$ENV_VAR supported', + 'channels.editor.field.sessionScope': 'Session scope', + 'channels.editor.field.sessionScope.description': + 'Controls which incoming conversations share one agent session.', + 'channels.editor.field.sessionScope.option.user': 'Per user and chat', + 'channels.editor.field.sessionScope.option.thread': 'Per thread', + 'channels.editor.field.sessionScope.option.chat_thread': + 'Per chat and thread', + 'channels.editor.field.sessionScope.option.single': 'One shared session', 'channels.editor.field.dingtalk.clientId': 'Client ID (AppKey)', 'channels.editor.field.dingtalk.clientSecret': 'Client Secret (AppSecret)', 'channels.editor.field.wecom.botId': 'Bot ID', @@ -4009,6 +4022,10 @@ const ZH: Messages = { 'sidebar.newWorktreeTask': '新建 Worktree 任务', 'sidebar.plugins': '插件', 'sidebar.channels': '频道', + 'sidebar.sessionSource': '会话来源', + 'sidebar.sessionSource.tasks': '任务', + 'sidebar.sessionSource.channels': '频道', + 'sidebar.channelType.other': '其他频道', 'sidebar.live': 'Live', 'sidebar.project': '项目', 'sidebar.pinnedSessions': '置顶', @@ -5292,10 +5309,18 @@ const ZH: Messages = { 'channels.editor.editDescription': '更新公开配置,或明确更改已保存的凭据。', 'channels.editor.section.identity': '频道标识', 'channels.editor.section.credentials': '应用凭据', + 'channels.editor.section.session': '会话', 'channels.editor.section.access': '准入策略', 'channels.editor.instanceName': '实例名称', 'channels.editor.instanceNamePlaceholder': '例如 release-bot', 'channels.editor.environmentReference': '支持 $ENV_VAR', + 'channels.editor.field.sessionScope': '会话作用域', + 'channels.editor.field.sessionScope.description': + '控制哪些来源的消息共享同一个 Agent 会话。', + 'channels.editor.field.sessionScope.option.user': '按用户和对话', + 'channels.editor.field.sessionScope.option.thread': '按话题', + 'channels.editor.field.sessionScope.option.chat_thread': '按对话和话题', + 'channels.editor.field.sessionScope.option.single': '整个频道共享', 'channels.editor.field.dingtalk.clientId': 'Client ID(原 AppKey)', 'channels.editor.field.dingtalk.clientSecret': 'Client Secret(原 AppSecret)',