feat: 按机器人选择 Agent Preset

在九个渠道的设置页卡片上选择 Host 已有预设;未选则跟随默认,只影响之后新建的会话。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
iamyaojie 2026-08-21 19:19:45 +08:00
parent c3b3a29f27
commit 3161816cc4
51 changed files with 2227 additions and 740 deletions

View file

@ -24,6 +24,7 @@ test('client exposes the fixed DingTalk RPC channel and endpoint names', () => {
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: 'bot.workspace.set',
setAgentPreset: 'bot.preset.set',
});
});

View file

@ -456,3 +456,59 @@ test('unmount does not cancel a Host provisioning task that already started', as
);
assert.equal(clock.frames.size, 0);
});
test('DingTalk settings save an Agent Preset through bot.preset.set', async (t) => {
const clock = createBrowserClock();
t.after(() => clock.restore());
const bot = {
botId: 'dt_test',
connected: true,
state: 'connected',
workspace: '/workspace/current',
bot: { name: '钉钉机器人', clientIdMasked: 'ding••••test' },
health: { status: 'healthy', summary: '连接正常', lastCheckedAt: Date.now() },
};
const catalog = {
defaultId: 'default',
items: [
{ id: 'coding', label: 'Coding' },
{ id: 'default', label: 'Default' },
],
};
const calls = [];
const rpcCall = async (endpoint, payload) => {
calls.push({ endpoint, payload });
if (endpoint === DINGTALK_ENDPOINTS.status) {
return ok(snapshot({
state: 'connected',
bots: [bot],
agentPresetCatalog: catalog,
}));
}
if (endpoint === DINGTALK_ENDPOINTS.setAgentPreset) {
return ok(snapshot({
state: 'connected',
bots: [{ ...bot, agentPreset: payload.agentPreset }],
agentPresetCatalog: catalog,
}));
}
throw new Error(`unexpected endpoint: ${endpoint}`);
};
let renderer;
await act(async () => {
renderer = create(React.createElement(DingtalkSettingsTab, { rpcCall }));
await flushMicrotasks();
});
await act(async () => {
renderer.root.findByProps({ className: 'dim-presetSelect' })
.props.onChange({ target: { value: 'coding' } });
await flushMicrotasks();
});
assert.deepEqual(
calls.find((call) => call.endpoint === DINGTALK_ENDPOINTS.setAgentPreset)?.payload,
{ botId: 'dt_test', agentPreset: 'coding' },
);
act(() => renderer.unmount());
});

View file

@ -41,6 +41,7 @@ test('QQ bot cards match the shared two-metric card treatment', () => {
assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2);
assert.match(markup, />消息通道<[^]*>最近检查</);
assert.match(markup, />检查连接<[^]*>移除接入</);
assert.match(markup, /class="dim-presetSelect"/);
assert.doesNotMatch(markup, /收到\s*\/\s*回复|dim-cardSummary|QQ WebSocket 长连接运行正常/);
const offlineMarkup = renderToStaticMarkup(React.createElement(AccountCard, {

View file

@ -70,6 +70,7 @@ test('Enterprise WeChat bot cards match the shared two-metric card treatment', (
assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2);
assert.match(markup, />消息通道<[^]*>最近检查</);
assert.match(markup, />检查连接<[^]*>移除接入</);
assert.match(markup, /class="dim-presetSelect"/);
assert.doesNotMatch(markup, /收到\s*\/\s*回复|dim-cardSummary|企业微信 WebSocket 长连接运行正常/);
});

View file

@ -73,6 +73,28 @@ test('HarnessClient lets the Host resolve an omitted agent preset and forwards a
assert.deepEqual(await createPayload({ agentPreset: null }), { workspaceId: 'workspace-one' });
});
test('HarnessClient forwards a per-session agent preset override', async () => {
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/default-workspace',
agentPreset: 'router-standard',
});
let payload;
client.ensureRunning = async () => true;
client.workspaceId = async () => 'workspace-one';
client.rpc = async (method, value) => {
assert.equal(method, 'session.create');
payload = value;
return { sessionId: 'session-one' };
};
assert.equal(await client.createSession({ agentPreset: 'marketing-jeep' }), 'session-one');
assert.deepEqual(payload, {
workspaceId: 'workspace-one',
agentPreset: 'marketing-jeep',
});
});
test('HarnessClient lists only absolute workspace paths', async () => {
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',

View file

@ -69,6 +69,7 @@ test('WhatsApp account card uses the unified compact channel layout', () => {
assert.match(markup, /WhatsApp Web/);
assert.match(markup, /检查连接/);
assert.match(markup, /移除接入/);
assert.match(markup, /class="dim-presetSelect"/);
assert.match(markup, /role="status"[^>]*>测试消息已发送/);
assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2);
});

View file

@ -237,6 +237,7 @@ test('Feishu bot cards place the application identifier under the bot name', asy
assert.doesNotMatch(markup, /custom-bot-avatar/);
assert.equal((markup.match(/class="bxf-metric dim-botMetric"/g) ?? []).length, 2);
assert.match(markup, />消息通道<[^]*>最近检查</);
assert.match(markup, /class="dim-presetSelect"/);
assert.doesNotMatch(markup, />应用标识<|>飞书机器人</);
assert.match(styles, /\.bxf-statusGrid \{[^}]*grid-template-columns: repeat\(2, minmax\(0, 1fr\)\)/);
});
@ -445,6 +446,7 @@ test('bot cards reuse the same channel brand logos as the channel rail', () => {
assert.match(accountMarkup, /class="dxw-avatar dim-botAvatar"[^]*data-im-channel-logo="weixin"/);
assert.match(accountMarkup, /class="dxw-health dim-botHealth"/);
assert.match(accountMarkup, /class="dxw-accountFooter dim-cardFooter"/);
assert.match(accountMarkup, /class="dim-presetSelect"/);
assert.doesNotMatch(accountMarkup, /dim-cardSummary|微信消息长轮询运行正常/);
assert.equal((accountMarkup.match(/dim-cardAction(?: |")/g) ?? []).length, 2);
assert.equal((accountMarkup.match(/class="dxw-metric dim-botMetric"/g) ?? []).length, 2);
@ -534,6 +536,13 @@ test('bot cards keep the full workspace path on its own single line', async () =
assert.match(styles, /\.dim-panel \.dim-workspaceEdit \{[^}]*grid-column: 2;[^}]*grid-row: 1;[^}]*white-space: nowrap;/);
});
test('bot cards keep the Agent Preset selector below the workspace path', async () => {
const styles = await readFile(STYLES_URL, 'utf8');
assert.match(styles, /\.dim-panel \.dim-preset \{[^}]*grid-template-columns: minmax\(0, 1fr\) max-content;[^}]*margin-top: 6px;[^}]*padding: 6px 10px;/);
assert.match(styles, /\.dim-panel \.dim-presetSelect \{[^}]*grid-column: 1 \/ -1;[^}]*grid-row: 2;/);
});
test('the bundled DingTalk channel has no local sender approval workflow', async () => {
const [source, bundle] = await Promise.all([
readFile(DINGTALK_CLIENT_SOURCE_URL, 'utf8'),

View file

@ -3,6 +3,10 @@ import test from 'node:test';
import * as React from 'react';
import TestRenderer from 'react-test-renderer';
import {
AgentPresetCatalogContext,
AgentPresetEditor,
} from '../plugin-src/client/agent-preset.js';
import {
WorkspaceDirectoryPickerContext,
WorkspaceEditor,
@ -663,3 +667,109 @@ test('an older reconnect snapshot cannot resurrect a bot deleted by a newer muta
assert.equal(renderer.root.findAllByProps({ 'data-bot-id': 'discord_first' }).length, 0);
await act(async () => { renderer.unmount(); });
});
const PRESET_CATALOG = {
defaultId: 'default',
items: [
{ id: 'coding', label: 'Coding' },
{ id: 'default', label: 'Default' },
],
};
function optionValues(select) {
return select.children.map((option) => option.props.value);
}
test('AgentPresetEditor lists Host presets and keeps the catalog label for the current id', () => {
const renderer = create(React.createElement(
AgentPresetCatalogContext.Provider,
{ value: PRESET_CATALOG },
React.createElement(AgentPresetEditor, {
agentPreset: 'coding',
onSave() {},
}),
));
const select = renderer.root.findByProps({ className: 'dim-presetSelect' });
assert.equal(select.props.value, 'coding');
assert.deepEqual(optionValues(select), ['', 'coding', 'default']);
assert.equal(textOf(select.children[0]), '跟随 Host 默认');
assert.equal(textOf(select.children[1]), 'Coding(coding)');
renderer.unmount();
});
test('AgentPresetEditor saves a selected preset and can follow the Host default', async () => {
const saved = [];
function Harness() {
const [agentPreset, setAgentPreset] = React.useState('');
return React.createElement(AgentPresetEditor, {
agentPreset,
onSave(value) {
saved.push(value);
setAgentPreset(value ?? '');
},
});
}
const renderer = create(React.createElement(
AgentPresetCatalogContext.Provider,
{ value: PRESET_CATALOG },
React.createElement(Harness),
));
const select = renderer.root.findByProps({ className: 'dim-presetSelect' });
await act(async () => {
select.props.onChange({ target: { value: 'coding' } });
await flushMicrotasks();
});
await act(async () => {
renderer.root.findByProps({ className: 'dim-presetSelect' })
.props.onChange({ target: { value: '' } });
await flushMicrotasks();
});
assert.deepEqual(saved, ['coding', null]);
renderer.unmount();
});
test('Discord settings save an Agent Preset through bot.preset.set', async (t) => {
const previousWindow = globalThis.window;
globalThis.window = { setInterval() { return 1; }, clearInterval() {} };
t.after(() => {
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
});
const snapshot = {
...discordSnapshot('/workspace/current'),
agentPresetCatalog: PRESET_CATALOG,
};
const calls = [];
const rpcCall = async (endpoint, payload) => {
calls.push({ endpoint, payload });
if (endpoint === 'connection.status') return { ok: true, value: snapshot };
if (endpoint === 'bot.preset.set') {
return {
ok: true,
value: {
...snapshot,
bots: [{ ...snapshot.bots[0], agentPreset: payload.agentPreset }],
},
};
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
};
let renderer;
await act(async () => {
renderer = create(React.createElement(DiscordSettingsTab, { rpcCall }));
await flushMicrotasks();
});
const card = renderer.root.findByProps({ 'data-bot-id': 'discord_test' });
await act(async () => {
card.findByProps({ className: 'dim-presetSelect' })
.props.onChange({ target: { value: 'coding' } });
await flushMicrotasks();
});
assert.deepEqual(calls.find((call) => call.endpoint === 'bot.preset.set')?.payload, {
botId: 'discord_test',
agentPreset: 'coding',
});
await act(async () => { renderer.unmount(); });
});

View file

@ -1286,3 +1286,137 @@ test('workspace RPC validates payloads and returns the updated public status', a
assert.equal(missing.error.code, 'workspace-not-found');
assert.match(missing.error.message, /不存在/);
});
test('BotWorkspaceStore persists per-bot agent presets without changing workspaces', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await Promise.all([store.ensure('bot_one'), store.ensure('bot_two')]);
assert.equal(store.agentPresetFor('bot_one'), null);
await store.setAgentPreset('bot_one', 'marketing-jeep');
assert.equal(store.agentPresetFor('bot_one'), 'marketing-jeep');
assert.equal(store.agentPresetFor('bot_two'), null);
assert.equal(store.workspaceFor('bot_one'), defaultWorkspace);
await store.setWorkspace('bot_one', alternateWorkspace);
assert.equal(store.agentPresetFor('bot_one'), 'marketing-jeep');
assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), {
version: 1,
workspaces: { bot_one: alternateWorkspace, bot_two: defaultWorkspace },
agentPresets: { bot_one: 'marketing-jeep' },
});
await store.setAgentPreset('bot_one', null);
assert.equal(store.agentPresetFor('bot_one'), null);
assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), {
version: 1,
workspaces: { bot_one: alternateWorkspace, bot_two: defaultWorkspace },
});
await writeFile(path, `${JSON.stringify({
version: 1,
workspaces: { bot_one: defaultWorkspace },
agentPresets: { bot_one: 'standard-claude' },
}, null, 2)}\n`);
const reloaded = await new BotWorkspaceStore(path, { defaultWorkspace: tmpdir() }).load();
assert.equal(reloaded.agentPresetFor('bot_one'), 'standard-claude');
});
test('BotWorkspaceStore rejects invalid agent preset ids and missing bots', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await store.ensure('bot_one');
await assert.rejects(store.setAgentPreset('bot_one', 'Standard'), { code: 'agent-preset-invalid' });
await assert.rejects(store.setAgentPreset('bot_missing', 'standard'), { code: 'workspace-bot-not-found' });
assert.equal(store.agentPresetFor('bot_one'), null);
});
test('changing a bot agent preset does not clear sessions', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await store.ensure('bot_one');
let clears = 0;
const generation = store.generationFor('bot_one');
await store.setAgentPreset('bot_one', 'marketing-jeep', {
clearSessions: async () => { clears += 1; },
});
assert.equal(clears, 0);
assert.equal(store.generationFor('bot_one'), generation);
});
test('bot-scoped Harness creates sessions with the selected agent preset', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await Promise.all([workspaces.ensure('bot_one'), workspaces.ensure('bot_two')]);
await workspaces.setAgentPreset('bot_one', 'marketing-jeep');
const calls = [];
const harness = {
async createSession(options) { calls.push(options); return `session-${calls.length}`; },
async ensureRunning() { return true; },
};
const state = { async clearSessions() {} };
const one = createBotScopedHarness(harness, { botId: 'bot_one', workspaces, state });
const two = createBotScopedHarness(harness, { botId: 'bot_two', workspaces, state });
await one.createSession();
await two.createSession();
assert.equal(calls[0].workspace, defaultWorkspace);
assert.equal(calls[0].agentPreset, 'marketing-jeep');
assert.equal(calls[1].workspace, defaultWorkspace);
assert.equal(Object.hasOwn(calls[1], 'agentPreset'), false);
});
test('workspace RPC can set a bot agent preset without switching workspace', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_one');
await workspaces.setWorkspace('bot_one', alternateWorkspace);
const catalog = {
defaultId: 'standard',
items: [
{ id: 'standard', label: 'Standard' },
{ id: 'marketing-jeep', label: '营销吉普' },
],
};
const base = {
status() { return { bots: [{ botId: 'bot_one', connected: true }] }; },
bindCredentials() { return this.status(); },
reconnectBot() { return this.status(); },
deleteBot() { return { bots: [] }; },
};
const controller = createWorkspaceAwareController(base, {
workspaces,
stateFor: async () => ({ async clearSessions() {} }),
agentPresetCatalog: catalog,
});
const handler = createTokenBotRpcHandler(controller, { channel: 'Telegram' });
const generation = workspaces.generationFor('bot_one');
const listed = await handler(TOKEN_BOT_ENDPOINTS.status, {});
assert.equal(listed.ok, true);
assert.deepEqual(listed.value.agentPresetCatalog, catalog);
assert.equal(listed.value.bots[0].agentPreset, null);
assert.equal(listed.value.bots[0].workspace, alternateWorkspace);
const success = await handler(TOKEN_BOT_ENDPOINTS.setAgentPreset, {
botId: 'bot_one', agentPreset: 'marketing-jeep',
});
assert.equal(success.ok, true);
assert.equal(success.value.bots[0].agentPreset, 'marketing-jeep');
assert.equal(success.value.bots[0].workspace, alternateWorkspace);
assert.equal(workspaces.generationFor('bot_one'), generation);
const cleared = await handler(TOKEN_BOT_ENDPOINTS.setAgentPreset, {
botId: 'bot_one', agentPreset: null,
});
assert.equal(cleared.ok, true);
assert.equal(cleared.value.bots[0].agentPreset, null);
const invalid = await handler(TOKEN_BOT_ENDPOINTS.setAgentPreset, {
botId: 'bot_one', agentPreset: 'Not Valid',
});
assert.equal(invalid.ok, false);
assert.equal(invalid.error.code, 'bad-request');
});