mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 04:13:17 +08:00
feat: split group and direct context enhancement
This commit is contained in:
parent
38096d1589
commit
75ebaebf2e
21 changed files with 1423 additions and 873 deletions
|
|
@ -260,7 +260,8 @@ for (const scenario of [
|
|||
const order = [];
|
||||
const asked = [];
|
||||
const configFor = ([directEnabled, groupEnabled], guidance) => ({
|
||||
directEnabled, groupEnabled, fields: ['channel', 'botId'], guidance,
|
||||
group: { enabled: groupEnabled, fields: ['channel', 'botId'], guidance },
|
||||
direct: { enabled: directEnabled, fields: ['channel', 'botId'], guidance },
|
||||
});
|
||||
let config = configFor(scenario.before, 'before callback returned');
|
||||
let callback;
|
||||
|
|
|
|||
|
|
@ -1446,10 +1446,12 @@ test('Discord captures context settings before asynchronous Thread routing and u
|
|||
let socket;
|
||||
let reads = 0;
|
||||
let config = {
|
||||
groupEnabled: true,
|
||||
directEnabled: false,
|
||||
fields: ['channel', 'conversationType', 'senderId', 'senderName', 'botId'],
|
||||
guidance: 'accepted before routing',
|
||||
group: {
|
||||
enabled: true,
|
||||
fields: ['channel', 'conversationType', 'senderId', 'senderName', 'botId'],
|
||||
guidance: 'accepted before routing',
|
||||
},
|
||||
direct: { enabled: false, fields: [], guidance: 'direct must not leak' },
|
||||
};
|
||||
const runtime = new DiscordRuntime({
|
||||
config: { botId: 'discord_internal', platformId: botId, name: 'Harness Discord' },
|
||||
|
|
@ -1502,7 +1504,7 @@ test('Discord captures context settings before asynchronous Thread routing and u
|
|||
member: { nick: 'Group Nick' }, mentions: [{ id: botId }], content: `<@${botId}> first`,
|
||||
} }) });
|
||||
await routingStarted.promise;
|
||||
config = { ...config, groupEnabled: false };
|
||||
config = { ...config, group: { ...config.group, enabled: false } };
|
||||
releaseRouting.resolve();
|
||||
await eventually(() => runtime.status.messagesReplied === 1);
|
||||
assert.match(prompts[0], /accepted before routing/);
|
||||
|
|
|
|||
|
|
@ -25,7 +25,20 @@ const FILE = Buffer.from('unchanged file bytes');
|
|||
const logger = { info() {}, warn() {}, error() {} };
|
||||
|
||||
function settings(overrides = {}) {
|
||||
return { groupEnabled: true, directEnabled: true, fields: [...CONTEXT_ENHANCEMENT_FIELDS], guidance: '', ...overrides };
|
||||
const {
|
||||
groupEnabled = true,
|
||||
directEnabled = true,
|
||||
fields = [...CONTEXT_ENHANCEMENT_FIELDS],
|
||||
guidance = '',
|
||||
group = {},
|
||||
direct = {},
|
||||
...extra
|
||||
} = overrides;
|
||||
return {
|
||||
group: { enabled: groupEnabled, fields: [...fields], guidance, ...group },
|
||||
direct: { enabled: directEnabled, fields: [...fields], guidance, ...direct },
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function provider(channel, config) {
|
||||
|
|
@ -304,6 +317,24 @@ for (const channel of CHANNELS) {
|
|||
});
|
||||
}
|
||||
|
||||
if (channel !== 'weixin') {
|
||||
test(`${channel}: group and direct fields and guidance stay isolated`, async () => {
|
||||
const config = settings({
|
||||
group: { enabled: true, fields: ['channel'], guidance: 'GROUP-ONLY-TOKEN' },
|
||||
direct: { enabled: true, fields: ['botId'], guidance: 'DIRECT-ONLY-TOKEN' },
|
||||
});
|
||||
const current = fixture(channel, { contextEnhancement: provider(channel, config) });
|
||||
await current.bridge.accept(current.event(1, 'direct message', { kind: 'direct' }));
|
||||
await current.bridge.accept(current.event(2, 'group message', { kind: 'group' }));
|
||||
assert.deepEqual(sourceOf(current.prompts[0]), { botId: `${channel}_internal` });
|
||||
assert.match(current.prompts[0], /DIRECT-ONLY-TOKEN/);
|
||||
assert.doesNotMatch(current.prompts[0], /GROUP-ONLY-TOKEN|"channel"/);
|
||||
assert.deepEqual(sourceOf(current.prompts[1]), { channel });
|
||||
assert.match(current.prompts[1], /GROUP-ONLY-TOKEN/);
|
||||
assert.doesNotMatch(current.prompts[1], /DIRECT-ONLY-TOKEN|"botId"/);
|
||||
});
|
||||
}
|
||||
|
||||
test(`${channel}: enabled image/file assembly adds one prefix and preserves the remaining payload`, async () => {
|
||||
for (const media of ['image', 'file', 'mixed']) {
|
||||
const plain = fixture(channel);
|
||||
|
|
@ -338,7 +369,7 @@ for (const channel of CHANNELS) {
|
|||
const first = current.bridge.accept(current.event(1, 'first'));
|
||||
await started.promise;
|
||||
const queued = current.bridge.accept(current.event(2, 'queued'));
|
||||
config.guidance = 'mutated after acceptance';
|
||||
config.direct.guidance = 'mutated after acceptance';
|
||||
config = settings({ fields: ['botId'], guidance: 'version two' });
|
||||
const newer = current.bridge.accept(current.event(3, 'newer'));
|
||||
config = settings({ groupEnabled: false, directEnabled: false });
|
||||
|
|
@ -409,8 +440,8 @@ for (const channel of CHANNELS) {
|
|||
for (const config of [settings({ fields: ['channel'] }), settings({ fields: [], guidance: 'custom' }), settings({ fields: [], guidance: '' })]) {
|
||||
const current = fixture(channel, { contextEnhancement: provider(channel, config) });
|
||||
await current.bridge.accept(current.event(1, 'hello'));
|
||||
if (config.fields.length) assert.deepEqual(sourceOf(current.prompts[0]), { channel });
|
||||
else if (config.guidance) assert.equal(current.prompts[0], '<dsh_im_source_guidance>\ncustom\n</dsh_im_source_guidance>\n\nhello');
|
||||
if (config.direct.fields.length) assert.deepEqual(sourceOf(current.prompts[0]), { channel });
|
||||
else if (config.direct.guidance) assert.equal(current.prompts[0], '<dsh_im_source_guidance>\ncustom\n</dsh_im_source_guidance>\n\nhello');
|
||||
else assert.equal(current.prompts[0], 'hello');
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -77,18 +77,24 @@ for (const channel of ['wecom', 'weixin', 'feishu', 'dingtalk', 'qq', 'slack', '
|
|||
assert.equal(provider.getSettings(), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
assert.equal(captureContextEnhancement(provider, 'direct'), null);
|
||||
const generation = workspaces.generationFor(botIds[0]);
|
||||
const selected = { groupEnabled: false, directEnabled: true, fields: [], guidance: 'runtime update' };
|
||||
const selected = {
|
||||
group: { enabled: false, fields: ['botId'], guidance: 'stored group guidance' },
|
||||
direct: { enabled: true, fields: [], guidance: 'runtime update' },
|
||||
};
|
||||
const saved = await production.controller.updateContextEnhancement(botIds[0], selected);
|
||||
assert.deepEqual(saved.bots[0].contextEnhancement, selected);
|
||||
assert.deepEqual(provider.getSettings(), selected);
|
||||
const beforeDisable = captureContextEnhancement(provider, 'direct');
|
||||
assert.deepEqual(beforeDisable.config, selected);
|
||||
assert.deepEqual(beforeDisable.config, selected.direct);
|
||||
assert.equal(runtimes[0].contextEnhancement, provider, 'runtime does not need reconstruction');
|
||||
assert.equal(runtimes[1].contextEnhancement.getSettings(), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
assert.equal(runtimes[1].contextEnhancement.botId, botIds[1]);
|
||||
await production.controller.updateContextEnhancement(botIds[0], { ...selected, directEnabled: false });
|
||||
await production.controller.updateContextEnhancement(botIds[0], {
|
||||
...selected,
|
||||
direct: { ...selected.direct, enabled: false },
|
||||
});
|
||||
assert.equal(captureContextEnhancement(provider, 'direct'), null);
|
||||
assert.equal(beforeDisable.config.directEnabled, true, 'an already received message keeps its snapshot');
|
||||
assert.equal(beforeDisable.config.enabled, true, 'an already received message keeps its snapshot');
|
||||
assert.equal(workspaces.generationFor(botIds[0]), generation);
|
||||
assert.equal(runtimes.length, 2);
|
||||
assert.equal(stateLoads, 2);
|
||||
|
|
|
|||
|
|
@ -1556,7 +1556,8 @@ for (const scenario of [
|
|||
let settingsReads = 0;
|
||||
let nextMessageId = 500;
|
||||
const configFor = ([directEnabled, groupEnabled], guidance) => ({
|
||||
directEnabled, groupEnabled, fields: ['channel', 'botId'], guidance,
|
||||
group: { enabled: groupEnabled, fields: ['channel', 'botId'], guidance },
|
||||
direct: { enabled: directEnabled, fields: ['channel', 'botId'], guidance },
|
||||
});
|
||||
let config = configFor(scenario.before, 'before cursor write');
|
||||
const update = (id, kind) => ({
|
||||
|
|
|
|||
|
|
@ -45,10 +45,22 @@ const CHANNELS = [
|
|||
['whatsapp', createWhatsappRpcHandler, WHATSAPP_ENDPOINTS],
|
||||
];
|
||||
|
||||
const enabled = (overrides = {}) => ({
|
||||
groupEnabled: true, directEnabled: true, fields: ['channel', 'botId'], guidance: 'saved guidance',
|
||||
...overrides,
|
||||
});
|
||||
const enabled = (overrides = {}) => {
|
||||
const {
|
||||
groupEnabled = true,
|
||||
directEnabled = true,
|
||||
fields = ['channel', 'botId'],
|
||||
guidance = 'saved guidance',
|
||||
group = {},
|
||||
direct = {},
|
||||
...extra
|
||||
} = overrides;
|
||||
return {
|
||||
group: { enabled: groupEnabled, fields, guidance, ...group },
|
||||
direct: { enabled: directEnabled, fields, guidance, ...direct },
|
||||
...extra,
|
||||
};
|
||||
};
|
||||
|
||||
async function fixture(t) {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-context-settings-'));
|
||||
|
|
@ -105,6 +117,37 @@ test('old workspace files remain untouched and default enhancement off', async (
|
|||
assert.equal(await readFile(path, 'utf8'), original, 'reading/decorating old config must not migrate/write it');
|
||||
});
|
||||
|
||||
test('legacy enhancement settings migrate in memory and persist lazily on the next successful write', async (t) => {
|
||||
const { directory, path } = await fixture(t);
|
||||
const legacy = {
|
||||
groupEnabled: true,
|
||||
directEnabled: false,
|
||||
fields: ['botId', 'channel', 'botId'],
|
||||
guidance: 'legacy guidance',
|
||||
};
|
||||
const original = JSON.stringify({
|
||||
version: 1,
|
||||
workspaces: { legacy_bot: directory },
|
||||
contextEnhancement: { legacy_bot: legacy },
|
||||
});
|
||||
await writeFile(path, original);
|
||||
const store = await new BotWorkspaceStore(path).load();
|
||||
assert.deepEqual(store.contextEnhancementFor('legacy_bot'), enabled({
|
||||
directEnabled: false,
|
||||
fields: ['channel', 'botId'],
|
||||
guidance: 'legacy guidance',
|
||||
}));
|
||||
assert.equal(await readFile(path, 'utf8'), original, 'loading legacy settings must not write');
|
||||
await store.setAgentPreset('legacy_bot', 'preset-one');
|
||||
const migrated = JSON.parse(await readFile(path, 'utf8')).contextEnhancement.legacy_bot;
|
||||
assert.deepEqual(migrated, enabled({
|
||||
directEnabled: false,
|
||||
fields: ['channel', 'botId'],
|
||||
guidance: 'legacy guidance',
|
||||
}));
|
||||
assert.equal(Object.hasOwn(migrated, 'guidance'), false);
|
||||
});
|
||||
|
||||
test('complete per-bot settings persist all switch combinations and explicit empty fields/text', async (t) => {
|
||||
const { path, directory, store } = await fixture(t);
|
||||
await store.ensure('bot_one', { defaultAgentPreset: 'preset-one' });
|
||||
|
|
@ -113,7 +156,7 @@ test('complete per-bot settings persist all switch combinations and explicit emp
|
|||
const incarnation = store.incarnationFor('bot_one');
|
||||
for (const groupEnabled of [false, true]) {
|
||||
for (const directEnabled of [false, true]) {
|
||||
const selected = { groupEnabled, directEnabled, fields: [], guidance: '' };
|
||||
const selected = enabled({ groupEnabled, directEnabled, fields: [], guidance: '' });
|
||||
const saved = await store.setContextEnhancement('bot_one', selected);
|
||||
assert.deepEqual(saved, selected);
|
||||
assert.equal(store.contextEnhancementFor('bot_one'), saved);
|
||||
|
|
@ -185,7 +228,8 @@ test('pending and failed writes never publish an uncommitted context snapshot',
|
|||
assert.match(text, /saved guidance/);
|
||||
assert.match(text, /"botId":"bot_one"/);
|
||||
assert.equal(Object.isFrozen(first), true);
|
||||
assert.equal(Object.isFrozen(first.fields), true);
|
||||
assert.equal(Object.isFrozen(first.group.fields), true);
|
||||
assert.equal(Object.isFrozen(first.direct.fields), true);
|
||||
});
|
||||
|
||||
test('invalid saves reject atomically and deleted or rebound bot incarnations cannot receive stale updates', async (t) => {
|
||||
|
|
@ -259,7 +303,7 @@ test('failed cleanup retires context settings and startup reconciliation heals s
|
|||
assert.equal(store.contextEnhancementFor('bot_one'), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
await rm(`${path}.tmp`, { recursive: true });
|
||||
const reloaded = await new BotWorkspaceStore(path).load();
|
||||
assert.equal(reloaded.contextEnhancementFor('bot_one').groupEnabled, true);
|
||||
assert.equal(reloaded.contextEnhancementFor('bot_one').group.enabled, true);
|
||||
await reloaded.reconcile(['bot_two']);
|
||||
assert.equal(reloaded.contextEnhancementFor('bot_one'), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
assert.equal(JSON.parse(await readFile(path, 'utf8')).contextEnhancement, undefined);
|
||||
|
|
@ -515,7 +559,9 @@ for (const [channel, createHandler, endpoints] of CHANNELS) {
|
|||
await rm(`${path}.tmp`, { recursive: true });
|
||||
const cleared = await handler(endpoints.setContextEnhancement, { botId, config: enabled({ fields: [], guidance: '' }) });
|
||||
assert.equal(cleared.ok, true);
|
||||
assert.deepEqual(cleared.value.bots[0].contextEnhancement.fields, []);
|
||||
assert.equal(cleared.value.bots[0].contextEnhancement.guidance, '');
|
||||
assert.deepEqual(cleared.value.bots[0].contextEnhancement.group.fields, []);
|
||||
assert.deepEqual(cleared.value.bots[0].contextEnhancement.direct.fields, []);
|
||||
assert.equal(cleared.value.bots[0].contextEnhancement.group.guidance, '');
|
||||
assert.equal(cleared.value.bots[0].contextEnhancement.direct.guidance, '');
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ import * as React from 'react';
|
|||
import TestRenderer from 'react-test-renderer';
|
||||
|
||||
import {
|
||||
CONTEXT_DIRECT_GUIDANCE_EXAMPLE,
|
||||
CONTEXT_ENHANCEMENT_FIELDS,
|
||||
CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH,
|
||||
CONTEXT_GUIDANCE_EXAMPLE,
|
||||
CONTEXT_GROUP_GUIDANCE_EXAMPLE,
|
||||
DEFAULT_CONTEXT_ENHANCEMENT_CONFIG,
|
||||
} from '../src/channels/shared/context-enhancement.mjs';
|
||||
import { ContextEnhancementEditor, contextEnhancementLabel } from '../plugin-src/client/context-enhancement.js';
|
||||
|
|
@ -42,12 +43,40 @@ function button(root, name) {
|
|||
return found;
|
||||
}
|
||||
|
||||
function fields(root) {
|
||||
return root.findAllByType('input').filter((node) => CONTEXT_ENHANCEMENT_FIELDS.includes(node.props.name));
|
||||
function scope(root, kind) {
|
||||
return root.findAllByType('fieldset').find((node) => node.props['data-context-kind'] === kind);
|
||||
}
|
||||
|
||||
function switches(root) {
|
||||
return root.findAllByProps({ role: 'switch' });
|
||||
function fields(root, kind) {
|
||||
const parent = kind ? scope(root, kind) : root;
|
||||
return parent.findAllByType('input').filter((node) => (
|
||||
typeof node.props.name === 'string'
|
||||
&& CONTEXT_ENHANCEMENT_FIELDS.includes(node.props.name.replace(/^(?:group|direct)-/, ''))
|
||||
));
|
||||
}
|
||||
|
||||
function fieldNames(nodes) {
|
||||
return nodes.map((node) => node.props.name.replace(/^(?:group|direct)-/, ''));
|
||||
}
|
||||
|
||||
function guidance(root, kind) {
|
||||
return scope(root, kind).findByType('textarea');
|
||||
}
|
||||
|
||||
function scopeSwitch(root, kind) {
|
||||
return scope(root, kind).findByProps({ role: 'switch' });
|
||||
}
|
||||
|
||||
function switchStates(root) {
|
||||
return ['group', 'direct'].map((kind) => scopeSwitch(root, kind).props.checked);
|
||||
}
|
||||
|
||||
function tabs(root) {
|
||||
return root.findAllByProps({ role: 'tab' });
|
||||
}
|
||||
|
||||
function panels(root) {
|
||||
return root.findAllByProps({ role: 'tabpanel' });
|
||||
}
|
||||
|
||||
function badge(root) {
|
||||
|
|
@ -66,6 +95,10 @@ async function click(root, label) {
|
|||
await act(async () => { button(root, label).props.onClick(); await flush(); });
|
||||
}
|
||||
|
||||
async function clickScope(root, kind, label) {
|
||||
await act(async () => { button(scope(root, kind), label).props.onClick(); await flush(); });
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
|
|
@ -118,7 +151,10 @@ test('context settings default to off with sender ID and empty guidance, and exp
|
|||
[false, false, '未开启'], [true, false, '仅群聊'],
|
||||
[false, true, '仅私聊'], [true, true, '群聊和私聊'],
|
||||
]) {
|
||||
const config = { ...DEFAULT_CONTEXT_ENHANCEMENT_CONFIG, groupEnabled, directEnabled };
|
||||
const config = {
|
||||
group: { ...DEFAULT_CONTEXT_ENHANCEMENT_CONFIG.group, enabled: groupEnabled },
|
||||
direct: { ...DEFAULT_CONTEXT_ENHANCEMENT_CONFIG.direct, enabled: directEnabled },
|
||||
};
|
||||
assert.equal(contextEnhancementLabel(config), label);
|
||||
}
|
||||
const saved = [];
|
||||
|
|
@ -132,26 +168,50 @@ test('context settings default to off with sender ID and empty guidance, and exp
|
|||
assert.equal(renderer.root.findByProps({ role: 'dialog' }).props['aria-describedby'], contextTooltip.props.id);
|
||||
assert.match(textOf(contextTooltip), /选择在哪些会话中启用.*不查询平台 API/);
|
||||
assert.equal(renderer.root.findAllByType('p').some((node) => textOf(node).startsWith('选择在哪些会话中启用')), false);
|
||||
assert.deepEqual(switches(renderer.root).map((node) => node.props.checked), [false, false]);
|
||||
assert.equal(renderer.root.findByType('textarea').props.rows, 4);
|
||||
assert.deepEqual(fields(renderer.root).map((node) => node.props.name), CONTEXT_ENHANCEMENT_FIELDS);
|
||||
assert.deepEqual(fields(renderer.root).filter((node) => node.props.checked).map((node) => node.props.name), ['senderId']);
|
||||
assert.deepEqual(renderer.root.findAllByProps({ className: 'dim-contextFieldKey' }).map(textOf), CONTEXT_ENHANCEMENT_FIELDS);
|
||||
const fieldsHelp = renderer.root.findByProps({ 'aria-label': '查看来源字段说明' });
|
||||
assert.deepEqual(tabs(renderer.root).map(textOf), ['私聊', '群聊']);
|
||||
assert.deepEqual(tabs(renderer.root).map((node) => node.props['aria-selected']), [true, false]);
|
||||
assert.deepEqual(tabs(renderer.root).map((node) => node.props.tabIndex), [0, -1]);
|
||||
assert.deepEqual(panels(renderer.root).map((node) => node.props.hidden), [false, true]);
|
||||
assert.deepEqual(tabs(renderer.root).map((node) => node.props['aria-controls']), panels(renderer.root).map((node) => node.props.id));
|
||||
let prevented = false;
|
||||
await act(async () => {
|
||||
tabs(renderer.root)[0].props.onKeyDown({ key: 'ArrowRight', preventDefault() { prevented = true; } });
|
||||
});
|
||||
assert.equal(prevented, true);
|
||||
assert.deepEqual(tabs(renderer.root).map((node) => node.props['aria-selected']), [false, true]);
|
||||
assert.deepEqual(panels(renderer.root).map((node) => node.props.hidden), [true, false]);
|
||||
await act(async () => { tabs(renderer.root)[1].props.onKeyDown({ key: 'Home', preventDefault() {} }); });
|
||||
assert.deepEqual(tabs(renderer.root).map((node) => node.props['aria-selected']), [true, false]);
|
||||
assert.deepEqual(renderer.root.findAllByProps({ className: 'dim-contextSwitchRow' }).map(textOf), ['启用', '启用']);
|
||||
assert.deepEqual(renderer.root.findAllByType('label').filter((node) => (
|
||||
typeof node.props.htmlFor === 'string' && node.props.htmlFor.endsWith('-guidance')
|
||||
)).map(textOf), ['增强提示词', '增强提示词']);
|
||||
assert.deepEqual(switchStates(renderer.root), [false, false]);
|
||||
assert.ok(renderer.root.findAllByType('textarea').every((node) => node.props.rows === 4));
|
||||
for (const kind of ['group', 'direct']) {
|
||||
assert.deepEqual(fieldNames(fields(renderer.root, kind)), CONTEXT_ENHANCEMENT_FIELDS);
|
||||
assert.deepEqual(fieldNames(fields(renderer.root, kind).filter((node) => node.props.checked)), ['senderId']);
|
||||
}
|
||||
assert.deepEqual(renderer.root.findAllByProps({ className: 'dim-contextFieldKey' }).map(textOf), [
|
||||
...CONTEXT_ENHANCEMENT_FIELDS, ...CONTEXT_ENHANCEMENT_FIELDS,
|
||||
]);
|
||||
const fieldsHelp = renderer.root.findByProps({ 'aria-label': '查看群聊来源字段说明' });
|
||||
const fieldsTooltip = renderer.root.findByProps({ id: fieldsHelp.props['aria-describedby'] });
|
||||
assert.match(textOf(fieldsTooltip), /增强提示词中请使用字段名(如 senderId、conversationType).*不会额外查询或补全/);
|
||||
assert.equal(renderer.root.findAllByType('p').some((node) => textOf(node).startsWith('增强提示词中请使用字段名')), false);
|
||||
assert.equal(renderer.root.findByType('textarea').props.value, '');
|
||||
assert.equal(renderer.root.findByType('textarea').props.placeholder, CONTEXT_GUIDANCE_EXAMPLE);
|
||||
const help = renderer.root.findByProps({ 'aria-label': '查看增强提示词使用说明' });
|
||||
assert.equal(guidance(renderer.root, 'group').props.value, '');
|
||||
assert.equal(guidance(renderer.root, 'direct').props.value, '');
|
||||
assert.equal(guidance(renderer.root, 'group').props.placeholder, CONTEXT_GROUP_GUIDANCE_EXAMPLE);
|
||||
assert.equal(guidance(renderer.root, 'direct').props.placeholder, CONTEXT_DIRECT_GUIDANCE_EXAMPLE);
|
||||
const help = renderer.root.findByProps({ 'aria-label': '查看群聊增强提示词使用说明' });
|
||||
const tooltip = renderer.root.findByProps({ id: help.props['aria-describedby'] });
|
||||
assert.equal(help.props.type, 'button');
|
||||
assert.equal(help.props['aria-describedby'], tooltip.props.id);
|
||||
assert.match(textOf(tooltip), /使用说明.*dsh_im_source.*生效规则.*清空并保存.*隐私提示.*会话历史.*使用示例.*conversationType/s);
|
||||
assert.equal(textOf(tooltip.findByProps({ className: 'dim-contextTooltipExample' })), CONTEXT_GUIDANCE_EXAMPLE);
|
||||
assert.equal(renderer.root.findByType('textarea').props['aria-describedby'], tooltip.props.id);
|
||||
assert.match(textOf(tooltip), /使用说明.*dsh_im_source.*生效规则.*清空并保存.*隐私提示.*会话历史.*使用示例.*群聊/s);
|
||||
assert.equal(textOf(tooltip.findByProps({ className: 'dim-contextTooltipExample' })), CONTEXT_GROUP_GUIDANCE_EXAMPLE);
|
||||
assert.equal(guidance(renderer.root, 'group').props['aria-describedby'], tooltip.props.id);
|
||||
assert.equal(renderer.root.findAllByType('p').some((node) => /只需填写正文|发送者标识可能包含/.test(textOf(node))), false);
|
||||
const senderNameHelp = renderer.root.findByProps({ 'aria-label': '查看发送者昵称字段说明' });
|
||||
const senderNameHelp = renderer.root.findByProps({ 'aria-label': '查看群聊发送者昵称字段说明' });
|
||||
const senderNameTooltip = renderer.root.findByProps({ id: senderNameHelp.props['aria-describedby'] });
|
||||
assert.equal(senderNameHelp.props.type, 'button');
|
||||
assert.equal(senderNameHelp.parent.props.className, 'dim-contextHelp dim-contextFieldHelp');
|
||||
|
|
@ -165,18 +225,18 @@ test('switches, fields, and guidance are local drafts until Save; Cancel and clo
|
|||
const renderer = await mount(t, ContextEnhancementEditor, { onSave: (value) => saved.push(value) });
|
||||
for (const dismiss of ['取消', 'close', 'escape', 'backdrop']) {
|
||||
await open(renderer.root);
|
||||
await act(async () => { switches(renderer.root)[0].props.onChange({ target: { checked: true } }); });
|
||||
assert.deepEqual(switches(renderer.root).map((node) => node.props.checked), [true, false]);
|
||||
await act(async () => { switches(renderer.root)[1].props.onChange({ target: { checked: true } }); });
|
||||
assert.deepEqual(switches(renderer.root).map((node) => node.props.checked), [true, true]);
|
||||
await act(async () => { scopeSwitch(renderer.root, 'group').props.onChange({ target: { checked: true } }); });
|
||||
assert.deepEqual(switchStates(renderer.root), [true, false]);
|
||||
await act(async () => { scopeSwitch(renderer.root, 'direct').props.onChange({ target: { checked: true } }); });
|
||||
assert.deepEqual(switchStates(renderer.root), [true, true]);
|
||||
await act(async () => {
|
||||
switches(renderer.root)[0].props.onChange({ target: { checked: false } });
|
||||
fields(renderer.root)[0].props.onChange({ target: { checked: false } });
|
||||
scopeSwitch(renderer.root, 'group').props.onChange({ target: { checked: false } });
|
||||
fields(renderer.root, 'group')[0].props.onChange({ target: { checked: false } });
|
||||
});
|
||||
assert.deepEqual(switches(renderer.root).map((node) => node.props.checked), [false, true]);
|
||||
await click(renderer.root, '清空');
|
||||
assert.deepEqual(switchStates(renderer.root), [false, true]);
|
||||
await clickScope(renderer.root, 'group', '清空');
|
||||
assert.equal(badge(renderer.root), '未开启');
|
||||
assert.equal(renderer.root.findByType('textarea').props.value, '');
|
||||
assert.equal(guidance(renderer.root, 'group').props.value, '');
|
||||
assert.deepEqual(saved, []);
|
||||
await act(async () => {
|
||||
if (dismiss === 'close') renderer.root.findByProps({ 'aria-label': '关闭弹窗' }).props.onClick();
|
||||
|
|
@ -191,9 +251,11 @@ test('switches, fields, and guidance are local drafts until Save; Cancel and clo
|
|||
});
|
||||
assert.equal(renderer.root.findAllByProps({ role: 'dialog' }).length, 0);
|
||||
await open(renderer.root);
|
||||
assert.deepEqual(switches(renderer.root).map((node) => node.props.checked), [false, false]);
|
||||
assert.deepEqual(fields(renderer.root).filter((node) => node.props.checked).map((node) => node.props.name), ['senderId']);
|
||||
assert.equal(renderer.root.findByType('textarea').props.value, '');
|
||||
assert.deepEqual(switchStates(renderer.root), [false, false]);
|
||||
for (const kind of ['group', 'direct']) {
|
||||
assert.deepEqual(fieldNames(fields(renderer.root, kind).filter((node) => node.props.checked)), ['senderId']);
|
||||
assert.equal(guidance(renderer.root, kind).props.value, '');
|
||||
}
|
||||
await click(renderer.root, '取消');
|
||||
}
|
||||
assert.deepEqual(saved, []);
|
||||
|
|
@ -211,30 +273,39 @@ test('Save submits one complete config, preserves explicit empty fields/guidance
|
|||
}
|
||||
const renderer = await mount(t, Fixture);
|
||||
await open(renderer.root);
|
||||
await act(async () => { switches(renderer.root)[0].props.onChange({ target: { checked: true } }); });
|
||||
await act(async () => { scopeSwitch(renderer.root, 'group').props.onChange({ target: { checked: true } }); });
|
||||
// Read each freshly rendered checkbox so every independent edit uses the current draft.
|
||||
for (const name of CONTEXT_ENHANCEMENT_FIELDS) {
|
||||
await act(async () => { renderer.root.findByProps({ name }).props.onChange({ target: { checked: false } }); });
|
||||
await act(async () => {
|
||||
scope(renderer.root, 'group').findByProps({ name: `group-${name}` }).props.onChange({ target: { checked: false } });
|
||||
});
|
||||
}
|
||||
await click(renderer.root, '清空');
|
||||
await clickScope(renderer.root, 'group', '清空');
|
||||
await click(renderer.root, '保存');
|
||||
assert.equal(calls.length, 1);
|
||||
assert.deepEqual(saved, { groupEnabled: true, directEnabled: false, fields: [], guidance: '' });
|
||||
assert.deepEqual(saved, {
|
||||
group: { enabled: true, fields: [], guidance: '' },
|
||||
direct: { enabled: false, fields: ['senderId'], guidance: '' },
|
||||
});
|
||||
assert.equal(renderer.root.findAllByProps({ role: 'dialog' }).length, 0);
|
||||
assert.equal(badge(renderer.root), '仅群聊');
|
||||
await open(renderer.root);
|
||||
assert.ok(fields(renderer.root).every((node) => !node.props.checked));
|
||||
assert.equal(renderer.root.findByType('textarea').props.value, '');
|
||||
assert.equal(renderer.root.findByType('textarea').props.placeholder, CONTEXT_GUIDANCE_EXAMPLE);
|
||||
await click(renderer.root, '填入示例');
|
||||
assert.equal(renderer.root.findByType('textarea').props.value, CONTEXT_GUIDANCE_EXAMPLE);
|
||||
assert.ok(fields(renderer.root).every((node) => !node.props.checked));
|
||||
assert.deepEqual(switches(renderer.root).map((node) => node.props.checked), [true, false]);
|
||||
assert.ok(fields(renderer.root, 'group').every((node) => !node.props.checked));
|
||||
assert.deepEqual(fieldNames(fields(renderer.root, 'direct').filter((node) => node.props.checked)), ['senderId']);
|
||||
assert.equal(guidance(renderer.root, 'group').props.value, '');
|
||||
assert.equal(guidance(renderer.root, 'group').props.placeholder, CONTEXT_GROUP_GUIDANCE_EXAMPLE);
|
||||
await clickScope(renderer.root, 'group', '填入示例');
|
||||
assert.equal(guidance(renderer.root, 'group').props.value, CONTEXT_GROUP_GUIDANCE_EXAMPLE);
|
||||
assert.equal(guidance(renderer.root, 'direct').props.value, '');
|
||||
assert.ok(fields(renderer.root, 'group').every((node) => !node.props.checked));
|
||||
assert.deepEqual(switchStates(renderer.root), [true, false]);
|
||||
await click(renderer.root, '取消');
|
||||
const reloaded = await mount(t, ContextEnhancementEditor, { config: JSON.parse(JSON.stringify(saved)) });
|
||||
await open(reloaded.root);
|
||||
assert.equal(reloaded.root.findByType('textarea').props.value, '');
|
||||
assert.ok(fields(reloaded.root).every((node) => !node.props.checked));
|
||||
assert.equal(guidance(reloaded.root, 'group').props.value, '');
|
||||
assert.equal(guidance(reloaded.root, 'direct').props.value, '');
|
||||
assert.ok(fields(reloaded.root, 'group').every((node) => !node.props.checked));
|
||||
assert.deepEqual(fieldNames(fields(reloaded.root, 'direct').filter((node) => node.props.checked)), ['senderId']);
|
||||
});
|
||||
|
||||
test('failed atomic saves retain the draft, lock edits and duplicate submits, and can be retried', async (t) => {
|
||||
|
|
@ -246,8 +317,8 @@ test('failed atomic saves retain the draft, lock edits and duplicate submits, an
|
|||
});
|
||||
await open(renderer.root);
|
||||
await act(async () => {
|
||||
switches(renderer.root)[1].props.onChange({ target: { checked: true } });
|
||||
renderer.root.findByType('textarea').props.onChange({ target: { value: 'local draft' } });
|
||||
scopeSwitch(renderer.root, 'direct').props.onChange({ target: { checked: true } });
|
||||
guidance(renderer.root, 'direct').props.onChange({ target: { value: 'local draft' } });
|
||||
});
|
||||
const saveButton = button(renderer.root, '保存');
|
||||
await act(async () => { saveButton.props.onClick(); saveButton.props.onClick(); await flush(); });
|
||||
|
|
@ -256,18 +327,18 @@ test('failed atomic saves retain the draft, lock edits and duplicate submits, an
|
|||
assert.ok(renderer.root.findAllByType('input').every((node) => node.props.disabled));
|
||||
assert.ok(renderer.root.findAllByType('button').every((node) => node.props.disabled || node.props.className === 'dim-contextEntry'));
|
||||
await act(async () => {
|
||||
renderer.root.findByType('textarea').props.onChange({ target: { value: 'do not commit' } });
|
||||
guidance(renderer.root, 'direct').props.onChange({ target: { value: 'do not commit' } });
|
||||
renderer.root.findByProps({ role: 'dialog' }).props.onKeyDown({ key: 'Escape', preventDefault() {}, stopPropagation() {} });
|
||||
button(renderer.root, '取消').props.onClick();
|
||||
});
|
||||
assert.equal(renderer.root.findAllByProps({ role: 'dialog' }).length, 1);
|
||||
assert.equal(renderer.root.findByType('textarea').props.value, 'local draft');
|
||||
assert.equal(guidance(renderer.root, 'direct').props.value, 'local draft');
|
||||
assert.equal(badge(renderer.root), '未开启');
|
||||
request.reject(new Error('Save rejected'));
|
||||
await act(async () => { await flush(); });
|
||||
assert.equal(textOf(renderer.root.findByProps({ role: 'alert' })), 'Save rejected');
|
||||
assert.equal(renderer.root.findByType('textarea').props.value, 'local draft');
|
||||
assert.deepEqual(switches(renderer.root).map((node) => node.props.checked), [false, true]);
|
||||
assert.equal(guidance(renderer.root, 'direct').props.value, 'local draft');
|
||||
assert.deepEqual(switchStates(renderer.root), [false, true]);
|
||||
assert.equal(badge(renderer.root), '未开启');
|
||||
await click(renderer.root, '保存');
|
||||
assert.equal(calls.length, 2);
|
||||
|
|
@ -279,25 +350,39 @@ test('Weixin displays and saves only its supported direct scope', async (t) => {
|
|||
const saved = [];
|
||||
const renderer = await mount(t, ContextEnhancementEditor, {
|
||||
groupSupported: false,
|
||||
config: { ...DEFAULT_CONTEXT_ENHANCEMENT_CONFIG, groupEnabled: true },
|
||||
config: {
|
||||
group: { enabled: true, fields: ['botId'], guidance: 'preserve group' },
|
||||
direct: DEFAULT_CONTEXT_ENHANCEMENT_CONFIG.direct,
|
||||
},
|
||||
onSave(value) { saved.push(value); },
|
||||
});
|
||||
assert.equal(badge(renderer.root), '未开启');
|
||||
await open(renderer.root);
|
||||
const group = switches(renderer.root)[0];
|
||||
assert.deepEqual(tabs(renderer.root).map((node) => node.props['aria-selected']), [true, false]);
|
||||
assert.deepEqual(panels(renderer.root).map((node) => node.props.hidden), [false, true]);
|
||||
const group = scopeSwitch(renderer.root, 'group');
|
||||
assert.equal(group.props.checked, false);
|
||||
assert.equal(group.props.disabled, true);
|
||||
const groupNotice = renderer.root.findByProps({ id: group.props['aria-describedby'] });
|
||||
assert.equal(textOf(groupNotice), '(当前渠道不支持群聊)');
|
||||
assert.equal(groupNotice.props.className, 'dim-contextUnavailable');
|
||||
assert.equal(groupNotice.parent.props.className, 'dim-contextSwitchLabel');
|
||||
assert.ok(scope(renderer.root, 'group').findAllByType('input').every((node) => node.props.disabled));
|
||||
assert.equal(guidance(renderer.root, 'group').props.disabled, true);
|
||||
await act(async () => { tabs(renderer.root)[1].props.onClick(); });
|
||||
assert.deepEqual(panels(renderer.root).map((node) => node.props.hidden), [true, false]);
|
||||
await act(async () => {
|
||||
group.props.onChange({ target: { checked: true } });
|
||||
switches(renderer.root)[1].props.onChange({ target: { checked: true } });
|
||||
tabs(renderer.root)[0].props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
scopeSwitch(renderer.root, 'direct').props.onChange({ target: { checked: true } });
|
||||
});
|
||||
await click(renderer.root, '保存');
|
||||
assert.equal(saved[0].groupEnabled, false);
|
||||
assert.equal(saved[0].directEnabled, true);
|
||||
assert.equal(saved[0].group.enabled, false);
|
||||
assert.deepEqual(saved[0].group.fields, ['botId']);
|
||||
assert.equal(saved[0].group.guidance, 'preserve group');
|
||||
assert.equal(saved[0].direct.enabled, true);
|
||||
});
|
||||
|
||||
test('dialog traps Tab and external focus, cancels with Escape, and restores entry focus', async (t) => {
|
||||
|
|
@ -349,16 +434,16 @@ test('all context dialog copy and validation errors localize without translating
|
|||
assert.equal(badge(renderer.root), 'Not enabled');
|
||||
await open(renderer.root);
|
||||
assert.doesNotMatch(textOf(renderer.root), /[\p{Script=Han}]/u);
|
||||
assert.equal(renderer.root.findByType('textarea').props.value, '');
|
||||
assert.equal(renderer.root.findByType('textarea').props.placeholder, en[CONTEXT_GUIDANCE_EXAMPLE]);
|
||||
assert.equal(renderer.root.findByType('textarea').props.maxLength, CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH);
|
||||
const localizedExample = textOf(renderer.root.findByProps({ className: 'dim-contextTooltipExample' }));
|
||||
assert.equal(localizedExample, en[CONTEXT_GUIDANCE_EXAMPLE]);
|
||||
await click(renderer.root, 'Use example');
|
||||
assert.equal(renderer.root.findByType('textarea').props.value, localizedExample);
|
||||
await click(renderer.root, 'Clear');
|
||||
assert.equal(guidance(renderer.root, 'direct').props.value, '');
|
||||
assert.equal(guidance(renderer.root, 'direct').props.placeholder, en[CONTEXT_DIRECT_GUIDANCE_EXAMPLE]);
|
||||
assert.equal(guidance(renderer.root, 'direct').props.maxLength, CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH);
|
||||
const localizedExample = textOf(scope(renderer.root, 'direct').findByProps({ className: 'dim-contextTooltipExample' }));
|
||||
assert.equal(localizedExample, en[CONTEXT_DIRECT_GUIDANCE_EXAMPLE]);
|
||||
await clickScope(renderer.root, 'direct', 'Use example');
|
||||
assert.equal(guidance(renderer.root, 'direct').props.value, localizedExample);
|
||||
await clickScope(renderer.root, 'direct', 'Clear');
|
||||
await act(async () => {
|
||||
renderer.root.findByType('textarea').props.onChange({ target: { value: 'x'.repeat(CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH + 1) } });
|
||||
guidance(renderer.root, 'direct').props.onChange({ target: { value: 'x'.repeat(CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH + 1) } });
|
||||
});
|
||||
await click(renderer.root, 'Save');
|
||||
assert.match(textOf(renderer.root.findByProps({ role: 'alert' })), /Guidance must not exceed 8000 characters/);
|
||||
|
|
@ -368,20 +453,32 @@ test('all context dialog copy and validation errors localize without translating
|
|||
test('all nine APIs preserve canonical, empty, absent and damaged context configurations', () => {
|
||||
for (const channel of channels) {
|
||||
assert.equal(channel.endpoints.setContextEnhancement, 'bot.context-enhancement.set');
|
||||
const empty = { groupEnabled: true, directEnabled: false, fields: [], guidance: '' };
|
||||
const empty = {
|
||||
group: { enabled: true, fields: [], guidance: '' },
|
||||
direct: { enabled: false, fields: ['senderId'], guidance: '' },
|
||||
};
|
||||
const raw = snapshot(channel.name, [undefined, empty]);
|
||||
const normalized = channel.normalize(raw);
|
||||
assert.deepEqual(normalized.bots[0].contextEnhancement, DEFAULT_CONTEXT_ENHANCEMENT_CONFIG, channel.name);
|
||||
assert.deepEqual(normalized.bots[1].contextEnhancement, empty, channel.name);
|
||||
const canonical = channel.normalize(snapshot(channel.name, [{
|
||||
groupEnabled: false, directEnabled: true, fields: ['botId', 'channel', 'botId'], guidance: ' \n ',
|
||||
group: { enabled: false, fields: ['botId', 'channel', 'botId'], guidance: ' \n ' },
|
||||
direct: { enabled: true, fields: [], guidance: 'direct only' },
|
||||
}]));
|
||||
assert.deepEqual(canonical.bots[0].contextEnhancement, {
|
||||
groupEnabled: false, directEnabled: true, fields: ['channel', 'botId'], guidance: '',
|
||||
group: { enabled: false, fields: ['channel', 'botId'], guidance: '' },
|
||||
direct: { enabled: true, fields: [], guidance: 'direct only' },
|
||||
}, channel.name);
|
||||
const legacy = channel.normalize(snapshot(channel.name, [{
|
||||
groupEnabled: true, directEnabled: false, fields: ['botId'], guidance: 'legacy',
|
||||
}]));
|
||||
assert.deepEqual(legacy.bots[0].contextEnhancement, {
|
||||
group: { enabled: true, fields: ['botId'], guidance: 'legacy' },
|
||||
direct: { enabled: false, fields: ['botId'], guidance: 'legacy' },
|
||||
}, channel.name);
|
||||
const damaged = channel.normalize(snapshot(channel.name, [{ groupEnabled: 'true', fields: ['secret'] }]));
|
||||
assert.equal(damaged.bots[0].contextEnhancement.groupEnabled, false, channel.name);
|
||||
assert.equal(damaged.bots[0].contextEnhancement.directEnabled, false, channel.name);
|
||||
assert.equal(damaged.bots[0].contextEnhancement.group.enabled, false, channel.name);
|
||||
assert.equal(damaged.bots[0].contextEnhancement.direct.enabled, false, channel.name);
|
||||
assert.deepEqual(raw.bots[1].contextEnhancement, empty);
|
||||
}
|
||||
});
|
||||
|
|
@ -406,18 +503,21 @@ test('all nine cards save through their existing RPC path, isolate bots and pres
|
|||
assert.deepEqual(editors.map((node) => node.type), [WorkspaceEditor, AgentPresetEditor, ContextEnhancementEditor]);
|
||||
assert.equal(badge(first()), '未开启');
|
||||
await open(first());
|
||||
await act(async () => { switches(first())[1].props.onChange({ target: { checked: true } }); });
|
||||
await act(async () => { scopeSwitch(first(), 'direct').props.onChange({ target: { checked: true } }); });
|
||||
for (const name of CONTEXT_ENHANCEMENT_FIELDS) await act(async () => {
|
||||
first().findByProps({ name }).props.onChange({ target: { checked: false } });
|
||||
scope(first(), 'direct').findByProps({ name: `direct-${name}` }).props.onChange({ target: { checked: false } });
|
||||
});
|
||||
await click(first(), '清空');
|
||||
await clickScope(first(), 'direct', '清空');
|
||||
assert.deepEqual(calls.map((call) => call.endpoint), ['connection.status']);
|
||||
assert.equal(badge(first()), '未开启');
|
||||
assert.equal(badge(second()), '未开启');
|
||||
await click(first(), '保存');
|
||||
const mutations = calls.filter((call) => call.endpoint !== 'connection.status');
|
||||
assert.deepEqual(mutations, [{ endpoint: 'bot.context-enhancement.set', payload: {
|
||||
botId: `${channel.name}_0`, config: { groupEnabled: false, directEnabled: true, fields: [], guidance: '' },
|
||||
botId: `${channel.name}_0`, config: {
|
||||
group: { enabled: false, fields: ['senderId'], guidance: '' },
|
||||
direct: { enabled: true, fields: [], guidance: '' },
|
||||
},
|
||||
} }]);
|
||||
assert.equal(badge(first()), '仅私聊');
|
||||
assert.equal(badge(second()), '未开启');
|
||||
|
|
@ -425,8 +525,9 @@ test('all nine cards save through their existing RPC path, isolate bots and pres
|
|||
assert.equal(current.bots[0].workspace, '/workspace/0');
|
||||
const reloaded = await mount(t, channel.Settings, { rpcCall });
|
||||
await open(reloaded.root.findByProps({ 'data-bot-id': `${channel.name}_0` }));
|
||||
assert.equal(reloaded.root.findByProps({ role: 'dialog' }).findByType('textarea').props.value, '');
|
||||
assert.ok(fields(reloaded.root).every((node) => !node.props.checked));
|
||||
assert.equal(guidance(reloaded.root, 'direct').props.value, '');
|
||||
assert.ok(fields(reloaded.root, 'direct').every((node) => !node.props.checked));
|
||||
assert.deepEqual(fieldNames(fields(reloaded.root, 'group').filter((node) => node.props.checked)), ['senderId']);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -437,7 +538,10 @@ test('all nine settings fence stale polls and reconcile against the actual saved
|
|||
const original = snapshot(channel.name);
|
||||
let current = original;
|
||||
let reads = 0;
|
||||
const actual = { groupEnabled: false, directEnabled: true, fields: ['botId'], guidance: '' };
|
||||
const actual = {
|
||||
group: { enabled: false, fields: ['senderId'], guidance: '' },
|
||||
direct: { enabled: true, fields: ['botId'], guidance: '' },
|
||||
};
|
||||
const rpcCall = async (endpoint) => {
|
||||
if (endpoint === 'connection.status') {
|
||||
reads += 1;
|
||||
|
|
@ -459,8 +563,8 @@ test('all nine settings fence stale polls and reconcile against the actual saved
|
|||
await act(async () => { await flush(); });
|
||||
assert.equal(badge(first()), '仅私聊');
|
||||
await open(first());
|
||||
assert.deepEqual(fields(first()).filter((node) => node.props.checked).map((node) => node.props.name), ['botId']);
|
||||
assert.equal(first().findByProps({ role: 'dialog' }).findByType('textarea').props.value, '');
|
||||
assert.deepEqual(fieldNames(fields(first(), 'direct').filter((node) => node.props.checked)), ['botId']);
|
||||
assert.equal(guidance(first(), 'direct').props.value, '');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -482,7 +586,7 @@ test('all nine settings ignore an older concurrent bot mutation after context se
|
|||
const second = renderer.root.findByProps({ 'data-bot-id': `${channel.name}_1` });
|
||||
await act(async () => { button(second, '检查连接').props.onClick(); await flush(); });
|
||||
await open(first());
|
||||
await act(async () => { switches(first())[1].props.onChange({ target: { checked: true } }); });
|
||||
await act(async () => { scopeSwitch(first(), 'direct').props.onChange({ target: { checked: true } }); });
|
||||
await click(first(), '保存');
|
||||
assert.equal(badge(first()), '仅私聊');
|
||||
reconnect.resolve({ ok: true, value: original });
|
||||
|
|
@ -506,20 +610,20 @@ test('all nine failed save RPCs keep runtime state and drafts intact through sta
|
|||
const renderer = await mount(t, channel.Settings, { rpcCall });
|
||||
const first = () => renderer.root.findByProps({ 'data-bot-id': `${channel.name}_0` });
|
||||
await open(first());
|
||||
await act(async () => { switches(first())[1].props.onChange({ target: { checked: true } }); });
|
||||
await click(first(), '清空');
|
||||
await act(async () => { scopeSwitch(first(), 'direct').props.onChange({ target: { checked: true } }); });
|
||||
await clickScope(first(), 'direct', '清空');
|
||||
await click(first(), '保存');
|
||||
assert.equal(badge(first()), '未开启');
|
||||
assert.equal(switches(first())[1].props.checked, true);
|
||||
assert.equal(scopeSwitch(first(), 'direct').props.checked, true);
|
||||
const dialog = first().findByProps({ role: 'dialog' });
|
||||
assert.equal(dialog.findByType('textarea').props.value, '');
|
||||
assert.equal(guidance(first(), 'direct').props.value, '');
|
||||
assert.ok(textOf(dialog.findByProps({ role: 'alert' })));
|
||||
assert.equal(original.bots[0].contextEnhancement, undefined);
|
||||
assert.equal(calls.filter((call) => call.endpoint !== 'connection.status').length, 1);
|
||||
await click(first(), '取消');
|
||||
await open(first());
|
||||
assert.equal(switches(first())[1].props.checked, false);
|
||||
assert.equal(first().findByProps({ role: 'dialog' }).findByType('textarea').props.value, '');
|
||||
assert.equal(scopeSwitch(first(), 'direct').props.checked, false);
|
||||
assert.equal(guidance(first(), 'direct').props.value, '');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -528,8 +632,10 @@ test('the approved neutral entry and theme-aware modal keep responsive labels an
|
|||
assert.match(styles, /\.dim-contextEntry \{[^}]*min-height: 40px;[^}]*minmax\(0, 1fr\)[^}]*border-radius: 8px;[^}]*font-size: 13px;/);
|
||||
assert.match(styles, /\.dim-contextStatus\[data-active="true"\] \{[^}]*--dsw-alias-state-business-primary/);
|
||||
assert.match(styles, /\.dim-contextDialog \{[^}]*width: min\(450px, 100%\);[^}]*overflow-y: auto;[^}]*border-radius: 12px;[^}]*--dsw-alias-bg-layer-3/);
|
||||
assert.match(styles, /\.dim-contextTabs \{[^}]*grid-template-columns: repeat\(2, minmax\(0, 1fr\)\);[^}]*border-radius: 8px;/);
|
||||
assert.match(styles, /\.dim-contextTab\[aria-selected="true"\] \{[^}]*--dsw-alias-state-business-primary[^}]*box-shadow:/);
|
||||
assert.match(styles, /\.dim-contextTabPanel\[hidden\] \{[^}]*display: none;/);
|
||||
assert.match(styles, /\.dim-contextFields \{[^}]*grid-template-columns: repeat\(2, minmax\(0, 1fr\)\)/);
|
||||
assert.match(styles, /\.dim-contextSwitches \{[^}]*grid-template-columns: 1fr;[^}]*gap: 2px;/);
|
||||
assert.match(styles, /\.dim-contextGuidance textarea \{[^}]*min-height: 88px;/);
|
||||
assert.match(styles, /\.dim-contextGuidance textarea::placeholder \{[^}]*--dsw-alias-label-tertiary[^}]*opacity: 1;/);
|
||||
assert.match(styles, /\.dim-contextFieldKey \{[^}]*ui-monospace/);
|
||||
|
|
|
|||
|
|
@ -13,12 +13,19 @@ import {
|
|||
} from '../src/channels/shared/context-enhancement.mjs';
|
||||
|
||||
function config(overrides = {}) {
|
||||
const {
|
||||
groupEnabled = true,
|
||||
directEnabled = false,
|
||||
fields = CONTEXT_ENHANCEMENT_FIELDS,
|
||||
guidance = '',
|
||||
group = {},
|
||||
direct = {},
|
||||
...extra
|
||||
} = overrides;
|
||||
return {
|
||||
...DEFAULT_CONTEXT_ENHANCEMENT_CONFIG,
|
||||
groupEnabled: true,
|
||||
fields: CONTEXT_ENHANCEMENT_FIELDS,
|
||||
guidance: '',
|
||||
...overrides,
|
||||
group: { enabled: groupEnabled, fields, guidance, ...group },
|
||||
direct: { enabled: directEnabled, fields, guidance, ...direct },
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -32,29 +39,52 @@ function sourceOf(text) {
|
|||
}
|
||||
|
||||
test('context config defaults are off with sender ID only and empty guidance', () => {
|
||||
assert.equal(DEFAULT_CONTEXT_ENHANCEMENT_CONFIG.groupEnabled, false);
|
||||
assert.equal(DEFAULT_CONTEXT_ENHANCEMENT_CONFIG.directEnabled, false);
|
||||
assert.equal(DEFAULT_CONTEXT_ENHANCEMENT_CONFIG.guidance, '');
|
||||
assert.deepEqual(DEFAULT_CONTEXT_ENHANCEMENT_CONFIG.fields, ['senderId']);
|
||||
assert.equal(Object.isFrozen(DEFAULT_CONTEXT_ENHANCEMENT_CONFIG.fields), true);
|
||||
for (const kind of ['group', 'direct']) {
|
||||
assert.equal(DEFAULT_CONTEXT_ENHANCEMENT_CONFIG[kind].enabled, false);
|
||||
assert.equal(DEFAULT_CONTEXT_ENHANCEMENT_CONFIG[kind].guidance, '');
|
||||
assert.deepEqual(DEFAULT_CONTEXT_ENHANCEMENT_CONFIG[kind].fields, ['senderId']);
|
||||
assert.equal(Object.isFrozen(DEFAULT_CONTEXT_ENHANCEMENT_CONFIG[kind].fields), true);
|
||||
}
|
||||
for (const guidance of ['', ' \r\n\t ']) {
|
||||
assert.deepEqual(validateContextEnhancementConfig(config({ fields: [], guidance })), {
|
||||
groupEnabled: true, directEnabled: false, fields: [], guidance: '',
|
||||
group: { enabled: true, fields: [], guidance: '' },
|
||||
direct: { enabled: false, fields: [], guidance: '' },
|
||||
});
|
||||
}
|
||||
const canonical = validateContextEnhancementConfig(config({
|
||||
fields: ['botId', 'senderName', 'botId', 'channel'], guidance: ' custom text\n',
|
||||
}));
|
||||
assert.deepEqual(canonical.fields, ['channel', 'senderName', 'botId']);
|
||||
assert.equal(canonical.guidance, ' custom text\n');
|
||||
assert.deepEqual(canonical.group.fields, ['channel', 'senderName', 'botId']);
|
||||
assert.deepEqual(canonical.direct.fields, ['channel', 'senderName', 'botId']);
|
||||
assert.equal(canonical.group.guidance, ' custom text\n');
|
||||
assert.equal(Object.isFrozen(canonical), true);
|
||||
assert.equal(Object.isFrozen(canonical.fields), true);
|
||||
assert.equal(Object.isFrozen(canonical.group), true);
|
||||
assert.equal(Object.isFrozen(canonical.group.fields), true);
|
||||
});
|
||||
|
||||
test('legacy context settings migrate in memory without weakening new-save validation', () => {
|
||||
const legacy = {
|
||||
groupEnabled: true,
|
||||
directEnabled: false,
|
||||
fields: ['botId', 'channel', 'botId'],
|
||||
guidance: 'legacy guidance',
|
||||
};
|
||||
assert.throws(() => validateContextEnhancementConfig(legacy));
|
||||
assert.deepEqual(normalizeContextEnhancementConfig(legacy), {
|
||||
group: { enabled: true, fields: ['channel', 'botId'], guidance: 'legacy guidance' },
|
||||
direct: { enabled: false, fields: ['channel', 'botId'], guidance: 'legacy guidance' },
|
||||
});
|
||||
assert.equal(normalizeContextEnhancementConfig({
|
||||
...legacy,
|
||||
group: { enabled: true, fields: [], guidance: 'mixed' },
|
||||
}), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
});
|
||||
|
||||
test('context saves reject missing, unknown and mistyped fields while normalization fails off', () => {
|
||||
const invalid = [
|
||||
undefined, null, [], {}, true,
|
||||
{ ...config(), unexpected: true },
|
||||
{ group: config().group },
|
||||
{ groupEnabled: true, directEnabled: false, fields: [] },
|
||||
config({ groupEnabled: 'true' }), config({ directEnabled: 1 }),
|
||||
config({ fields: null }), config({ fields: 'channel' }),
|
||||
|
|
@ -63,7 +93,7 @@ test('context saves reject missing, unknown and mistyped fields while normalizat
|
|||
config({ guidance: null }),
|
||||
config({ guidance: 'x'.repeat(CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH + 1) }),
|
||||
new Proxy({}, { getPrototypeOf() { throw new Error('bad config'); } }),
|
||||
{ ...config(), get guidance() { throw new Error('bad guidance'); } },
|
||||
{ ...config(), group: { ...config().group, get guidance() { throw new Error('bad guidance'); } } },
|
||||
];
|
||||
for (const value of invalid) {
|
||||
assert.throws(() => validateContextEnhancementConfig(value));
|
||||
|
|
@ -72,7 +102,7 @@ test('context saves reject missing, unknown and mistyped fields while normalizat
|
|||
}
|
||||
assert.equal(validateContextEnhancementConfig(config({
|
||||
guidance: 'x'.repeat(CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH),
|
||||
})).guidance.length, CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH);
|
||||
})).group.guidance.length, CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH);
|
||||
});
|
||||
|
||||
test('off path preserves content identity and never reads fields, guidance, source or formatter', (t) => {
|
||||
|
|
@ -81,9 +111,16 @@ test('off path preserves content identity and never reads fields, guidance, sour
|
|||
let settingReads = 0;
|
||||
const throwIfRead = () => { throw new Error('off path must not inspect this'); };
|
||||
const settings = {
|
||||
groupEnabled: false, directEnabled: false,
|
||||
get fields() { return throwIfRead(); },
|
||||
get guidance() { return throwIfRead(); },
|
||||
group: {
|
||||
enabled: false,
|
||||
get fields() { return throwIfRead(); },
|
||||
get guidance() { return throwIfRead(); },
|
||||
},
|
||||
direct: {
|
||||
enabled: false,
|
||||
get fields() { return throwIfRead(); },
|
||||
get guidance() { return throwIfRead(); },
|
||||
},
|
||||
};
|
||||
const provider = {
|
||||
get botId() { return throwIfRead(); },
|
||||
|
|
@ -123,14 +160,31 @@ test('only the actual conversation type can enable capture, regardless of select
|
|||
}
|
||||
for (const kind of ['group', 'direct']) {
|
||||
const offSettings = {
|
||||
[kind === 'group' ? 'groupEnabled' : 'directEnabled']: false,
|
||||
get fields() { throw new Error('no field projection'); },
|
||||
get guidance() { throw new Error('no guidance lookup'); },
|
||||
[kind]: {
|
||||
enabled: false,
|
||||
get fields() { throw new Error('no field projection'); },
|
||||
get guidance() { throw new Error('no guidance lookup'); },
|
||||
},
|
||||
};
|
||||
assert.equal(snapshot(offSettings, kind), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('group and direct messages use only their own fields and guidance', () => {
|
||||
const settings = config({
|
||||
group: { enabled: true, fields: ['channel'], guidance: 'GROUP-ONLY-TOKEN' },
|
||||
direct: { enabled: true, fields: ['botId'], guidance: 'DIRECT-ONLY-TOKEN' },
|
||||
});
|
||||
const group = enhanceContextContent('group text', snapshot(settings, 'group'), () => ({ channel: 'feishu' }));
|
||||
const direct = enhanceContextContent('direct text', snapshot(settings, 'direct'), () => ({ channel: 'feishu' }));
|
||||
assert.deepEqual(sourceOf(group), { channel: 'feishu' });
|
||||
assert.match(group, /GROUP-ONLY-TOKEN/);
|
||||
assert.doesNotMatch(group, /DIRECT-ONLY-TOKEN|botId/);
|
||||
assert.deepEqual(sourceOf(direct), { botId: 'bot_one' });
|
||||
assert.match(direct, /DIRECT-ONLY-TOKEN/);
|
||||
assert.doesNotMatch(direct, /GROUP-ONLY-TOKEN|channel/);
|
||||
});
|
||||
|
||||
test('all 32 source-field subsets are projected in canonical order with no hidden fields', () => {
|
||||
const expected = {
|
||||
channel: 'telegram', conversationType: 'group',
|
||||
|
|
@ -231,9 +285,9 @@ test('queued snapshots are immutable and retain a complete setting version after
|
|||
let settings = config({ fields: ['senderId'], guidance: 'old guidance' });
|
||||
const provider = { botId: 'bot_queued', getSettings: () => settings };
|
||||
const before = captureContextEnhancement(provider, 'group');
|
||||
settings.fields.push('senderName');
|
||||
settings.guidance = 'new guidance';
|
||||
settings.groupEnabled = false;
|
||||
settings.group.fields.push('senderName');
|
||||
settings.group.guidance = 'new guidance';
|
||||
settings.group.enabled = false;
|
||||
assert.equal(captureContextEnhancement(provider, 'group'), null);
|
||||
assert.deepEqual(before.config.fields, ['senderId']);
|
||||
const result = enhanceContextContent('queued', before, () => ({ senderId: 'old', senderName: 'new' }));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue