mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 01:53:21 +08:00
feat: add agent preset chat commands
This commit is contained in:
parent
2ad6f1a44e
commit
eea29fe296
33 changed files with 1519 additions and 234 deletions
403
test/preset-command.test.mjs
Normal file
403
test/preset-command.test.mjs
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue