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

@ -92,7 +92,7 @@ After installation, follow the built-in instructions on each channel page to sca
| Default behavior | Description |
| --- | --- |
| Bot workspace | Each bot stores its workspace independently. New bots start with the Host's current working directory, which can later be changed from the bot card. |
| Agent Preset | Each bot can choose an Agent Preset on its settings card. When none is chosen, new Sessions follow the Host's `agent-presets.default`. A channel-level `config.agentPreset` is only the default for later new bots on that channel. Changing the preset affects only later new Sessions and never clears existing ones. |
| Agent Preset | Each bot can choose an Agent Preset on its settings card. When none is chosen, new Sessions follow the Host's `agent-presets.default`. A channel-level `config.agentPreset` is only the default for later new bots on that channel. Changing the preset never modifies or clears existing Sessions; if the current chat already has a Session, send `/new` and then a regular message to create one with the new selection. |
Each Telegram bot has its own access-mode control on its bot card. Existing and newly connected bots both default to **Compatible mode**: DMs receive replies, while group messages require a mention of or reply to the bot. Restrictions apply only after explicitly switching that bot to **Safe mode (private-chat allowlist)**. Safe mode ignores every group message and admits only numeric User IDs in that bot's allowlist. Enter one ID per line. Switching back to Compatible mode retains the allowlist without enforcing it, so it is available when Safe mode is enabled again. An empty allowlist in Safe mode rejects all inbound messages for that bot.

View file

@ -95,7 +95,7 @@ GitHub 源安装会直接拉取并构建 Git 依赖;pnpm 10 及以上版本可
| 默认行为 | 说明 |
| --- | --- |
| 机器人工作区 | 每个机器人独立保存工作区。新机器人默认使用 Host 当时的工作目录;之后可在机器人卡片中修改。 |
| Agent Preset | 每个机器人可在设置页卡片中选择 Agent Preset。未选择时跟随 Host 的 `agent-presets.default`;渠道级 `config.agentPreset` 只作为该渠道之后新接入机器人的默认值。切换只影响之后新建的会话,不会清空已有会话。 |
| Agent Preset | 每个机器人可在设置页卡片中选择 Agent Preset。未选择时跟随 Host 的 `agent-presets.default`;渠道级 `config.agentPreset` 只作为该渠道之后新接入机器人的默认值。切换不会修改或清空已有会话;若当前聊天已有会话,需先发送 `/new`,再发送一条普通消息,才会按新选择创建会话。 |
每个 Telegram 机器人都可以在自己的卡片中切换访问模式。旧机器人和新接入机器人均默认使用**兼容模式**:私聊直接响应,群聊仅在提及机器人或回复机器人消息时响应。只有主动切换到**安全模式(私聊白名单)**后,机器人才会忽略全部群聊,并只接受该机器人白名单中的数字 User ID。白名单每行一个 ID、按机器人独立保存;切回兼容模式时会保留但不使用,再切回安全模式即可继续使用。安全模式的空白名单会拒绝该机器人的所有入站消息。

View file

@ -309,10 +309,13 @@ var EN = Object.freeze({
"\u627E\u4E0D\u5230\u8981\u4FEE\u6539\u7684\u673A\u5668\u4EBA\u3002": "The bot could not be found.",
"Agent Preset": "Agent Preset",
"\u8DDF\u968F Host \u9ED8\u8BA4": "Follow the Host default",
"\u53EA\u5F71\u54CD\u4E4B\u540E\u65B0\u5EFA\u7684\u4F1A\u8BDD\u3002": "Only later new sessions use this preset.",
"\uFF08\u5DF2\u4E0D\u53EF\u7528\uFF09": " (unavailable)",
"\u53EA\u5F71\u54CD\u65B0\u5EFA\u4F1A\u8BDD\uFF1B\u82E5\u5F53\u524D\u804A\u5929\u5DF2\u6709\u4F1A\u8BDD\uFF0C\u5148\u53D1\u9001 /new\uFF0C\u518D\u53D1\u9001\u666E\u901A\u6D88\u606F\u751F\u6548\u3002": "This affects only new sessions. If the current chat already has a session, send /new, then send a regular message to apply it.",
"\u5F53\u524D Agent Preset \u5DF2\u4E0D\u53EF\u7528\uFF0C\u8BF7\u9009\u62E9\u5176\u4ED6 Preset \u6216\u8DDF\u968F Host \u9ED8\u8BA4\u3002": "The current Agent Preset is unavailable. Choose another preset or follow the Host default.",
"Agent Preset \u4FEE\u6539\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5\u3002": "Could not update the Agent Preset. Try again.",
"\u8BF7\u9009\u62E9 Agent Preset\u3002": "Choose an Agent Preset.",
"Agent Preset \u65E0\u6548\u3002": "The Agent Preset is invalid.",
"Agent Preset \u4E0D\u5B58\u5728\u6216\u4E0D\u53EF\u7528\u3002": "The Agent Preset does not exist or is unavailable.",
"\u5C1A\u672A\u68C0\u67E5": "Not checked yet",
"\u521A\u521A": "Just now",
"\u68C0\u67E5\u8FDE\u63A5": "Check connection",
@ -854,9 +857,8 @@ function AgentPresetEditor({ agentPreset = "", disabled = false, onSave }) {
seen.add(item.id);
items.push(item);
}
if (current && !seen.has(current)) {
items.push({ id: current, label: current });
}
const currentUnavailable = Boolean(current && !seen.has(current));
if (currentUnavailable) items.push({ id: current, label: current, unavailable: true });
const inheritLabel = "\u8DDF\u968F Host \u9ED8\u8BA4";
const change = async (event) => {
const next = event.target.value;
@ -895,11 +897,19 @@ function AgentPresetEditor({ agentPreset = "", disabled = false, onSave }) {
...items.map((item) => h2(
"option",
{ key: item.id, value: item.id },
item.label && item.label !== item.id ? `${item.label}\uFF08${item.id}\uFF09` : item.id
item.unavailable ? [item.id, "\uFF08\u5DF2\u4E0D\u53EF\u7528\uFF09"] : item.label && item.label !== item.id ? `${item.label}\uFF08${item.id}\uFF09` : item.id
))
),
h2("small", { className: "dim-presetHelp" }, "\u53EA\u5F71\u54CD\u4E4B\u540E\u65B0\u5EFA\u7684\u4F1A\u8BDD\u3002"),
error ? h2("p", { className: "dim-presetError", role: "alert" }, error) : null
h2(
"small",
{ className: "dim-presetHelp" },
"\u53EA\u5F71\u54CD\u65B0\u5EFA\u4F1A\u8BDD\uFF1B\u82E5\u5F53\u524D\u804A\u5929\u5DF2\u6709\u4F1A\u8BDD\uFF0C\u5148\u53D1\u9001 /new\uFF0C\u518D\u53D1\u9001\u666E\u901A\u6D88\u606F\u751F\u6548\u3002"
),
error || currentUnavailable ? h2(
"p",
{ className: "dim-presetError", role: error ? "alert" : "status" },
error ?? "\u5F53\u524D Agent Preset \u5DF2\u4E0D\u53EF\u7528\uFF0C\u8BF7\u9009\u62E9\u5176\u4ED6 Preset \u6216\u8DDF\u968F Host \u9ED8\u8BA4\u3002"
) : null
);
}

File diff suppressed because one or more lines are too long

View file

@ -57,9 +57,8 @@ export function AgentPresetEditor({ agentPreset = '', disabled = false, onSave }
seen.add(item.id);
items.push(item);
}
if (current && !seen.has(current)) {
items.push({ id: current, label: current });
}
const currentUnavailable = Boolean(current && !seen.has(current));
if (currentUnavailable) items.push({ id: current, label: current, unavailable: true });
const inheritLabel = '跟随 Host 默认';
@ -92,10 +91,20 @@ export function AgentPresetEditor({ agentPreset = '', disabled = false, onSave }
...items.map((item) => h(
'option',
{ key: item.id, value: item.id },
item.label && item.label !== item.id ? `${item.label}(${item.id})` : item.id,
item.unavailable
? [item.id, '(已不可用)']
: item.label && item.label !== item.id ? `${item.label}(${item.id})` : item.id,
)),
),
h('small', { className: 'dim-presetHelp' }, '只影响之后新建的会话。'),
error ? h('p', { className: 'dim-presetError', role: 'alert' }, error) : null,
h(
'small',
{ className: 'dim-presetHelp' },
'只影响新建会话;若当前聊天已有会话,先发送 /new,再发送普通消息生效。',
),
error || currentUnavailable ? h(
'p',
{ className: 'dim-presetError', role: error ? 'alert' : 'status' },
error ?? '当前 Agent Preset 已不可用,请选择其他 Preset 或跟随 Host 默认。',
) : null,
);
}

View file

@ -119,10 +119,13 @@ const EN = Object.freeze({
'找不到要修改的机器人。': 'The bot could not be found.',
'Agent Preset': 'Agent Preset',
'跟随 Host 默认': 'Follow the Host default',
'只影响之后新建的会话。': 'Only later new sessions use this preset.',
'(已不可用)': ' (unavailable)',
'只影响新建会话;若当前聊天已有会话,先发送 /new,再发送普通消息生效。': 'This affects only new sessions. If the current chat already has a session, send /new, then send a regular message to apply it.',
'当前 Agent Preset 已不可用,请选择其他 Preset 或跟随 Host 默认。': 'The current Agent Preset is unavailable. Choose another preset or follow the Host default.',
'Agent Preset 修改失败,请重试。': 'Could not update the Agent Preset. Try again.',
'请选择 Agent Preset。': 'Choose an Agent Preset.',
'Agent Preset 无效。': 'The Agent Preset is invalid.',
'Agent Preset 不存在或不可用。': 'The Agent Preset does not exist or is unavailable.',
'尚未检查': 'Not checked yet',
'刚刚': 'Just now',
'检查连接': 'Check connection',

View file

@ -64,7 +64,9 @@ export async function createProductionController(ctx, config = {}, internals = {
if (canListConfiguredBots) {
await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
}
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId)));
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId, {
defaultAgentPreset: config.agentPreset,
})));
const observedConfigStore = typeof configStore.remove === 'function'
? observeBotWorkspaceRemovals(configStore, { workspaces })
: configStore;
@ -90,7 +92,6 @@ export async function createProductionController(ctx, config = {}, internals = {
const harness = new Harness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
workspace: defaultWorkspace,
...(config.agentPreset == null ? {} : { agentPreset: config.agentPreset }),
autostart: false,
dshBin: config.dshBin ?? 'dsh',
...(commandExecutor ? { commandExecutor } : {}),
@ -104,7 +105,7 @@ export async function createProductionController(ctx, config = {}, internals = {
logger,
createRuntime: async ({ botId, config: botConfig, clientSecret }) => {
const state = await stateFor(botId);
await workspaces.ensure(botId);
await workspaces.ensure(botId, { defaultAgentPreset: config.agentPreset });
const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state });
return new Runtime({
config: botConfig,
@ -139,7 +140,7 @@ export async function createProductionController(ctx, config = {}, internals = {
const controller = createWorkspaceAwareController(coreController, {
workspaces,
stateFor,
agentPresetCatalog: await listAgentPresetCatalog(ctx),
agentPresetCatalog: () => listAgentPresetCatalog(ctx),
});
const supervisor = createSupervisor({
controller,

View file

@ -129,7 +129,7 @@ export class ConnectionSupervisor {
await this.#controller.initialize();
if (this.#closed) return;
const status = this.#controller.status();
const status = await this.#controller.status();
this.#resolveReady?.(status);
this.#resolveReady = null;
const current = totals(status);

View file

@ -78,7 +78,9 @@ export async function createProductionController(ctx, config = {}, internals = {
if (canListConfiguredBots) {
await workspaces.reconcile(configuredBots.map((bot) => bot.id));
}
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.id)));
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.id, {
defaultAgentPreset: config.agentPreset,
})));
const observedConfigStore = typeof configStore.removeBot === 'function'
? observeBotWorkspaceRemovals(configStore, {
workspaces,
@ -116,7 +118,6 @@ export async function createProductionController(ctx, config = {}, internals = {
const harness = new Harness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
workspace: defaultWorkspace,
...(config.agentPreset == null ? {} : { agentPreset: config.agentPreset }),
// This plugin is already hosted by a running DSH process. Starting a
// second DSH would create a competing server and lifecycle.
autostart: false,
@ -134,7 +135,7 @@ export async function createProductionController(ctx, config = {}, internals = {
createRuntime: async ({ botId, config: botConfig, appSecret, repair }) => {
const state = await stateFor(botConfig);
const id = botId ?? botConfig.id ?? botConfig.appId;
await workspaces.ensure(id);
await workspaces.ensure(id, { defaultAgentPreset: config.agentPreset });
const workspaceScope = createBotWorkspaceScope(harness, { botId: id, workspaces, state });
return new Runtime({
lark,
@ -167,7 +168,7 @@ export async function createProductionController(ctx, config = {}, internals = {
const controller = createWorkspaceAwareController(coreController, {
workspaces,
stateFor: stateForBotId,
agentPresetCatalog: await listAgentPresetCatalog(ctx),
agentPresetCatalog: () => listAgentPresetCatalog(ctx),
});
const supervisor = createSupervisor({

View file

@ -1,4 +1,8 @@
import QRCode from 'qrcode';
import {
normalizeAgentPresetCatalog,
normalizeAgentPresetId,
} from '../../../../src/channels/shared/agent-preset.mjs';
import { publicConnectionTestResult } from '../../../../src/channels/shared/connection-test.mjs';
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import { publicWorkspaceError, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
@ -254,6 +258,7 @@ function publicBotEntry(entry) {
state: connectionState(source, registration, connected),
connected,
configured: source.configured === true,
agentPreset: normalizeAgentPresetId(source.agentPreset),
bot: publicBot(source.bot),
health: publicHealth(source, connected),
};
@ -295,6 +300,7 @@ export async function toPublicFeishuStatus(status, { encodeQr = qrCodeDataUrl }
bot: publicBot(source.bot),
health: publicHealth(source, connected),
bots,
agentPresetCatalog: normalizeAgentPresetCatalog(source.agentPresetCatalog),
totals: {
configured: bots.length || (source.configured === true ? 1 : 0),
connected: bots.length ? bots.filter((bot) => bot.connected).length : (connected ? 1 : 0),

View file

@ -58,7 +58,9 @@ export async function createProductionController(ctx, config = {}, internals = {
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
const configuredBots = configStore.list();
await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId)));
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId, {
defaultAgentPreset: config.agentPreset,
})));
const observedConfigStore = typeof configStore.remove === 'function'
? observeBotWorkspaceRemovals(configStore, { workspaces })
: configStore;
@ -81,7 +83,6 @@ export async function createProductionController(ctx, config = {}, internals = {
const harness = new Harness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
workspace: defaultWorkspace,
...(config.agentPreset == null ? {} : { agentPreset: config.agentPreset }),
autostart: false,
dshBin: config.dshBin ?? 'dsh',
...(commandExecutor ? { commandExecutor } : {}),
@ -95,7 +96,7 @@ export async function createProductionController(ctx, config = {}, internals = {
logger,
createRuntime: async ({ botId, config: botConfig, appSecret }) => {
const state = await stateFor(botId);
await workspaces.ensure(botId);
await workspaces.ensure(botId, { defaultAgentPreset: config.agentPreset });
const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state });
return new Runtime({
config: botConfig,
@ -129,7 +130,7 @@ export async function createProductionController(ctx, config = {}, internals = {
const controller = createWorkspaceAwareController(coreController, {
workspaces,
stateFor,
agentPresetCatalog: await listAgentPresetCatalog(ctx),
agentPresetCatalog: () => listAgentPresetCatalog(ctx),
});
const supervisor = createSupervisor({
controller,

View file

@ -16,6 +16,10 @@ export function validAgentPresetPayload(payload) {
}
export function publicAgentPresetError(error) {
if (!['agent-preset-invalid', 'workspace-bot-not-found'].includes(error?.code)) return null;
if (![
'agent-preset-invalid',
'agent-preset-unavailable',
'workspace-bot-not-found',
].includes(error?.code)) return null;
return { code: error.code, message: error.message };
}

View file

@ -60,7 +60,9 @@ export async function createTokenProductionController(ctx, config, internals, de
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
const configuredBots = configStore.list();
await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId)));
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId, {
defaultAgentPreset: config.agentPreset,
})));
const observedConfigStore = typeof configStore.remove === 'function'
? observeBotWorkspaceRemovals(configStore, { workspaces })
: configStore;
@ -82,7 +84,6 @@ export async function createTokenProductionController(ctx, config, internals, de
const harness = new ResolvedHarness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
workspace: defaultWorkspace,
...(config.agentPreset == null ? {} : { agentPreset: config.agentPreset }),
autostart: false,
dshBin: config.dshBin ?? 'dsh',
...(commandExecutor ? { commandExecutor } : {}),
@ -96,7 +97,7 @@ export async function createTokenProductionController(ctx, config, internals, de
...(internals.inspectToken ? { inspectToken: internals.inspectToken } : {}),
createRuntime: async ({ botId, config: botConfig, token }) => {
const state = await stateFor(botId);
await workspaces.ensure(botId);
await workspaces.ensure(botId, { defaultAgentPreset: config.agentPreset });
const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state });
return new ResolvedRuntime({
...channelRuntimeOptions,
@ -131,7 +132,7 @@ export async function createTokenProductionController(ctx, config, internals, de
const controller = createWorkspaceAwareController(coreController, {
workspaces,
stateFor,
agentPresetCatalog: await listAgentPresetCatalog(ctx),
agentPresetCatalog: () => listAgentPresetCatalog(ctx),
});
const supervisor = createSupervisor({
channel,

View file

@ -21,6 +21,7 @@ export function publicWorkspaceError(error) {
'workspace-not-directory',
'workspace-bot-not-found',
'agent-preset-invalid',
'agent-preset-unavailable',
].includes(error?.code)) return null;
return { code: error.code, message: error.message };
}

View file

@ -38,7 +38,9 @@ export async function createProductionController(ctx, config = {}, internals = {
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
const configuredBots = configStore.list();
await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId)));
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId, {
defaultAgentPreset: config.agentPreset,
})));
const observedConfigStore = typeof configStore.remove === 'function'
? observeBotWorkspaceRemovals(configStore, { workspaces })
: configStore;
@ -60,7 +62,6 @@ export async function createProductionController(ctx, config = {}, internals = {
const harness = new ResolvedHarness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
workspace: defaultWorkspace,
...(config.agentPreset == null ? {} : { agentPreset: config.agentPreset }),
autostart: false,
dshBin: config.dshBin ?? 'dsh',
...(commandExecutor ? { commandExecutor } : {}),
@ -74,7 +75,7 @@ export async function createProductionController(ctx, config = {}, internals = {
...(internals.inspectCredentials ? { inspectCredentials: internals.inspectCredentials } : {}),
createRuntime: async ({ botId, config: botConfig, botToken, appToken }) => {
const state = await stateFor(botId);
await workspaces.ensure(botId);
await workspaces.ensure(botId, { defaultAgentPreset: config.agentPreset });
const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state });
return new ResolvedRuntime({
config: botConfig,
@ -109,7 +110,7 @@ export async function createProductionController(ctx, config = {}, internals = {
const controller = createWorkspaceAwareController(coreController, {
workspaces,
stateFor,
agentPresetCatalog: await listAgentPresetCatalog(ctx),
agentPresetCatalog: () => listAgentPresetCatalog(ctx),
});
const supervisor = createSupervisor({
channel: 'slack',

View file

@ -58,7 +58,9 @@ export async function createProductionController(ctx, config = {}, internals = {
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
const configuredBots = configStore.list();
await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId)));
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId, {
defaultAgentPreset: config.agentPreset,
})));
const observedConfigStore = typeof configStore.remove === 'function'
? observeBotWorkspaceRemovals(configStore, { workspaces })
: configStore;
@ -84,7 +86,6 @@ export async function createProductionController(ctx, config = {}, internals = {
const harness = new Harness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
workspace: defaultWorkspace,
...(config.agentPreset == null ? {} : { agentPreset: config.agentPreset }),
autostart: false,
dshBin: config.dshBin ?? 'dsh',
...(commandExecutor ? { commandExecutor } : {}),
@ -98,7 +99,7 @@ export async function createProductionController(ctx, config = {}, internals = {
logger,
createRuntime: async ({ botId, config: botConfig, secret }) => {
const state = await stateFor(botId);
await workspaces.ensure(botId);
await workspaces.ensure(botId, { defaultAgentPreset: config.agentPreset });
const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state });
return new Runtime({
config: botConfig,
@ -133,7 +134,7 @@ export async function createProductionController(ctx, config = {}, internals = {
const controller = createWorkspaceAwareController(coreController, {
workspaces,
stateFor,
agentPresetCatalog: await listAgentPresetCatalog(ctx),
agentPresetCatalog: () => listAgentPresetCatalog(ctx),
});
const supervisor = createSupervisor({
controller,

View file

@ -61,7 +61,9 @@ export async function createProductionController(ctx, config = {}, internals = {
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
const configuredBots = configStore.list();
await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId)));
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId, {
defaultAgentPreset: config.agentPreset,
})));
const observedConfigStore = typeof configStore.remove === 'function'
? observeBotWorkspaceRemovals(configStore, { workspaces })
: configStore;
@ -84,7 +86,6 @@ export async function createProductionController(ctx, config = {}, internals = {
const harness = new Harness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
workspace: defaultWorkspace,
...(config.agentPreset == null ? {} : { agentPreset: config.agentPreset }),
autostart: false,
dshBin: config.dshBin ?? 'dsh',
...(commandExecutor ? { commandExecutor } : {}),
@ -98,7 +99,7 @@ export async function createProductionController(ctx, config = {}, internals = {
logger,
createRuntime: async ({ botId, config: accountConfig, token }) => {
const state = await stateFor(botId);
await workspaces.ensure(botId);
await workspaces.ensure(botId, { defaultAgentPreset: config.agentPreset });
const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state });
return new Runtime({
api,
@ -133,7 +134,7 @@ export async function createProductionController(ctx, config = {}, internals = {
const controller = createWorkspaceAwareController(coreController, {
workspaces,
stateFor,
agentPresetCatalog: await listAgentPresetCatalog(ctx),
agentPresetCatalog: () => listAgentPresetCatalog(ctx),
});
const supervisor = createSupervisor({
controller,

View file

@ -65,7 +65,9 @@ export async function createProductionController(ctx, config = {}, internals = {
?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
const configuredBots = configStore.list();
await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId)));
await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId, {
defaultAgentPreset: config.agentPreset,
})));
const observedConfigStore = typeof configStore.remove === 'function'
? observeBotWorkspaceRemovals(configStore, { workspaces })
: configStore;
@ -87,7 +89,6 @@ export async function createProductionController(ctx, config = {}, internals = {
const harness = new Harness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
workspace: defaultWorkspace,
...(config.agentPreset == null ? {} : { agentPreset: config.agentPreset }),
autostart: false,
dshBin: config.dshBin ?? 'dsh',
...(commandExecutor ? { commandExecutor } : {}),
@ -101,7 +102,7 @@ export async function createProductionController(ctx, config = {}, internals = {
logger,
createRuntime: async ({ botId, config: botConfig, authDir }) => {
const state = await stateFor(botId);
await workspaces.ensure(botId);
await workspaces.ensure(botId, { defaultAgentPreset: config.agentPreset });
const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state });
return new Runtime({
config: botConfig,
@ -140,7 +141,7 @@ export async function createProductionController(ctx, config = {}, internals = {
const controller = createWorkspaceAwareController(coreController, {
workspaces,
stateFor,
agentPresetCatalog: await listAgentPresetCatalog(ctx),
agentPresetCatalog: () => listAgentPresetCatalog(ctx),
});
const supervisor = createSupervisor({
channel: 'whatsapp',

View file

@ -30,6 +30,7 @@ function catalogItem(value) {
return id ? { id, label: id } : null;
}
if (!value || typeof value !== 'object') return null;
if (value.broken !== undefined) return null;
const id = normalizeAgentPresetId(value.id);
if (!id) return null;
const label = typeof value.name === 'string' && value.name.trim()
@ -59,16 +60,15 @@ export function normalizeAgentPresetCatalog(value) {
}
export async function listAgentPresetCatalog(ctx) {
const service = typeof ctx?.get === 'function' ? ctx.get('agentPresets') : ctx?.agentPresets;
if (!service || typeof service.list !== 'function') return { defaultId: '', items: [] };
let listed = [];
try {
listed = await service.list();
const service = typeof ctx?.get === 'function' ? ctx.get('agentPresets') : ctx?.agentPresets;
if (!service || typeof service.list !== 'function') return { defaultId: '', items: [] };
const listed = await service.list();
return normalizeAgentPresetCatalog({
defaultId: typeof service.defaultId === 'string' ? service.defaultId : '',
items: Array.isArray(listed) ? listed : [],
});
} catch {
return { defaultId: '', items: [] };
}
return normalizeAgentPresetCatalog({
defaultId: typeof service.defaultId === 'string' ? service.defaultId : '',
items: Array.isArray(listed) ? listed : [],
});
}

View file

@ -9,7 +9,10 @@ import {
} from 'node:fs/promises';
import { dirname, isAbsolute, resolve } from 'node:path';
import { validateAgentPresetId } from './agent-preset.mjs';
import {
normalizeAgentPresetCatalog,
validateAgentPresetId,
} from './agent-preset.mjs';
import { CONNECTION_TEST_STATE_IDENTITY } from './connection-test.mjs';
import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
@ -171,18 +174,24 @@ export class BotWorkspaceStore {
}
}
async ensure(botId, { workspace = this.#defaultWorkspace } = {}) {
async ensure(botId, { workspace = this.#defaultWorkspace, defaultAgentPreset } = {}) {
const id = botIdOf(botId);
const initialWorkspace = resolve(workspace);
return this.#enqueue(id, async () => {
if (!this.#workspaces[id]) {
const agentPreset = validateAgentPresetId(defaultAgentPreset);
const hadAgentPreset = Object.hasOwn(this.#agentPresets, id);
const previousAgentPreset = this.#agentPresets[id];
this.#workspaces[id] = initialWorkspace;
if (agentPreset) this.#agentPresets[id] = agentPreset;
this.#generations.set(id, this.#freshGeneration());
this.#incarnations.set(id, this.#freshIncarnation());
try {
await this.#persist();
} catch (error) {
delete this.#workspaces[id];
if (hadAgentPreset) this.#agentPresets[id] = previousAgentPreset;
else delete this.#agentPresets[id];
this.#generations.delete(id);
this.#incarnations.delete(id);
throw error;
@ -518,14 +527,25 @@ export class BotWorkspaceStore {
}
}
function resolveAgentPresetCatalog(catalog) {
if (!catalog) return null;
const value = typeof catalog === 'function' ? catalog() : catalog;
return value && typeof value.then === 'function'
? value.then(normalizeAgentPresetCatalog)
: normalizeAgentPresetCatalog(value);
}
function decorateResult(workspaces, result, catalog) {
const decorate = (value) => {
const decorated = workspaces.decorateStatus(value);
if (!catalog || !decorated || typeof decorated !== 'object') return decorated;
const agentPresetCatalog = typeof catalog === 'function' ? catalog() : catalog;
return agentPresetCatalog
? { ...decorated, agentPresetCatalog }
: decorated;
const attachCatalog = (agentPresetCatalog) => (
agentPresetCatalog ? { ...decorated, agentPresetCatalog } : decorated
);
const agentPresetCatalog = resolveAgentPresetCatalog(catalog);
return agentPresetCatalog && typeof agentPresetCatalog.then === 'function'
? agentPresetCatalog.then(attachCatalog)
: attachCatalog(agentPresetCatalog);
};
return result && typeof result.then === 'function'
? result.then(decorate)
@ -879,6 +899,7 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
};
const updateAgentPreset = (botId, agentPreset) => {
const incarnation = workspaces.incarnationFor(botId);
const normalizedAgentPreset = validateAgentPresetId(agentPreset);
return withBotTransition(botId, async () => {
const snapshot = await controller.status();
if (!snapshot?.bots?.some((bot) => bot?.botId === botId)) {
@ -886,8 +907,21 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
error.code = 'workspace-bot-not-found';
throw error;
}
await workspaces.setAgentPreset(botId, agentPreset, { incarnation });
return decorate(await controller.status());
const catalog = normalizedAgentPreset && agentPresetCatalog
? await resolveAgentPresetCatalog(agentPresetCatalog)
: null;
if (normalizedAgentPreset && agentPresetCatalog
&& !catalog?.items.some((item) => item.id === normalizedAgentPreset)) {
const error = new Error('Agent Preset 不存在或不可用。');
error.code = 'agent-preset-unavailable';
throw error;
}
await workspaces.setAgentPreset(botId, normalizedAgentPreset, { incarnation });
return decorateResult(
workspaces,
await controller.status(),
catalog ?? agentPresetCatalog,
);
});
};
const deleteWithWorkspace = (botId, invokeDelete) => withBotTransition(botId, async () => {

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);
});