feat: add agent preset chat commands

This commit is contained in:
xmanrui 2026-08-22 04:04:44 +08:00
parent 2ad6f1a44e
commit eea29fe296
33 changed files with 1519 additions and 234 deletions

View file

@ -7,14 +7,9 @@ import test from 'node:test';
import {
BotWorkspaceStore,
createBotWorkspaceScope,
createWorkspaceAwareController,
} from '../src/channels/shared/bot-workspace-store.mjs';
import { ConversationStateStore } from '../src/channels/shared/conversation-state-store.mjs';
import { TextHarnessBridge } from '../src/channels/shared/text-harness-bridge.mjs';
import {
TOKEN_BOT_ENDPOINTS,
createTokenBotRpcHandler,
} from '../plugin-src/host/channels/shared/rpc.mjs';
function message(messageId, content) {
return {
@ -29,7 +24,7 @@ function message(messageId, content) {
};
}
test('an agent preset change applies only after /new creates the next session', async (t) => {
test('/preset changes only the sessions created after /new and --default follows Host', async (t) => {
const root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-im-preset-lifecycle-')));
t.after(() => rm(root, { recursive: true, force: true }));
@ -46,7 +41,7 @@ test('an agent preset change applies only after /new creates the next session',
const sessions = new Set();
const harness = {
async createSession(options) {
const sessionId = `session-${creations.length === 0 ? 'a' : 'b'}`;
const sessionId = `session-${String.fromCharCode(97 + creations.length)}`;
creations.push({ sessionId, options });
sessions.add(sessionId);
return sessionId;
@ -59,7 +54,19 @@ test('an agent preset change applies only after /new creates the next session',
return `answer-from-${sessionId}`;
},
};
const scope = createBotWorkspaceScope(harness, { botId, workspaces, state });
const agentPresetCatalog = {
defaultId: 'preset-old',
items: [
{ id: 'preset-old', label: 'Old preset' },
{ id: 'preset-new', label: 'New preset' },
],
};
const scope = createBotWorkspaceScope(harness, {
botId,
workspaces,
state,
agentPresetCatalog,
});
const sent = [];
const bridge = new TextHarnessBridge({
descriptor: { key: 'test', label: 'Test' },
@ -69,25 +76,6 @@ test('an agent preset change applies only after /new creates the next session',
logger: { warn() {}, error() {} },
});
const baseController = {
status() { return { bots: [{ botId, connected: true }] }; },
bindCredentials() { return this.status(); },
reconnectBot() { return this.status(); },
deleteBot() { return { bots: [] }; },
};
const controller = createWorkspaceAwareController(baseController, {
workspaces,
stateFor: async () => state,
agentPresetCatalog: {
defaultId: 'preset-old',
items: [
{ id: 'preset-old', label: 'Old preset' },
{ id: 'preset-new', label: 'New preset' },
],
},
});
const rpc = createTokenBotRpcHandler(controller, { channel: 'Test' });
await bridge.accept(message('message-one', 'first prompt'));
assert.equal(state.sessionFor(conversationKey), 'session-a');
assert.deepEqual(creations, [{
@ -95,13 +83,14 @@ test('an agent preset change applies only after /new creates the next session',
options: { workspace: root, agentPreset: 'preset-old' },
}]);
const updated = await rpc(TOKEN_BOT_ENDPOINTS.setAgentPreset, {
botId,
agentPreset: 'preset-new',
});
assert.equal(updated.ok, true);
assert.equal(updated.value.bots[0].agentPreset, 'preset-new');
await bridge.accept(message('message-list', '/presetlist'));
assert.match(sent.at(-1), /2\. New preset(preset-new)/u);
await bridge.accept(message('message-preset', '/preset 2'));
assert.equal(workspaces.agentPresetFor(botId), 'preset-new');
assert.match(sent.at(-1), /已有会话不变/u);
assert.equal(state.sessionFor(conversationKey), 'session-a');
assert.equal(creations.length, 1, '/preset itself must not create a session');
await bridge.accept(message('message-two', 'still in the current chat'));
assert.equal(creations.length, 1, 'the current chat must reuse session A');
@ -123,15 +112,24 @@ test('an agent preset change applies only after /new creates the next session',
options: { workspace: root, agentPreset: 'preset-new' },
},
]);
await bridge.accept(message('message-default', '/preset --default'));
assert.equal(workspaces.agentPresetFor(botId), null);
assert.equal(state.sessionFor(conversationKey), 'session-b');
assert.equal(creations.length, 2, '--default itself must not create a session');
await bridge.accept(message('message-new-default', '/new'));
await bridge.accept(message('message-four', 'first prompt using Host default'));
assert.equal(state.sessionFor(conversationKey), 'session-c');
assert.deepEqual(creations.at(-1), {
sessionId: 'session-c',
options: { workspace: root },
});
assert.deepEqual(asks, [
{ sessionId: 'session-a', text: 'first prompt' },
{ sessionId: 'session-a', text: 'still in the current chat' },
{ sessionId: 'session-b', text: 'first prompt after /new' },
{ sessionId: 'session-c', text: 'first prompt using Host default' },
]);
assert.deepEqual(sent, [
'answer-from-session-a',
'answer-from-session-a',
'已开启新会话。请发送你的问题。',
'answer-from-session-b',
]);
assert.equal(sent.at(-1), 'answer-from-session-c');
});

View file

@ -321,11 +321,20 @@ test('DingTalk executes /compact for the bound Session without prompting the mod
assert.equal(sent.at(-1).text, '暂无可压缩的历史记录。');
});
test('DingTalk lists models without prompting and help advertises all four commands', async () => {
test('DingTalk lists models and presets without prompting and advertises fast commands', async () => {
const fixture = stateFixture();
const sent = [];
const presetUpdates = [];
let agentPreset = null;
let asks = 0;
let creates = 0;
const agentPresetCatalog = {
defaultId: 'preset-001',
items: Array.from({ length: 70 }, (_, index) => ({
id: `preset-${String(index + 1).padStart(3, '0')}`,
label: `DingTalk Preset ${index + 1} ${'x'.repeat(64)}`,
})),
};
const bridge = new DingtalkHarnessBridge({
api: { sendText: async (request) => sent.push(request) },
clientId: 'ding-client',
@ -339,6 +348,12 @@ test('DingTalk lists models without prompting and help advertises all four comma
}],
failures: [],
}),
agentPresetSettings: async () => ({ agentPreset, agentPresetCatalog }),
updateAgentPreset: async (value) => {
presetUpdates.push(value);
agentPreset = value;
return { agentPreset, agentPresetCatalog };
},
createSession: async () => { creates += 1; return 'dingtalk-session'; },
ask: async () => { asks += 1; return 'unexpected model reply'; },
},
@ -351,12 +366,42 @@ test('DingTalk lists models without prompting and help advertises all four comma
assert.equal(creates, 0);
assert.equal(fixture.sessions.size, 0);
const presetReplyStart = sent.length;
await bridge.accept(message('presets-dingtalk', '/presetlist'));
const presetReplies = sent.slice(presetReplyStart).map((entry) => entry.text);
assert.ok(presetReplies.length > 1);
assert.match(presetReplies.join('\n'), /preset-070/);
assert.equal(asks, 0);
assert.equal(creates, 0);
assert.equal(fixture.sessions.size, 0);
await bridge.accept(message('preset-current-dingtalk', '/preset'));
assert.match(sent.at(-1).text, /跟随 Host 默认/);
assert.equal(asks, 0);
assert.equal(creates, 0);
const selectReplyStart = sent.length;
await bridge.accept(message('preset-select-dingtalk', '/preset 2'));
assert.deepEqual(presetUpdates, ['preset-002']);
assert.equal(sent.length, selectReplyStart + 1);
assert.match(sent.at(-1).text, /preset-002/);
const defaultReplyStart = sent.length;
await bridge.accept(message('preset-default-dingtalk', '/preset --default'));
assert.deepEqual(presetUpdates, ['preset-002', null]);
assert.equal(sent.length, defaultReplyStart + 1);
assert.match(sent.at(-1).text, /跟随 Host 默认/);
assert.equal(asks, 0);
assert.equal(creates, 0);
assert.equal(fixture.sessions.size, 0);
await bridge.accept(message('help-models-dingtalk', '/help'));
const help = sent.at(-1).text;
for (const command of ['/models', '/model', '/stop', '/steer']) {
for (const command of ['/models', '/model', '/presetlist', '/preset', '/preset --default', '/stop', '/steer']) {
assert.equal(help.includes(command), true, command);
}
assert.match(help, /\/model 2/);
assert.match(help, /\/preset id:<ID>/);
});
test('bridge maps a DingTalk direct conversation to one persistent Harness session', async () => {

View file

@ -164,11 +164,20 @@ test('Feishu executes /compact for the bound Session without prompting the model
assert.deepEqual(sent, ['暂无可压缩的历史记录。']);
});
test('Feishu lists models without prompting and help advertises all four commands', async () => {
test('Feishu lists models and presets without prompting and advertises fast commands', async () => {
const fixture = stateFixture();
const sent = [];
const presetUpdates = [];
let agentPreset = null;
let asks = 0;
let creates = 0;
const agentPresetCatalog = {
defaultId: 'preset-001',
items: Array.from({ length: 70 }, (_, index) => ({
id: `preset-${String(index + 1).padStart(3, '0')}`,
label: `Feishu Preset ${index + 1} ${'x'.repeat(64)}`,
})),
};
const bridge = new FeishuHarnessBridge({
client: textClient(async ({ text }) => sent.push(text)),
channel: {},
@ -181,6 +190,12 @@ test('Feishu lists models without prompting and help advertises all four command
}],
failures: [],
}),
agentPresetSettings: async () => ({ agentPreset, agentPresetCatalog }),
updateAgentPreset: async (value) => {
presetUpdates.push(value);
agentPreset = value;
return { agentPreset, agentPresetCatalog };
},
createSession: async () => { creates += 1; return 'feishu-session'; },
ask: async () => { asks += 1; return 'unexpected model reply'; },
},
@ -196,13 +211,47 @@ test('Feishu lists models without prompting and help advertises all four command
assert.equal(creates, 0);
assert.equal(fixture.sessions.size, 0);
const presetReplyStart = sent.length;
await bridge.accept(event('presets-feishu', '/presetlist'));
await bridge.waitForIdle();
const presetReplies = sent.slice(presetReplyStart);
assert.ok(presetReplies.length > 1);
assert.match(presetReplies.join('\n'), /preset-070/);
assert.equal(asks, 0);
assert.equal(creates, 0);
assert.equal(fixture.sessions.size, 0);
await bridge.accept(event('preset-current-feishu', '/preset'));
await bridge.waitForIdle();
assert.match(sent.at(-1), /跟随 Host 默认/);
assert.equal(asks, 0);
assert.equal(creates, 0);
const selectReplyStart = sent.length;
await bridge.accept(event('preset-select-feishu', '/preset 2'));
await bridge.waitForIdle();
assert.deepEqual(presetUpdates, ['preset-002']);
assert.equal(sent.length, selectReplyStart + 1);
assert.match(sent.at(-1), /preset-002/);
const defaultReplyStart = sent.length;
await bridge.accept(event('preset-default-feishu', '/preset --default'));
await bridge.waitForIdle();
assert.deepEqual(presetUpdates, ['preset-002', null]);
assert.equal(sent.length, defaultReplyStart + 1);
assert.match(sent.at(-1), /跟随 Host 默认/);
assert.equal(asks, 0);
assert.equal(creates, 0);
assert.equal(fixture.sessions.size, 0);
await bridge.accept(event('help-feishu', '/help'));
await bridge.waitForIdle();
const help = sent.at(-1);
for (const command of ['/models', '/model', '/stop', '/steer']) {
for (const command of ['/models', '/model', '/presetlist', '/preset', '/preset --default', '/stop', '/steer']) {
assert.equal(help.includes(command), true, command);
}
assert.match(help, /\/model 2/);
assert.match(help, /\/preset id:<ID>/);
});
test('bridge maps a Feishu conversation to a persistent Harness session and replies', async () => {

View file

@ -3,6 +3,7 @@ import test from 'node:test';
import {
cardActionProbeCard,
menuCard,
menuHelpText,
} from '../../../src/channels/feishu/feishu-cards.mjs';
function buttons(value, result = []) {
@ -26,6 +27,14 @@ test('menu appends watchlist and keeps repair number-only', () => {
assert.equal(actions.includes('repair'), false);
});
test('menu help advertises Agent Preset commands', () => {
const help = menuHelpText();
assert.match(help, /\/presetlist/);
assert.match(help, /\/preset \[序号或完整ID\]/);
assert.match(help, /\/preset id:<ID>/);
assert.match(help, /\/preset --default/);
});
test('card-action probe carries only its action and opaque nonce', () => {
const nonce = '0123456789abcdef0123456789abcdef';
const card = JSON.parse(cardActionProbeCard(nonce));

View file

@ -259,11 +259,20 @@ test('QQ executes /compact for the bound Session without prompting the model', a
assert.equal(fixture.seen.has('compact-qq'), true);
});
test('QQ lists models without prompting and help advertises all four commands', async () => {
test('QQ lists models and presets without prompting and advertises fast commands', async () => {
const fixture = stateFixture();
const sent = [];
const presetUpdates = [];
let agentPreset = null;
let asks = 0;
let creates = 0;
const agentPresetCatalog = {
defaultId: 'preset-001',
items: Array.from({ length: 70 }, (_, index) => ({
id: `preset-${String(index + 1).padStart(3, '0')}`,
label: `QQ Preset ${index + 1} ${'x'.repeat(64)}`,
})),
};
const bridge = new QqHarnessBridge({
bot: { sendText: async (_target, text) => sent.push(text) },
ownerUserOpenid: 'owner-openid',
@ -276,6 +285,12 @@ test('QQ lists models without prompting and help advertises all four commands',
}],
failures: [],
}),
agentPresetSettings: async () => ({ agentPreset, agentPresetCatalog }),
updateAgentPreset: async (value) => {
presetUpdates.push(value);
agentPreset = value;
return { agentPreset, agentPresetCatalog };
},
createSession: async () => { creates += 1; return 'qq-session'; },
ask: async () => { asks += 1; return 'unexpected model reply'; },
},
@ -288,12 +303,42 @@ test('QQ lists models without prompting and help advertises all four commands',
assert.equal(creates, 0);
assert.equal(fixture.sessions.size, 0);
const presetReplyStart = sent.length;
await bridge.accept(message({ messageId: 'presets-qq', content: '/presetlist' }));
const presetReplies = sent.slice(presetReplyStart);
assert.ok(presetReplies.length > 1);
assert.match(presetReplies.join('\n'), /preset-070/);
assert.equal(asks, 0);
assert.equal(creates, 0);
assert.equal(fixture.sessions.size, 0);
await bridge.accept(message({ messageId: 'preset-current-qq', content: '/preset' }));
assert.match(sent.at(-1), /跟随 Host 默认/);
assert.equal(asks, 0);
assert.equal(creates, 0);
const selectReplyStart = sent.length;
await bridge.accept(message({ messageId: 'preset-select-qq', content: '/preset 2' }));
assert.deepEqual(presetUpdates, ['preset-002']);
assert.equal(sent.length, selectReplyStart + 1);
assert.match(sent.at(-1), /preset-002/);
const defaultReplyStart = sent.length;
await bridge.accept(message({ messageId: 'preset-default-qq', content: '/preset --default' }));
assert.deepEqual(presetUpdates, ['preset-002', null]);
assert.equal(sent.length, defaultReplyStart + 1);
assert.match(sent.at(-1), /跟随 Host 默认/);
assert.equal(asks, 0);
assert.equal(creates, 0);
assert.equal(fixture.sessions.size, 0);
await bridge.accept(message({ messageId: 'help-models-qq', content: '/help' }));
const help = sent.at(-1);
for (const command of ['/models', '/model', '/stop', '/steer']) {
for (const command of ['/models', '/model', '/presetlist', '/preset', '/preset --default', '/stop', '/steer']) {
assert.equal(help.includes(command), true, command);
}
assert.match(help, /\/model 2/);
assert.match(help, /\/preset id:<ID>/);
});
test('QQ remembers any authorized private inbound as a connection-test target', async () => {

View file

@ -164,7 +164,7 @@ test('all four shared text channels execute /compact outside the model prompt pa
}
});
test('all four shared text channels list models locally and advertise all four commands', async () => {
test('all four shared text channels list models and presets locally and advertise fast commands', async () => {
for (const [name, Bridge] of [
['slack', SlackHarnessBridge],
['telegram', TelegramHarnessBridge],
@ -173,8 +173,17 @@ test('all four shared text channels list models locally and advertise all four c
]) {
const fixture = stateFixture();
const sent = [];
const presetUpdates = [];
let agentPreset = null;
let asks = 0;
let creates = 0;
const agentPresetCatalog = {
defaultId: 'preset-001',
items: Array.from({ length: 70 }, (_, index) => ({
id: `preset-${String(index + 1).padStart(3, '0')}`,
label: `${name} Preset ${index + 1} ${'x'.repeat(64)}`,
})),
};
const bridge = new Bridge({
bot: { sendText: async (_target, text) => sent.push(text) },
state: fixture.state,
@ -187,6 +196,12 @@ test('all four shared text channels list models locally and advertise all four c
}],
failures: [],
}),
agentPresetSettings: async () => ({ agentPreset, agentPresetCatalog }),
updateAgentPreset: async (value) => {
presetUpdates.push(value);
agentPreset = value;
return { agentPreset, agentPresetCatalog };
},
createSession: async () => { creates += 1; return `${name}-session`; },
ask: async () => { asks += 1; return 'unexpected model reply'; },
},
@ -198,12 +213,42 @@ test('all four shared text channels list models locally and advertise all four c
assert.equal(creates, 0, `${name} create`);
assert.equal(fixture.sessions.size, 0, `${name} session binding`);
const presetReplyStart = sent.length;
await bridge.accept(message(`presets-${name}`, '/presetlist'));
const presetReplies = sent.slice(presetReplyStart);
assert.ok(presetReplies.length > 1, `${name} sends every preset-list chunk`);
assert.match(presetReplies.join('\n'), /preset-070/, name);
assert.equal(asks, 0, `${name} preset ask`);
assert.equal(creates, 0, `${name} preset create`);
assert.equal(fixture.sessions.size, 0, `${name} preset session binding`);
await bridge.accept(message(`preset-current-${name}`, '/preset'));
assert.match(sent.at(-1), /跟随 Host 默认/, name);
assert.equal(asks, 0, `${name} preset current ask`);
assert.equal(creates, 0, `${name} preset current create`);
const selectReplyStart = sent.length;
await bridge.accept(message(`preset-select-${name}`, '/preset 2'));
assert.deepEqual(presetUpdates, ['preset-002'], `${name} scoped preset update`);
assert.equal(sent.length, selectReplyStart + 1, `${name} sends the complete select reply`);
assert.match(sent.at(-1), /preset-002/, name);
const defaultReplyStart = sent.length;
await bridge.accept(message(`preset-default-${name}`, '/preset --default'));
assert.deepEqual(presetUpdates, ['preset-002', null], `${name} scoped preset reset`);
assert.equal(sent.length, defaultReplyStart + 1, `${name} sends the complete default reply`);
assert.match(sent.at(-1), /跟随 Host 默认/, name);
assert.equal(asks, 0, `${name} mutation ask`);
assert.equal(creates, 0, `${name} mutation create`);
assert.equal(fixture.sessions.size, 0, `${name} mutation session binding`);
await bridge.accept(message(`help-${name}`, '/help'));
const help = sent.at(-1);
for (const command of ['/models', '/model', '/stop', '/steer']) {
for (const command of ['/models', '/model', '/presetlist', '/preset', '/preset --default', '/stop', '/steer']) {
assert.match(help, new RegExp(`\\${command}`), `${name} ${command}`);
}
assert.match(help, /\/model 2/, `${name} numbered model selection`);
assert.match(help, /\/preset id:<ID>/, `${name} numeric preset ID selection`);
}
});

View file

@ -168,6 +168,13 @@ test('Telegram API registers the command menu and commands-type menu button', as
await api.setMyCommands({ commands: TELEGRAM_COMMAND_MENU });
await api.setChatMenuButton();
assert.equal(calls.length, 2);
assert.deepEqual(
TELEGRAM_COMMAND_MENU.filter(({ command }) => command === 'presetlist' || command === 'preset'),
[
{ command: 'presetlist', description: '列出可用 Agent Preset' },
{ command: 'preset', description: '查看或设置新会话 Agent Preset' },
],
);
assert.match(calls[0].url.pathname, /setMyCommands$/);
assert.deepEqual(calls[0].body, { commands: TELEGRAM_COMMAND_MENU });
assert.match(calls[1].url.pathname, /setChatMenuButton$/);

View file

@ -153,12 +153,21 @@ test('Enterprise WeChat executes /compact for the bound Session without promptin
assert.deepEqual(transport.active, []);
});
test('Enterprise WeChat lists models without prompting and help advertises all four commands', async () => {
test('Enterprise WeChat lists models and presets without prompting and advertises fast commands', async () => {
const store = state();
store.sessionFor = () => null;
const transport = testClient();
const presetUpdates = [];
let agentPreset = null;
let asks = 0;
let creates = 0;
const agentPresetCatalog = {
defaultId: 'preset-001',
items: Array.from({ length: 70 }, (_, index) => ({
id: `preset-${String(index + 1).padStart(3, '0')}`,
label: `WeCom Preset ${index + 1} ${'x'.repeat(64)}`,
})),
};
const bridge = new WecomHarnessBridge({
client: transport.client,
generateStreamId: () => 'models-stream',
@ -171,6 +180,12 @@ test('Enterprise WeChat lists models without prompting and help advertises all f
}],
failures: [],
}),
agentPresetSettings: async () => ({ agentPreset, agentPresetCatalog }),
updateAgentPreset: async (value) => {
presetUpdates.push(value);
agentPreset = value;
return { agentPreset, agentPresetCatalog };
},
createSession: async () => { creates += 1; return 'wecom-session'; },
ask: async () => { asks += 1; return 'unexpected model reply'; },
},
@ -182,12 +197,44 @@ test('Enterprise WeChat lists models without prompting and help advertises all f
assert.equal(asks, 0);
assert.equal(creates, 0);
const presetReplyStart = transport.streamed.length;
await bridge.accept(frame({ msgid: 'presets-wecom', text: { content: '/presetlist' } }));
const presetReplies = transport.streamed
.slice(presetReplyStart)
.map((entry) => entry.content);
assert.ok(presetReplies.length > 1);
assert.match(presetReplies.join('\n'), /preset-070/);
assert.equal(asks, 0);
assert.equal(creates, 0);
await bridge.accept(frame({ msgid: 'preset-current-wecom', text: { content: '/preset' } }));
assert.match(transport.streamed.at(-1).content, /跟随 Host 默认/);
assert.equal(asks, 0);
assert.equal(creates, 0);
const selectReplyStart = transport.streamed.length;
await bridge.accept(frame({ msgid: 'preset-select-wecom', text: { content: '/preset 2' } }));
assert.deepEqual(presetUpdates, ['preset-002']);
assert.equal(transport.streamed.length, selectReplyStart + 1);
assert.match(transport.streamed.at(-1).content, /preset-002/);
assert.equal(transport.streamed.at(-1).finish, true);
const defaultReplyStart = transport.streamed.length;
await bridge.accept(frame({ msgid: 'preset-default-wecom', text: { content: '/preset --default' } }));
assert.deepEqual(presetUpdates, ['preset-002', null]);
assert.equal(transport.streamed.length, defaultReplyStart + 1);
assert.match(transport.streamed.at(-1).content, /跟随 Host 默认/);
assert.equal(transport.streamed.at(-1).finish, true);
assert.equal(asks, 0);
assert.equal(creates, 0);
await bridge.accept(frame({ msgid: 'help-models-wecom', text: { content: '/help' } }));
const help = transport.streamed.at(-1).content;
for (const command of ['/models', '/model', '/stop', '/steer']) {
for (const command of ['/models', '/model', '/presetlist', '/preset', '/preset --default', '/stop', '/steer']) {
assert.equal(help.includes(command), true, command);
}
assert.match(help, /\/model 2/);
assert.match(help, /\/preset id:<ID>/);
});
test('Enterprise WeChat messages stream Harness progress and finalize once', async () => {

View file

@ -262,11 +262,20 @@ test('Weixin executes /compact for the bound Session without prompting the model
assert.equal(fixture.seen.has('compact-weixin'), true);
});
test('Weixin lists models without prompting and help advertises all four commands', async () => {
test('Weixin lists models and presets without prompting and advertises fast commands', async () => {
const fixture = stateFixture();
const sent = [];
const presetUpdates = [];
let agentPreset = null;
let asks = 0;
let creates = 0;
const agentPresetCatalog = {
defaultId: 'preset-001',
items: Array.from({ length: 70 }, (_, index) => ({
id: `preset-${String(index + 1).padStart(3, '0')}`,
label: `Weixin Preset ${index + 1} ${'x'.repeat(64)}`,
})),
};
const bridge = new WeixinHarnessBridge({
api: { sendText: async (request) => sent.push(request) },
baseUrl: 'https://ilinkai.weixin.qq.com/',
@ -281,6 +290,12 @@ test('Weixin lists models without prompting and help advertises all four command
}],
failures: [],
}),
agentPresetSettings: async () => ({ agentPreset, agentPresetCatalog }),
updateAgentPreset: async (value) => {
presetUpdates.push(value);
agentPreset = value;
return { agentPreset, agentPresetCatalog };
},
createSession: async () => { creates += 1; return 'weixin-session'; },
ask: async () => { asks += 1; return 'unexpected model reply'; },
},
@ -293,12 +308,42 @@ test('Weixin lists models without prompting and help advertises all four command
assert.equal(creates, 0);
assert.equal(fixture.sessions.size, 0);
const presetReplyStart = sent.length;
await bridge.accept(message('presets-weixin', '/presetlist'));
const presetReplies = sent.slice(presetReplyStart).map((entry) => entry.text);
assert.ok(presetReplies.length > 1);
assert.match(presetReplies.join('\n'), /preset-070/);
assert.equal(asks, 0);
assert.equal(creates, 0);
assert.equal(fixture.sessions.size, 0);
await bridge.accept(message('preset-current-weixin', '/preset'));
assert.match(sent.at(-1).text, /跟随 Host 默认/);
assert.equal(asks, 0);
assert.equal(creates, 0);
const selectReplyStart = sent.length;
await bridge.accept(message('preset-select-weixin', '/preset 2'));
assert.deepEqual(presetUpdates, ['preset-002']);
assert.equal(sent.length, selectReplyStart + 1);
assert.match(sent.at(-1).text, /preset-002/);
const defaultReplyStart = sent.length;
await bridge.accept(message('preset-default-weixin', '/preset --default'));
assert.deepEqual(presetUpdates, ['preset-002', null]);
assert.equal(sent.length, defaultReplyStart + 1);
assert.match(sent.at(-1).text, /跟随 Host 默认/);
assert.equal(asks, 0);
assert.equal(creates, 0);
assert.equal(fixture.sessions.size, 0);
await bridge.accept(message('help-models-weixin', '/help'));
const help = sent.at(-1).text;
for (const command of ['/models', '/model', '/stop', '/steer']) {
for (const command of ['/models', '/model', '/presetlist', '/preset', '/preset --default', '/stop', '/steer']) {
assert.equal(help.includes(command), true, command);
}
assert.match(help, /\/model 2/);
assert.match(help, /\/preset id:<ID>/);
});
test('bridge maps the scanning Weixin user to one persistent Harness session and echoes context_token', async () => {

View file

@ -185,9 +185,19 @@ test('all nine production channels use channel presets only as bot creation defa
);
assert.match(
source,
/agentPresetCatalog:\s*\(\)\s*=>\s*listAgentPresetCatalog\(ctx\)/,
/const agentPresetCatalog\s*=\s*\(\)\s*=>\s*listAgentPresetCatalog\(ctx\)/,
`${path} must read the Host preset catalog dynamically`,
);
assert.match(
source,
/createBotWorkspaceScope\([^;]*agentPresetCatalog[^;]*\)/,
`${path} must expose the same dynamic catalog to bot commands`,
);
assert.match(
source,
/createWorkspaceAwareController\([^;]*agentPresetCatalog,?[^;]*\)/,
`${path} must expose the same dynamic catalog to RPC updates`,
);
}
for (const channel of ['telegram', 'discord']) {

View file

@ -0,0 +1,403 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
isPresetCommand,
PRESET_LIST_SNAPSHOT_MAX_ENTRIES,
PRESET_LIST_SNAPSHOT_TTL_MS,
runPresetCommand,
} from '../src/channels/shared/preset-command.mjs';
import { withSessionBindingLock } from '../src/channels/shared/session-binding-lock.mjs';
const CATALOG = Object.freeze({
defaultId: 'standard',
items: Object.freeze([
Object.freeze({ id: 'standard', label: 'Standard' }),
Object.freeze({ id: 'coding', label: 'Coding' }),
Object.freeze({ id: 'marketing', label: 'Marketing' }),
]),
});
function fixture({
agentPreset = null,
catalog = CATALOG,
settingsError = null,
updateError = null,
updateHook = null,
} = {}) {
const calls = [];
let selected = agentPreset;
const harness = {
async agentPresetSettings(options) {
calls.push(['agentPresetSettings', options]);
if (settingsError) throw settingsError;
const currentCatalog = typeof catalog === 'function' ? catalog() : catalog;
return { agentPreset: selected, agentPresetCatalog: currentCatalog };
},
async updateAgentPreset(value, options) {
calls.push(['updateAgentPreset', value, options]);
if (updateHook) await updateHook({ value, options });
if (updateError) throw updateError;
selected = value;
const currentCatalog = typeof catalog === 'function' ? catalog() : catalog;
return { agentPreset: selected, agentPresetCatalog: currentCatalog };
},
};
return { calls, harness, state: {}, selected: () => selected };
}
test('isPresetCommand recognizes only /presetlist and /preset command prefixes', () => {
for (const command of [
'/presetlist', ' /PRESETLIST ', '/presetlist ignored',
'/preset', '/PrEsEt coding',
]) {
assert.equal(isPresetCommand(command), true, command);
}
for (const value of [
null, '', 'preset', '/presetx', '/presetlisting', 'hello /preset',
]) {
assert.equal(isPresetCommand(value), false, String(value));
}
});
test('/presetlist dynamically lists, annotates, and snapshots presets without updating', async () => {
const { calls, harness, state } = fixture({ agentPreset: 'coding' });
const signal = new AbortController().signal;
const result = await runPresetCommand('/PRESETLIST', harness, state, 'direct:one', { signal });
assert.match(result.message, /当前机器人用于新会话/);
assert.match(result.message, /Host 默认:Standard(standard)/);
assert.match(result.message, /1\. Standard(standard)(Host 默认)/);
assert.match(result.message, /2\. Coding(coding)(当前选择)/);
assert.match(result.message, /\/preset --default/);
assert.deepEqual(calls, [['agentPresetSettings', { signal }]]);
const selected = await runPresetCommand('/preset 2', harness, state, 'direct:one');
assert.match(selected.message, /Coding(coding)/);
assert.deepEqual(calls.at(-1), ['updateAgentPreset', 'coding', {}]);
});
test('/presetlist shows following Host default as the effective preset', async () => {
const { harness, state } = fixture();
const result = await runPresetCommand('/presetlist', harness, state, 'direct:one');
assert.match(result.message, /跟随 Host 默认:Standard(standard)/);
assert.match(result.message, /Standard(standard)(Host 默认,当前生效)/);
});
test('/presetlist filters invalid, duplicate, and broken entries and sanitizes labels', async () => {
const catalog = {
defaultId: 'safe',
items: [
{ id: 'safe', label: 'Safe\nPreset\u202e' },
{ id: 'safe', label: 'Duplicate' },
{ id: 'UPPER', label: 'Invalid' },
{ id: 'broken', label: 'Broken', broken: { message: 'secret' } },
],
};
const { harness, state } = fixture({ catalog });
const result = await runPresetCommand('/presetlist', harness, state, 'direct:one');
assert.match(result.message, /可用 Agent Preset(1)/);
assert.match(result.message, /Safe Preset(safe)/);
assert.doesNotMatch(result.message, /Duplicate|Invalid|Broken|secret|\u202e/);
});
test('/preset reports an explicit unavailable current preset without changing it', async () => {
const { calls, harness, state, selected } = fixture({ agentPreset: 'removed' });
const result = await runPresetCommand('/preset', harness, state, 'direct:one');
assert.match(result.message, /removed(已不可用)/);
assert.match(result.message, /已有会话不会受此设置影响/);
assert.equal(selected(), 'removed');
assert.equal(calls.some(([name]) => name === 'updateAgentPreset'), false);
});
test('/preset and /presetlist expose an unavailable Host default safely', async () => {
const catalog = { defaultId: 'removed-default', items: CATALOG.items };
const current = fixture({ catalog });
assert.match(
(await runPresetCommand('/preset', current.harness, current.state, 'direct:one')).message,
/跟随 Host 默认(Host 默认当前不可用)/,
);
const listed = fixture({ catalog });
assert.match(
(await runPresetCommand('/presetlist', listed.harness, listed.state, 'direct:one')).message,
/Host 默认:removed-default(当前不可用)/,
);
});
test('numeric selection requires a snapshot for the same state and conversation key', async () => {
const first = fixture();
assert.match(
(await runPresetCommand('/preset 1', first.harness, first.state, 'direct:one')).message,
/先执行 \/presetlist/,
);
assert.equal(first.calls.length, 0);
await runPresetCommand('/presetlist', first.harness, first.state, 'direct:one');
assert.match(
(await runPresetCommand('/preset 1', first.harness, first.state, 'direct:two')).message,
/先执行 \/presetlist/,
);
const otherState = {};
assert.match(
(await runPresetCommand('/preset 1', first.harness, otherState, 'direct:one')).message,
/先执行 \/presetlist/,
);
});
test('numeric selection rejects an expired snapshot and requires a new /presetlist', async (t) => {
let now = 1_000;
t.mock.method(Date, 'now', () => now);
const { calls, harness, state } = fixture();
await runPresetCommand('/presetlist', harness, state, 'direct:one');
now += PRESET_LIST_SNAPSHOT_TTL_MS - 1;
const stillFresh = await runPresetCommand('/preset 1', harness, state, 'direct:one');
assert.match(stillFresh.message, /Standard(standard)/);
now += 1;
const expired = await runPresetCommand('/preset 1', harness, state, 'direct:one');
assert.match(expired.message, /请先执行 \/presetlist/);
assert.equal(calls.filter(([name]) => name === 'updateAgentPreset').length, 1);
});
test('numeric snapshots are capacity-bounded and evicted in LRU order', async () => {
const { calls, harness, state } = fixture();
for (let index = 0; index < PRESET_LIST_SNAPSHOT_MAX_ENTRIES; index += 1) {
await runPresetCommand('/presetlist', harness, state, `direct:${index}`);
}
await runPresetCommand('/preset 1', harness, state, 'direct:0');
await runPresetCommand(
'/presetlist',
harness,
state,
`direct:${PRESET_LIST_SNAPSHOT_MAX_ENTRIES}`,
);
const evicted = await runPresetCommand('/preset 1', harness, state, 'direct:1');
assert.match(evicted.message, /请先执行 \/presetlist/);
const retained = await runPresetCommand('/preset 1', harness, state, 'direct:0');
assert.match(retained.message, /Standard(standard)/);
assert.equal(calls.filter(([name]) => name === 'updateAgentPreset').length, 2);
});
test('numeric selection keeps the listed ID when the fresh catalog is reordered', async () => {
let currentCatalog = CATALOG;
const fixtureValue = fixture({ catalog: () => currentCatalog });
await runPresetCommand(
'/presetlist',
fixtureValue.harness,
fixtureValue.state,
'direct:one',
);
currentCatalog = {
defaultId: 'standard',
items: [CATALOG.items[2], CATALOG.items[0], CATALOG.items[1]],
};
await runPresetCommand('/preset 2', fixtureValue.harness, fixtureValue.state, 'direct:one');
assert.deepEqual(
fixtureValue.calls.findLast(([name]) => name === 'updateAgentPreset'),
['updateAgentPreset', 'coding', {}],
);
});
test('/preset rejects invalid and out-of-range numeric selections without updating', async () => {
const { calls, harness, state } = fixture();
await runPresetCommand('/presetlist', harness, state, 'direct:one');
for (const requested of ['0', '4', '9007199254740992']) {
const result = await runPresetCommand(`/preset ${requested}`, harness, state, 'direct:one');
assert.match(result.message, /序号.*无效|序号不存在/, requested);
}
assert.equal(calls.filter(([name]) => name === 'updateAgentPreset').length, 0);
});
test('/preset accepts exact IDs and id: for a purely numeric ID', async () => {
const direct = fixture();
const directResult = await runPresetCommand(
'/preset marketing',
direct.harness,
direct.state,
'direct:one',
);
assert.match(directResult.message, /Marketing(marketing)/);
assert.deepEqual(direct.calls.at(-1), ['updateAgentPreset', 'marketing', {}]);
const numericCatalog = {
defaultId: '123',
items: [{ id: '123', label: 'Numeric' }],
};
const numeric = fixture({ catalog: numericCatalog });
const numericResult = await runPresetCommand(
'/preset id:123',
numeric.harness,
numeric.state,
'direct:one',
);
assert.match(numericResult.message, /Numeric(123)/);
assert.deepEqual(numeric.calls.at(-1), ['updateAgentPreset', '123', {}]);
});
test('/preset --default clears the override without first reading the catalog', async () => {
const settingsError = new Error('catalog private failure');
const { calls, harness, state, selected } = fixture({
agentPreset: 'coding',
settingsError,
});
const signal = new AbortController().signal;
const result = await runPresetCommand(
'/preset --DEFAULT',
harness,
state,
'direct:one',
{ signal },
);
assert.match(result.message, /跟随 Host 默认/);
assert.equal(selected(), null);
assert.deepEqual(calls, [['updateAgentPreset', null, { signal }]]);
assert.doesNotMatch(result.message, /private/);
});
test('/preset updates even while an interaction is pending and never consults Session state', async () => {
const { calls, harness, state } = fixture();
state.sessionFor = () => {
throw new Error('must not inspect current Session');
};
const result = await runPresetCommand(
'/preset coding',
harness,
state,
'direct:one',
{ pendingInteraction: true, control: { owner: 'one' } },
);
assert.match(result.message, /已设置为/);
assert.match(result.message, /先发送 \/new/);
assert.deepEqual(calls, [['updateAgentPreset', 'coding', {}]]);
});
test('/preset serializes with the next Session-binding transaction', async () => {
let releaseUpdate;
let markUpdateStarted;
const updateStarted = new Promise((resolve) => { markUpdateStarted = resolve; });
const updateGate = new Promise((resolve) => { releaseUpdate = resolve; });
const order = [];
const { harness, state } = fixture({
updateHook: async () => {
markUpdateStarted();
await updateGate;
order.push('preset');
},
});
const updating = runPresetCommand('/preset coding', harness, state, 'direct:one');
await updateStarted;
const binding = withSessionBindingLock(state, 'direct:one', async () => {
order.push('session');
});
await Promise.resolve();
assert.deepEqual(order, []);
releaseUpdate();
await Promise.all([updating, binding]);
assert.deepEqual(order, ['preset', 'session']);
});
test('preset commands validate syntax and reject images without calling the harness', async () => {
const { calls, harness, state } = fixture();
for (const command of ['/presetlist extra', '/preset coding extra', '/preset id:abc']) {
const result = await runPresetCommand(command, harness, state, 'direct:one');
assert.match(result.message, /用法|格式无效/, command);
}
for (const command of ['/preset', '/presetlist', '/preset coding']) {
const result = await runPresetCommand(command, harness, state, 'direct:one', {
hasImages: true,
});
assert.match(result.message, /仅支持纯文字/, command);
}
assert.equal(calls.length, 0);
});
test('fresh validation failures and internal errors use safe messages', async () => {
const unavailableError = new Error('path=/private/preset token=secret');
unavailableError.code = 'agent-preset-unavailable';
const unavailable = fixture({ updateError: unavailableError });
const unavailableResult = await runPresetCommand(
'/preset coding',
unavailable.harness,
unavailable.state,
'direct:one',
);
assert.match(unavailableResult.message, /不存在或当前不可用/);
assert.doesNotMatch(unavailableResult.message, /private|secret/);
const missingError = new Error('bot details');
missingError.code = 'workspace-bot-not-found';
const missing = fixture({ updateError: missingError });
assert.match(
(await runPresetCommand('/preset coding', missing.harness, missing.state, 'direct:one')).message,
/机器人状态已发生变化/,
);
const listing = fixture({ settingsError: new Error('private catalog endpoint') });
const listingResult = await runPresetCommand(
'/presetlist',
listing.harness,
listing.state,
'direct:one',
);
assert.match(listingResult.message, /暂时无法获取 Agent Preset 列表/);
assert.doesNotMatch(listingResult.message, /private catalog endpoint/);
});
test('cancelled and malformed harness results fail safely', async () => {
const cancelledError = new Error('secret abort details');
cancelledError.name = 'AbortError';
const cancelled = fixture({ settingsError: cancelledError });
assert.match(
(await runPresetCommand(
'/presetlist',
cancelled.harness,
cancelled.state,
'direct:one',
)).message,
/列表已取消/,
);
const badHarness = {
async agentPresetSettings() {
return { agentPreset: null, agentPresetCatalog: { items: null } };
},
};
const result = await runPresetCommand('/preset', badHarness, {}, 'direct:one');
assert.match(result.message, /暂时无法获取 Agent Preset 设置/);
});
test('/presetlist splits long output into lossless 1,800-character messages', async () => {
const catalog = {
defaultId: 'preset-000',
items: Array.from({ length: 80 }, (_, index) => ({
id: `preset-${String(index).padStart(3, '0')}`,
label: `Preset ${index} ${'x'.repeat(80)}`,
})),
};
const { harness, state } = fixture({ catalog });
const result = await runPresetCommand('/presetlist', harness, state, 'direct:one');
assert.ok(result.messages.length > 1);
assert.ok(result.messages.every((message) => message.length <= 1_800));
assert.equal(result.messages.join(''), result.message);
assert.match(result.message, /80\. Preset 79/);
});
test('non-preset input is left for ordinary message routing', async () => {
assert.equal(await runPresetCommand('hello', {}, {}, 'direct:one'), null);
});

View file

@ -1556,3 +1556,91 @@ test('workspace RPC refreshes the Agent Preset catalog and rejects unavailable c
assert.equal(cleared.value.bots[0].agentPreset, null);
assert.deepEqual(cleared.value.agentPresetCatalog, catalog);
});
test('bot-scoped Agent Preset settings use the latest catalog and persist selections', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_preset');
let catalog = {
defaultId: 'standard',
items: [
{ id: 'standard', name: 'Standard' },
{ id: 'broken-one', name: 'Broken', broken: 'missing entrypoint' },
],
};
let catalogFailure = null;
const scope = createBotWorkspaceScope({}, {
botId: 'bot_preset',
workspaces,
state: {},
agentPresetCatalog: async () => {
if (catalogFailure) throw catalogFailure;
return catalog;
},
});
assert.deepEqual(await scope.harness.agentPresetSettings(), {
agentPreset: null,
agentPresetCatalog: {
defaultId: 'standard',
items: [{ id: 'standard', label: 'Standard' }],
},
});
catalog = {
defaultId: 'marketing-jeep',
items: [{ id: 'marketing-jeep', label: 'Marketing' }],
};
await assert.rejects(scope.harness.updateAgentPreset('standard'), {
code: 'agent-preset-unavailable',
});
assert.equal(workspaces.agentPresetFor('bot_preset'), null);
assert.deepEqual(await scope.harness.updateAgentPreset('marketing-jeep'), {
agentPreset: 'marketing-jeep',
agentPresetCatalog: catalog,
});
assert.equal(workspaces.agentPresetFor('bot_preset'), 'marketing-jeep');
assert.equal((await scope.harness.updateAgentPreset(null)).agentPreset, null);
await scope.harness.updateAgentPreset('marketing-jeep');
catalogFailure = new Error('Host catalog temporarily unavailable');
assert.deepEqual(await scope.harness.updateAgentPreset('--default'), {
agentPreset: null,
agentPresetCatalog: { defaultId: '', items: [] },
});
assert.equal(workspaces.agentPresetFor('bot_preset'), null);
});
test('old bot scopes cannot read or update Agent Presets after same-id rebinding', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_preset_rebind');
let releaseCatalog;
let catalogCalls = 0;
let markCatalogsStarted;
const catalogsStarted = new Promise((resolveStarted) => { markCatalogsStarted = resolveStarted; });
const catalogGate = new Promise((resolveCatalog) => { releaseCatalog = resolveCatalog; });
const oldScope = createBotWorkspaceScope({}, {
botId: 'bot_preset_rebind',
workspaces,
state: {},
agentPresetCatalog: async () => {
catalogCalls += 1;
if (catalogCalls === 2) markCatalogsStarted();
await catalogGate;
return { defaultId: 'standard', items: [{ id: 'standard', label: 'Standard' }] };
},
});
const reading = oldScope.harness.agentPresetSettings();
const updating = oldScope.harness.updateAgentPreset('standard');
await catalogsStarted;
await workspaces.remove('bot_preset_rebind');
await workspaces.ensure('bot_preset_rebind');
releaseCatalog();
await assert.rejects(reading, { code: 'workspace-bot-not-found' });
await assert.rejects(updating, { code: 'workspace-bot-not-found' });
assert.equal(workspaces.agentPresetFor('bot_preset_rebind'), null);
});