mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-10 20:55:59 +08:00
feat: 按机器人选择 Agent Preset
在九个渠道的设置页卡片上选择 Host 已有预设;未选则跟随默认,只影响之后新建的会话。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
c3b3a29f27
commit
3161816cc4
51 changed files with 2227 additions and 740 deletions
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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 长连接运行正常/);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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(); });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue