mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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.
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)}
|
||||
|
||||
+1094
-5
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
@@ -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)',
|
||||
|
||||
Reference in new issue
Block a user