fix: complete per-bot agent preset lifecycle

This commit is contained in:
xmanrui 2026-08-22 00:20:23 +08:00
parent 8ab4b9e7de
commit 01576f65c6
27 changed files with 721 additions and 208 deletions

View file

@ -0,0 +1,137 @@
import assert from 'node:assert/strict';
import { mkdtemp, realpath, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
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 {
messageId,
senderId: 'actor-one',
senderIsBot: false,
kind: 'direct',
conversationId: 'chat-one',
content,
addressed: true,
replyTarget: { id: 'chat-one' },
};
}
test('an agent preset change applies only after /new creates the next session', async (t) => {
const root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-im-preset-lifecycle-')));
t.after(() => rm(root, { recursive: true, force: true }));
const botId = 'bot-one';
const conversationKey = 'direct:chat-one';
const workspaces = await new BotWorkspaceStore(join(root, 'workspaces.json'), {
defaultWorkspace: root,
}).load();
await workspaces.ensure(botId, { defaultAgentPreset: 'preset-old' });
const state = await new ConversationStateStore(join(root, 'state.json')).load();
const creations = [];
const asks = [];
const sessions = new Set();
const harness = {
async createSession(options) {
const sessionId = `session-${creations.length === 0 ? 'a' : 'b'}`;
creations.push({ sessionId, options });
sessions.add(sessionId);
return sessionId;
},
async sessionExists(sessionId) {
return sessions.has(sessionId);
},
async ask(sessionId, text) {
asks.push({ sessionId, text });
return `answer-from-${sessionId}`;
},
};
const scope = createBotWorkspaceScope(harness, { botId, workspaces, state });
const sent = [];
const bridge = new TextHarnessBridge({
descriptor: { key: 'test', label: 'Test' },
bot: { async sendText(_target, text) { sent.push(text); } },
harness: scope.harness,
state: scope.state,
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, [{
sessionId: 'session-a',
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');
assert.equal(state.sessionFor(conversationKey), 'session-a');
await bridge.accept(message('message-two', 'still in the current chat'));
assert.equal(creations.length, 1, 'the current chat must reuse session A');
assert.equal(state.sessionFor(conversationKey), 'session-a');
await bridge.accept(message('message-new', '/new'));
assert.equal(state.sessionFor(conversationKey), null);
assert.equal(creations.length, 1, '/new itself must not create a session');
await bridge.accept(message('message-three', 'first prompt after /new'));
assert.equal(state.sessionFor(conversationKey), 'session-b');
assert.deepEqual(creations, [
{
sessionId: 'session-a',
options: { workspace: root, agentPreset: 'preset-old' },
},
{
sessionId: 'session-b',
options: { workspace: root, agentPreset: 'preset-new' },
},
]);
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' },
]);
assert.deepEqual(sent, [
'answer-from-session-a',
'answer-from-session-a',
'已开启新会话。请发送你的问题。',
'answer-from-session-b',
]);
});

View file

@ -1,5 +1,5 @@
import assert from 'node:assert/strict';
import { mkdtemp } from 'node:fs/promises';
import { mkdtemp, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
@ -87,6 +87,14 @@ test('production assembly keeps secrets in credentials and creates per-bot runti
createConnectionSupervisor: () => supervisor,
});
assert.equal(seen.harnessOptions.agentPreset, 'router-standard');
assert.equal(Object.hasOwn(seen.harnessOptions, 'agentPreset'), false);
await seen.controllerOptions.createRuntime({
botId: 'dt_new',
config: { botId: 'dt_new', clientId: 'dingnew' },
clientSecret: 'host-only-secret',
});
const stored = JSON.parse(await readFile(join(directory, 'workspaces.json'), 'utf8'));
assert.equal(stored.agentPresets.dt_new, 'router-standard');
assert.equal(Object.hasOwn(stored.agentPresets, 'dt_abc'), false);
await productionWithPreset.close();
});

View file

@ -96,3 +96,33 @@ test('supervisor retries an offline bot and leaves the recovered connection on t
await supervisor.close();
});
test('supervisor awaits an asynchronous controller status before resolving readiness', async () => {
const timers = scheduler();
let resolveStatus;
let readyResolved = false;
const pendingStatus = new Promise((resolve) => { resolveStatus = resolve; });
const supervisor = new ConnectionSupervisor({
controller: {
async initialize() {},
status() { return pendingStatus; },
},
harness: { async ensureRunning() {} },
logger: { warn() {} },
healthyIntervalMs: 103,
setTimeoutImpl: timers.setTimeoutImpl,
clearTimeoutImpl: timers.clearTimeoutImpl,
}).start();
void supervisor.ready.then(() => { readyResolved = true; });
await timers.runNext();
assert.equal(readyResolved, false);
assert.equal(timers.pending.length, 0);
resolveStatus({ totals: { configured: 1, connected: 1 } });
assert.equal((await supervisor.ready).totals.connected, 1);
await flush();
assert.equal(timers.pending[0].delay, 103);
await supervisor.close();
});

View file

@ -110,6 +110,94 @@ test('Host exposes configured offline as a redacted capability fact', async () =
assert.equal('credentials' in result.value, false);
});
test('Host exposes only browser-safe Agent Preset fields in status and update responses', async () => {
let selectedPreset = 'marketing-jeep';
const calls = [];
const current = () => status({
schemaVersion: 2,
revision: 3,
configured: true,
agentPresetCatalog: {
defaultId: 'standard',
root: 'private-preset-root',
failures: [{ message: 'private-catalog-failure' }],
items: [
{
id: 'standard',
name: 'Standard',
path: 'private-standard-path',
trust: 'private-trust-level',
},
{
id: 'marketing-jeep',
label: '营销吉普',
error: { message: 'private-preset-error' },
},
{
id: 'broken-preset',
label: 'Broken',
broken: { message: 'private-broken-reason' },
},
{ id: 'INVALID', label: 'invalid-item-must-be-filtered' },
{ id: 'standard', label: 'duplicate-item-must-be-filtered' },
],
},
bots: [{
botId: 'bot_safe',
phase: 'connected',
connected: true,
configured: true,
agentPreset: selectedPreset,
bot: { name: '安全机器人', domain: 'feishu' },
connection: {
ready: true,
feishuLongConnectionState: 'connected',
harnessReachable: true,
},
}],
});
const controller = {
status: async () => current(),
startRegistration: async () => current(),
cancelRegistration: async () => current(),
disconnect: async () => status(),
updateAgentPreset: async (botId, agentPreset) => {
calls.push({ botId, agentPreset });
selectedPreset = agentPreset;
return current();
},
};
const fx = await rpcFixture(controller);
const listed = await fx.registration.handler(FEISHU_ENDPOINTS.status, {}, signal());
assert.equal(listed.ok, true);
assert.equal(listed.value.bots[0].agentPreset, 'marketing-jeep');
assert.deepEqual(listed.value.agentPresetCatalog, {
defaultId: 'standard',
items: [
{ id: 'standard', label: 'Standard' },
{ id: 'marketing-jeep', label: '营销吉普' },
],
});
assert.doesNotMatch(
JSON.stringify(listed),
/private-preset-root|private-catalog-failure|private-standard-path|private-trust-level|private-preset-error|private-broken-reason|invalid-item|duplicate-item/,
);
const cleared = await fx.registration.handler(
FEISHU_ENDPOINTS.setAgentPreset,
{ botId: 'bot_safe', agentPreset: null },
signal(),
);
assert.equal(cleared.ok, true);
assert.deepEqual(calls, [{ botId: 'bot_safe', agentPreset: null }]);
assert.equal(cleared.value.bots[0].agentPreset, null);
assert.deepEqual(cleared.value.agentPresetCatalog, listed.value.agentPresetCatalog);
assert.doesNotMatch(JSON.stringify(cleared), /private-|invalid-item|duplicate-item/);
await fx.dispose();
});
test('RPC dispatch matches every endpoint in client/api.js', async () => {
const calls = [];
let current = status({
@ -1068,7 +1156,7 @@ test('a corrupt legacy state file cannot prevent a healthy v2 bot from starting'
});
await production.ready;
const statusValue = production.controller.status();
const statusValue = await production.controller.status();
assert.equal(statusValue.bots.find((entry) => entry.botId === 'bot_legacy').phase, 'error');
assert.equal(statusValue.bots.find((entry) => entry.botId === 'bot_healthy').connected, true);
assert.equal(statusValue.totals.connected, 1);

View file

@ -171,14 +171,22 @@ test('all nine production channels receive the Host command executor', async ()
}
});
test('all nine production channels defer an omitted agent preset to the Harness Host', async () => {
test('all nine production channels use channel presets only as bot creation defaults', async () => {
for (const path of PRODUCTION_FILES) {
const source = await readFile(new URL(`../${path}`, import.meta.url), 'utf8');
assert.doesNotMatch(source, /agentPreset:\s*config\.agentPreset\s*\?\?\s*['"]standard['"]/, path);
assert.doesNotMatch(source, /\bagentPreset:\s*config\.agentPreset/, path);
const creationDefaults = source.match(
/workspaces\.ensure\([^;]*\{\s*defaultAgentPreset:\s*config\.agentPreset,?\s*\}\)/g,
) ?? [];
assert.equal(
creationDefaults.length,
2,
`${path} must initialize both restored and newly connected bots`,
);
assert.match(
source,
/\.\.\.\(config\.agentPreset == null \? \{\} : \{ agentPreset: config\.agentPreset \}\)/,
path,
/agentPresetCatalog:\s*\(\)\s*=>\s*listAgentPresetCatalog\(ctx\)/,
`${path} must read the Host preset catalog dynamically`,
);
}

View file

@ -694,6 +694,37 @@ test('AgentPresetEditor lists Host presets and keeps the catalog label for the c
assert.deepEqual(optionValues(select), ['', 'coding', 'default']);
assert.equal(textOf(select.children[0]), '跟随 Host 默认');
assert.equal(textOf(select.children[1]), 'Coding(coding)');
assert.equal(
textOf(renderer.root.findByProps({ className: 'dim-presetHelp' })),
'只影响新建会话;若当前聊天已有会话,先发送 /new,再发送普通消息生效。',
);
renderer.unmount();
});
test('AgentPresetEditor marks a removed current preset and still allows clearing it', async () => {
const saved = [];
const renderer = create(React.createElement(
AgentPresetCatalogContext.Provider,
{ value: PRESET_CATALOG },
React.createElement(AgentPresetEditor, {
agentPreset: 'removed-preset',
onSave(value) { saved.push(value); },
}),
));
const select = renderer.root.findByProps({ className: 'dim-presetSelect' });
assert.equal(select.props.value, 'removed-preset');
assert.deepEqual(optionValues(select), ['', 'coding', 'default', 'removed-preset']);
assert.equal(textOf(select.children[3]), 'removed-preset(已不可用)');
assert.equal(
textOf(renderer.root.findByProps({ role: 'status' })),
'当前 Agent Preset 已不可用,请选择其他 Preset 或跟随 Host 默认。',
);
await act(async () => {
select.props.onChange({ target: { value: '' } });
await flushMicrotasks();
});
assert.deepEqual(saved, [null]);
renderer.unmount();
});

View file

@ -12,6 +12,7 @@ import {
observeBotWorkspaceRemovals,
validateWorkspacePath,
} from '../src/channels/shared/bot-workspace-store.mjs';
import { listAgentPresetCatalog } from '../src/channels/shared/agent-preset.mjs';
import {
connectionTestTarget,
rememberConnectionTestTarget,
@ -125,8 +126,9 @@ test('workspace writes roll back updates while committed removals stay retired i
const blockedParent = join(root, 'blocked-parent');
await writeFile(blockedParent, 'not a directory');
const broken = new BotWorkspaceStore(join(blockedParent, 'workspaces.json'), { defaultWorkspace });
await assert.rejects(broken.ensure('bot_new'));
await assert.rejects(broken.ensure('bot_new', { defaultAgentPreset: 'router-standard' }));
assert.equal(broken.workspaceFor('bot_new'), defaultWorkspace);
assert.equal(broken.agentPresetFor('bot_new'), null);
});
test('workspace validation rejects relative, missing, and file paths', async (t) => {
@ -1322,6 +1324,81 @@ test('BotWorkspaceStore persists per-bot agent presets without changing workspac
assert.equal(reloaded.agentPresetFor('bot_one'), 'standard-claude');
});
test('BotWorkspaceStore applies a channel preset only when a bot is first created', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await store.ensure('bot_seeded', { defaultAgentPreset: 'marketing-jeep' });
await store.ensure('bot_existing');
await store.ensure('bot_existing', { defaultAgentPreset: 'router-standard' });
assert.equal(store.agentPresetFor('bot_seeded'), 'marketing-jeep');
assert.equal(store.agentPresetFor('bot_existing'), null);
await store.setAgentPreset('bot_seeded', null);
await store.ensure('bot_seeded', { defaultAgentPreset: 'router-standard' });
assert.equal(store.agentPresetFor('bot_seeded'), null);
await store.ensure('bot_existing', { defaultAgentPreset: 'Not Valid' });
await assert.rejects(
store.ensure('bot_invalid', { defaultAgentPreset: 'Not Valid' }),
{ code: 'agent-preset-invalid' },
);
assert.equal(store.has('bot_invalid'), false);
await store.remove('bot_seeded');
await store.ensure('bot_seeded', { defaultAgentPreset: 'router-standard' });
assert.equal(store.agentPresetFor('bot_seeded'), 'router-standard');
});
test('Agent Preset catalog filters broken entries and exposes public fields only', async () => {
const catalog = await listAgentPresetCatalog({
get(name) {
assert.equal(name, 'agentPresets');
return {
defaultId: 'standard',
async list() {
return [
{ id: 'standard', name: 'Standard', path: '/secret/standard', trust: 'trusted' },
{ id: 'broken-one', name: 'Broken', broken: 'missing file', path: '/secret/broken' },
{ id: 'marketing-jeep', label: 'Marketing' },
{ id: 'marketing-jeep', label: 'Duplicate' },
{ id: 'Not Valid', label: 'Invalid' },
];
},
};
},
});
assert.deepEqual(catalog, {
defaultId: 'standard',
items: [
{ id: 'standard', label: 'Standard' },
{ id: 'marketing-jeep', label: 'Marketing' },
],
});
});
test('Agent Preset catalog lookup failures fail soft', async () => {
const empty = { defaultId: '', items: [] };
assert.deepEqual(await listAgentPresetCatalog({
get() {
throw new Error('service was unloaded');
},
}), empty);
const service = {
async list() {
return [{ id: 'standard', name: 'Standard' }];
},
};
Object.defineProperty(service, 'defaultId', {
get() {
throw new Error('service was replaced');
},
});
assert.deepEqual(await listAgentPresetCatalog({ agentPresets: service }), empty);
});
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();
@ -1420,3 +1497,62 @@ test('workspace RPC can set a bot agent preset without switching workspace', asy
assert.equal(invalid.ok, false);
assert.equal(invalid.error.code, 'bad-request');
});
test('workspace RPC refreshes the Agent Preset catalog and rejects unavailable choices', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_one');
let catalog = {
defaultId: 'standard',
items: [{ id: 'standard', label: 'Standard' }],
};
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: async () => catalog,
});
const handler = createTokenBotRpcHandler(controller, { channel: 'Telegram' });
const initial = await handler(TOKEN_BOT_ENDPOINTS.status, {});
assert.deepEqual(initial.value.agentPresetCatalog, catalog);
catalog = {
defaultId: 'marketing-jeep',
items: [
{ id: 'standard', label: 'Broken Standard', broken: 'missing entrypoint' },
{ id: 'marketing-jeep', label: 'Marketing' },
],
};
const refreshed = await handler(TOKEN_BOT_ENDPOINTS.status, {});
assert.deepEqual(refreshed.value.agentPresetCatalog, {
defaultId: 'marketing-jeep',
items: [{ id: 'marketing-jeep', label: 'Marketing' }],
});
const unavailable = await handler(TOKEN_BOT_ENDPOINTS.setAgentPreset, {
botId: 'bot_one', agentPreset: 'standard',
});
assert.equal(unavailable.ok, false);
assert.equal(unavailable.error.code, 'agent-preset-unavailable');
assert.equal(workspaces.agentPresetFor('bot_one'), null);
const selected = await handler(TOKEN_BOT_ENDPOINTS.setAgentPreset, {
botId: 'bot_one', agentPreset: 'marketing-jeep',
});
assert.equal(selected.ok, true);
assert.equal(selected.value.bots[0].agentPreset, 'marketing-jeep');
catalog = { defaultId: '', items: [] };
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);
assert.deepEqual(cleared.value.agentPresetCatalog, catalog);
});