feat(web-shell): expose channel sessions in sidebar and settings (#8457)

* feat(web-shell): show channel sessions in sidebar

* feat(web-shell): configure channel session scope

* fix(web-shell): refresh channel session transcripts

* feat(web-shell): group channel sessions by platform

* fix(channels): address session review feedback

* test(web-shell): harden channel session coverage

* test: pin remaining channel session review findings

Close the round-1 review gaps the previous fixes left unpinned: the
channel-base/acp-bridge display-text wire key now has a cross-package
equality assertion, the secondary-workspace catalog test covers pinned
channel sessions, the collect-coalescing test pins the raw display text
when buffered messages carry model-only metadata, the editor descriptor
labels diverge from the i18n values so missing keys surface, and the
channels e2e asserts the legacy fixture is absent from the Tasks tab.

* fix: harden channel session sidebar and display projections (#8457)

Round-2 review fixes:

- Guard the sidebar channel-instance lookup with Object.hasOwn so a
  sourceId like 'constructor' cannot crash the Channels view through
  Object.prototype (R2-1).
- Drop the {text:''} part synthesis for empty displayText replays so
  image-only channel prompts stop emitting a spurious
  unknown_record_or_part diagnostic and complete:false (R2-2).
- Route the webhook display projection through the same
  truncate+sanitize treatment as the model prompt via a shared helper
  (R2-3).
- Refresh the per-workspace channel catalog on the session poll tick
  and render the channel list flat — never under organization groups —
  while the catalog is unavailable (R2-4).
- Remove the dead prompt re-truncation in sessionService (R2-5).
- Extract getChannelPromptDisplayText as the single source of truth for
  the echo, pending-entry, and re-arm display-text reads (R2-7).
- Add workspaceCwd to the channel e2e session fixtures (R2-8).
- Pin the archived source switch, the deleteSessionData validation
  contract, and the stale-response ordering guard with tests
  (R2-10, R2-11, R2-13).

* fix(channels): address session review feedback

* fix(channels): address channel session review feedback

* fix(channels): harden channel session review round-7 criticals (#8457)

Co-authored-by: Qwen-Coder <[email protected]>

* fix(web-shell): harden first-visit session section latches (#8457)

* fix(channels): address review round-8 findings (#8457)

* test(web-shell): pin round-8 sidebar behavior fixes (#8457)

* fix(channels): address review round-9 findings (#8457)

* fix(web-shell): pin Other channels group after platform sections (#8457)

---------

Co-authored-by: 克竟 <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: 丁炳智 <[email protected]>
Co-authored-by: qwen-code-autofix[bot] <qwen-code-autofix[bot]@users.noreply.github.com>
This commit is contained in:
66 files changed
+4792 -177

No files matched your search

@@ -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.
@@ -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.
+323 -5
View File
@@ -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<PromptResponse>();
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',
);
+62 -9
View File
@@ -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<SessionEntry, 'sourceType'>,
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) {
+4
View File
@@ -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
@@ -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<string, string | undefined>;
};
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<string, unknown> };
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',
+26 -4
View File
@@ -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<void> {
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<string> {
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);
@@ -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<string>;
cancelSession(sessionId: string): Promise<void>;
/** 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<void>;
/**
* 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<void>;
respondToPermission?(
requestId: string,
response: RequestPermissionResponse,
+137 -8
View File
@@ -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<typeof vi.fn>
).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mock
.calls[1][1] as string;
expect(collectedPrompt).toContain('follow-up while webhook runs');
expect(
(bridge.prompt as ReturnType<typeof vi.fn>).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' },
);
});
+39 -3
View File
@@ -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<string> {
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
@@ -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;
@@ -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<DaemonChannelEvent> {
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);
@@ -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<void>;
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<void>;
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<string> {
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,
);
+6
View File
@@ -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';
@@ -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);
});
});
+18
View File
@@ -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: <path>`). Unlike
+2
View File
@@ -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;
@@ -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])|(?<![\ud800-\udbff])[\udc00-\udfff]/,
);
});
it('records aggregated comments that exceed the summary cap', async () => {
await initWithoutLoop();
const comments = Array.from({ length: 25 }, (_, index) =>
+10 -1
View File
@@ -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<GithubCursor> {
? 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<GithubCursor> {
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<GithubCursor> {
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<GithubCursor> {
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,
+28 -2
View File
@@ -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,
@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
expect(env['isGroup']).toBe(true);
expect(env['text']).toContain('[atMention=false]');
expect(env['displayText']).toBe('hello world');
});
it('policy=keyword 时只有匹配关键词才触发', async () => {
@@ -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',
},
});
@@ -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 {
@@ -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];
@@ -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);
@@ -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' },
],
},
],
});
});
@@ -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(
@@ -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<void> {
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,
};
});
}
@@ -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<void>;
};
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<void>;
};
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<void>;
};
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<void>;
};
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 {
@@ -115,6 +115,13 @@ interface DaemonCapabilitiesLike {
interface DaemonClientLike {
capabilities(): Promise<DaemonCapabilitiesLike>;
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<void>;
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<unknown, DaemonWorkerArgs> = {
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<unknown, DaemonWorkerArgs> = {
const handle = await runChannelDaemonWorker({
daemonUrl,
daemonToken,
promptAuthorization,
workspace,
selection,
startupSignal: startupAbortController.signal,
@@ -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,
);
});
});
@@ -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();
});
@@ -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`,
@@ -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);
});
});
@@ -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<string, unknown>
>
)['bot']?.['sessionScope'],
).toBe('chat_thread');
});
it('replaces and clears secrets only through explicit operations', async () => {
writeWorkspaceSettings(`{
"$version": 4,
@@ -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']),
};
@@ -0,0 +1,24 @@
const workspacesByToken = new Map<string, string>();
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
);
}
@@ -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);
@@ -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) {
+35
View File
@@ -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<string, unknown>) }
: 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: {
+55
View File
@@ -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 =
@@ -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,
@@ -180,9 +180,19 @@ export const renameCommand: SlashCommand = {
// future regressions don't leak an interval timer).
let outcome: Awaited<ReturnType<typeof tryGenerateSessionTitle>>;
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);
@@ -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.
@@ -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);
@@ -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',
@@ -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<string | undefined> = [];
/**
* 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<string | undefined> {
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
@@ -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<fs.Dirent<Buffer>>);
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<fs.Dirent<Buffer>>);
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<fs.Dirent<Buffer>>);
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<fs.Dirent<Buffer>>);
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<fs.Dirent<Buffer>>);
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();
+27 -4
View File
@@ -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
@@ -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',
+9 -1
View File
@@ -109,6 +109,7 @@ export type SessionTitleOutcome =
export async function tryGenerateSessionTitle(
config: Config,
abortSignal: AbortSignal,
userDisplayTexts: ReadonlyArray<string | undefined> = [],
): Promise<SessionTitleOutcome> {
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' };
@@ -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' } },
@@ -93,6 +93,10 @@ const FIELD_LABEL_KEYS: Record<string, Record<string, string>> = {
},
};
const COMMON_FIELD_LABEL_KEYS: Record<string, string> = {
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({
<SelectContent>
{field.options?.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
{fieldOptionLabel(field, option)}
</SelectItem>
))}
</SelectContent>
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[calc(100%-2rem)] p-5 sm:max-w-xl">
@@ -607,12 +635,23 @@ export function ChannelEditorDialog({
</FieldShell>
</section>
<section className={styles.section}>
<h3 className={styles.sectionHeading}>
{t('channels.editor.section.credentials')}
</h3>
{descriptor.fields.map(renderField)}
</section>
{platformFields.length > 0 ? (
<section className={styles.section}>
<h3 className={styles.sectionHeading}>
{t('channels.editor.section.credentials')}
</h3>
{platformFields.map(renderField)}
</section>
) : null}
{sessionScopeField ? (
<section className={styles.section}>
<h3 className={styles.sectionHeading}>
{t('channels.editor.section.session')}
</h3>
{renderField(sessionScopeField)}
</section>
) : null}
{(() => {
const descriptorPolicy = hasDescriptorSenderPolicy(descriptor);
@@ -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);
@@ -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 : '';
}
@@ -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;
@@ -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<SidebarSessionSource>('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<SidebarSessionSource>(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<Set<string>>(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<SidebarSessionSource, boolean>
>({ 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<HTMLElement>(null);
const groupMenuRef = useRef<HTMLDivElement>(null);
const sessionMenuPointerDismissRef = useRef(false);
const previousRunningRef = useRef<Map<string, boolean> | null>(null);
const previousRunningBySourceRef = useRef<
Record<SidebarSessionSource, Map<string, boolean> | null>
>({ default: null, channel: null });
const lastTrackedSessionSourceRef = useRef(sessionSource);
const autoOpenedContextRef = useRef<string | null>(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<SessionSection[]>(() => {
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 (
<div className={styles.notice}>{t('sidebar.loadingSessions')}</div>
);
}
if (error && sessions.length === 0) {
if (error && sessionsPage === undefined) {
return (
<button className={styles.retry} type="button" onClick={reload}>
{t('sidebar.loadFailed')}
@@ -3438,12 +3592,31 @@ export function WebShellSidebar({
}
if (
filteredSessions.length === 0 &&
(searchQuery.trim() ||
(selectedSessionSource === 'channel' ||
channelSessionSections !== null ||
searchQuery.trim() ||
!organizationEnabled ||
sessionSections.length === 0)
) {
return <div className={styles.notice}>{t('sidebar.searchEmpty')}</div>;
}
if (channelSessionSections) {
return channelSessionSections.map((section) => (
<SessionGroupSection
key={section.id}
id={section.id}
label={section.label}
count={section.sessions.length}
expanded={!collapsedSessionSectionIds.has(section.id)}
onToggle={() => toggleSessionSection(section.id)}
>
{section.sessions.map((session) => renderSessionRow(session))}
</SessionGroupSection>
));
}
if (selectedSessionSource === 'channel') {
return filteredSessions.map((session) => renderSessionRow(session));
}
if (!organizationEnabled) {
return filteredSessions.map((session) => renderSessionRow(session));
}
@@ -3484,6 +3657,7 @@ export function WebShellSidebar({
}, [
collapsedSessionSectionIds,
canOrganizeWorkspace,
channelSessionSections,
error,
filteredSessions,
groupBusy,
@@ -3494,8 +3668,9 @@ export function WebShellSidebar({
reload,
renderSessionRow,
searchQuery,
selectedSessionSource,
sessionSections,
sessions.length,
sessionsPage,
t,
toggleSessionSection,
]);
@@ -4094,30 +4269,55 @@ export function WebShellSidebar({
</div>
<div className={styles.body}>
<div className={styles.sessionList}>
{!collapsed && pinnedSessions.length > 0 && (
<>
<div className={styles.projectsHeader}>
<button
className={styles.projectsHeaderToggle}
type="button"
aria-expanded={pinnedExpanded}
onClick={() => setPinnedExpanded((expanded) => !expanded)}
>
<span>{t('sidebar.pinnedSessions')}</span>
<IconChevron expanded={pinnedExpanded} />
</button>
</div>
{pinnedExpanded && (
<div className={styles.pinnedSessionList}>
{pinnedSessions.map((session) =>
renderSessionRow(session, {
readOnly: isActiveSessionReadOnly(session),
}),
)}
</div>
)}
</>
{!collapsed && sourceMetadataEnabled && (
<Tabs
className="px-2 pb-2"
value={sessionSource}
onValueChange={(value) =>
setSessionSource(value as SidebarSessionSource)
}
>
<TabsList
className="w-full"
aria-label={t('sidebar.sessionSource')}
>
<TabsTrigger value="default">
<ListTodoIcon />
{t('sidebar.sessionSource.tasks')}
</TabsTrigger>
<TabsTrigger value="channel">
<MessageCircleIcon />
{t('sidebar.sessionSource.channels')}
</TabsTrigger>
</TabsList>
</Tabs>
)}
{!collapsed &&
selectedSessionSource !== 'channel' &&
pinnedSessions.length > 0 && (
<>
<div className={styles.projectsHeader}>
<button
className={styles.projectsHeaderToggle}
type="button"
aria-expanded={pinnedExpanded}
onClick={() => setPinnedExpanded((expanded) => !expanded)}
>
<span>{t('sidebar.pinnedSessions')}</span>
<IconChevron expanded={pinnedExpanded} />
</button>
</div>
{pinnedExpanded && (
<div className={styles.pinnedSessionList}>
{pinnedSessions.map((session) =>
renderSessionRow(session, {
readOnly: isActiveSessionReadOnly(session),
}),
)}
</div>
)}
</>
)}
{!collapsed &&
liveWorkspaces.map((ws) => (
<WorkspaceSection
@@ -4156,6 +4356,8 @@ export function WebShellSidebar({
noSessionsLabel={t('sidebar.noSessions')}
loadErrorLabel={t('sidebar.loadFailed')}
organizationEnabled={false}
sourceType={selectedSessionSource}
channelGroupingEnabled={channelGroupingEnabled}
ungroupedLabel={t('sidebar.groupUngrouped')}
formatTime={(iso) => 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)}
@@ -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<string, unknown>;
}) => void;
const staleSnapshot = new Promise<{
revision: string;
instances: Record<string, unknown>;
}>((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', () => {
@@ -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<DaemonSessionGroup[]>([]);
const [channelCatalog, setChannelCatalog] = useState<{
catalog: DaemonChannelTypeCatalog;
snapshot: DaemonChannelsSnapshot;
}>();
const [internalExpanded, setInternalExpanded] = useState(false);
const [collapsedGroupIds, setCollapsedGroupIds] = useState<Set<string>>(() =>
readWorkspaceCollapsedGroupIds(workspace.id),
@@ -142,10 +148,12 @@ export function WorkspaceSection({
const [actionsVisible, setActionsVisible] = useState(false);
const [gitStatus, setGitStatus] = useState<DaemonWorkspaceGitStatus>();
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<string>();
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 (
<div className={styles.section}>
@@ -416,8 +487,35 @@ export function WorkspaceSection({
{loadErrorLabel}
</div>
) : visibleSessions.length === 0 ? (
<div className={styles.empty}>{noSessionsLabel}</div>
) : 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 : (
<div className={styles.empty}>{noSessionsLabel}</div>
)
) : channelSessionGroups ? (
<>
{channelSessionGroups.map((group) => (
<SessionGroupSection
id={group.id}
key={group.id}
label={group.label}
count={group.sessions.length}
expanded={!collapsedGroupIds.has(group.id)}
onToggle={() => {
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))}
</SessionGroupSection>
))}
</>
) : groupedSessions && !channelGroupingEnabled ? (
<>
{groupedSessions.sections.map(({ group, sessions }) => (
<SessionGroupSection
@@ -0,0 +1,166 @@
import { describe, expect, it } from 'vitest';
import type {
DaemonChannelInstanceSnapshot,
DaemonChannelTypeCatalog,
DaemonSessionSummary,
} from '@qwen-code/sdk/daemon';
import { groupSessionsByChannelType } from './channelSessionGroups';
function session(sessionId: string, sourceId?: string): DaemonSessionSummary {
return {
sessionId,
workspaceCwd: '/workspace',
sourceType: 'channel',
sourceId,
};
}
const catalog: DaemonChannelTypeCatalog = [
{ type: 'dingtalk', displayName: 'DingTalk', manageable: true, fields: [] },
{ type: 'feishu', displayName: 'Feishu', manageable: true, fields: [] },
];
function instance(name: string, type: string): DaemonChannelInstanceSnapshot {
return {
name,
config: { type },
secrets: {},
startsWithServe: false,
runtime: { state: 'stopped' },
};
}
function groupIds(groups: ReturnType<typeof groupSessionsByChannelType>) {
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'],
},
]);
});
});
@@ -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<Record<string, DaemonChannelInstanceSnapshot>>,
otherLabel: string,
): ChannelSessionGroup[] {
const labels = new Map(
catalog.map((descriptor) => [descriptor.type, descriptor.displayName]),
);
const groups = new Map<string, ChannelSessionGroup>();
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];
}
@@ -4,7 +4,7 @@
* key so preferences survive reload without competing overwrites.
*
* Id conventions:
* - Primary catalog: `group:<id>`, `recent`, `color:<name>`
* - Primary catalog: `group:<id>`, `recent`, `color:<name>`, `channel-type:<type>`
* - Workspace-scoped: `ws:<workspaceId>|group:<id>`, `ws:<workspaceId>|ungrouped`
*/
@@ -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)) {
@@ -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
+25
View File
@@ -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)',