mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 01:53:21 +08:00
feat: add message source context enhancement
This commit is contained in:
parent
5928b33057
commit
0cb79f74e9
69 changed files with 6217 additions and 597 deletions
|
|
@ -25,6 +25,7 @@ test('client exposes the fixed DingTalk RPC channel and endpoint names', () => {
|
|||
deleteBot: 'bot.delete',
|
||||
setWorkspace: 'bot.workspace.set',
|
||||
setAgentPreset: 'bot.preset.set',
|
||||
setContextEnhancement: 'bot.context-enhancement.set',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -226,6 +226,84 @@ test('runtime sends visible-scope messages to Harness without local sender appro
|
|||
await runtime.stop();
|
||||
});
|
||||
|
||||
for (const scenario of [
|
||||
{ name: 'private enabled to disabled', conversationType: '1', before: [true, false], after: [false, true] },
|
||||
{ name: 'group enabled to disabled', conversationType: '2', before: [false, true], after: [true, false] },
|
||||
{ name: 'private disabled to enabled', conversationType: '1', before: [false, true], after: [true, false] },
|
||||
]) {
|
||||
test(`DingTalk received callback retains settings before its parse microtask: ${scenario.name}`, async () => {
|
||||
const order = [];
|
||||
const asked = [];
|
||||
const configFor = ([directEnabled, groupEnabled], guidance) => ({
|
||||
directEnabled, groupEnabled, fields: ['channel', 'botId'], guidance,
|
||||
});
|
||||
let config = configFor(scenario.before, 'before callback returned');
|
||||
let callback;
|
||||
const client = {
|
||||
connected: true,
|
||||
socket: { readyState: 1 },
|
||||
registerCallbackListener(_topic, listener) { callback = listener; },
|
||||
async connect() {},
|
||||
socketCallBackResponse(messageId) { order.push(['ack', messageId]); },
|
||||
disconnect() {},
|
||||
};
|
||||
const runtime = new DingtalkRuntime({
|
||||
config: { clientId: 'ding-client', approvedSenders: [] },
|
||||
clientSecret: 'host-secret',
|
||||
contextEnhancement: {
|
||||
botId: 'dingtalk_internal',
|
||||
getSettings: () => { order.push(['settings']); return config; },
|
||||
},
|
||||
harness: {
|
||||
ensureRunning: async () => true,
|
||||
createSession: async () => 'session-existing',
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, text) => { asked.push(text); order.push(['ask']); return 'done'; },
|
||||
},
|
||||
state: stateFixture(),
|
||||
api: { sendText: async () => {} },
|
||||
streamFactory: async () => ({ client, topic: 'robot-topic' }),
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
const response = (id) => ({
|
||||
headers: { messageId: `callback-${id}` },
|
||||
get data() {
|
||||
order.push(['read-data', id]);
|
||||
return JSON.stringify({
|
||||
msgId: `message-${id}`, msgtype: 'text', text: { content: `message ${id}` },
|
||||
conversationType: scenario.conversationType, conversationId: 'group-chat',
|
||||
isInAtList: true, senderStaffId: 'staff-id', senderNick: 'Ada',
|
||||
sessionWebhook: 'https://oapi.dingtalk.com/robot/reply?ticket=test',
|
||||
});
|
||||
},
|
||||
});
|
||||
try {
|
||||
await runtime.start();
|
||||
callback(response(1));
|
||||
assert.deepEqual(order, [['ack', 'callback-1'], ['settings']], 'ACK stays first and JSON remains unparsed until the original microtask');
|
||||
config = configFor(scenario.after, 'after callback returned');
|
||||
await eventually(() => runtime.status.messagesReplied === 1, 5_000);
|
||||
callback(response(2));
|
||||
await eventually(() => runtime.status.messagesReplied === 2, 5_000);
|
||||
for (const [index, switches] of [scenario.before, scenario.after].entries()) {
|
||||
const enabled = switches[scenario.conversationType === '1' ? 0 : 1];
|
||||
if (enabled) {
|
||||
assert.match(asked[index], index === 0 ? /before callback returned/ : /after callback returned/);
|
||||
assert.deepEqual(JSON.parse(/^<dsh_im_source>(.*?)<\/dsh_im_source>/su.exec(asked[index])[1]), {
|
||||
channel: 'dingtalk', botId: 'dingtalk_internal',
|
||||
});
|
||||
} else {
|
||||
assert.equal(asked[index], `message ${index + 1}`);
|
||||
}
|
||||
}
|
||||
assert.equal(order.filter(([action]) => action === 'settings').length, 2, 'one source configuration read per callback');
|
||||
assert.equal(order.filter(([action]) => action === 'read-data').length, 4, 'preserves the original data parsing path');
|
||||
} finally {
|
||||
await runtime.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('runtime accepts an OPEN DingTalk socket when the SDK registered flag remains false', async () => {
|
||||
const client = {
|
||||
connected: true,
|
||||
|
|
|
|||
|
|
@ -1425,6 +1425,92 @@ test('Discord runtime keeps one isolated Session per managed thread and reuses i
|
|||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('Discord captures context settings before asynchronous Thread routing and updates future messages live', async (t) => {
|
||||
const botId = '1234567890123456789';
|
||||
const parentId = '222222222222222290';
|
||||
const threadId = '111111111111111190';
|
||||
const routingStarted = deferred();
|
||||
const releaseRouting = deferred();
|
||||
const seen = new Set();
|
||||
const prompts = [];
|
||||
let socket;
|
||||
let reads = 0;
|
||||
let config = {
|
||||
groupEnabled: true,
|
||||
directEnabled: false,
|
||||
fields: ['channel', 'conversationType', 'senderId', 'senderName', 'botId'],
|
||||
guidance: 'accepted before routing',
|
||||
};
|
||||
const runtime = new DiscordRuntime({
|
||||
config: { botId: 'discord_internal', platformId: botId, name: 'Harness Discord' },
|
||||
token: TOKEN,
|
||||
contextEnhancement: {
|
||||
botId: 'discord_internal',
|
||||
getSettings: () => { reads += 1; return config; },
|
||||
},
|
||||
harness: {
|
||||
ensureRunning: async () => true,
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, content) => { prompts.push(content); return 'answer'; },
|
||||
},
|
||||
state: {
|
||||
hasSeen: (id) => seen.has(id),
|
||||
markSeen: async (id) => seen.add(id),
|
||||
sessionFor: () => 'session-existing',
|
||||
},
|
||||
createApi: () => ({
|
||||
getCurrentUser: async () => ({ id: botId, bot: true }),
|
||||
getGatewayBot: async () => ({ url: 'wss://gateway.discord.gg' }),
|
||||
getChannel: async () => assert.fail('The channel is already in the gateway cache'),
|
||||
startThreadFromMessage: async () => {
|
||||
routingStarted.resolve();
|
||||
await releaseRouting.promise;
|
||||
return { id: threadId, type: 11, parent_id: parentId, owner_id: botId };
|
||||
},
|
||||
sendTyping: async () => {},
|
||||
createMessage: async () => ({ id: '888888888888888890' }),
|
||||
editMessage: async ({ messageId }) => ({ id: messageId }),
|
||||
}),
|
||||
createWebSocket: () => {
|
||||
socket = new FakeSocket();
|
||||
queueMicrotask(() => socket.emit('message', {
|
||||
data: JSON.stringify({ op: 10, d: { heartbeat_interval: 45_000 } }),
|
||||
}));
|
||||
return socket;
|
||||
},
|
||||
random: () => 0.5,
|
||||
logger: { warn() {}, error(...args) { assert.fail(args.join(' ')); } },
|
||||
});
|
||||
t.after(async () => { releaseRouting.resolve(); await runtime.stop(); });
|
||||
await runtime.start();
|
||||
socket.emit('message', { data: JSON.stringify({ op: 0, t: 'GUILD_CREATE', s: 2,
|
||||
d: { id: '444444444444444444', channels: [{ id: parentId, type: 0 }], threads: [] },
|
||||
}) });
|
||||
socket.emit('message', { data: JSON.stringify({ op: 0, t: 'MESSAGE_CREATE', s: 3, d: {
|
||||
id: threadId, channel_id: parentId, guild_id: '444444444444444444',
|
||||
author: { id: '333333333333333333', bot: false, global_name: 'Global Name', username: 'username' },
|
||||
member: { nick: 'Group Nick' }, mentions: [{ id: botId }], content: `<@${botId}> first`,
|
||||
} }) });
|
||||
await routingStarted.promise;
|
||||
config = { ...config, groupEnabled: false };
|
||||
releaseRouting.resolve();
|
||||
await eventually(() => runtime.status.messagesReplied === 1);
|
||||
assert.match(prompts[0], /accepted before routing/);
|
||||
assert.deepEqual(JSON.parse(/^<dsh_im_source>(.*?)<\/dsh_im_source>/su.exec(prompts[0])[1]), {
|
||||
channel: 'discord', conversationType: 'group', senderId: '333333333333333333',
|
||||
senderName: 'Group Nick', botId: 'discord_internal',
|
||||
});
|
||||
assert.equal(reads, 1, 'routing and Bridge share one accepted configuration read');
|
||||
|
||||
socket.emit('message', { data: JSON.stringify({ op: 0, t: 'MESSAGE_CREATE', s: 4, d: {
|
||||
id: '111111111111111191', channel_id: threadId, guild_id: '444444444444444444',
|
||||
author: { id: '333333333333333333', bot: false }, content: 'second without enhancement',
|
||||
} }) });
|
||||
await eventually(() => runtime.status.messagesReplied === 2);
|
||||
assert.equal(prompts[1], 'second without enhancement');
|
||||
assert.equal(reads, 2);
|
||||
});
|
||||
|
||||
test('Discord runtime records one uncertain Thread result and suppresses Gateway replays', async () => {
|
||||
const botId = '1234567890123456789';
|
||||
const parentChannelId = '222222222222222290';
|
||||
|
|
|
|||
492
test/channels/shared/context-enhancement-bridges.test.mjs
Normal file
492
test/channels/shared/context-enhancement-bridges.test.mjs
Normal file
|
|
@ -0,0 +1,492 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { Readable } from 'node:stream';
|
||||
import test from 'node:test';
|
||||
|
||||
import { DingtalkHarnessBridge } from '../../../src/channels/dingtalk/dingtalk-bridge.mjs';
|
||||
import { DiscordHarnessBridge } from '../../../src/channels/discord/discord-bridge.mjs';
|
||||
import { normalizeDiscordMessage } from '../../../src/channels/discord/discord-runtime.mjs';
|
||||
import { FeishuHarnessBridge } from '../../../src/channels/feishu/bridge.mjs';
|
||||
import { QqHarnessBridge } from '../../../src/channels/qq/qq-bridge.mjs';
|
||||
import { SlackHarnessBridge } from '../../../src/channels/slack/slack-bridge.mjs';
|
||||
import { normalizeSlackEvent } from '../../../src/channels/slack/slack-runtime.mjs';
|
||||
import { TelegramHarnessBridge } from '../../../src/channels/telegram/telegram-bridge.mjs';
|
||||
import { normalizeTelegramUpdate } from '../../../src/channels/telegram/telegram-runtime.mjs';
|
||||
import { WecomHarnessBridge } from '../../../src/channels/wecom/wecom-bridge.mjs';
|
||||
import { WeixinHarnessBridge } from '../../../src/channels/weixin/weixin-bridge.mjs';
|
||||
import { WhatsappHarnessBridge } from '../../../src/channels/whatsapp/whatsapp-bridge.mjs';
|
||||
import { normalizeWhatsappMessage } from '../../../src/channels/whatsapp/whatsapp-runtime.mjs';
|
||||
import { CONTEXT_ENHANCEMENT_FIELDS } from '../../../src/channels/shared/context-enhancement.mjs';
|
||||
|
||||
const CHANNELS = ['wecom', 'weixin', 'feishu', 'dingtalk', 'qq', 'slack', 'telegram', 'discord', 'whatsapp'];
|
||||
const TEXT_BRIDGES = { slack: SlackHarnessBridge, telegram: TelegramHarnessBridge,
|
||||
discord: DiscordHarnessBridge, whatsapp: WhatsappHarnessBridge };
|
||||
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64');
|
||||
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 };
|
||||
}
|
||||
|
||||
function provider(channel, config) {
|
||||
return { botId: `${channel}_internal`, getSettings: () => config };
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function eventually(predicate) {
|
||||
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||
if (predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
assert.fail('Expected message processing did not finish');
|
||||
}
|
||||
|
||||
function sourceOf(content) {
|
||||
const text = Array.isArray(content) ? content[0].text : content;
|
||||
const match = /^<dsh_im_source>(.*?)<\/dsh_im_source>/su.exec(text);
|
||||
assert.ok(match, `No source in ${text}`);
|
||||
return JSON.parse(match[1]);
|
||||
}
|
||||
|
||||
function withoutPrompt(calls) {
|
||||
return calls.map(([operation, ...args]) => operation === 'ask'
|
||||
? [operation, args[0], '(prompt)', args[2]] : [operation, ...args]);
|
||||
}
|
||||
|
||||
function fixture(channel, { contextEnhancement, onAsk } = {}) {
|
||||
const calls = [];
|
||||
const prompts = [];
|
||||
const sessions = new Map();
|
||||
const seen = new Set();
|
||||
let sourceReads = 0;
|
||||
const state = {
|
||||
hasSeen: (id) => seen.has(id),
|
||||
markSeen: async (id) => { seen.add(id); calls.push(['seen', id]); },
|
||||
sessionFor: (key) => sessions.get(key) ?? null,
|
||||
setSession: async (key, id) => { sessions.set(key, id); calls.push(['bind', key, id]); },
|
||||
clearSession: async (key) => { sessions.delete(key); calls.push(['clear', key]); },
|
||||
pendingSenders: () => [],
|
||||
};
|
||||
const harness = {
|
||||
ensureRunning: async () => { calls.push(['ensureRunning']); },
|
||||
createSession: async () => { calls.push(['createSession']); return 'session-existing'; },
|
||||
sessionExists: async (id) => { calls.push(['sessionExists', id]); return true; },
|
||||
hasActiveTurn: async () => false,
|
||||
isSessionRunning: async () => false,
|
||||
currentWorkspace: () => null,
|
||||
ask: async (sessionId, content, options) => {
|
||||
prompts.push(content);
|
||||
calls.push(['ask', sessionId, content, Object.keys(options).sort()]);
|
||||
for (const file of options.files ?? []) {
|
||||
const loaded = await file.load({ signal: options.signal });
|
||||
calls.push(['file', file.name, Buffer.from(loaded?.data ?? loaded).toString('hex')]);
|
||||
}
|
||||
return onAsk ? onAsk({ sessionId, content, options, prompts }) : 'answer unchanged';
|
||||
},
|
||||
};
|
||||
const sendText = async (...args) => {
|
||||
calls.push(['sendText', ...args]);
|
||||
return { messageId: 'reply-one', id: 'reply-one' };
|
||||
};
|
||||
const image = (name) => ({ name, mediaType: 'image/png', load: async () => {
|
||||
calls.push(['image', name]); return PNG;
|
||||
} });
|
||||
const file = () => ({ name: 'report.txt', load: async () => {
|
||||
calls.push(['file-download']); return FILE;
|
||||
} });
|
||||
const dependencies = { harness, state, contextEnhancement, logger };
|
||||
let bridge;
|
||||
if (TEXT_BRIDGES[channel]) {
|
||||
bridge = new TEXT_BRIDGES[channel]({ ...dependencies, bot: { sendText } });
|
||||
} else if (channel === 'wecom') {
|
||||
bridge = new WecomHarnessBridge({ ...dependencies, generateStreamId: () => 'stream-one', client: {
|
||||
replyStream: async (frame, streamId, text, finish) => {
|
||||
calls.push(['replyStream', frame.body.msgid, streamId, text, finish]);
|
||||
return { body: { msgid: 'reply-one' } };
|
||||
},
|
||||
sendMessage: sendText,
|
||||
downloadFile: async (url) => {
|
||||
calls.push(['downloadFile', url]);
|
||||
return { buffer: url.endsWith('.png') ? PNG : FILE, filename: url.split('/').at(-1) };
|
||||
},
|
||||
} });
|
||||
} else if (channel === 'weixin') {
|
||||
bridge = new WeixinHarnessBridge({ ...dependencies, baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
token: 'private-token', ownerUserId: 'actor', api: {
|
||||
sendText: async ({ toUserId, text, contextToken, runId }) => sendText({ toUserId, text, contextToken, runId }),
|
||||
inboundImages: (event) => event.item_list.filter((item) => item.image_item).map((_item, index) => image(`image-${index}.png`)),
|
||||
inboundFiles: (event) => event.item_list.filter((item) => item.file_item).map(file),
|
||||
} });
|
||||
} else if (channel === 'dingtalk') {
|
||||
bridge = new DingtalkHarnessBridge({ ...dependencies, clientId: 'app', clientSecret: 'private-secret', api: {
|
||||
sendText: async ({ sessionWebhook, text, at }) => sendText({ sessionWebhook, text, at }),
|
||||
downloadImage: async ({ downloadCode }) => { calls.push(['downloadImage', downloadCode]); return PNG; },
|
||||
downloadFile: async ({ downloadCode }) => { calls.push(['downloadFile', downloadCode]); return FILE; },
|
||||
} });
|
||||
} else if (channel === 'qq') {
|
||||
bridge = new QqHarnessBridge({ ...dependencies, ownerUserOpenid: '*', bot: { sendText },
|
||||
fetchImpl: async (url) => {
|
||||
calls.push(['fetch', String(url)]);
|
||||
const isImage = String(url).endsWith('.png');
|
||||
return new Response(isImage ? PNG : FILE, { headers: { 'content-type': isImage ? 'image/png' : 'text/plain' } });
|
||||
},
|
||||
});
|
||||
} else {
|
||||
bridge = new FeishuHarnessBridge({ ...dependencies, status: {}, allowedSenderOpenIds: new Set(['*']),
|
||||
channel: {}, client: { im: { v1: {
|
||||
message: { create: async (request) => {
|
||||
calls.push(['createMessage', request]);
|
||||
return { code: 0, data: { message_id: 'reply-one' } };
|
||||
} },
|
||||
messageResource: { get: async ({ path, params }) => {
|
||||
calls.push(['messageResource', path, params]);
|
||||
const bytes = params.type === 'image' ? PNG : FILE;
|
||||
return { headers: { 'content-length': String(bytes.length) }, getReadableStream: () => Readable.from([bytes]) };
|
||||
} },
|
||||
} } },
|
||||
});
|
||||
}
|
||||
|
||||
function event(id, text = ' \t user text\nsecond line ', { kind = 'direct', name = 'Ada', actor = 'actor', media, poisonName = false } = {}) {
|
||||
const group = kind === 'group';
|
||||
const nameValue = (object, key) => Object.defineProperty(object, key, { configurable: true, get() {
|
||||
sourceReads += 1;
|
||||
if (poisonName) throw new Error('enhancement-only source must not be read');
|
||||
return name;
|
||||
} });
|
||||
let value;
|
||||
if (channel === 'telegram') {
|
||||
const from = { id: actor === 'actor' ? 42 : 43, is_bot: false };
|
||||
nameValue(from, 'first_name');
|
||||
value = normalizeTelegramUpdate({ update_id: id, message: {
|
||||
message_id: id, chat: { id: 100, type: group ? 'supergroup' : 'private' }, from,
|
||||
text: group ? `@testbot ${text}` : text, entities: group ? [{ type: 'mention', offset: 0, length: 8 }] : [],
|
||||
} }, { botId: 'bot', username: 'testbot' });
|
||||
} else if (channel === 'discord') {
|
||||
const author = { id: actor, bot: false };
|
||||
nameValue(author, 'global_name');
|
||||
value = normalizeDiscordMessage({ id: String(id), channel_id: 'chat', author,
|
||||
guild_id: group ? 'guild' : undefined, content: text, mentions: [{ id: 'bot' }],
|
||||
}, 'bot');
|
||||
} else if (channel === 'whatsapp') {
|
||||
const raw = { key: { id: String(id), remoteJid: group ? 'chat@g.us' : `${actor}@s.whatsapp.net`,
|
||||
participant: group ? `${actor}@s.whatsapp.net` : undefined, fromMe: false },
|
||||
message: { extendedTextMessage: { text, contextInfo: { mentionedJid: ['bot@s.whatsapp.net'] } } } };
|
||||
nameValue(raw, 'pushName');
|
||||
value = normalizeWhatsappMessage(raw, 'bot@s.whatsapp.net');
|
||||
} else if (channel === 'slack') {
|
||||
value = normalizeSlackEvent({ event_id: String(id), event: { type: group ? 'app_mention' : 'message',
|
||||
ts: String(id), channel: 'chat', channel_type: group ? 'channel' : 'im', user: actor,
|
||||
thread_ts: group ? 'thread-existing' : undefined,
|
||||
text: group ? `<@bot> ${text}` : text,
|
||||
} }, 'bot');
|
||||
} else if (channel === 'wecom') {
|
||||
value = { headers: { req_id: String(id) }, body: { msgid: String(id), from: { userid: actor },
|
||||
chattype: group ? 'group' : 'single', chatid: 'chat', msgtype: 'text', text: { content: text } } };
|
||||
} else if (channel === 'weixin') {
|
||||
value = { message_id: String(id), message_type: 1, from_user_id: actor, context_token: 'context',
|
||||
item_list: [{ type: 1, text_item: { text } }] };
|
||||
} else if (channel === 'dingtalk') {
|
||||
value = { msgId: String(id), msgtype: 'text', text: { content: text }, senderStaffId: actor,
|
||||
conversationType: group ? '2' : '1', conversationId: 'chat', isInAtList: true,
|
||||
sessionWebhook: 'https://oapi.dingtalk.com/robot/reply?ticket=test' };
|
||||
nameValue(value, 'senderNick');
|
||||
} else if (channel === 'qq') {
|
||||
value = { messageId: String(id), senderId: actor, kind: group ? 'group' : 'c2c', groupOpenid: 'chat',
|
||||
rawEventType: group ? 'GROUP_AT_MESSAGE_CREATE' : 'C2C_MESSAGE_CREATE', senderIsBot: false, content: text,
|
||||
replyTarget: { scope: group ? 'group' : 'c2c', targetId: group ? 'chat' : actor, msgId: String(id) } };
|
||||
nameValue(value, 'senderName');
|
||||
} else {
|
||||
value = { sender: { sender_type: 'user', sender_id: { open_id: actor } }, message: {
|
||||
message_id: String(id), message_type: 'text', chat_id: 'chat', chat_type: group ? 'group' : 'p2p',
|
||||
content: JSON.stringify({ text }), mentions: group ? [{ key: '@bot', id: { open_id: 'bot' } }] : [],
|
||||
} };
|
||||
}
|
||||
assert.ok(value, `${channel} event should normalize`);
|
||||
if (!media) return value;
|
||||
const imageNames = media === 'mixed' ? ['first.png', 'second.png'] : media === 'image' ? ['first.png'] : [];
|
||||
const includeFile = media === 'file' || media === 'mixed';
|
||||
if (TEXT_BRIDGES[channel]) {
|
||||
value.images = imageNames.map(image);
|
||||
value.files = includeFile ? [file()] : [];
|
||||
} else if (channel === 'wecom') {
|
||||
value.body.msgtype = 'mixed';
|
||||
value.body.mixed = { msg_item: [
|
||||
{ msgtype: 'text', text: { content: text } },
|
||||
...imageNames.map((name) => ({ msgtype: 'image', image: { url: `https://wecom.test/${name}` } })),
|
||||
...(includeFile ? [{ msgtype: 'file', file: { url: 'https://wecom.test/report.txt', filename: 'report.txt' } }] : []),
|
||||
] };
|
||||
} else if (channel === 'weixin') {
|
||||
value.item_list.push(...imageNames.map(() => ({ type: 2, image_item: {} })),
|
||||
...(includeFile ? [{ type: 4, file_item: { file_name: 'report.txt' } }] : []));
|
||||
} else if (channel === 'dingtalk') {
|
||||
value.msgtype = media === 'file' ? 'file' : 'richText';
|
||||
value.content = media === 'file' ? { downloadCode: 'file-one', fileName: 'report.txt' }
|
||||
: { richText: [{ text }, ...imageNames.map((downloadCode) => ({ type: 'picture', downloadCode }))] };
|
||||
} else if (channel === 'qq') {
|
||||
value.attachments = [
|
||||
...imageNames.map((name) => ({ filename: name, url: `https://gchat.qpic.cn/${name}`, content_type: 'image/png' })),
|
||||
...(includeFile ? [{ filename: 'report.txt', url: 'https://gchat.qpic.cn/report.txt', content_type: 'text/plain' }] : []),
|
||||
];
|
||||
} else {
|
||||
value.message.message_type = media === 'file' ? 'file' : 'post';
|
||||
value.message.content = JSON.stringify(media === 'file' ? { file_key: 'file-one', file_name: 'report.txt' }
|
||||
: { content: [[{ tag: 'text', text }, ...imageNames.map((image_key) => ({ tag: 'img', image_key }))]] });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
return { bridge, event, calls, prompts, sessions, seen, get sourceReads() { return sourceReads; } };
|
||||
}
|
||||
|
||||
for (const channel of CHANNELS) {
|
||||
const kinds = channel === 'weixin' ? ['direct'] : ['direct', 'group'];
|
||||
for (const kind of kinds) {
|
||||
test(`${channel} ${kind}: disabled settings preserve text/media, calls and session bindings`, async () => {
|
||||
const oppositeOnly = settings({ groupEnabled: kind === 'direct', directEnabled: kind === 'group' });
|
||||
const poisonedOff = {
|
||||
groupEnabled: false, directEnabled: false,
|
||||
get fields() { throw new Error('off fields accessed'); },
|
||||
get guidance() { throw new Error('off guidance accessed'); },
|
||||
};
|
||||
const variants = [
|
||||
provider(channel, undefined),
|
||||
provider(channel, settings({ groupEnabled: false, directEnabled: false })),
|
||||
provider(channel, oppositeOnly),
|
||||
provider(channel, poisonedOff),
|
||||
provider(channel, { groupEnabled: 'true', directEnabled: 1 }),
|
||||
{ getSettings() { throw new Error('damaged optional settings'); } },
|
||||
];
|
||||
for (const media of [undefined, 'image', 'file', 'mixed']) {
|
||||
const baseline = fixture(channel);
|
||||
await baseline.bridge.accept(baseline.event(1, undefined, { kind, media, poisonName: true }));
|
||||
assert.equal(baseline.prompts.length, 1);
|
||||
assert.equal(baseline.sourceReads, 0);
|
||||
for (const contextEnhancement of variants) {
|
||||
const current = fixture(channel, { contextEnhancement });
|
||||
await current.bridge.accept(current.event(1, undefined, { kind, media, poisonName: true }));
|
||||
assert.deepEqual(current.calls, baseline.calls, `${media ?? 'text'}: preserve platform/Harness call order and arguments`);
|
||||
assert.deepEqual(current.sessions, baseline.sessions);
|
||||
assert.deepEqual(current.seen, baseline.seen);
|
||||
assert.equal(current.sourceReads, 0);
|
||||
assert.equal(current.bridge.status?.lastMessageError ?? null, null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test(`${channel} ${kind}: enabled source uses the actual event, optional name and internal bot ID`, async () => {
|
||||
const current = fixture(channel, { contextEnhancement: provider(channel, settings()) });
|
||||
await current.bridge.accept(current.event(1, 'hello', { kind }));
|
||||
const expected = {
|
||||
channel, conversationType: kind,
|
||||
senderId: channel === 'telegram' ? '42' : channel === 'whatsapp' ? 'actor@s.whatsapp.net' : 'actor',
|
||||
...(['dingtalk', 'telegram', 'discord', 'whatsapp'].includes(channel) || (channel === 'qq' && kind === 'group')
|
||||
? { senderName: 'Ada' } : {}),
|
||||
botId: `${channel}_internal`,
|
||||
};
|
||||
assert.deepEqual(sourceOf(current.prompts[0]), expected);
|
||||
assert.ok(current.prompts[0].endsWith('\n\nhello'));
|
||||
assert.doesNotMatch(current.prompts[0], /source_guidance|private-token|private-secret/);
|
||||
// Explicit null represents a provider event that does not include a nickname.
|
||||
await current.bridge.accept(current.event(2, 'missing name', { kind, name: null }));
|
||||
assert.equal(Object.hasOwn(sourceOf(current.prompts[1]), 'senderName'), false);
|
||||
if (kind === 'group') {
|
||||
await current.bridge.accept(current.event(3, 'another sender', { kind, name: 'Grace', actor: 'actor-two' }));
|
||||
const next = sourceOf(current.prompts[2]);
|
||||
assert.notEqual(next.senderId, expected.senderId);
|
||||
if (expected.senderName) assert.equal(next.senderName, 'Grace');
|
||||
assert.equal(current.sessions.size, 1, 'group speakers keep sharing the existing group Session');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
const enabled = fixture(channel, { contextEnhancement: provider(channel, settings({ guidance: 'Use only provided fields.' })) });
|
||||
await plain.bridge.accept(plain.event(1, 'caption', { media }));
|
||||
await enabled.bridge.accept(enabled.event(1, 'caption', { media }));
|
||||
const original = plain.prompts[0];
|
||||
const enhanced = enabled.prompts[0];
|
||||
if (Array.isArray(original)) {
|
||||
assert.equal(enhanced[0].type, 'text');
|
||||
assert.deepEqual(enhanced.slice(1), original);
|
||||
assert.match(enhanced[0].text, /<dsh_im_source_guidance>\nUse only provided fields\./);
|
||||
} else {
|
||||
assert.ok(enhanced.endsWith(`\n\n${original}`));
|
||||
}
|
||||
assert.deepEqual(enabled.calls.filter(([op]) => op === 'file'), plain.calls.filter(([op]) => op === 'file'));
|
||||
assert.deepEqual(withoutPrompt(enabled.calls), withoutPrompt(plain.calls), 'enhancement adds no provider or Harness requests');
|
||||
}
|
||||
});
|
||||
|
||||
test(`${channel}: queued messages keep their accepted snapshot across saves and disablement`, async () => {
|
||||
let config = settings({ fields: ['channel'], guidance: 'version one' });
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
const current = fixture(channel, {
|
||||
contextEnhancement: { botId: `${channel}_internal`, getSettings: () => config },
|
||||
onAsk: async ({ prompts }) => {
|
||||
if (prompts.length === 1) { started.resolve(); await release.promise; }
|
||||
return 'answer unchanged';
|
||||
},
|
||||
});
|
||||
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 = settings({ fields: ['botId'], guidance: 'version two' });
|
||||
const newer = current.bridge.accept(current.event(3, 'newer'));
|
||||
config = settings({ groupEnabled: false, directEnabled: false });
|
||||
const off = current.bridge.accept(current.event(4, 'off'));
|
||||
config = settings({ fields: ['botId'], guidance: 'version three' });
|
||||
const reenabled = current.bridge.accept(current.event(5, 'reenabled'));
|
||||
release.resolve();
|
||||
await Promise.all([first, queued, newer, off, reenabled]);
|
||||
assert.match(current.prompts[0], /version one/);
|
||||
assert.match(current.prompts[1], /version one/);
|
||||
assert.doesNotMatch(current.prompts[1], /mutated|version two/);
|
||||
assert.deepEqual(sourceOf(current.prompts[2]), { botId: `${channel}_internal` });
|
||||
assert.match(current.prompts[2], /version two/);
|
||||
assert.equal(current.prompts[3], 'off');
|
||||
assert.match(current.prompts[4], /version three/);
|
||||
assert.equal(current.calls.filter(([op]) => op === 'createSession').length, 1);
|
||||
});
|
||||
|
||||
test(`${channel}: batches gain one prefix only at final submission`, async () => {
|
||||
const current = fixture(channel, { contextEnhancement: provider(channel, settings()) });
|
||||
await current.bridge.accept(current.event(1, '/batch'));
|
||||
await current.bridge.accept(current.event(2, 'first item'));
|
||||
await current.bridge.accept(current.event(3, 'second item'));
|
||||
assert.equal(current.prompts.length, 0);
|
||||
await current.bridge.accept(current.event(4, '/send'));
|
||||
assert.equal(current.prompts.length, 1);
|
||||
assert.equal(current.prompts[0].split('<dsh_im_source>').length - 1, 1);
|
||||
assert.match(current.prompts[0], /first item[\s\S]*second item/);
|
||||
});
|
||||
|
||||
test(`${channel}: disabled batches retain the original one-submission behavior and calls`, async () => {
|
||||
const baseline = fixture(channel);
|
||||
const disabled = fixture(channel, {
|
||||
contextEnhancement: provider(channel, settings({ groupEnabled: false, directEnabled: false })),
|
||||
});
|
||||
for (const current of [baseline, disabled]) {
|
||||
for (const [index, text] of ['/batch', ' first item\n', 'second\nitem ', '/send'].entries()) {
|
||||
await current.bridge.accept(current.event(index + 1, text, { poisonName: true }));
|
||||
}
|
||||
assert.equal(current.prompts.length, 1);
|
||||
assert.equal(current.sourceReads, 0);
|
||||
}
|
||||
assert.deepEqual(disabled.calls, baseline.calls);
|
||||
assert.deepEqual(disabled.sessions, baseline.sessions);
|
||||
assert.deepEqual(disabled.seen, baseline.seen);
|
||||
});
|
||||
|
||||
test(`${channel}: filtered and duplicate events never assemble source metadata`, async () => {
|
||||
const current = fixture(channel, { contextEnhancement: provider(channel, settings()) });
|
||||
const rejected = current.event(1, 'ignored', { kind: channel === 'weixin' ? 'direct' : 'group', poisonName: true });
|
||||
if (TEXT_BRIDGES[channel]) rejected.addressed = false;
|
||||
else if (channel === 'wecom') rejected.body.chattype = 'unknown';
|
||||
else if (channel === 'weixin') rejected.from_user_id = 'not-the-owner';
|
||||
else if (channel === 'feishu') rejected.sender.sender_type = 'bot';
|
||||
else if (channel === 'dingtalk') rejected.isInAtList = false;
|
||||
else rejected.rawEventType = 'GROUP_MESSAGE_CREATE';
|
||||
await current.bridge.accept(rejected);
|
||||
assert.equal(current.prompts.length, 0);
|
||||
assert.equal(current.sourceReads, 0);
|
||||
await current.bridge.accept(current.event(2, 'accepted'));
|
||||
const reads = current.sourceReads;
|
||||
await current.bridge.accept(current.event(2, 'duplicate', { poisonName: true }));
|
||||
assert.equal(current.prompts.length, 1);
|
||||
assert.equal(current.sourceReads, reads);
|
||||
});
|
||||
|
||||
test(`${channel}: field subsets and guidance-only/empty configurations do not restore defaults`, async () => {
|
||||
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');
|
||||
else assert.equal(current.prompts[0], 'hello');
|
||||
}
|
||||
});
|
||||
|
||||
test(`${channel}: local help/new/steer do not inspect enhancement-only sources`, async () => {
|
||||
const current = fixture(channel, { contextEnhancement: provider(channel, settings()) });
|
||||
for (const [index, text] of ['/help', '/new', '/steer supplement'].entries()) {
|
||||
await current.bridge.accept(current.event(index + 1, text, { poisonName: true }));
|
||||
}
|
||||
assert.equal(current.prompts.length, 0);
|
||||
assert.equal(current.sourceReads, 0);
|
||||
assert.doesNotMatch(JSON.stringify(current.calls), /<dsh_im_source/);
|
||||
});
|
||||
|
||||
for (const kind of ['question', 'approval']) {
|
||||
test(`${channel}: ${kind} replies remain unenhanced while ordinary messages are enabled`, async () => {
|
||||
const finished = deferred();
|
||||
const responses = [];
|
||||
const current = fixture(channel, {
|
||||
contextEnhancement: provider(channel, settings()),
|
||||
onAsk: async ({ sessionId, options }) => {
|
||||
await options.onInteraction({
|
||||
kind, interactionId: 'interaction', rpcId: 'interaction', sessionId,
|
||||
payload: kind === 'question'
|
||||
? { type: 'question/requested', sessionId, questions: [{ id: 'answer', question: 'waiting for answer' }] }
|
||||
: { type: 'approval/requested', sessionId, approvalId: 'interaction', toolName: 'fixture-tool', callId: 'call', reason: 'waiting for approval' },
|
||||
toolCall: kind === 'approval' ? { callId: 'call', name: 'fixture-tool', arguments: '{}' } : undefined,
|
||||
respond: async (result) => { responses.push(result); finished.resolve(); return { accepted: true }; },
|
||||
});
|
||||
await finished.promise;
|
||||
return 'answer unchanged';
|
||||
},
|
||||
});
|
||||
const processing = current.bridge.accept(current.event(1, 'ordinary question'));
|
||||
await eventually(() => JSON.stringify(current.calls).includes('waiting for'));
|
||||
const readsBeforeReply = current.sourceReads;
|
||||
await current.bridge.accept(current.event(2, kind === 'question' ? 'blue' : '批准', { poisonName: true }));
|
||||
await processing;
|
||||
assert.equal(current.prompts.length, 1);
|
||||
assert.equal(responses.length, 1);
|
||||
assert.equal(current.sourceReads, readsBeforeReply);
|
||||
assert.doesNotMatch(JSON.stringify(responses), /dsh_im_source/);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('Telegram retains event first/last names and falls back only to an existing username', async () => {
|
||||
const current = fixture('telegram', { contextEnhancement: provider('telegram', settings()) });
|
||||
for (const [index, from] of [
|
||||
{ first_name: 'Ada', last_name: 'Lovelace', username: 'ada' },
|
||||
{ first_name: '', last_name: ' ', username: 'ada' },
|
||||
{},
|
||||
].entries()) {
|
||||
const message = normalizeTelegramUpdate({ update_id: index + 1, message: {
|
||||
message_id: index + 1, chat: { id: 100, type: 'private' }, from: { id: 42, ...from }, text: 'hello',
|
||||
} }, { botId: 'bot', username: 'testbot' });
|
||||
await current.bridge.accept(message);
|
||||
}
|
||||
assert.equal(sourceOf(current.prompts[0]).senderName, 'Ada Lovelace');
|
||||
assert.equal(sourceOf(current.prompts[1]).senderName, 'ada');
|
||||
assert.equal(Object.hasOwn(sourceOf(current.prompts[2]), 'senderName'), false);
|
||||
});
|
||||
|
||||
test('Discord prefers event member nickname, global name, then username without a profile lookup', async () => {
|
||||
const current = fixture('discord', { contextEnhancement: provider('discord', settings()) });
|
||||
for (const [index, names] of [
|
||||
{ member: { nick: 'Group Nick' }, author: { global_name: 'Global Name', username: 'username' } },
|
||||
{ member: { nick: '' }, author: { global_name: 'Global Name', username: 'username' } },
|
||||
{ author: { global_name: null, username: 'username' } },
|
||||
{ author: {} },
|
||||
].entries()) {
|
||||
await current.bridge.accept(normalizeDiscordMessage({
|
||||
...names, id: String(index + 1), channel_id: 'chat', guild_id: 'guild',
|
||||
author: { id: 'actor', ...names.author }, content: 'hello', mentions: [{ id: 'bot' }],
|
||||
}, 'bot'));
|
||||
}
|
||||
assert.deepEqual(current.prompts.map((content) => sourceOf(content).senderName),
|
||||
['Group Nick', 'Global Name', 'username', undefined]);
|
||||
});
|
||||
96
test/channels/shared/context-enhancement-production.test.mjs
Normal file
96
test/channels/shared/context-enhancement-production.test.mjs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { BotWorkspaceStore } from '../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import {
|
||||
DEFAULT_CONTEXT_ENHANCEMENT_CONFIG,
|
||||
captureContextEnhancement,
|
||||
} from '../../../src/channels/shared/context-enhancement.mjs';
|
||||
|
||||
for (const channel of ['wecom', 'weixin', 'feishu', 'dingtalk', 'qq', 'slack', 'telegram', 'discord', 'whatsapp']) {
|
||||
test(`${channel} production passes live, isolated context settings without reconnecting or changing Harness options`, async (t) => {
|
||||
const dataDir = await mkdtemp(join(tmpdir(), `dsh-context-${channel}-`));
|
||||
t.after(() => rm(dataDir, { recursive: true, force: true }));
|
||||
const { createProductionController } = await import(`../../../plugin-src/host/channels/${channel}/production.mjs`);
|
||||
const workspaces = await new BotWorkspaceStore(join(dataDir, 'workspaces.json'), { defaultWorkspace: dataDir }).load();
|
||||
const botIds = [`${channel}_one`, `${channel}_two`];
|
||||
const bots = botIds.map((botId) => ({ botId, id: botId, appId: 'cli_fixture', secretRef: 'fixture-only' }));
|
||||
const runtimes = [];
|
||||
let controllerOptions;
|
||||
let harnessOptions;
|
||||
let stateLoads = 0;
|
||||
class ConfigStore {
|
||||
async load() { return this; }
|
||||
list() { return bots; }
|
||||
}
|
||||
class StateStore {
|
||||
async load() { stateLoads += 1; return this; }
|
||||
clearSessions() { throw new Error('enhancement must not reset sessions'); }
|
||||
}
|
||||
class Harness {
|
||||
constructor(options) { harnessOptions = options; }
|
||||
stopManagedProcess() {}
|
||||
}
|
||||
class Runtime {
|
||||
constructor(options) { runtimes.push(options); }
|
||||
}
|
||||
class Controller {
|
||||
constructor(options) { controllerOptions = options; }
|
||||
status() { return { bots: botIds.map((botId) => ({ botId, connected: true })) }; }
|
||||
reconnectBot() { throw new Error('enhancement must not reconnect'); }
|
||||
async close() {}
|
||||
}
|
||||
const production = await createProductionController({
|
||||
credentials: {},
|
||||
apiProxy: {},
|
||||
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
||||
}, { dataDir, workspace: dataDir }, {
|
||||
ConfigStore,
|
||||
StateStore,
|
||||
HarnessClient: Harness,
|
||||
Controller,
|
||||
Runtime,
|
||||
FeishuRuntime: Runtime,
|
||||
workspaces,
|
||||
api: {},
|
||||
qrAuth: {},
|
||||
deviceAuth: {},
|
||||
lark: {},
|
||||
proxyEnv: {},
|
||||
createConnectionSupervisor: () => ({
|
||||
ready: Promise.resolve(), start() { return this; }, async close() {},
|
||||
}),
|
||||
});
|
||||
t.after(() => production.close());
|
||||
for (const bot of bots) {
|
||||
await controllerOptions.createRuntime({ botId: bot.botId, config: bot });
|
||||
}
|
||||
assert.equal(runtimes.length, 2);
|
||||
assert.equal(stateLoads, 2);
|
||||
assert.equal(Object.hasOwn(harnessOptions, 'contextEnhancement'), false);
|
||||
assert.equal(Object.hasOwn(harnessOptions, 'source'), false);
|
||||
const provider = runtimes[0].contextEnhancement;
|
||||
assert.equal(provider.botId, botIds[0]);
|
||||
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 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.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 });
|
||||
assert.equal(captureContextEnhancement(provider, 'direct'), null);
|
||||
assert.equal(beforeDisable.config.directEnabled, true, 'an already received message keeps its snapshot');
|
||||
assert.equal(workspaces.generationFor(botIds[0]), generation);
|
||||
assert.equal(runtimes.length, 2);
|
||||
assert.equal(stateLoads, 2);
|
||||
});
|
||||
}
|
||||
|
|
@ -1515,6 +1515,125 @@ test('Telegram runtime enforces the selected bot private allowlist', async () =>
|
|||
}
|
||||
});
|
||||
|
||||
for (const scenario of [
|
||||
{ name: 'two private messages', kinds: ['private', 'private'], before: [true, true], after: [false, false] },
|
||||
{ name: 'mixed private/group messages', kinds: ['private', 'supergroup'], before: [false, true], after: [true, false] },
|
||||
{ name: 'disabled private messages before enabling', kinds: ['private', 'private'], before: [false, false], after: [true, true] },
|
||||
]) {
|
||||
test(`Telegram received poll batch retains its settings across cursor writes: ${scenario.name}`, async () => {
|
||||
const firstCursorStarted = deferred();
|
||||
const releaseFirstCursor = deferred();
|
||||
const allPrompts = deferred();
|
||||
const seen = new Set();
|
||||
const asked = [];
|
||||
const pollOffsets = [];
|
||||
const cursorWrites = [];
|
||||
let cursor = 10;
|
||||
let settingsReads = 0;
|
||||
let nextMessageId = 500;
|
||||
const configFor = ([directEnabled, groupEnabled], guidance) => ({
|
||||
directEnabled, groupEnabled, fields: ['channel', 'botId'], guidance,
|
||||
});
|
||||
let config = configFor(scenario.before, 'before cursor write');
|
||||
const update = (id, kind) => ({
|
||||
update_id: id,
|
||||
message: {
|
||||
message_id: id + 100,
|
||||
chat: { id: kind === 'private' ? 42 : -1001, type: kind },
|
||||
from: { id: 7, is_bot: false, first_name: 'Ada' },
|
||||
text: kind === 'private' ? `message ${id}` : `@HarnessBot message ${id}`,
|
||||
entities: kind === 'private' ? [] : [{ type: 'mention', offset: 0, length: 11 }],
|
||||
},
|
||||
});
|
||||
const firstBatch = scenario.kinds.map((kind, index) => update(10 + index, kind));
|
||||
const nextBatch = ['private', 'supergroup'].map((kind, index) => update(12 + index, kind));
|
||||
const runtime = new TelegramRuntime({
|
||||
config: { botId: 'telegram_poll', platformId: '123456789', username: 'HarnessBot' },
|
||||
token: TOKEN,
|
||||
contextEnhancement: {
|
||||
botId: 'telegram_poll',
|
||||
getSettings: () => { settingsReads += 1; return config; },
|
||||
},
|
||||
state: {
|
||||
cursor: () => cursor,
|
||||
setCursor: async (value) => {
|
||||
cursorWrites.push(value);
|
||||
if (value === 11) {
|
||||
firstCursorStarted.resolve();
|
||||
await releaseFirstCursor.promise;
|
||||
}
|
||||
cursor = value;
|
||||
},
|
||||
hasSeen: (id) => seen.has(id),
|
||||
markSeen: async (id) => seen.add(id),
|
||||
sessionFor: () => 'session-existing',
|
||||
},
|
||||
harness: {
|
||||
ensureRunning: async () => true,
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, text) => {
|
||||
asked.push(text);
|
||||
if (asked.length === 4) allPrompts.resolve();
|
||||
return 'done';
|
||||
},
|
||||
},
|
||||
createApi: () => ({
|
||||
getMe: async () => ({ id: 123456789, is_bot: true }),
|
||||
getWebhookInfo: async () => ({ url: '' }),
|
||||
setMyCommands: async () => true,
|
||||
setChatMenuButton: async () => true,
|
||||
getUpdates: async ({ offset, signal }) => {
|
||||
pollOffsets.push(offset);
|
||||
if (offset === 10) return firstBatch;
|
||||
if (offset === 12) return nextBatch;
|
||||
assert.equal(offset, 14);
|
||||
return new Promise((_, reject) => {
|
||||
if (signal.aborted) return reject(signal.reason);
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
});
|
||||
},
|
||||
setMessageReaction: async () => true,
|
||||
sendChatAction: async () => true,
|
||||
sendRichMessageDraft: async () => true,
|
||||
sendRichMessage: async () => ({ message_id: nextMessageId++ }),
|
||||
sendMessage: async () => ({ message_id: nextMessageId++ }),
|
||||
editMessageText: async () => true,
|
||||
}),
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
try {
|
||||
await runtime.start();
|
||||
await bounded(firstCursorStarted.promise, 'first cursor write was not reached', 5_000);
|
||||
assert.equal(settingsReads, 2, 'every received update captures its settings before the first cursor await');
|
||||
config = configFor(scenario.after, 'after cursor write');
|
||||
releaseFirstCursor.resolve();
|
||||
await bounded(allPrompts.promise, 'both poll batches did not reach Harness', 5_000);
|
||||
for (const [index, event] of [...firstBatch, ...nextBatch].entries()) {
|
||||
const expectedText = `message ${event.update_id}`;
|
||||
const content = asked.find((text) => text.endsWith(expectedText));
|
||||
assert.ok(content, `Missing prompt ${event.update_id}`);
|
||||
const switches = index < 2 ? scenario.before : scenario.after;
|
||||
const enabled = switches[event.message.chat.type === 'private' ? 0 : 1];
|
||||
if (enabled) {
|
||||
assert.match(content, index < 2 ? /before cursor write/ : /after cursor write/);
|
||||
assert.deepEqual(JSON.parse(/^<dsh_im_source>(.*?)<\/dsh_im_source>/su.exec(content)[1]), {
|
||||
channel: 'telegram', botId: 'telegram_poll',
|
||||
});
|
||||
} else {
|
||||
assert.equal(content, expectedText);
|
||||
}
|
||||
}
|
||||
assert.equal(settingsReads, 4, 'Bridge reuses the snapshot instead of reading settings again');
|
||||
assert.deepEqual(pollOffsets, [10, 12, 14]);
|
||||
assert.deepEqual(cursorWrites, [11, 12, 13, 14]);
|
||||
assert.deepEqual([...seen].sort(), ['10', '11', '12', '13']);
|
||||
} finally {
|
||||
releaseFirstCursor.resolve();
|
||||
await runtime.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('Telegram runtime keeps polling while a Harness question waits for its answer', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-interaction-'));
|
||||
const state = await new TelegramStateStore(join(directory, 'state.json')).load();
|
||||
|
|
|
|||
521
test/context-enhancement-settings.test.mjs
Normal file
521
test/context-enhancement-settings.test.mjs
Normal file
|
|
@ -0,0 +1,521 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
BotWorkspaceStore,
|
||||
createWorkspaceAwareController,
|
||||
observeBotWorkspaceRemovals,
|
||||
} from '../src/channels/shared/bot-workspace-store.mjs';
|
||||
import {
|
||||
DEFAULT_CONTEXT_ENHANCEMENT_CONFIG,
|
||||
captureContextEnhancement,
|
||||
enhanceContextContent,
|
||||
} from '../src/channels/shared/context-enhancement.mjs';
|
||||
import { TokenBotController } from '../src/channels/shared/token-bot-controller.mjs';
|
||||
import {
|
||||
TokenBotConfigStore,
|
||||
deriveTokenBotIdentity,
|
||||
} from '../src/channels/shared/token-config-store.mjs';
|
||||
import { createWecomRpcHandler, WECOM_ENDPOINTS } from '../plugin-src/host/channels/wecom/rpc.mjs';
|
||||
import { createWeixinRpcHandler, WEIXIN_ENDPOINTS } from '../plugin-src/host/channels/weixin/rpc.mjs';
|
||||
import { createFeishuRpcHandler, FEISHU_ENDPOINTS } from '../plugin-src/host/channels/feishu/rpc.mjs';
|
||||
import { createDingtalkRpcHandler, DINGTALK_ENDPOINTS } from '../plugin-src/host/channels/dingtalk/rpc.mjs';
|
||||
import { createQqRpcHandler, QQ_ENDPOINTS } from '../plugin-src/host/channels/qq/rpc.mjs';
|
||||
import { createSlackRpcHandler, SLACK_ENDPOINTS } from '../plugin-src/host/channels/slack/rpc.mjs';
|
||||
import { createTelegramRpcHandler, TELEGRAM_ENDPOINTS } from '../plugin-src/host/channels/telegram/rpc.mjs';
|
||||
import { createDiscordRpcHandler, DISCORD_ENDPOINTS } from '../plugin-src/host/channels/discord/rpc.mjs';
|
||||
import { createWhatsappRpcHandler, WHATSAPP_ENDPOINTS } from '../plugin-src/host/channels/whatsapp/rpc.mjs';
|
||||
import {
|
||||
SET_CONTEXT_ENHANCEMENT_ENDPOINT,
|
||||
validContextEnhancementPayload,
|
||||
} from '../plugin-src/host/channels/shared/context-enhancement-rpc.mjs';
|
||||
|
||||
const CHANNELS = [
|
||||
['wecom', createWecomRpcHandler, WECOM_ENDPOINTS],
|
||||
['weixin', createWeixinRpcHandler, WEIXIN_ENDPOINTS],
|
||||
['feishu', createFeishuRpcHandler, FEISHU_ENDPOINTS],
|
||||
['dingtalk', createDingtalkRpcHandler, DINGTALK_ENDPOINTS],
|
||||
['qq', createQqRpcHandler, QQ_ENDPOINTS],
|
||||
['slack', createSlackRpcHandler, SLACK_ENDPOINTS],
|
||||
['telegram', createTelegramRpcHandler, TELEGRAM_ENDPOINTS],
|
||||
['discord', createDiscordRpcHandler, DISCORD_ENDPOINTS],
|
||||
['whatsapp', createWhatsappRpcHandler, WHATSAPP_ENDPOINTS],
|
||||
];
|
||||
|
||||
const enabled = (overrides = {}) => ({
|
||||
groupEnabled: true, directEnabled: true, fields: ['channel', 'botId'], guidance: 'saved guidance',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
async function fixture(t) {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-context-settings-'));
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
const path = join(directory, 'workspaces.json');
|
||||
const store = await new BotWorkspaceStore(path, { defaultWorkspace: directory }).load();
|
||||
return { directory, path, store };
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function fakeController(botIds) {
|
||||
const unavailable = () => { throw new Error('unrelated lifecycle action must not run'); };
|
||||
return {
|
||||
status: () => ({ bots: botIds.map((botId) => ({ botId, configured: true, connected: false })) }),
|
||||
startProvisioning: unavailable,
|
||||
registrationStatus: unavailable,
|
||||
submitVerification: unavailable,
|
||||
cancelProvisioning: unavailable,
|
||||
bindCredentials: unavailable,
|
||||
reconnectBot: unavailable,
|
||||
deleteBot: unavailable,
|
||||
startRegistration: unavailable,
|
||||
cancelRegistration: unavailable,
|
||||
disconnect: unavailable,
|
||||
setAccessPolicy: unavailable,
|
||||
approveSender: unavailable,
|
||||
revokeSender: unavailable,
|
||||
};
|
||||
}
|
||||
|
||||
function awareController(store, core, extra = {}) {
|
||||
return createWorkspaceAwareController(core, {
|
||||
workspaces: store,
|
||||
stateFor: () => { throw new Error('saving enhancement must not load or clear session state'); },
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
test('old workspace files remain untouched and default enhancement off', async (t) => {
|
||||
const { directory, path } = await fixture(t);
|
||||
const original = JSON.stringify({ version: 1, workspaces: { old_bot: directory }, agentPresets: { old_bot: 'preset-one' } });
|
||||
await writeFile(path, original);
|
||||
const store = await new BotWorkspaceStore(path).load();
|
||||
assert.equal(store.contextEnhancementFor('old_bot'), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
assert.equal(store.contextEnhancementFor('unknown_bot'), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
assert.equal(store.workspaceFor('old_bot'), directory);
|
||||
assert.equal(store.agentPresetFor('old_bot'), 'preset-one');
|
||||
assert.equal(store.decorateStatus({ bots: [{ botId: 'old_bot' }] }).bots[0].contextEnhancement, DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
assert.equal(await readFile(path, 'utf8'), original, 'reading/decorating old config must not migrate/write it');
|
||||
});
|
||||
|
||||
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' });
|
||||
await store.ensure('bot_two');
|
||||
const generation = store.generationFor('bot_one');
|
||||
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 saved = await store.setContextEnhancement('bot_one', selected);
|
||||
assert.deepEqual(saved, selected);
|
||||
assert.equal(store.contextEnhancementFor('bot_one'), saved);
|
||||
assert.equal(store.contextEnhancementFor('bot_two'), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
const reloaded = await new BotWorkspaceStore(path).load();
|
||||
assert.deepEqual(reloaded.contextEnhancementFor('bot_one'), selected);
|
||||
}
|
||||
}
|
||||
assert.equal(store.workspaceFor('bot_one'), directory);
|
||||
assert.equal(store.agentPresetFor('bot_one'), 'preset-one');
|
||||
assert.equal(store.generationFor('bot_one'), generation);
|
||||
assert.equal(store.incarnationFor('bot_one'), incarnation);
|
||||
const document = JSON.parse(await readFile(path, 'utf8'));
|
||||
assert.deepEqual(document.contextEnhancement.bot_one, enabled({ fields: [], guidance: '' }));
|
||||
assert.equal(Object.hasOwn(document.contextEnhancement, 'bot_two'), false);
|
||||
|
||||
const alternate = join(directory, 'alternate');
|
||||
await mkdir(alternate);
|
||||
const before = store.contextEnhancementFor('bot_one');
|
||||
await store.setWorkspace('bot_one', alternate);
|
||||
await store.setAgentPreset('bot_one', 'preset-two');
|
||||
assert.equal(store.contextEnhancementFor('bot_one'), before);
|
||||
});
|
||||
|
||||
test('malformed enhancement entries fail off without poisoning valid workspace or preset data', async (t) => {
|
||||
const { directory, path } = await fixture(t);
|
||||
const malformed = [null, [], true, 'bad', {}, enabled({ fields: ['secret'] }), enabled({ directEnabled: 'yes' })];
|
||||
for (const invalid of malformed) {
|
||||
const original = {
|
||||
version: 1,
|
||||
workspaces: { broken_bot: directory, healthy_bot: directory },
|
||||
agentPresets: { broken_bot: 'preset-one' },
|
||||
contextEnhancement: { broken_bot: invalid, healthy_bot: enabled(), '../bad-id': enabled() },
|
||||
};
|
||||
await writeFile(path, JSON.stringify(original));
|
||||
const store = await new BotWorkspaceStore(path).load();
|
||||
assert.equal(store.contextEnhancementFor('broken_bot'), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
assert.deepEqual(store.contextEnhancementFor('healthy_bot'), enabled());
|
||||
assert.equal(store.workspaceFor('broken_bot'), directory);
|
||||
assert.equal(store.agentPresetFor('broken_bot'), 'preset-one');
|
||||
}
|
||||
for (const contextEnhancement of [null, [], 'broken', true, 1]) {
|
||||
await writeFile(path, JSON.stringify({ version: 1, workspaces: { bot_one: directory }, contextEnhancement }));
|
||||
const store = await new BotWorkspaceStore(path).load();
|
||||
assert.equal(store.contextEnhancementFor('bot_one'), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
}
|
||||
await writeFile(path, JSON.stringify({ version: 1, workspaces: { bot_one: 'relative' }, contextEnhancement: null }));
|
||||
await assert.rejects(new BotWorkspaceStore(path).load(), /workspace config is invalid/);
|
||||
});
|
||||
|
||||
test('pending and failed writes never publish an uncommitted context snapshot', async (t) => {
|
||||
const { path, store } = await fixture(t);
|
||||
await store.ensure('bot_one');
|
||||
const first = await store.setContextEnhancement('bot_one', enabled());
|
||||
const provider = { botId: 'bot_one', getSettings: () => store.contextEnhancementFor('bot_one') };
|
||||
const queuedMessage = captureContextEnhancement(provider, 'group');
|
||||
const diskBefore = await readFile(path, 'utf8');
|
||||
await mkdir(`${path}.tmp`);
|
||||
const save = store.setContextEnhancement('bot_one', enabled({ fields: [], guidance: '' }));
|
||||
await Promise.resolve();
|
||||
assert.equal(store.contextEnhancementFor('bot_one'), first, 'the persist await must not expose partial settings');
|
||||
await assert.rejects(save);
|
||||
assert.equal(store.contextEnhancementFor('bot_one'), first);
|
||||
assert.equal(await readFile(path, 'utf8'), diskBefore);
|
||||
await rm(`${path}.tmp`, { recursive: true });
|
||||
await store.setContextEnhancement('bot_one', enabled({ groupEnabled: false, directEnabled: false, fields: [], guidance: '' }));
|
||||
assert.equal(captureContextEnhancement(provider, 'group'), null);
|
||||
const text = enhanceContextContent('queued text', queuedMessage, () => ({ channel: 'qq' }));
|
||||
assert.match(text, /saved guidance/);
|
||||
assert.match(text, /"botId":"bot_one"/);
|
||||
assert.equal(Object.isFrozen(first), true);
|
||||
assert.equal(Object.isFrozen(first.fields), true);
|
||||
});
|
||||
|
||||
test('invalid saves reject atomically and deleted or rebound bot incarnations cannot receive stale updates', async (t) => {
|
||||
const { path, store } = await fixture(t);
|
||||
await store.ensure('bot_one');
|
||||
const initial = await store.setContextEnhancement('bot_one', enabled());
|
||||
const diskBefore = await readFile(path, 'utf8');
|
||||
await assert.rejects(store.setContextEnhancement('bot_one', enabled({ fields: ['token'] })), { code: 'context-enhancement-invalid' });
|
||||
assert.equal(store.contextEnhancementFor('bot_one'), initial);
|
||||
assert.equal(await readFile(path, 'utf8'), diskBefore);
|
||||
|
||||
const oldIncarnation = store.incarnationFor('bot_one');
|
||||
await store.remove('bot_one');
|
||||
await store.ensure('bot_one');
|
||||
await assert.rejects(store.setContextEnhancement('bot_one', enabled(), { incarnation: oldIncarnation }), { code: 'workspace-bot-not-found' });
|
||||
await assert.rejects(store.setContextEnhancement('bot_one', enabled(), { incarnation: null }), { code: 'workspace-bot-not-found' });
|
||||
await assert.rejects(store.setContextEnhancement('missing_bot', enabled()), { code: 'workspace-bot-not-found' });
|
||||
assert.equal(store.contextEnhancementFor('bot_one'), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
});
|
||||
|
||||
test('context update captures incarnation before waiting on controller status', async (t) => {
|
||||
const { store } = await fixture(t);
|
||||
await store.ensure('bot_one');
|
||||
const statusStarted = deferred();
|
||||
const statusResult = deferred();
|
||||
const controller = awareController(store, {
|
||||
async status() { statusStarted.resolve(); return statusResult.promise; },
|
||||
});
|
||||
const pending = controller.updateContextEnhancement('bot_one', enabled());
|
||||
await statusStarted.promise;
|
||||
await store.remove('bot_one');
|
||||
await store.ensure('bot_one');
|
||||
statusResult.resolve({ bots: [{ botId: 'bot_one' }] });
|
||||
await assert.rejects(pending, { code: 'workspace-bot-not-found' });
|
||||
assert.equal(store.contextEnhancementFor('bot_one'), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
});
|
||||
|
||||
test('removal abort preserves settings, committed deletion removes them, and reconciliation cleans orphans', async (t) => {
|
||||
const { directory, path, store } = await fixture(t);
|
||||
await store.ensure('bot_one');
|
||||
await store.ensure('bot_two');
|
||||
const saved = await store.setContextEnhancement('bot_one', enabled({ fields: [], guidance: '' }));
|
||||
const removal = await store.beginRemoval('bot_one');
|
||||
await store.abortRemoval(removal);
|
||||
assert.equal(store.contextEnhancementFor('bot_one'), saved);
|
||||
const observed = observeBotWorkspaceRemovals({ async remove(botId) { return { botId }; } }, { workspaces: store });
|
||||
await observed.remove('bot_one');
|
||||
assert.equal(store.contextEnhancementFor('bot_one'), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
const remaining = JSON.parse(await readFile(path, 'utf8'));
|
||||
assert.equal(remaining.contextEnhancement, undefined);
|
||||
assert.deepEqual(remaining.workspaces, { bot_two: directory });
|
||||
|
||||
await writeFile(path, JSON.stringify({
|
||||
version: 1, workspaces: { bot_two: directory }, contextEnhancement: { orphan_bot: enabled() },
|
||||
}));
|
||||
const reloaded = await new BotWorkspaceStore(path).load();
|
||||
await reloaded.reconcile(['bot_two']);
|
||||
assert.equal(JSON.parse(await readFile(path, 'utf8')).contextEnhancement, undefined);
|
||||
await reloaded.remove('bot_two');
|
||||
await assert.rejects(readFile(path), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
test('failed cleanup retires context settings and startup reconciliation heals stale disk state', async (t) => {
|
||||
const { path, store } = await fixture(t);
|
||||
await store.ensure('bot_one');
|
||||
await store.ensure('bot_two');
|
||||
await store.setContextEnhancement('bot_one', enabled());
|
||||
await mkdir(`${path}.tmp`);
|
||||
await assert.rejects(store.remove('bot_one'));
|
||||
assert.equal(store.has('bot_one'), false);
|
||||
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);
|
||||
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);
|
||||
});
|
||||
|
||||
for (const recovery of ['retry', 'restart']) {
|
||||
test(`failed same-id rebind rolls back credentials and cannot revive old context after ${recovery}`, async (t) => {
|
||||
const { directory, path, store } = await fixture(t);
|
||||
const configPath = join(directory, 'bots.json');
|
||||
const identityOptions = { channel: 'test', botPrefix: 'bot', tokenRefPrefix: 'BOT_TOKEN' };
|
||||
const configStore = await new TokenBotConfigStore(configPath, identityOptions).load();
|
||||
const credentials = new Map();
|
||||
const runtimeStarts = [];
|
||||
const controllers = [];
|
||||
const createController = (workspaces, configs) => {
|
||||
const core = new TokenBotController({
|
||||
descriptor: { key: 'test', label: 'Test', connectionLabel: 'connection' },
|
||||
configStore: observeBotWorkspaceRemovals(configs, { workspaces }),
|
||||
credentials: {
|
||||
async resolve(ref) { return credentials.has(ref) ? { value: credentials.get(ref) } : undefined; },
|
||||
async set(ref, value) { credentials.set(ref, value); },
|
||||
async unset(ref) { credentials.delete(ref); },
|
||||
},
|
||||
async inspectToken(token) { return { platformId: token.split(':')[0], name: 'Test bot' }; },
|
||||
deriveIdentity: (id) => deriveTokenBotIdentity(id, identityOptions),
|
||||
maskPlatformId: (id) => id,
|
||||
async createRuntime({ botId }) {
|
||||
runtimeStarts.push(botId);
|
||||
await workspaces.ensure(botId);
|
||||
return {
|
||||
status: { ready: true, connectionState: 'connected', harnessReachable: true },
|
||||
async start() {},
|
||||
async stop() {},
|
||||
};
|
||||
},
|
||||
logger: { warn() {} },
|
||||
});
|
||||
const controller = createWorkspaceAwareController(core, {
|
||||
workspaces,
|
||||
stateFor: async () => ({ async clearSessions() {} }),
|
||||
});
|
||||
controllers.push(controller);
|
||||
return controller;
|
||||
};
|
||||
t.after(async () => { await Promise.all(controllers.map((controller) => controller.close())); });
|
||||
let controller = createController(store, configStore);
|
||||
await controller.bindCredentials({ token: 'one:original' });
|
||||
await controller.bindCredentials({ token: 'two:original' });
|
||||
const first = deriveTokenBotIdentity('one', identityOptions);
|
||||
const second = deriveTokenBotIdentity('two', identityOptions);
|
||||
const oldWorkspace = join(directory, 'old-workspace');
|
||||
await mkdir(oldWorkspace);
|
||||
await store.setWorkspace(first.botId, oldWorkspace);
|
||||
await store.setAgentPreset(first.botId, 'old-preset');
|
||||
await store.setContextEnhancement(first.botId, enabled());
|
||||
const otherSettings = await store.setContextEnhancement(second.botId, enabled({ guidance: 'other bot' }));
|
||||
const originalDocument = await readFile(path, 'utf8');
|
||||
const otherGeneration = store.generationFor(second.botId);
|
||||
|
||||
// Only metadata persistence fails; the independent bot config remains writable.
|
||||
await mkdir(`${path}.tmp`);
|
||||
await controller.deleteBot(first.botId);
|
||||
assert.equal(configStore.get(first.botId), null);
|
||||
assert.equal(store.contextEnhancementFor(first.botId), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
assert.equal(await readFile(path, 'utf8'), originalDocument);
|
||||
const previousStarts = runtimeStarts.length;
|
||||
await assert.rejects(controller.bindCredentials({ token: 'one:rebound' }));
|
||||
assert.equal(configStore.get(first.botId), null, 'failed cleanup must precede the new config commit');
|
||||
assert.equal(credentials.has(first.tokenRef), false, 'the existing config-save catch rolls back the new credential');
|
||||
assert.equal(runtimeStarts.length, previousStarts, 'a blocked rebind must not attempt runtime startup');
|
||||
assert.equal(await readFile(path, 'utf8'), originalDocument);
|
||||
assert.equal(store.contextEnhancementFor(second.botId), otherSettings);
|
||||
assert.equal(store.generationFor(second.botId), otherGeneration);
|
||||
const reloadedConfigs = await new TokenBotConfigStore(configPath, identityOptions).load();
|
||||
assert.equal(reloadedConfigs.get(first.botId), null, 'restart has no active config for the deleted id');
|
||||
|
||||
await rm(`${path}.tmp`, { recursive: true });
|
||||
let currentStore = store;
|
||||
if (recovery === 'restart') {
|
||||
await controller.close();
|
||||
currentStore = await new BotWorkspaceStore(path, { defaultWorkspace: directory }).load();
|
||||
await currentStore.reconcile(reloadedConfigs.list().map((bot) => bot.botId));
|
||||
assert.equal(currentStore.contextEnhancementFor(first.botId), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
controller = createController(currentStore, reloadedConfigs);
|
||||
}
|
||||
const rebound = await controller.bindCredentials({ token: 'one:rebound' });
|
||||
assert.equal(rebound.bots.find((bot) => bot.botId === first.botId).connected, true);
|
||||
assert.equal(currentStore.workspaceFor(first.botId), directory);
|
||||
assert.equal(currentStore.agentPresetFor(first.botId), null);
|
||||
assert.equal(currentStore.contextEnhancementFor(first.botId), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
assert.equal(credentials.get(first.tokenRef), 'one:rebound');
|
||||
const reloaded = await new BotWorkspaceStore(path, { defaultWorkspace: directory }).load();
|
||||
assert.equal(reloaded.workspaceFor(first.botId), directory);
|
||||
assert.equal(reloaded.agentPresetFor(first.botId), null);
|
||||
assert.equal(reloaded.contextEnhancementFor(first.botId), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
assert.deepEqual(reloaded.contextEnhancementFor(second.botId), otherSettings);
|
||||
});
|
||||
}
|
||||
|
||||
for (const idKey of ['botId', 'id']) {
|
||||
test(`${idKey} config-save observation retries only dirty IDs and preserves normal save timing/results`, async (t) => {
|
||||
const { path, store } = await fixture(t);
|
||||
await store.ensure('bot_one');
|
||||
await store.ensure('bot_two');
|
||||
await store.setContextEnhancement('bot_one', enabled());
|
||||
const saveMethod = idKey === 'id' ? 'saveBot' : 'save';
|
||||
const method = idKey === 'id' ? 'removeBot' : 'remove';
|
||||
const result = Promise.resolve('saved');
|
||||
const calls = [];
|
||||
const target = {
|
||||
[saveMethod](config) { assert.equal(this, target); calls.push(config); return result; },
|
||||
async [method](botId) { return { [idKey]: botId }; },
|
||||
};
|
||||
const observed = observeBotWorkspaceRemovals(target, {
|
||||
workspaces: store,
|
||||
method,
|
||||
botIdFromRemoved: (removed) => removed[idKey],
|
||||
...(idKey === 'id' ? { saveMethod, botIdFromSave: (config) => config.id } : {}),
|
||||
});
|
||||
assert.equal(observed[saveMethod]({ [idKey]: 'bot_two' }), result);
|
||||
assert.equal(calls.length, 1, 'normal save starts synchronously, without a new queue/await');
|
||||
await mkdir(`${path}.tmp`);
|
||||
await observed[method]('bot_one');
|
||||
await assert.rejects(observed[saveMethod]({ [idKey]: 'bot_one' }));
|
||||
assert.equal(calls.length, 1, 'failed metadata cleanup prevents config save');
|
||||
assert.equal(observed[saveMethod]({ [idKey]: 'bot_two' }), result);
|
||||
assert.equal(calls.length, 2, 'another bot is not blocked by this retired id');
|
||||
await rm(`${path}.tmp`, { recursive: true });
|
||||
assert.equal(await observed[saveMethod]({ [idKey]: 'bot_one' }), 'saved');
|
||||
assert.equal(calls.length, 3);
|
||||
const document = JSON.parse(await readFile(path, 'utf8'));
|
||||
assert.equal(Object.hasOwn(document.workspaces, 'bot_one'), false);
|
||||
assert.equal(Object.hasOwn(document.workspaces, 'bot_two'), true);
|
||||
assert.equal(document.contextEnhancement, undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test('context save performs no session/lifecycle work and no fallible status/catalog I/O after commit', async (t) => {
|
||||
const { store } = await fixture(t);
|
||||
await store.ensure('bot_one');
|
||||
let statusReads = 0;
|
||||
let catalogReads = 0;
|
||||
const controller = awareController(store, {
|
||||
status() {
|
||||
statusReads += 1;
|
||||
assert.equal(statusReads, 1, 'must not make a second status call after persistence');
|
||||
return { bots: [{ botId: 'bot_one' }] };
|
||||
},
|
||||
}, {
|
||||
agentPresetCatalog() {
|
||||
catalogReads += 1;
|
||||
assert.equal(store.contextEnhancementFor('bot_one'), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
return { items: [{ id: 'preset-one', label: 'One' }] };
|
||||
},
|
||||
});
|
||||
const result = await controller.updateContextEnhancement('bot_one', enabled());
|
||||
assert.deepEqual(result.bots[0].contextEnhancement, enabled());
|
||||
assert.equal(result.agentPresetCatalog.items[0].id, 'preset-one');
|
||||
assert.equal(catalogReads, 1);
|
||||
|
||||
const brokenCatalog = awareController(store, fakeController(['bot_one']), {
|
||||
agentPresetCatalog() { throw new Error('catalog unavailable'); },
|
||||
});
|
||||
await assert.rejects(brokenCatalog.updateContextEnhancement('bot_one', enabled({ fields: [] })), /catalog unavailable/);
|
||||
assert.deepEqual(store.contextEnhancementFor('bot_one'), enabled());
|
||||
});
|
||||
|
||||
test('shared context RPC payload validator rejects unknown keys and incomplete settings', () => {
|
||||
assert.equal(validContextEnhancementPayload({ botId: 'bot_one', config: enabled() }), true);
|
||||
for (const payload of [
|
||||
null, [], {}, { botId: 'bot_one' }, { botId: '../bot_one', config: enabled() },
|
||||
{ botId: 'bot_one', config: enabled(), token: 'no' },
|
||||
{ botId: 'bot_one', config: { groupEnabled: true } },
|
||||
{ botId: 'bot_one', config: enabled({ fields: ['password'] }) },
|
||||
]) assert.equal(validContextEnhancementPayload(payload), false);
|
||||
});
|
||||
|
||||
for (const [channel, createHandler, endpoints] of CHANNELS.filter(([name]) => (
|
||||
!['slack', 'telegram', 'discord'].includes(name)
|
||||
))) {
|
||||
test(`${channel} QR/status projection failure happens before a context save commits`, async (t) => {
|
||||
const { path, store } = await fixture(t);
|
||||
const botId = `${channel}_projection`;
|
||||
await store.ensure(botId);
|
||||
const previous = store.contextEnhancementFor(botId);
|
||||
const diskBefore = await readFile(path, 'utf8');
|
||||
const core = fakeController([botId]);
|
||||
core.status = () => ({
|
||||
bots: [{ botId, configured: true }],
|
||||
provisioning: {
|
||||
verificationUrl: 'https://example.test/qr', qrValue: 'whatsapp-qr',
|
||||
},
|
||||
registration: {
|
||||
state: 'qr_ready', attempt: 1, qrCodeUrl: 'https://accounts.feishu.cn/fixture',
|
||||
},
|
||||
});
|
||||
let projections = 0;
|
||||
const handler = createHandler(awareController(store, core), {
|
||||
async encodeQr() {
|
||||
projections += 1;
|
||||
assert.equal(store.contextEnhancementFor(botId), previous);
|
||||
throw new Error('QR rendering unavailable');
|
||||
},
|
||||
});
|
||||
const result = await handler(endpoints.setContextEnhancement, { botId, config: enabled() });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(projections, 1);
|
||||
assert.equal(store.contextEnhancementFor(botId), previous);
|
||||
assert.equal(await readFile(path, 'utf8'), diskBefore);
|
||||
});
|
||||
}
|
||||
|
||||
for (const [channel, createHandler, endpoints] of CHANNELS) {
|
||||
test(`${channel} context RPC returns its complete status, preserves empty settings and rejects invalid saves`, async (t) => {
|
||||
const { path, store } = await fixture(t);
|
||||
const botId = `${channel}_one`;
|
||||
const otherBotId = `${channel}_two`;
|
||||
await store.ensure(botId);
|
||||
await store.ensure(otherBotId);
|
||||
const controller = awareController(store, fakeController([botId, otherBotId]));
|
||||
const handler = createHandler(controller);
|
||||
assert.equal(endpoints.setContextEnhancement, SET_CONTEXT_ENHANCEMENT_ENDPOINT);
|
||||
const before = await handler(endpoints.status, {});
|
||||
assert.equal(before.ok, true);
|
||||
assert.deepEqual(before.value.bots[0].contextEnhancement, DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
const selected = enabled({ fields: ['botId', 'channel', 'botId'], guidance: ' \n ' });
|
||||
const saved = await handler(endpoints.setContextEnhancement, { botId, config: selected });
|
||||
assert.equal(saved.ok, true);
|
||||
assert.equal(saved.value.bots.length, 2);
|
||||
assert.deepEqual(saved.value.bots[0].contextEnhancement, enabled({ fields: ['channel', 'botId'], guidance: '' }));
|
||||
assert.deepEqual(saved.value.bots[1].contextEnhancement, DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
const original = store.contextEnhancementFor(botId);
|
||||
const diskBefore = await readFile(path, 'utf8');
|
||||
for (const config of [enabled({ groupEnabled: 'yes' }), enabled({ fields: ['secret'] }), enabled({ guidance: 'x'.repeat(8001) })]) {
|
||||
const failed = await handler(endpoints.setContextEnhancement, { botId, config });
|
||||
assert.equal(failed.ok, false);
|
||||
assert.equal(failed.error.code, 'bad-request');
|
||||
assert.equal(store.contextEnhancementFor(botId), original);
|
||||
}
|
||||
assert.equal(await readFile(path, 'utf8'), diskBefore);
|
||||
const missing = await handler(endpoints.setContextEnhancement, { botId: 'missing_bot', config: enabled() });
|
||||
assert.equal(missing.ok, false);
|
||||
assert.equal(missing.error.code, 'workspace-bot-not-found');
|
||||
const cancelled = await handler(endpoints.setContextEnhancement, { botId, config: enabled() }, AbortSignal.abort());
|
||||
assert.equal(cancelled.ok, false);
|
||||
assert.equal(cancelled.error.code, 'cancelled');
|
||||
assert.equal(store.contextEnhancementFor(botId), original);
|
||||
await mkdir(`${path}.tmp`);
|
||||
const failedWrite = await handler(endpoints.setContextEnhancement, { botId, config: enabled({ fields: [] }) });
|
||||
assert.equal(failedWrite.ok, false);
|
||||
assert.equal(store.contextEnhancementFor(botId), original);
|
||||
assert.equal(await readFile(path, 'utf8'), diskBefore);
|
||||
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, '');
|
||||
});
|
||||
}
|
||||
547
test/context-enhancement-ui.test.mjs
Normal file
547
test/context-enhancement-ui.test.mjs
Normal file
|
|
@ -0,0 +1,547 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import * as React from 'react';
|
||||
import TestRenderer from 'react-test-renderer';
|
||||
|
||||
import {
|
||||
CONTEXT_ENHANCEMENT_FIELDS,
|
||||
CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH,
|
||||
CONTEXT_GUIDANCE_EXAMPLE,
|
||||
DEFAULT_CONTEXT_ENHANCEMENT_CONFIG,
|
||||
} from '../src/channels/shared/context-enhancement.mjs';
|
||||
import { ContextEnhancementEditor, contextEnhancementLabel } from '../plugin-src/client/context-enhancement.js';
|
||||
import { AgentPresetEditor } from '../plugin-src/client/agent-preset.js';
|
||||
import { WorkspaceEditor } from '../plugin-src/client/workspace-editor.js';
|
||||
import { en, setImTranslator } from '../plugin-src/client/i18n.js';
|
||||
|
||||
const { act, create } = TestRenderer;
|
||||
const channels = await Promise.all([
|
||||
['weixin', 'WeixinSettingsTab'], ['wecom', 'WecomSettingsTab'], ['feishu', 'FeishuSettingsTab'],
|
||||
['dingtalk', 'DingtalkSettingsTab'], ['qq', 'QqSettingsTab'], ['slack', 'SlackSettingsTab'],
|
||||
['telegram', 'TelegramSettingsTab'], ['discord', 'DiscordSettingsTab'], ['whatsapp', 'WhatsappSettingsTab'],
|
||||
].map(async ([name, component]) => {
|
||||
const api = await import(`../plugin-src/client/channels/${name}/api.js`);
|
||||
const ui = await import(`../plugin-src/client/channels/${name}/index.js`);
|
||||
return {
|
||||
name,
|
||||
Settings: ui[component],
|
||||
normalize: api.normalizeBotsSnapshot ?? api.normalizeSnapshot,
|
||||
endpoints: Object.entries(api).find(([key]) => key.endsWith('_ENDPOINTS'))[1],
|
||||
};
|
||||
}));
|
||||
|
||||
function textOf(node) {
|
||||
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
||||
return node?.children?.map(textOf).join('') ?? '';
|
||||
}
|
||||
|
||||
function button(root, name) {
|
||||
const found = root.findAllByType('button').find((node) => textOf(node) === name);
|
||||
assert.ok(found, `missing button: ${name}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
function fields(root) {
|
||||
return root.findAllByType('input').filter((node) => CONTEXT_ENHANCEMENT_FIELDS.includes(node.props.name));
|
||||
}
|
||||
|
||||
function switches(root) {
|
||||
return root.findAllByProps({ role: 'switch' });
|
||||
}
|
||||
|
||||
function badge(root) {
|
||||
return textOf(root.findByProps({ className: 'dim-contextStatus' }));
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
for (let index = 0; index < 12; index += 1) await Promise.resolve();
|
||||
}
|
||||
|
||||
async function open(root) {
|
||||
await act(async () => { root.findByProps({ className: 'dim-contextEntry' }).props.onClick(); });
|
||||
}
|
||||
|
||||
async function click(root, label) {
|
||||
await act(async () => { button(root, label).props.onClick(); await flush(); });
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function mockWindow(t) {
|
||||
const previous = globalThis.window;
|
||||
const intervals = new Map();
|
||||
let nextId = 0;
|
||||
globalThis.window = {
|
||||
setInterval(callback, delay) { const id = ++nextId; intervals.set(id, { callback, delay }); return id; },
|
||||
clearInterval(id) { intervals.delete(id); },
|
||||
setTimeout() { return ++nextId; }, clearTimeout() {},
|
||||
requestAnimationFrame(callback) { callback(); return ++nextId; }, cancelAnimationFrame() {},
|
||||
};
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete globalThis.window;
|
||||
else globalThis.window = previous;
|
||||
});
|
||||
return { poll: () => [...intervals.values()].find(({ delay }) => delay === 15_000)?.callback() };
|
||||
}
|
||||
|
||||
function snapshot(channel, configs = [undefined, undefined]) {
|
||||
return {
|
||||
revision: 1,
|
||||
bots: configs.map((config, index) => ({
|
||||
botId: `${channel}_${index}`, configured: true, connected: true, state: 'connected',
|
||||
workspace: `/workspace/${index}`, agentPreset: '', contextEnhancement: config,
|
||||
bot: {
|
||||
name: `Bot ${index}`, username: `bot${index}`, idMasked: '123•••',
|
||||
accountIdMasked: '123•••', appIdMasked: 'cli•••', clientIdMasked: 'ding•••',
|
||||
},
|
||||
health: { status: 'healthy', summary: 'Connected', lastCheckedAt: 1_700_000_000_000 },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function mount(t, component, props, options) {
|
||||
let renderer;
|
||||
await act(async () => { renderer = create(React.createElement(component, props), options); await flush(); });
|
||||
t.after(async () => { await act(async () => { renderer.unmount(); await flush(); }); });
|
||||
return renderer;
|
||||
}
|
||||
|
||||
test('context settings default to off with sender ID and empty guidance, and explain the guidance', async (t) => {
|
||||
assert.equal(contextEnhancementLabel(undefined), '未开启');
|
||||
for (const [groupEnabled, directEnabled, label] of [
|
||||
[false, false, '未开启'], [true, false, '仅群聊'],
|
||||
[false, true, '仅私聊'], [true, true, '群聊和私聊'],
|
||||
]) {
|
||||
const config = { ...DEFAULT_CONTEXT_ENHANCEMENT_CONFIG, groupEnabled, directEnabled };
|
||||
assert.equal(contextEnhancementLabel(config), label);
|
||||
}
|
||||
const saved = [];
|
||||
const renderer = await mount(t, ContextEnhancementEditor, { onSave: (value) => saved.push(value) });
|
||||
const entry = renderer.root.findByProps({ className: 'dim-contextEntry' });
|
||||
assert.equal(entry.props.disabled, false);
|
||||
assert.equal(entry.props['aria-haspopup'], 'dialog');
|
||||
await open(renderer.root);
|
||||
const contextHelp = renderer.root.findByProps({ 'aria-label': '查看上下文增强说明' });
|
||||
const contextTooltip = renderer.root.findByProps({ id: contextHelp.props['aria-describedby'] });
|
||||
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': '查看来源字段说明' });
|
||||
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': '查看增强提示词使用说明' });
|
||||
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.equal(renderer.root.findAllByType('p').some((node) => /只需填写正文|发送者标识可能包含/.test(textOf(node))), false);
|
||||
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');
|
||||
assert.equal(senderNameHelp.parent.parent.props.className, 'dim-contextFieldText');
|
||||
assert.match(textOf(senderNameTooltip), /不是每个渠道.*dsh_im_source.*省略 senderName/s);
|
||||
assert.deepEqual(saved, []);
|
||||
});
|
||||
|
||||
test('switches, fields, and guidance are local drafts until Save; Cancel and close discard them', async (t) => {
|
||||
const saved = [];
|
||||
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 () => {
|
||||
switches(renderer.root)[0].props.onChange({ target: { checked: false } });
|
||||
fields(renderer.root)[0].props.onChange({ target: { checked: false } });
|
||||
});
|
||||
assert.deepEqual(switches(renderer.root).map((node) => node.props.checked), [false, true]);
|
||||
await click(renderer.root, '清空');
|
||||
assert.equal(badge(renderer.root), '未开启');
|
||||
assert.equal(renderer.root.findByType('textarea').props.value, '');
|
||||
assert.deepEqual(saved, []);
|
||||
await act(async () => {
|
||||
if (dismiss === 'close') renderer.root.findByProps({ 'aria-label': '关闭弹窗' }).props.onClick();
|
||||
else if (dismiss === 'escape') renderer.root.findByProps({ role: 'dialog' }).props.onKeyDown({
|
||||
key: 'Escape', preventDefault() {}, stopPropagation() {},
|
||||
});
|
||||
else if (dismiss === 'backdrop') {
|
||||
const target = {};
|
||||
renderer.root.findByProps({ className: 'dim-contextBackdrop' }).props.onMouseDown({ target, currentTarget: target });
|
||||
} else button(renderer.root, dismiss).props.onClick();
|
||||
await flush();
|
||||
});
|
||||
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, '');
|
||||
await click(renderer.root, '取消');
|
||||
}
|
||||
assert.deepEqual(saved, []);
|
||||
});
|
||||
|
||||
test('Save submits one complete config, preserves explicit empty fields/guidance, and fills the example alone', async (t) => {
|
||||
let saved;
|
||||
const calls = [];
|
||||
function Fixture() {
|
||||
const [config, setConfig] = React.useState(undefined);
|
||||
return React.createElement(ContextEnhancementEditor, {
|
||||
config,
|
||||
onSave(value) { saved = value; calls.push(value); setConfig(value); },
|
||||
});
|
||||
}
|
||||
const renderer = await mount(t, Fixture);
|
||||
await open(renderer.root);
|
||||
await act(async () => { switches(renderer.root)[0].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 click(renderer.root, '清空');
|
||||
await click(renderer.root, '保存');
|
||||
assert.equal(calls.length, 1);
|
||||
assert.deepEqual(saved, { groupEnabled: true, directEnabled: false, fields: [], 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]);
|
||||
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));
|
||||
});
|
||||
|
||||
test('failed atomic saves retain the draft, lock edits and duplicate submits, and can be retried', async (t) => {
|
||||
const request = deferred();
|
||||
const calls = [];
|
||||
const renderer = await mount(t, ContextEnhancementEditor, {
|
||||
config: DEFAULT_CONTEXT_ENHANCEMENT_CONFIG,
|
||||
onSave(value) { calls.push(value); return calls.length === 1 ? request.promise : undefined; },
|
||||
});
|
||||
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' } });
|
||||
});
|
||||
const saveButton = button(renderer.root, '保存');
|
||||
await act(async () => { saveButton.props.onClick(); saveButton.props.onClick(); await flush(); });
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(renderer.root.findByProps({ role: 'dialog' }).props['aria-busy'], true);
|
||||
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' } });
|
||||
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(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(badge(renderer.root), '未开启');
|
||||
await click(renderer.root, '保存');
|
||||
assert.equal(calls.length, 2);
|
||||
assert.deepEqual(calls[1], calls[0]);
|
||||
assert.equal(renderer.root.findAllByProps({ role: 'dialog' }).length, 0);
|
||||
});
|
||||
|
||||
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 },
|
||||
onSave(value) { saved.push(value); },
|
||||
});
|
||||
assert.equal(badge(renderer.root), '未开启');
|
||||
await open(renderer.root);
|
||||
const group = switches(renderer.root)[0];
|
||||
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');
|
||||
await act(async () => {
|
||||
group.props.onChange({ target: { checked: true } });
|
||||
switches(renderer.root)[1].props.onChange({ target: { checked: true } });
|
||||
});
|
||||
await click(renderer.root, '保存');
|
||||
assert.equal(saved[0].groupEnabled, false);
|
||||
assert.equal(saved[0].directEnabled, true);
|
||||
});
|
||||
|
||||
test('dialog traps Tab and external focus, cancels with Escape, and restores entry focus', async (t) => {
|
||||
const previous = globalThis.document;
|
||||
const listeners = new Map();
|
||||
const document = { activeElement: null, addEventListener(type, fn) { listeners.set(type, fn); }, removeEventListener(type) { listeners.delete(type); } };
|
||||
globalThis.document = document;
|
||||
t.after(() => { if (previous === undefined) delete globalThis.document; else globalThis.document = previous; });
|
||||
const focusable = () => ({ focus() { document.activeElement = this; } });
|
||||
const entry = focusable();
|
||||
const first = focusable();
|
||||
const last = focusable();
|
||||
const dialog = { ...focusable(), contains(node) { return [this, first, last].includes(node); }, querySelectorAll() { return [first, last]; } };
|
||||
const renderer = await mount(t, ContextEnhancementEditor, {}, {
|
||||
createNodeMock(element) {
|
||||
if (element.props.className === 'dim-contextEntry') return entry;
|
||||
if (element.props.role === 'dialog') return dialog;
|
||||
return {};
|
||||
},
|
||||
});
|
||||
await open(renderer.root);
|
||||
assert.equal(document.activeElement, dialog);
|
||||
const keydown = (shiftKey) => {
|
||||
let prevented = false;
|
||||
renderer.root.findByProps({ role: 'dialog' }).props.onKeyDown({ key: 'Tab', shiftKey, preventDefault() { prevented = true; } });
|
||||
assert.equal(prevented, true);
|
||||
};
|
||||
keydown(false);
|
||||
assert.equal(document.activeElement, first);
|
||||
keydown(true);
|
||||
assert.equal(document.activeElement, last);
|
||||
keydown(false);
|
||||
assert.equal(document.activeElement, first);
|
||||
listeners.get('focusin')({ target: {} });
|
||||
assert.equal(document.activeElement, dialog);
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({ role: 'dialog' }).props.onKeyDown({ key: 'Escape', preventDefault() {}, stopPropagation() {} });
|
||||
await flush();
|
||||
});
|
||||
assert.equal(document.activeElement, entry);
|
||||
assert.equal(listeners.has('focusin'), false);
|
||||
});
|
||||
|
||||
test('all context dialog copy and validation errors localize without translating the saved body', async (t) => {
|
||||
setImTranslator((key) => en[key] ?? key);
|
||||
t.after(() => setImTranslator(null));
|
||||
const saved = [];
|
||||
const renderer = await mount(t, ContextEnhancementEditor, { groupSupported: false, onSave: (value) => saved.push(value) });
|
||||
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');
|
||||
await act(async () => {
|
||||
renderer.root.findByType('textarea').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/);
|
||||
assert.deepEqual(saved, []);
|
||||
});
|
||||
|
||||
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 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 ',
|
||||
}]));
|
||||
assert.deepEqual(canonical.bots[0].contextEnhancement, {
|
||||
groupEnabled: false, directEnabled: true, fields: ['channel', 'botId'], guidance: '',
|
||||
}, 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.deepEqual(raw.bots[1].contextEnhancement, empty);
|
||||
}
|
||||
});
|
||||
|
||||
test('all nine cards save through their existing RPC path, isolate bots and preserve explicit empty on reload', async (t) => {
|
||||
mockWindow(t);
|
||||
for (const channel of channels) await t.test(channel.name, async (t) => {
|
||||
let current = snapshot(channel.name);
|
||||
const calls = [];
|
||||
const rpcCall = async (endpoint, payload) => {
|
||||
calls.push({ endpoint, payload });
|
||||
if (endpoint === 'connection.status') return { ok: true, value: current };
|
||||
assert.equal(endpoint, 'bot.context-enhancement.set');
|
||||
current = { ...current, revision: 2, bots: current.bots.map((bot) => bot.botId === payload.botId
|
||||
? { ...bot, contextEnhancement: payload.config } : bot) };
|
||||
return { ok: true, value: current };
|
||||
};
|
||||
const renderer = await mount(t, channel.Settings, { rpcCall });
|
||||
const first = () => renderer.root.findByProps({ 'data-bot-id': `${channel.name}_0` });
|
||||
const second = () => renderer.root.findByProps({ 'data-bot-id': `${channel.name}_1` });
|
||||
const editors = first().findAll((node) => [WorkspaceEditor, AgentPresetEditor, ContextEnhancementEditor].includes(node.type));
|
||||
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 } }); });
|
||||
for (const name of CONTEXT_ENHANCEMENT_FIELDS) await act(async () => {
|
||||
first().findByProps({ name }).props.onChange({ target: { checked: false } });
|
||||
});
|
||||
await click(first(), '清空');
|
||||
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: '' },
|
||||
} }]);
|
||||
assert.equal(badge(first()), '仅私聊');
|
||||
assert.equal(badge(second()), '未开启');
|
||||
assert.equal(renderer.root.findAllByProps({ role: 'dialog' }).length, 0);
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
||||
test('all nine settings fence stale polls and reconcile against the actual saved response', async (t) => {
|
||||
const timers = mockWindow(t);
|
||||
for (const channel of channels) await t.test(channel.name, async (t) => {
|
||||
const oldRead = deferred();
|
||||
const original = snapshot(channel.name);
|
||||
let current = original;
|
||||
let reads = 0;
|
||||
const actual = { groupEnabled: false, directEnabled: true, fields: ['botId'], guidance: '' };
|
||||
const rpcCall = async (endpoint) => {
|
||||
if (endpoint === 'connection.status') {
|
||||
reads += 1;
|
||||
return reads === 2 ? oldRead.promise : { ok: true, value: current };
|
||||
}
|
||||
assert.equal(endpoint, 'bot.context-enhancement.set');
|
||||
current = snapshot(channel.name, [actual, undefined]);
|
||||
return { ok: true, value: current };
|
||||
};
|
||||
const renderer = await mount(t, channel.Settings, { rpcCall });
|
||||
await act(async () => { void timers.poll(); await flush(); });
|
||||
assert.equal(reads, 2);
|
||||
const first = () => renderer.root.findByProps({ 'data-bot-id': `${channel.name}_0` });
|
||||
await open(first());
|
||||
// The server response, not an optimistic copy of this draft, is authoritative.
|
||||
await click(first(), '保存');
|
||||
assert.equal(badge(first()), '仅私聊');
|
||||
oldRead.resolve({ ok: true, value: original });
|
||||
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, '');
|
||||
});
|
||||
});
|
||||
|
||||
test('all nine settings ignore an older concurrent bot mutation after context settings are saved', async (t) => {
|
||||
mockWindow(t);
|
||||
for (const channel of channels) await t.test(channel.name, async (t) => {
|
||||
const reconnect = deferred();
|
||||
const original = snapshot(channel.name);
|
||||
let current = original;
|
||||
const rpcCall = async (endpoint, payload) => {
|
||||
if (endpoint === 'connection.status') return { ok: true, value: current };
|
||||
if (endpoint === 'bot.reconnect') return reconnect.promise;
|
||||
assert.equal(endpoint, 'bot.context-enhancement.set');
|
||||
current = snapshot(channel.name, [payload.config, undefined]);
|
||||
return { ok: true, value: current };
|
||||
};
|
||||
const renderer = await mount(t, channel.Settings, { rpcCall });
|
||||
const first = () => renderer.root.findByProps({ 'data-bot-id': `${channel.name}_0` });
|
||||
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 click(first(), '保存');
|
||||
assert.equal(badge(first()), '仅私聊');
|
||||
reconnect.resolve({ ok: true, value: original });
|
||||
await act(async () => { await flush(); });
|
||||
assert.equal(badge(first()), '仅私聊');
|
||||
assert.equal(first().findByProps({ className: 'dim-contextEntry' }).props.disabled, false);
|
||||
});
|
||||
});
|
||||
|
||||
test('all nine failed save RPCs keep runtime state and drafts intact through status reconciliation', async (t) => {
|
||||
mockWindow(t);
|
||||
for (const channel of channels) await t.test(channel.name, async (t) => {
|
||||
const original = snapshot(channel.name);
|
||||
const calls = [];
|
||||
const rpcCall = async (endpoint, payload) => {
|
||||
calls.push({ endpoint, payload });
|
||||
if (endpoint === 'connection.status') return { ok: true, value: original };
|
||||
assert.equal(endpoint, 'bot.context-enhancement.set');
|
||||
return { ok: false, error: { code: 'context-enhancement-invalid', message: 'Save rejected' } };
|
||||
};
|
||||
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 click(first(), '保存');
|
||||
assert.equal(badge(first()), '未开启');
|
||||
assert.equal(switches(first())[1].props.checked, true);
|
||||
const dialog = first().findByProps({ role: 'dialog' });
|
||||
assert.equal(dialog.findByType('textarea').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, '');
|
||||
});
|
||||
});
|
||||
|
||||
test('the approved neutral entry and theme-aware modal keep responsive labels and touch targets', async () => {
|
||||
const styles = await readFile(new URL('../plugin-src/client/styles.js', import.meta.url), 'utf8');
|
||||
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-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/);
|
||||
assert.match(styles, /\.dim-contextFieldText \{[^}]*grid-template-columns: max-content max-content;[^}]*column-gap: 5px;/);
|
||||
assert.match(styles, /\.dim-contextTooltip\.dim-contextFieldTooltip \{[^}]*right: 0;[^}]*left: auto;/);
|
||||
assert.match(styles, /@media \(pointer: coarse\) \{\s*\.dim-contextEntry[^}]*min-height: 44px;/);
|
||||
assert.match(styles, /\.dim-contextLabel \{[^}]*overflow-wrap: anywhere;/);
|
||||
assert.match(styles, /\.dim-contextTooltip \{[^}]*opacity: 0;[^}]*visibility: hidden;/);
|
||||
assert.match(styles, /\.dim-contextTooltip\.dim-contextGuidanceTooltip \{[^}]*bottom: calc\(100% \+ 7px\);[^}]*overflow-y: auto;/);
|
||||
assert.match(styles, /\.dim-contextHeader \{[^}]*position: relative;/);
|
||||
assert.match(styles, /\.dim-contextLegend \{[^}]*position: relative;[^}]*inline-flex/);
|
||||
assert.match(styles, /\.dim-contextHelp:hover \.dim-contextTooltip, \.dim-contextHelp:focus-within \.dim-contextTooltip \{[^}]*opacity: 1;[^}]*visibility: visible;/);
|
||||
const office = await readFile(new URL('../plugin-src/client/channels/office/index.js', import.meta.url), 'utf8');
|
||||
assert.doesNotMatch(office, /ContextEnhancement|context-enhancement/);
|
||||
});
|
||||
253
test/context-enhancement.test.mjs
Normal file
253
test/context-enhancement.test.mjs
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
CONTEXT_ENHANCEMENT_FIELDS,
|
||||
CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH,
|
||||
DEFAULT_CONTEXT_ENHANCEMENT_CONFIG,
|
||||
DEFAULT_CONTEXT_GUIDANCE,
|
||||
captureContextEnhancement,
|
||||
enhanceContextContent,
|
||||
normalizeContextEnhancementConfig,
|
||||
validateContextEnhancementConfig,
|
||||
} from '../src/channels/shared/context-enhancement.mjs';
|
||||
|
||||
function config(overrides = {}) {
|
||||
return {
|
||||
...DEFAULT_CONTEXT_ENHANCEMENT_CONFIG,
|
||||
groupEnabled: true,
|
||||
fields: CONTEXT_ENHANCEMENT_FIELDS,
|
||||
guidance: '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(settings = config(), conversationType = 'group', botId = 'bot_one') {
|
||||
return captureContextEnhancement({ botId, getSettings: () => settings }, conversationType);
|
||||
}
|
||||
|
||||
function sourceOf(text) {
|
||||
const match = text.match(/<dsh_im_source>(.*?)<\/dsh_im_source>/s);
|
||||
return match ? JSON.parse(match[1]) : null;
|
||||
}
|
||||
|
||||
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 guidance of ['', ' \r\n\t ']) {
|
||||
assert.deepEqual(validateContextEnhancementConfig(config({ fields: [], guidance })), {
|
||||
groupEnabled: true, directEnabled: 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.equal(Object.isFrozen(canonical), true);
|
||||
assert.equal(Object.isFrozen(canonical.fields), true);
|
||||
});
|
||||
|
||||
test('context saves reject missing, unknown and mistyped fields while normalization fails off', () => {
|
||||
const invalid = [
|
||||
undefined, null, [], {}, true,
|
||||
{ ...config(), unexpected: true },
|
||||
{ groupEnabled: true, directEnabled: false, fields: [] },
|
||||
config({ groupEnabled: 'true' }), config({ directEnabled: 1 }),
|
||||
config({ fields: null }), config({ fields: 'channel' }),
|
||||
config({ fields: ['channel', 'token'] }), config({ fields: [null] }),
|
||||
config({ fields: Array(1) }),
|
||||
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'); } },
|
||||
];
|
||||
for (const value of invalid) {
|
||||
assert.throws(() => validateContextEnhancementConfig(value));
|
||||
assert.equal(normalizeContextEnhancementConfig(value), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
assert.equal(captureContextEnhancement({ botId: 'bot_one', getSettings: () => value }, 'group'), null);
|
||||
}
|
||||
assert.equal(validateContextEnhancementConfig(config({
|
||||
guidance: 'x'.repeat(CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH),
|
||||
})).guidance.length, CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH);
|
||||
});
|
||||
|
||||
test('off path preserves content identity and never reads fields, guidance, source or formatter', (t) => {
|
||||
const contents = [' \n\t原文\r\n ', '', [], [{ type: 'text', text: 'x' }], { untouched: true }];
|
||||
let sourceCalls = 0;
|
||||
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(); },
|
||||
};
|
||||
const provider = {
|
||||
get botId() { return throwIfRead(); },
|
||||
getSettings() { settingReads += 1; return settings; },
|
||||
};
|
||||
const stringify = t.mock.method(JSON, 'stringify', throwIfRead);
|
||||
try {
|
||||
for (const content of contents) {
|
||||
for (const kind of ['direct', 'group']) {
|
||||
const captured = captureContextEnhancement(provider, kind);
|
||||
assert.equal(captured, null);
|
||||
assert.equal(enhanceContextContent(content, captured, () => {
|
||||
sourceCalls += 1;
|
||||
throwIfRead();
|
||||
}), content);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
stringify.mock.restore();
|
||||
}
|
||||
assert.equal(settingReads, contents.length * 2);
|
||||
assert.equal(sourceCalls, 0);
|
||||
assert.equal(stringify.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
test('only the actual conversation type can enable capture, regardless of selected fields', () => {
|
||||
for (const groupEnabled of [false, true]) {
|
||||
for (const directEnabled of [false, true]) {
|
||||
const settings = config({ groupEnabled, directEnabled, fields: [], guidance: 'guidance' });
|
||||
assert.equal(Boolean(snapshot(settings, 'group')), groupEnabled);
|
||||
assert.equal(Boolean(snapshot(settings, 'direct')), directEnabled);
|
||||
}
|
||||
}
|
||||
const provider = { getSettings() { throw new Error('read failed'); } };
|
||||
for (const kind of ['group', 'direct', 'single', 'unknown', null, undefined]) {
|
||||
assert.equal(captureContextEnhancement(provider, kind), null);
|
||||
}
|
||||
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'); },
|
||||
};
|
||||
assert.equal(snapshot(offSettings, kind), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('all 32 source-field subsets are projected in canonical order with no hidden fields', () => {
|
||||
const expected = {
|
||||
channel: 'telegram', conversationType: 'group',
|
||||
senderId: '123', senderName: '张三', botId: 'bot_one',
|
||||
};
|
||||
for (let mask = 0; mask < 32; mask += 1) {
|
||||
const fields = CONTEXT_ENHANCEMENT_FIELDS.filter((_field, index) => mask & (1 << index));
|
||||
const selected = snapshot(config({ fields: [...fields].reverse() }));
|
||||
const actual = enhanceContextContent(' original\n', selected, () => ({
|
||||
channel: 'telegram', senderId: 123, senderName: '张三',
|
||||
botId: 'platform-secret-not-used', conversationType: 'direct', token: 'never-sent',
|
||||
}));
|
||||
if (fields.length === 0) {
|
||||
assert.equal(actual, ' original\n');
|
||||
} else {
|
||||
assert.deepEqual(sourceOf(actual), Object.fromEntries(fields.map((field) => [field, expected[field]])));
|
||||
assert.deepEqual(Object.keys(sourceOf(actual)), fields);
|
||||
assert.equal(actual.endsWith('\n\n original\n'), true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('source availability and guidance are independent, including empty multimodal identity', () => {
|
||||
const content = [{ type: 'image', image_url: { url: 'memory:image' } }];
|
||||
const throwSource = () => { throw new Error('no metadata lookup required'); };
|
||||
assert.equal(enhanceContextContent(content, snapshot(config({ fields: [] })), throwSource), content);
|
||||
for (const senderName of [undefined, null, '', ' \t ', 12]) {
|
||||
assert.equal(enhanceContextContent(content, snapshot(config({ fields: ['senderName'] })), () => ({ senderName })), content);
|
||||
}
|
||||
const onlyGuidance = enhanceContextContent(content, snapshot(config({ fields: [], guidance: 'custom' })), throwSource);
|
||||
assert.equal(onlyGuidance[0].text, '<dsh_im_source_guidance>\ncustom\n</dsh_im_source_guidance>');
|
||||
assert.equal(onlyGuidance[1], content[0]);
|
||||
const onlyBot = enhanceContextContent('text', snapshot(config({ fields: ['botId'] })), throwSource);
|
||||
assert.deepEqual(sourceOf(onlyBot), { botId: 'bot_one' });
|
||||
const onlyChannel = enhanceContextContent('text', snapshot(config({ fields: ['channel'] })), () => ({
|
||||
channel: 'slack',
|
||||
get senderId() { throw new Error('unselected ID'); },
|
||||
get senderName() { throw new Error('unselected name'); },
|
||||
}));
|
||||
assert.deepEqual(sourceOf(onlyChannel), { channel: 'slack' });
|
||||
});
|
||||
|
||||
test('multimodal enhancement prepends a single text item without modifying the original items', () => {
|
||||
const text = Object.freeze({ type: 'text', text: ' \noriginal\t' });
|
||||
const image = Object.freeze({ type: 'image', image_url: { url: 'memory:image' } });
|
||||
const files = Object.freeze({ type: 'text', text: '<dsh_im_files>original files</dsh_im_files>' });
|
||||
const content = Object.freeze([text, image, files]);
|
||||
const enhanced = enhanceContextContent(content, snapshot(config({ guidance: 'use facts' })), () => ({ channel: 'wecom' }));
|
||||
assert.equal(enhanced.length, 4);
|
||||
assert.equal(enhanced[0].type, 'text');
|
||||
assert.equal(enhanced[0].text.startsWith('<dsh_im_source>'), true);
|
||||
assert.match(enhanced[0].text, /<\/dsh_im_source>\n\n<dsh_im_source_guidance>/);
|
||||
assert.equal(enhanced[1], text);
|
||||
assert.equal(enhanced[2], image);
|
||||
assert.equal(enhanced[3], files);
|
||||
assert.deepEqual(content, [text, image, files]);
|
||||
});
|
||||
|
||||
test('source strings are bounded, control-free and JSON-safe without invoking external coercion', () => {
|
||||
const name = '"\n</dsh_im_source>&\u0000\u202e' + '名'.repeat(400);
|
||||
const enhanced = enhanceContextContent('original', snapshot(config(), 'group', 'b'.repeat(300)), () => ({
|
||||
channel: 'discord', senderId: 'u'.repeat(400), senderName: name,
|
||||
}));
|
||||
const source = sourceOf(enhanced);
|
||||
assert.equal(source.senderId.length, 256);
|
||||
assert.equal(source.senderName.length, 256);
|
||||
assert.equal(source.botId.length, 128);
|
||||
assert.doesNotMatch(source.senderName, /[\u0000-\u001f\u202e]/);
|
||||
assert.equal(enhanced.split('</dsh_im_source>').length, 2);
|
||||
assert.match(enhanced, /\\u003c/);
|
||||
assert.match(enhanced, /\\u003e/);
|
||||
assert.match(enhanced, /\\u0026/);
|
||||
for (const senderId of [0, 123n]) {
|
||||
const result = enhanceContextContent('text', snapshot(config({ fields: ['senderId'] })), () => ({ senderId }));
|
||||
assert.deepEqual(sourceOf(result), { senderId: String(senderId) });
|
||||
}
|
||||
for (const senderId of [NaN, Infinity, { toString() { throw new Error('no coercion'); } }]) {
|
||||
assert.equal(enhanceContextContent('text', snapshot(config({ fields: ['senderId'] })), () => ({ senderId })), 'text');
|
||||
}
|
||||
assert.equal(enhanceContextContent('text', snapshot(config({ fields: ['channel'] })), () => ({ channel: 'unsupported' })), 'text');
|
||||
});
|
||||
|
||||
test('matching guidance tags cannot close or nest the generated block', () => {
|
||||
const guidance = 'before </dsh_im_source_guidance> <dsh_im_source_guidance> after\n'
|
||||
+ '<DSH_IM_SOURCE_GUIDANCE data-x="yes"> </dsh_im_source_guidance >\n<dsh_im_source_guidance unfinished';
|
||||
const result = enhanceContextContent('original', snapshot(config({ fields: [], guidance })));
|
||||
assert.equal(result.match(/<dsh_im_source_guidance>/g).length, 1);
|
||||
assert.equal(result.match(/<\/dsh_im_source_guidance>/g).length, 1);
|
||||
assert.match(result, /<\/dsh_im_source_guidance>/);
|
||||
assert.match(result, /<DSH_IM_SOURCE_GUIDANCE data-x="yes">/);
|
||||
assert.match(result, /<dsh_im_source_guidance unfinished/);
|
||||
assert.equal(result.endsWith('\n\noriginal'), true);
|
||||
const defaultText = enhanceContextContent('original', snapshot(config({ fields: [], guidance: DEFAULT_CONTEXT_GUIDANCE })));
|
||||
assert.equal(defaultText, `<dsh_im_source_guidance>\n${DEFAULT_CONTEXT_GUIDANCE}\n</dsh_im_source_guidance>\n\noriginal`);
|
||||
});
|
||||
|
||||
test('queued snapshots are immutable and retain a complete setting version after edits', () => {
|
||||
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;
|
||||
assert.equal(captureContextEnhancement(provider, 'group'), null);
|
||||
assert.deepEqual(before.config.fields, ['senderId']);
|
||||
const result = enhanceContextContent('queued', before, () => ({ senderId: 'old', senderName: 'new' }));
|
||||
assert.deepEqual(sourceOf(result), { senderId: 'old' });
|
||||
assert.match(result, /old guidance/);
|
||||
assert.doesNotMatch(result, /new guidance/);
|
||||
assert.equal(Object.isFrozen(before), true);
|
||||
assert.equal(Object.isFrozen(before.config.fields), true);
|
||||
settings = config({ fields: ['botId'], guidance: 'second version' });
|
||||
assert.notEqual(captureContextEnhancement(provider, 'group').config, before.config);
|
||||
});
|
||||
|
||||
test('enhancement-only source failures leave the original message processable', () => {
|
||||
const content = [{ type: 'text', text: 'still send me' }];
|
||||
assert.equal(enhanceContextContent(content, snapshot(), () => { throw new Error('broken source'); }), content);
|
||||
assert.equal(enhanceContextContent(content, snapshot(), () => ({ get channel() { throw new Error('broken getter'); } })), content);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue