diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index de9b9da317..bef7f5296d 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -227,6 +227,7 @@ def get_local_permission_defaults(system: str | None = None) -> dict: "read_only": True, "report_via_conversation": True, "workspace_root": "", + "coding_agents": [], }, "work_session": {"max_age_seconds": 3600}, "plugin_routes": [], @@ -4865,6 +4866,16 @@ def get_local_permission_defaults(system: str | None = None) -> dict: "hint": "每个委派任务在此目录下拥有独立文件夹。留空时使用数据目录下的 btw/workspaces。", "condition": {"btw.work_loop.enabled": True}, }, + "btw.work_loop.coding_agents": { + "description": "第三方编码代理", + "type": "list", + "hint": "可委派写入任务的本地 CLI 代理(如 Claude Code、Codex)。每项包含 id、type、command、权限模式与 provider 预设;provider 会以该 CLI 原生配置层的形式生效,不改动用户全局配置。权限模式与沙箱是该 CLI 自己执行的策略,不是操作系统级隔离。委派会启动本地进程并写入文件,因此还要求工作循环的 Computer Use 运行时为 local(sandbox 下委派等于绕过沙箱),并通过 tool.local_exec 与 tool.file_write 的授权。Claude Code 以非交互方式(-p)运行,没有终端可以回答权限询问:acceptEdits 只自动放行编辑,需要跑 shell 命令的任务会一直等到超时,这类任务要显式选择 bypassPermissions。", + "_special": "select_coding_agents", + # The editor is a list of cards holding a nested preset list; half + # of a row is not enough to lay one out. + "full_width": True, + "condition": {"btw.work_loop.enabled": True}, + }, "btw.work_session.max_age_seconds": { "description": "终态工作会话保留秒数", "type": "int", diff --git a/astrbot/dashboard/services/config_service.py b/astrbot/dashboard/services/config_service.py index 0c12f398b5..c8f3927263 100644 --- a/astrbot/dashboard/services/config_service.py +++ b/astrbot/dashboard/services/config_service.py @@ -141,6 +141,98 @@ def _is_sensitive_config_key(key: str | None) -> bool: return normalized.endswith(SENSITIVE_CONFIG_SUFFIXES) +def _item_identity(item: Any) -> str | None: + """Return the ``id`` that names a list item, when it carries one.""" + if not isinstance(item, dict): + return None + identity = item.get("id") + return identity if isinstance(identity, str) and identity else None + + +def _items_by_identity(items: list) -> dict[str, int]: + """Map each item's ``id`` to its position, or ``{}`` when that does not fit. + + A list of objects is read by identity only when every item carries a unique, + non-empty ``id``. Anything else -- a list of scalars, a repeated or missing + id -- leaves position as the only reading the two lists share. + """ + by_id: dict[str, int] = {} + for index, item in enumerate(items): + identity = _item_identity(item) + if identity is None or identity in by_id: + return {} + by_id[identity] = index + return by_id + + +def _stored_twins(posted: list, current: list) -> list[Any]: + """Return the stored item each posted item is the same entry as, if any. + + An entry that carries an ``id`` is matched by it, so that moving, removing + or copying one cannot hand it a neighbour's stored value; that is the + failure this exists to prevent, and it is also what makes the match survive + a reordering. An entry the stored list does not name -- a new one, or one + the client renamed, which it has no way to say -- falls back to its own + position, but only while that stored item is not already claimed by a name, + so a fallback can never hand the same stored value out twice. + """ + current_by_id = _items_by_identity(current) + if not current_by_id: + return [ + current[index] if index < len(current) else None + for index in range(len(posted)) + ] + + twins: list[Any] = [None] * len(posted) + matched: set[int] = set() + claimed: set[int] = set() + for index, item in enumerate(posted): + position = current_by_id.get(_item_identity(item) or "") + if position is None: + continue + twins[index] = current[position] + matched.add(index) + claimed.add(position) + for index in range(len(posted)): + if index in matched or index >= len(current) or index in claimed: + continue + twins[index] = current[index] + return twins + + +def _blank_placeholders(value: Any, *, key_name: str | None) -> Any: + """Replace every secret marker that has no stored value to stand for. + + A response writes ``REDACTED_SECRET_PLACEHOLDER`` where a secret is stored, + so the marker is never itself a secret. A request that carries it for + something the profile does not have -- a copied entry, a hand-built one -- + has nothing to resolve it back to, and keeping it would write the marker + into the configuration as if it were a credential. + """ + if isinstance(value, dict): + blanked = { + key: _blank_placeholders(item, key_name=key) for key, item in value.items() + } + return blanked if blanked != value else value + + if isinstance(value, list): + if key_name and _is_sensitive_config_key(key_name): + blanked = [ + "" if item == REDACTED_SECRET_PLACEHOLDER else item for item in value + ] + return blanked if blanked != value else value + return [_blank_placeholders(item, key_name=key_name) for item in value] + + if ( + key_name + and _is_sensitive_config_key(key_name) + and value == REDACTED_SECRET_PLACEHOLDER + ): + return "" + + return value + + def _redact_sensitive_config(value: Any, *, key_name: str | None = None) -> Any: if isinstance(value, dict): return { @@ -172,6 +264,7 @@ def _restore_redacted_sensitive_config( if isinstance(posted_value, dict) and isinstance(current_value, dict): for key, item in posted_value.items(): if key not in current_value: + posted_value[key] = _blank_placeholders(item, key_name=key) continue posted_value[key] = _restore_redacted_sensitive_config( item, @@ -184,22 +277,22 @@ def _restore_redacted_sensitive_config( if key_name and _is_sensitive_config_key(key_name): restored_items = [] for idx, item in enumerate(posted_value): - if ( - item == REDACTED_SECRET_PLACEHOLDER - and idx < len(current_value) - and isinstance(current_value[idx], str) - ): - restored_items.append(current_value[idx]) - else: + if item != REDACTED_SECRET_PLACEHOLDER: restored_items.append(item) + continue + stored = current_value[idx] if idx < len(current_value) else None + restored_items.append(stored if isinstance(stored, str) else "") return restored_items + twins = _stored_twins(posted_value, current_value) for idx, item in enumerate(posted_value): - if idx >= len(current_value): - break + counterpart = twins[idx] + if counterpart is None: + posted_value[idx] = _blank_placeholders(item, key_name=key_name) + continue posted_value[idx] = _restore_redacted_sensitive_config( item, - current_value[idx], + counterpart, key_name=key_name, ) return posted_value @@ -209,7 +302,7 @@ def _restore_redacted_sensitive_config( and _is_sensitive_config_key(key_name) and posted_value == REDACTED_SECRET_PLACEHOLDER ): - return current_value + return current_value if current_value != REDACTED_SECRET_PLACEHOLDER else "" return posted_value @@ -269,9 +362,10 @@ def changed( return False if isinstance(posted, list): current_list = current if isinstance(current, list) else [] + twins = _stored_twins(posted, current_list) if any( changed( - current_list[index] if index < len(current_list) else None, + twins[index], value, key_name=key_name, path=path, @@ -279,14 +373,19 @@ def changed( for index, value in enumerate(posted) ): return True - return bool( - missing_is_change - and key_name - and _is_sensitive_config_key(key_name) - and any( - item not in (None, "", [], {}) - for item in current_list[len(posted) :] + if not ( + missing_is_change and key_name and _is_sensitive_config_key(key_name) + ): + return False + if _items_by_identity(current_list): + posted_identities = {_item_identity(value) for value in posted} + return any( + _item_identity(item) not in posted_identities + for item in current_list + if item not in (None, "", [], {}) ) + return any( + item not in (None, "", [], {}) for item in current_list[len(posted) :] ) return False diff --git a/dashboard/src/components/config/AiConfigPanel.vue b/dashboard/src/components/config/AiConfigPanel.vue index a962e03979..6afce36f4f 100644 --- a/dashboard/src/components/config/AiConfigPanel.vue +++ b/dashboard/src/components/config/AiConfigPanel.vue @@ -368,7 +368,9 @@ const localTabGroups = computed(() => { 'websearch', 'agent_computer_use', 'proactive_capability', - 'btw', + // `btw` lives on its own page now (More Features > BTW Dual Loop); the + // work loop is a separate execution path with its own boundary, and it + // was easy to lose among the model and runner options here. ] .filter((key) => props.metadata?.[key]) .map((key) => ({ diff --git a/dashboard/src/components/shared/CodingAgentsEditor.vue b/dashboard/src/components/shared/CodingAgentsEditor.vue new file mode 100644 index 0000000000..3dd8615b78 --- /dev/null +++ b/dashboard/src/components/shared/CodingAgentsEditor.vue @@ -0,0 +1,920 @@ + + + + + + diff --git a/dashboard/src/components/shared/ConfigItemRenderer.vue b/dashboard/src/components/shared/ConfigItemRenderer.vue index 9688e0e0c1..22a840fe7e 100644 --- a/dashboard/src/components/shared/ConfigItemRenderer.vue +++ b/dashboard/src/components/shared/ConfigItemRenderer.vue @@ -83,6 +83,12 @@ @update:model-value="emitUpdate" /> + @@ -352,6 +358,7 @@ import KnowledgeBaseSelector from './KnowledgeBaseSelector.vue'; import PluginSetSelector from './PluginSetSelector.vue'; import PluginLoopSelector from './PluginLoopSelector.vue'; import CapabilityLoopSelector from './CapabilityLoopSelector.vue'; +import CodingAgentsEditor from './CodingAgentsEditor.vue'; import T2ITemplateEditor from './T2ITemplateEditor.vue'; import DashboardTotpManager from './DashboardTotpManager.vue'; import LocalPermissionMatrix from './LocalPermissionMatrix.vue'; diff --git a/dashboard/src/i18n/locales/en-US/core/navigation.json b/dashboard/src/i18n/locales/en-US/core/navigation.json index 7a25db9b07..356ff77548 100644 --- a/dashboard/src/i18n/locales/en-US/core/navigation.json +++ b/dashboard/src/i18n/locales/en-US/core/navigation.json @@ -49,5 +49,6 @@ "selectVersion": "Select Version", "current": "Current" }, - "pluginWebui": "Plugin Pages" + "pluginWebui": "Plugin Pages", + "btw": "BTW Dual Loop" } diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index bf0986e7ea..a94ac21dff 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -493,6 +493,10 @@ "workspace_root": { "description": "Coding-agent task folder root", "hint": "Each delegated task gets its own folder under this path. Empty uses btw/workspaces inside the data directory." + }, + "coding_agents": { + "description": "Third-party coding agents", + "hint": "Local CLI agents that can be handed write tasks (for example Claude Code or Codex). Each entry carries an id, type, command, permission mode, and provider presets. A provider's endpoint and model take effect as that CLI's own config layer, and its key is passed to the child process for the length of the run rather than written into that layer. The key itself is stored in this AstrBot profile, like any other provider credential. A delegation starts a local process and writes files, so the work loop's Computer Use runtime must be `local`: `sandbox` is not enough, because the process is started on the host rather than inside the sandbox. `tool.local_exec` and `tool.file_write` must both be authorized." } }, "work_session": { diff --git a/dashboard/src/i18n/locales/en-US/features/config.json b/dashboard/src/i18n/locales/en-US/features/config.json index 8eb08f1147..a5f687df30 100644 --- a/dashboard/src/i18n/locales/en-US/features/config.json +++ b/dashboard/src/i18n/locales/en-US/features/config.json @@ -218,5 +218,75 @@ "emptyMcp": "There are no enabled MCP servers.", "skillHint": "Skills default to both loops. Workspace Skills remain available only to the work loop. Workspace Skills remain work-only with the local runtime.", "emptySkill": "There are no enabled Skills." + }, + "codingAgentsEditor": { + "hint": "Each entry names a local CLI agent the work loop hands writes to. Only enabled agents are used, and only while the work loop's Computer Use runtime is local. The permission mode and the sandbox are policies the CLI itself enforces, not operating-system isolation, so bypassPermissions and danger-full-access have to be chosen deliberately.", + "empty": "No coding agent yet. Add one to let the work loop delegate writes.", + "newAgent": "New agent", + "addAgent": "Add agent", + "removeAgent": "Remove agent", + "moveUp": "Move up", + "moveDown": "Move down", + "enabled": "Enabled", + "disabledWarning": "A disabled agent is not offered to the work loop.", + "agentId": "ID", + "agentIdHint": "A unique name for this agent. It names the generated CLI config files, so prefer letters, digits, dots, dashes and underscores.", + "missingIdWarning": "An agent without an ID is ignored when the configuration is read.", + "duplicateIdWarning": "Another agent already uses this ID; the later one is ignored when the configuration is read.", + "agentName": "Display name", + "agentType": "Type", + "command": "Command", + "commandHint": "The executable to run. Empty uses the command shown as the placeholder.", + "missingCommandWarning": "A custom agent without a command is ignored when the configuration is read.", + "model": "Model", + "modelHint": "Empty uses the active preset's model.", + "permissionMode": "Permission mode", + "permissionModeHint": "Passed to Claude Code as --permission-mode. A delegated run is non-interactive, so it approves edits only: a task that runs shell commands waits for a confirmation nothing can give. Choose bypassPermissions deliberately when the task needs them.", + "riskyPermissionWarning": "bypassPermissions lets the CLI run without asking. Only use it where the task folder can be discarded.", + "sandbox": "Sandbox", + "sandboxHint": "Passed to Codex as --sandbox.", + "riskySandboxWarning": "danger-full-access removes the Codex sandbox. Only use it where the task folder can be discarded.", + "projectDir": "Extra writable directory", + "projectDirHint": "An optional absolute path added to the run. Empty keeps writes inside the task folder.", + "extraArgs": "Extra arguments", + "extraArgsHint": "Passed before the flags AstrBot manages, so a managed flag always wins.", + "addArgument": "Add argument", + "env": "Environment", + "envHint": "Added to the child process only. One NAME=value per entry.", + "addEnv": "Add variable", + "timeoutSeconds": "Timeout (seconds)", + "maxOutputChars": "Output limit (characters)", + "providers": "Provider presets", + "providersHint": "The active preset's endpoint and model take effect as this CLI's own config layer. Its key is passed to the child process for the length of the run rather than written into that layer; the key itself is stored in this AstrBot profile.", + "providersCustomHint": "A custom CLI does not read a generated config layer; give it credentials through Environment instead.", + "noProviders": "No preset. The CLI falls back to the account you already signed in with.", + "activeProvider": "Active preset", + "activeProviderHint": "The preset handed to this CLI.", + "addProvider": "Add preset", + "removeProvider": "Remove preset", + "providerId": "ID", + "providerName": "Name", + "providerBaseUrl": "Base URL", + "providerModel": "Model", + "providerApiKey": "API key", + "providerApiKeyHint": "Passed to the child process for the length of the run. It is stored in this AstrBot profile, and is not written into the CLI's own configuration.", + "providerWireApi": "Wire API", + "providerWireApiHint": "Codex only. \"responses\" is the default; \"chat\" selects the chat completions API." + }, + "btwPage": { + "title": "BTW Dual Loop", + "subtitle": "The conversation loop answers and the work loop carries out longer tasks. The work loop reads and plans: anything that has to write is handed to a coding agent running in a task folder of its own.", + "appliesTo": "Configuration", + "appliesToHint": "Which configuration profile these settings are saved to.", + "loadError": "Could not load the BTW settings.", + "retry": "Retry", + "metadataMissing": "This build does not describe the BTW settings, so there is nothing to edit here.", + "actions": "BTW settings actions", + "save": "Save", + "saveSuccess": "BTW settings saved", + "saveError": "Could not save the BTW settings", + "twoFactorRejected": "That code was not accepted. Enter the current one.", + "unsavedTitle": "Unsaved BTW settings", + "unsavedMessage": "Leaving now discards them. The work loop's boundary is among what would be lost." } } diff --git a/dashboard/src/i18n/locales/zh-CN/core/navigation.json b/dashboard/src/i18n/locales/zh-CN/core/navigation.json index ea698b56b3..f0a95d0b0a 100644 --- a/dashboard/src/i18n/locales/zh-CN/core/navigation.json +++ b/dashboard/src/i18n/locales/zh-CN/core/navigation.json @@ -49,5 +49,6 @@ "selectVersion": "选择版本", "current": "当前" }, - "pluginWebui": "插件页面" + "pluginWebui": "插件页面", + "btw": "BTW 双循环" } diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index 6948b03280..7974368ae8 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -487,6 +487,10 @@ "workspace_root": { "description": "编码代理任务目录根路径", "hint": "每个委派任务在此目录下拥有独立文件夹。留空时使用数据目录下的 btw/workspaces。" + }, + "coding_agents": { + "description": "第三方编码代理", + "hint": "可委派写入任务的本地 CLI 代理(如 Claude Code、Codex)。每项包含 id、type、command、权限模式与 provider 预设;provider 的端点与模型会以该 CLI 原生配置层的形式生效,密钥只在运行时传给子进程,不会写进该配置层。密钥本身与其他 provider 凭据一样保存在本配置档中。委派会启动本地进程并写入文件,因此工作循环的 Computer Use 运行时必须是 local:sandbox 不够,因为进程由本机直接拉起,并不在沙箱内。同时需要 tool.local_exec 与 tool.file_write 的授权。" } }, "work_session": { diff --git a/dashboard/src/i18n/locales/zh-CN/features/config.json b/dashboard/src/i18n/locales/zh-CN/features/config.json index 332efe9e67..9fc87784b3 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config.json @@ -218,5 +218,75 @@ "emptyMcp": "当前没有已启用的 MCP 服务器。", "skillHint": "Skill 默认注入两个循环;工作区 Skill 仍仅在工作循环中可用。 工作区 Skill 仅在本地工作循环可用。", "emptySkill": "当前没有已启用的 Skill。" + }, + "codingAgentsEditor": { + "hint": "每一项声明一个可被工作循环委派写入的本地 CLI 代理。只有已启用、且工作循环的 Computer Use 运行时为 local 时才会使用该代理。权限模式与沙箱由该 CLI 自己执行,不是操作系统级隔离,因此 bypassPermissions 与 danger-full-access 必须显式选择。", + "empty": "暂无编码代理。添加一个后工作循环才能委派写入。", + "newAgent": "新代理", + "addAgent": "添加代理", + "removeAgent": "删除代理", + "moveUp": "上移", + "moveDown": "下移", + "enabled": "已启用", + "disabledWarning": "未启用的代理不会交给工作循环使用。", + "agentId": "ID", + "agentIdHint": "该代理的唯一名称。它会作为生成配置文件的文件名,建议只用字母、数字、点、短横线和下划线。", + "missingIdWarning": "没有 ID 的代理在读取配置时会被忽略。", + "duplicateIdWarning": "已有代理使用该 ID;读取配置时后者会被忽略。", + "agentName": "显示名称", + "agentType": "类型", + "command": "命令", + "commandHint": "要运行的可执行文件。留空时使用占位符中显示的默认命令。", + "missingCommandWarning": "自定义代理缺少命令时,读取配置会被忽略。", + "model": "模型", + "modelHint": "留空时使用当前预设的模型。", + "permissionMode": "权限模式", + "permissionModeHint": "以 --permission-mode 传给 Claude Code。委派是非交互运行,只自动放行编辑:需要跑 shell 命令的任务会一直等一个没人能给的确认。确实需要时请显式选择 bypassPermissions。", + "riskyPermissionWarning": "bypassPermissions 会让该 CLI 不再询问直接执行。仅建议在任务目录可随时丢弃时使用。", + "sandbox": "沙箱", + "sandboxHint": "以 --sandbox 传给 Codex。", + "riskySandboxWarning": "danger-full-access 会取消 Codex 沙箱。仅建议在任务目录可随时丢弃时使用。", + "projectDir": "额外可写目录", + "projectDirHint": "可选,绝对路径,会加入本次运行的可写范围。留空表示只在任务目录内写入。", + "extraArgs": "额外参数", + "extraArgsHint": "排在 AstrBot 托管的参数之前,因此托管参数始终优先。", + "addArgument": "添加参数", + "env": "环境变量", + "envHint": "仅注入子进程。每一条写成 名称=值。", + "addEnv": "添加变量", + "timeoutSeconds": "超时(秒)", + "maxOutputChars": "输出上限(字符)", + "providers": "Provider 预设", + "providersHint": "当前预设的端点与模型会以该 CLI 自己的配置层生效。密钥只在本次运行期间传给子进程,不会写进该配置层;密钥本身保存在本配置档中。", + "providersCustomHint": "自定义 CLI 不会读取生成的配置层,请在环境变量中给它凭据。", + "noProviders": "没有预设。该 CLI 会回退到你已经登录的账号。", + "activeProvider": "当前预设", + "activeProviderHint": "交给该 CLI 使用的预设。", + "addProvider": "添加预设", + "removeProvider": "删除预设", + "providerId": "ID", + "providerName": "名称", + "providerBaseUrl": "Base URL", + "providerModel": "模型", + "providerApiKey": "API Key", + "providerApiKeyHint": "仅在本次运行期间传给子进程。密钥保存在本配置档中,不会写进该 CLI 自己的配置。", + "providerWireApi": "Wire API", + "providerWireApiHint": "仅 Codex 使用。默认 \"responses\",\"chat\" 表示走 chat completions 接口。" + }, + "btwPage": { + "title": "BTW 双循环", + "subtitle": "对话循环负责应答,工作循环负责较长的任务。工作循环只读取与规划:需要写盘的步骤交给在独立任务目录中运行的编码代理。", + "appliesTo": "配置档", + "appliesToHint": "这些设置保存到哪一个配置档。", + "loadError": "无法加载 BTW 设置。", + "retry": "重试", + "metadataMissing": "当前构建没有描述 BTW 设置,这里没有可编辑的内容。", + "actions": "BTW 设置操作", + "save": "保存", + "saveSuccess": "BTW 设置已保存", + "saveError": "BTW 设置保存失败", + "twoFactorRejected": "验证码未被接受,请输入当前验证码。", + "unsavedTitle": "BTW 设置尚未保存", + "unsavedMessage": "现在离开会丢弃这些改动,其中包含工作循环的边界设置。" } } diff --git a/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts b/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts index 0a25ce5a85..378aaa94b3 100644 --- a/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts +++ b/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts @@ -112,6 +112,11 @@ const sidebarItem: menu[] = [ icon: 'mdi-vector-link', to: '/subagent', }, + { + title: 'core.navigation.btw', + icon: 'mdi-swap-horizontal', + to: '/btw', + }, { title: 'core.navigation.data', icon: 'mdi-database', diff --git a/dashboard/src/router/MainRoutes.ts b/dashboard/src/router/MainRoutes.ts index 208dc0e7db..b603f9c7f2 100644 --- a/dashboard/src/router/MainRoutes.ts +++ b/dashboard/src/router/MainRoutes.ts @@ -114,6 +114,11 @@ const MainRoutes = { path: '/subagent', component: () => import('@/views/SubAgentPage.vue'), }, + { + name: 'BtwSettings', + path: '/btw', + component: () => import('@/views/BtwPage.vue'), + }, { name: 'CronJobs', path: '/cron', diff --git a/dashboard/src/views/BtwPage.vue b/dashboard/src/views/BtwPage.vue new file mode 100644 index 0000000000..59ec64d98e --- /dev/null +++ b/dashboard/src/views/BtwPage.vue @@ -0,0 +1,389 @@ + + + + + + diff --git a/dashboard/tests/aiConfigCapabilities.vitest.ts b/dashboard/tests/aiConfigCapabilities.vitest.ts index c035bdfab3..0c4f544a91 100644 --- a/dashboard/tests/aiConfigCapabilities.vitest.ts +++ b/dashboard/tests/aiConfigCapabilities.vitest.ts @@ -57,7 +57,10 @@ function mountAiPanel(metadata: Record) { } describe('AI capabilities panel', () => { - it('lists the BTW dual-loop group after the other capabilities', async () => { + it('lists only the capability groups that stay in this panel', async () => { + // BTW is deliberately absent: it moved to its own page under More Features, + // because the work loop is a separate execution path with its own boundary + // and was easy to lose among the model and agent-runner options here. await initI18n('en-US'); const wrapper = mountAiPanel(buildMetadata(true)); @@ -65,21 +68,6 @@ describe('AI capabilities panel', () => { expect(capabilitiesTab?.text()).toBe('Capabilities'); await capabilitiesTab?.trigger('click'); - expect(wrapper.findAll('.v4-group').map((group) => group.text())).toEqual([ - 'knowledgebase', - 'websearch', - 'agent_computer_use', - 'proactive_capability', - 'btw', - ]); - }); - - it('omits the BTW group when the panel metadata does not carry it', async () => { - await initI18n('en-US'); - const wrapper = mountAiPanel(buildMetadata(false)); - - await wrapper.findAll('.ai-config-tabs__item')[2]?.trigger('click'); - expect(wrapper.findAll('.v4-group').map((group) => group.text())).toEqual([ 'knowledgebase', 'websearch', diff --git a/dashboard/tests/btwPage.vitest.ts b/dashboard/tests/btwPage.vitest.ts new file mode 100644 index 0000000000..3f94ba5356 --- /dev/null +++ b/dashboard/tests/btwPage.vitest.ts @@ -0,0 +1,294 @@ +import { flushPromises } from '@vue/test-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import BtwPage from '@/views/BtwPage.vue'; +import { mountWithVuetify } from './utils/mountWithVuetify'; + +const testState = vi.hoisted(() => ({ + listMock: vi.fn(), + getProfileMock: vi.fn(), + updateProfileMock: vi.fn(), + stepUpMock: vi.fn(), +})); + +vi.mock('@/utils/monacoLoader', () => ({})); + +vi.mock('@guolao/vue-monaco-editor', () => ({ + VueMonacoEditor: { + name: 'VueMonacoEditor', + template: '
', + }, +})); + +vi.mock('@/api/v1', () => ({ + configProfileApi: { + list: testState.listMock, + get: testState.getProfileMock, + update: testState.updateProfileMock, + }, +})); + +/** + * The save/discard/stay prompt, under test control. + * + * It is stubbed rather than driven through the DOM because the real one renders + * inside a teleported dialog, and what matters here is which way the page goes + * for each answer. + */ +const dialogControl = vi.hoisted(() => ({ + answer: false as boolean | 'close', + asked: false, +})); + +vi.mock('@/components/config/UnsavedChangesConfirmDialog.vue', () => ({ + default: { + name: 'UnsavedChangesConfirmDialog', + setup(_props: unknown, { expose }: { expose: (api: object) => void }) { + expose({ + open: () => { + dialogControl.asked = true; + return Promise.resolve(dialogControl.answer); + }, + }); + return () => null; + }, + }, +})); + +vi.mock('@/api/v1/authorization', () => ({ + STEP_UP_TTL_SECONDS: 300, + authorizationApi: { + stepUp: testState.stepUpMock, + webChatStepUp: vi.fn(), + }, +})); + +/** The metadata shape the config endpoints return for the BTW section. */ +const METADATA = { + ai_group: { + metadata: { + btw: { + description: 'BTW 双循环', + type: 'object', + items: { + 'btw.enabled': { + description: 'Enable BTW', + type: 'bool', + hint: 'Experimental.', + }, + }, + }, + }, + }, +}; + +const CONFIG = { btw: { enabled: false } }; + +function mountPage() { + return mountWithVuetify(BtwPage); +} + +describe('BtwPage', () => { + beforeEach(() => { + dialogControl.answer = false; + dialogControl.asked = false; + CONFIG.btw.enabled = false; + testState.listMock.mockReset(); + testState.getProfileMock.mockReset(); + testState.updateProfileMock.mockReset(); + testState.listMock.mockResolvedValue({ + data: { status: 'ok', data: { info_list: [] } }, + }); + testState.getProfileMock.mockResolvedValue({ + data: { status: 'ok', data: { config: CONFIG, metadata: METADATA } }, + }); + testState.updateProfileMock.mockResolvedValue({ + data: { status: 'ok', message: 'saved' }, + }); + }); + + it('loads through the profile endpoint and renders the BTW settings', async () => { + // Not the system-config endpoint: that one serves the system metadata + // tree alone, so the AI section holding `btw` would be missing and the + // page would report nothing to edit. + const wrapper = mountPage(); + await flushPromises(); + + expect(testState.getProfileMock).toHaveBeenCalledWith('default'); + expect(wrapper.text()).toContain('BTW'); + // The group renders through the shared config renderer, so the settings + // themselves are the ones the config file page used to show. + expect(wrapper.find('.config-item-renderer, .v-input').exists()).toBe(true); + wrapper.unmount(); + }); + + it('reports a load failure instead of rendering an empty form', async () => { + testState.getProfileMock.mockRejectedValue(new Error('nope')); + + const wrapper = mountPage(); + await flushPromises(); + + expect(wrapper.text()).toContain('Could not load the BTW settings'); + wrapper.unmount(); + }); + + it('saves the edited value back to the system profile', async () => { + const wrapper = mountPage(); + await flushPromises(); + + await wrapper.find('.btw-page__save').trigger('click'); + await flushPromises(); + + expect(testState.updateProfileMock).toHaveBeenCalledWith( + 'default', + CONFIG, + expect.objectContaining({ headers: {} }), + ); + wrapper.unmount(); + }); + + it('offers the named profiles alongside the default one', async () => { + testState.listMock.mockResolvedValue({ + data: { + status: 'ok', + data: { + info_list: [ + { id: 'default', name: 'Default' }, + { id: 'work', name: 'Work profile' }, + ], + }, + }, + }); + + const wrapper = mountPage(); + await flushPromises(); + + const select = wrapper.findComponent('.btw-page__scope-select'); + expect(select.exists()).toBe(true); + const values = (select.props('items') as { value: string }[]).map( + (item) => item.value, + ); + expect(values).toEqual(['default', 'work']); + wrapper.unmount(); + }); + + it('switches profiles without asking when there is nothing unsaved', async () => { + testState.listMock.mockResolvedValue({ + data: { + status: 'ok', + data: { info_list: [{ id: 'work', name: 'Work' }] }, + }, + }); + + const wrapper = mountPage(); + await flushPromises(); + await switchScope(wrapper, 'work'); + + expect(dialogControl.asked).toBe(false); + expect(testState.getProfileMock).toHaveBeenLastCalledWith('work'); + wrapper.unmount(); + }); + + it('stays on the profile being edited when the prompt is closed', async () => { + testState.listMock.mockResolvedValue({ + data: { + status: 'ok', + data: { info_list: [{ id: 'work', name: 'Work' }] }, + }, + }); + dialogControl.answer = 'close'; + + const wrapper = mountPage(); + await flushPromises(); + CONFIG.btw.enabled = true; + await switchScope(wrapper, 'work'); + + expect(dialogControl.asked).toBe(true); + // The select is bound one way, so closing the prompt leaves it where it + // was rather than needing the page to undo the move v-model would have made. + expect(scopeValue(wrapper)).toBe('default'); + expect(testState.updateProfileMock).not.toHaveBeenCalled(); + expect(testState.getProfileMock).toHaveBeenCalledTimes(1); + wrapper.unmount(); + }); + + it('switches and discards when the prompt is cancelled', async () => { + // The dialog spells this out: its cancel button is "discard and switch". + // The safer outcome, staying, is closing the prompt. + testState.listMock.mockResolvedValue({ + data: { + status: 'ok', + data: { info_list: [{ id: 'work', name: 'Work' }] }, + }, + }); + dialogControl.answer = false; + + const wrapper = mountPage(); + await flushPromises(); + CONFIG.btw.enabled = true; + await switchScope(wrapper, 'work'); + + expect(testState.updateProfileMock).not.toHaveBeenCalled(); + expect(testState.getProfileMock).toHaveBeenLastCalledWith('work'); + wrapper.unmount(); + }); + + it('saves the profile it is leaving before switching away from it', async () => { + testState.listMock.mockResolvedValue({ + data: { + status: 'ok', + data: { info_list: [{ id: 'work', name: 'Work' }] }, + }, + }); + dialogControl.answer = true; + + const wrapper = mountPage(); + await flushPromises(); + CONFIG.btw.enabled = true; + await switchScope(wrapper, 'work'); + + expect(testState.updateProfileMock).toHaveBeenCalledWith( + 'default', + CONFIG, + expect.objectContaining({ headers: {} }), + ); + expect(testState.getProfileMock).toHaveBeenLastCalledWith('work'); + wrapper.unmount(); + }); + + it('stays put when the profile it is leaving cannot be saved', async () => { + testState.listMock.mockResolvedValue({ + data: { + status: 'ok', + data: { info_list: [{ id: 'work', name: 'Work' }] }, + }, + }); + testState.updateProfileMock.mockResolvedValue({ + data: { status: 'error', message: 'no' }, + }); + dialogControl.answer = true; + + const wrapper = mountPage(); + await flushPromises(); + CONFIG.btw.enabled = true; + await switchScope(wrapper, 'work'); + + expect(scopeValue(wrapper)).toBe('default'); + expect(testState.getProfileMock).toHaveBeenCalledTimes(1); + wrapper.unmount(); + }); +}); + +/** Move the profile select the way Vuetify would, without the DOM behind it. */ +async function switchScope( + wrapper: ReturnType, + value: string, +) { + await wrapper + .findComponent('.btw-page__scope-select') + .vm.$emit('update:model-value', value); + await flushPromises(); +} + +function scopeValue(wrapper: ReturnType) { + return wrapper.findComponent('.btw-page__scope-select').props('modelValue'); +} diff --git a/dashboard/tests/codingAgentsEditor.vitest.ts b/dashboard/tests/codingAgentsEditor.vitest.ts new file mode 100644 index 0000000000..1fcee6914a --- /dev/null +++ b/dashboard/tests/codingAgentsEditor.vitest.ts @@ -0,0 +1,482 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import CodingAgentsEditor from '@/components/shared/CodingAgentsEditor.vue'; +import { mountWithVuetify } from './utils/mountWithVuetify'; + +/** The projected shape the editor emits, for one agent with no presets. */ +const claudeAgent = { + id: 'claude_code', + name: 'Claude Code', + type: 'claude_code', + enabled: true, + command: 'claude', + model: '', + permission_mode: 'acceptEdits', + sandbox: 'workspace-write', + project_dir: '', + extra_args: [], + env: {}, + timeout_seconds: 1800, + max_output_chars: 20000, + active_provider: 'official', + providers: [ + { + id: 'official', + name: 'official', + base_url: '', + api_key: '', + model: '', + wire_api: 'responses', + }, + ], +}; + +function mountEditor(modelValue: unknown) { + return mountWithVuetify(CodingAgentsEditor, { + props: { modelValue }, + }); +} + +/** The component behind one classed control, so its value can be driven. */ +function control(wrapper: ReturnType, selector: string) { + const found = wrapper.findComponent(selector); + expect(found.exists(), `${selector} should be rendered`).toBe(true); + return found; +} + +function lastEmitted(wrapper: ReturnType) { + const emitted = wrapper.emitted('update:modelValue'); + expect(emitted).toBeTruthy(); + const value = emitted!.at(-1)![0] as Record[]; + return value; +} + +describe('CodingAgentsEditor', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('allows class attrs without fragment warnings', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const wrapper = mountWithVuetify(CodingAgentsEditor, { + attrs: { class: 'config-field' }, + props: { modelValue: [claudeAgent] }, + }); + + expect(wrapper.classes()).toContain('config-field'); + expect( + warnSpy.mock.calls.some((args) => + String(args[0]).includes('Extraneous non-props attributes'), + ), + ).toBe(false); + wrapper.unmount(); + }); + + it.each([[null], ['oops'], [undefined], [[null, 7]]])( + 'renders the empty state for the malformed value %s', + (modelValue) => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const wrapper = mountEditor(modelValue); + + expect(wrapper.find('.coding-agents-editor__empty').exists()).toBe(true); + expect(wrapper.find('.coding-agents-editor__agent').exists()).toBe(false); + expect(errorSpy).not.toHaveBeenCalled(); + expect( + warnSpy.mock.calls.some((args) => + String(args[0]).includes('Extraneous non-props attributes'), + ), + ).toBe(false); + wrapper.unmount(); + }, + ); + + it('shows the fields each agent type actually uses', () => { + const wrapper = mountEditor([ + { ...claudeAgent }, + { ...claudeAgent, id: 'codex', type: 'codex' }, + { ...claudeAgent, id: 'other', type: 'custom', command: 'other-cli' }, + ]); + + const agents = wrapper.findAll('.coding-agents-editor__agent'); + expect(agents).toHaveLength(3); + + expect( + agents[0].find('.coding-agents-editor__permission-mode').exists(), + ).toBe(true); + expect(agents[0].find('.coding-agents-editor__sandbox').exists()).toBe( + false, + ); + expect( + agents[1].find('.coding-agents-editor__permission-mode').exists(), + ).toBe(false); + expect(agents[1].find('.coding-agents-editor__sandbox').exists()).toBe( + true, + ); + expect( + agents[2].find('.coding-agents-editor__permission-mode').exists(), + ).toBe(false); + expect(agents[2].find('.coding-agents-editor__sandbox').exists()).toBe( + false, + ); + + // The wire API is a Codex field: only `_codex_profile` writes it. + expect( + agents[0].find('.coding-agents-editor__provider-wire-api').exists(), + ).toBe(false); + expect( + agents[1].find('.coding-agents-editor__provider-wire-api').exists(), + ).toBe(true); + expect( + agents[2].find('.coding-agents-editor__provider-wire-api').exists(), + ).toBe(false); + + for (const agent of agents) { + expect(agent.find('.coding-agents-editor__command').exists()).toBe(true); + expect(agent.find('.coding-agents-editor__extra-args').exists()).toBe( + true, + ); + expect(agent.find('.coding-agents-editor__env').exists()).toBe(true); + expect(agent.find('.coding-agents-editor__providers').exists()).toBe( + true, + ); + } + wrapper.unmount(); + }); + + it('emits the whole normalized list once, and only after an edit', async () => { + const wrapper = mountEditor([claudeAgent]); + expect(wrapper.emitted('update:modelValue')).toBeFalsy(); + + control(wrapper, '.coding-agents-editor__name').vm.$emit( + 'update:modelValue', + 'My Claude', + ); + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('update:modelValue')).toHaveLength(1); + expect(lastEmitted(wrapper)).toEqual([ + { ...claudeAgent, name: 'My Claude' }, + ]); + wrapper.unmount(); + }); + + it('adds an agent with a unique id, a default command and disabled', async () => { + const wrapper = mountEditor([claudeAgent]); + + await wrapper.find('.coding-agents-editor__add-agent').trigger('click'); + + const emitted = lastEmitted(wrapper); + expect(emitted).toHaveLength(2); + expect(emitted[1]).toEqual({ + id: 'agent', + name: '', + type: 'claude_code', + enabled: false, + command: 'claude', + model: '', + permission_mode: 'acceptEdits', + sandbox: 'workspace-write', + project_dir: '', + extra_args: [], + env: {}, + timeout_seconds: 1800, + max_output_chars: 20000, + active_provider: '', + providers: [], + }); + wrapper.unmount(); + }); + + it('does not reuse an id that is already taken', async () => { + const wrapper = mountEditor([{ ...claudeAgent, id: 'agent' }]); + + await wrapper.find('.coding-agents-editor__add-agent').trigger('click'); + + expect(lastEmitted(wrapper)[1]).toMatchObject({ id: 'agent-2' }); + wrapper.unmount(); + }); + + it('removes and reorders agents, and bounds the move buttons', async () => { + const second = { ...claudeAgent, id: 'second', name: 'Second' }; + const wrapper = mountEditor([claudeAgent, second]); + + const cards = wrapper.findAll('.coding-agents-editor__agent'); + expect( + cards[0] + .find('.coding-agents-editor__move-up') + .classes() + .some((name) => name.includes('disabled')), + ).toBe(true); + expect( + cards[1] + .find('.coding-agents-editor__move-down') + .classes() + .some((name) => name.includes('disabled')), + ).toBe(true); + + await cards[0].find('.coding-agents-editor__move-down').trigger('click'); + const reordered = lastEmitted(wrapper); + expect(reordered.map((entry) => entry.id)).toEqual([ + 'second', + 'claude_code', + ]); + + await wrapper.setProps({ modelValue: reordered }); + await wrapper + .findAll('.coding-agents-editor__agent')[0] + .find('.coding-agents-editor__remove-agent') + .trigger('click'); + expect(lastEmitted(wrapper).map((entry) => entry.id)).toEqual([ + 'claude_code', + ]); + wrapper.unmount(); + }); + + it('activates a new preset only when none was active', async () => { + const unset = { ...claudeAgent, active_provider: '', providers: [] }; + const wrapper = mountEditor([unset]); + + await wrapper.find('.coding-agents-editor__add-provider').trigger('click'); + + const added = lastEmitted(wrapper)[0]; + expect(added.providers).toEqual([ + { + id: 'provider', + name: 'provider', + base_url: '', + api_key: '', + model: '', + wire_api: 'responses', + }, + ]); + expect(added.active_provider).toBe('provider'); + wrapper.unmount(); + + const configured = mountEditor([{ ...claudeAgent }]); + await configured + .find('.coding-agents-editor__add-provider') + .trigger('click'); + expect(lastEmitted(configured)[0].active_provider).toBe('official'); + configured.unmount(); + }); + + it('repoints the active preset when the active one is removed', async () => { + const wrapper = mountEditor([ + { + ...claudeAgent, + active_provider: 'official', + providers: [...claudeAgent.providers, { id: 'gw', name: 'gw' }], + }, + ]); + + await wrapper + .find('.coding-agents-editor__remove-provider') + .trigger('click'); + + const entry = lastEmitted(wrapper)[0]; + expect((entry.providers as { id: string }[]).map((p) => p.id)).toEqual([ + 'gw', + ]); + expect(entry.active_provider).toBe('gw'); + wrapper.unmount(); + }); + + it('keeps a renamed preset active instead of falling back to the first', async () => { + const wrapper = mountEditor([ + { + ...claudeAgent, + // Active, and not first: renaming it must not repoint to `official`. + active_provider: 'gw', + providers: [ + { + id: 'gw', + name: 'gw', + base_url: '', + api_key: '', + model: '', + wire_api: 'responses', + }, + { + id: 'official', + name: 'official', + base_url: '', + api_key: '', + model: '', + wire_api: 'responses', + }, + ], + }, + ]); + + control(wrapper, '.coding-agents-editor__provider-id').vm.$emit( + 'update:modelValue', + 'gateway', + ); + await wrapper.vm.$nextTick(); + + const entry = lastEmitted(wrapper)[0]; + expect(entry.active_provider).toBe('gateway'); + wrapper.unmount(); + }); + + it('round-trips extra arguments as a list', async () => { + const wrapper = mountEditor([ + { ...claudeAgent, extra_args: ['--verbose'] }, + ]); + + const list = wrapper + .find('.coding-agents-editor__extra-args') + .findComponent({ name: 'ListConfigItem' }); + expect(list.exists()).toBe(true); + list.vm.$emit('update:modelValue', ['--verbose', '--debug']); + await wrapper.vm.$nextTick(); + + expect(lastEmitted(wrapper)[0].extra_args).toEqual([ + '--verbose', + '--debug', + ]); + wrapper.unmount(); + }); + + it('round-trips environment variables as NAME=value lines', async () => { + const wrapper = mountEditor([ + { ...claudeAgent, env: { ANTHROPIC_MODEL: 'opus' } }, + ]); + + const list = wrapper + .find('.coding-agents-editor__env') + .findComponent({ name: 'ListConfigItem' }); + expect(list.exists()).toBe(true); + expect(list.props('modelValue')).toEqual(['ANTHROPIC_MODEL=opus']); + + list.vm.$emit('update:modelValue', [ + 'A=1', + 'B=2=3', + 'no-equals', + '=orphan', + ]); + await wrapper.vm.$nextTick(); + + // A value keeps its own `=`; a line with no name names nothing. + expect(lastEmitted(wrapper)[0].env).toEqual({ + A: '1', + B: '2=3', + 'no-equals': '', + }); + wrapper.unmount(); + }); + + it('projects a malformed entry the way the backend would read it', async () => { + const wrapper = mountEditor([ + { + id: 42, + type: 'nope', + enabled: 'yes', + permission_mode: 'bogus', + sandbox: null, + extra_args: 'oops', + env: ['oops'], + timeout_seconds: 'x', + max_output_chars: 5, + providers: [null, { id: '' }, { id: 'p' }], + }, + ]); + + control(wrapper, '.coding-agents-editor__type').vm.$emit( + 'update:modelValue', + 'codex', + ); + await wrapper.vm.$nextTick(); + + expect(lastEmitted(wrapper)).toEqual([ + { + id: '', + name: '', + type: 'codex', + enabled: false, + command: 'codex', + model: '', + permission_mode: 'acceptEdits', + sandbox: 'workspace-write', + project_dir: '', + extra_args: [], + env: {}, + timeout_seconds: 1800, + max_output_chars: 20000, + active_provider: 'p', + providers: [ + { + id: 'p', + name: 'p', + base_url: '', + api_key: '', + model: '', + wire_api: 'responses', + }, + ], + }, + ]); + wrapper.unmount(); + }); + + it('marks an unusable entry without dropping it', async () => { + const wrapper = mountEditor([ + { id: '', type: 'custom', command: '', enabled: false }, + ]); + + expect(wrapper.find('.coding-agents-editor__agent').exists()).toBe(true); + const warnings = wrapper.find('.coding-agents-editor__warning').text(); + expect(warnings).toContain('ignored when the configuration is read'); + expect(warnings.length).toBeGreaterThan(0); + expect(wrapper.emitted('update:modelValue')).toBeFalsy(); + wrapper.unmount(); + }); + + it('warns about the permission choices that leave the boundary open', () => { + const wrapper = mountEditor([ + { ...claudeAgent, permission_mode: 'bypassPermissions' }, + { + ...claudeAgent, + id: 'codex', + type: 'codex', + sandbox: 'danger-full-access', + }, + ]); + + const cards = wrapper.findAll('.coding-agents-editor__agent'); + expect(cards[0].find('.coding-agents-editor__warning').text()).toContain( + 'bypassPermissions', + ); + expect(cards[1].find('.coding-agents-editor__warning').text()).toContain( + 'danger-full-access', + ); + wrapper.unmount(); + }); + + it('follows the type default command without overwriting a typed one', async () => { + const wrapper = mountEditor([{ ...claudeAgent }]); + const type = control(wrapper, '.coding-agents-editor__type'); + + type.vm.$emit('update:modelValue', 'codex'); + await wrapper.vm.$nextTick(); + expect(lastEmitted(wrapper)[0]).toMatchObject({ + type: 'codex', + command: 'codex', + }); + wrapper.unmount(); + + const typed = mountEditor([{ ...claudeAgent, command: 'my-claude' }]); + control(typed, '.coding-agents-editor__type').vm.$emit( + 'update:modelValue', + 'codex', + ); + await typed.vm.$nextTick(); + expect(lastEmitted(typed)[0]).toMatchObject({ + type: 'codex', + command: 'my-claude', + }); + typed.unmount(); + }); +}); diff --git a/dashboard/tests/configItemRendererLoopRoutes.vitest.ts b/dashboard/tests/configItemRendererLoopRoutes.vitest.ts index e95d3f62e8..20f6070e33 100644 --- a/dashboard/tests/configItemRendererLoopRoutes.vitest.ts +++ b/dashboard/tests/configItemRendererLoopRoutes.vitest.ts @@ -26,6 +26,9 @@ function mountRenderer(special: string) { props: ['kind'], template: '
{{ kind }}
', }, + CodingAgentsEditor: { + template: '
', + }, ListConfigItem: { template: '
', }, @@ -53,4 +56,13 @@ describe('ConfigItemRenderer loop-route specials', () => { expect(skill.find('.list-config-stub').exists()).toBe(false); skill.unmount(); }); + + it('renders CodingAgentsEditor for a coding-agent profile', () => { + // The regression guard: a `_special` with no branch falls through to the + // string-list editor, which cannot hold the objects this value is made of. + const wrapper = mountRenderer('select_coding_agents'); + expect(wrapper.find('.coding-agents-stub').exists()).toBe(true); + expect(wrapper.find('.list-config-stub').exists()).toBe(false); + wrapper.unmount(); + }); }); diff --git a/docs/en/dev/astrbot-config.md b/docs/en/dev/astrbot-config.md index f77f7c07cd..93c1a9957b 100644 --- a/docs/en/dev/astrbot-config.md +++ b/docs/en/dev/astrbot-config.md @@ -209,7 +209,7 @@ The conversation loop can read the work loop's history: its request context carr ## BTW plugin tool assignments -When BTW is enabled in a configuration profile, **Config → AI → Capabilities → BTW dual loops → Plugin tool loop assignments** assigns each enabled non-system plugin's LLM tools to conversation, work, or both loops. An unassigned plugin defaults to work. Selecting both saves an explicit override; selecting work again removes it. Disabling BTW preserves normal tool availability. +When BTW is enabled in a configuration profile, **More Features → BTW Dual Loop → Plugin tool loop assignments** assigns each enabled non-system plugin's LLM tools to conversation, work, or both loops. An unassigned plugin defaults to work. Selecting both saves an explicit override; selecting work again removes it. Disabling BTW preserves normal tool availability. The main Agent and its subagent handoffs apply the same assignment, together with existing Persona, profile, and authorization restrictions. An assignment never grants permission to execute a tool. Plugin event handlers and explicit commands keep their existing execution path; this setting does not turn an entire plugin into a background task. diff --git a/docs/en/use/webui.md b/docs/en/use/webui.md index cdffdcf892..f65ababc04 100644 --- a/docs/en/use/webui.md +++ b/docs/en/use/webui.md @@ -8,20 +8,23 @@ The AstrBot admin panel features plugin management, log viewing, visual configur These entries match the default WebUI sidebar. If you customized the sidebar, check or restore the default layout under `Settings → Appearance` in the lower-left corner. -| Previous entry point | Current entry point | -| --------------------------------------- | ----------------------------------------------------------------------------------- | -| Providers → Add Provider → Agent Runner | Config → Select a profile → AI → `…` next to the AI heading → Change execution mode | -| Data / Dashboard | More → Data → Statistics | -| Conversation Management / Conversations | More → Data → Conversations | -| Logs / Console | More → Data → Logs | -| Trace | More → Data → Trace | -| Config → Normal Config | Config → Select a profile | -| Config → System Config | Settings → General, Appearance, Network, or Security | -| Command Management | Plugins → Manage behavior → Commands | -| Standalone MCP / Skills entries | Plugins → MCP / Skills | +| Previous entry point | Current entry point | +| ------------------------------------------- | ----------------------------------------------------------------------------------- | +| Providers → Add Provider → Agent Runner | Config → Select a profile → AI → `…` next to the AI heading → Change execution mode | +| Data / Dashboard | More → Data → Statistics | +| Conversation Management / Conversations | More → Data → Conversations | +| Logs / Console | More → Data → Logs | +| Trace | More → Data → Trace | +| Config → Normal Config | Config → Select a profile | +| Config → System Config | Settings → General, Appearance, Network, or Security | +| Command Management | Plugins → Manage behavior → Commands | +| Standalone MCP / Skills entries | Plugins → MCP / Skills | +| Config → AI → Capabilities → BTW dual loops | More Features → BTW Dual Loop (`/btw`) | Legacy log, trace, conversation, and statistics URLs still redirect to the matching tabs. Agent runners are now saved with each profile instead of being created as a provider; see [Agent Runner](./agent-runner.md). +The BTW dual-loop settings moved out of the AI panel on the configuration page to **More Features → BTW Dual Loop** (`/btw`). The page loads and saves whichever profile it is pointed at, and holds what it held before: both loops and their models, the work loop's read-only boundary, the Computer Use runtime, the task folder root and result reporting, and the coding-agent list. + ## Accessing the Admin Panel After starting AstrBot, the local machine can open the admin panel at `http://localhost:6185`. diff --git a/docs/zh/dev/astrbot-config.md b/docs/zh/dev/astrbot-config.md index 3e57a0573b..e69d752d46 100644 --- a/docs/zh/dev/astrbot-config.md +++ b/docs/zh/dev/astrbot-config.md @@ -211,7 +211,7 @@ API Key 属于敏感配置。不要把真实 `cmd_config.json`、截图、日志 ## BTW 插件工具循环分配 -在配置档中启用 BTW 后,可通过 **配置文件 → AI 配置 → 能力 → BTW 双循环 → 插件工具循环分配** 为每个已启用的非系统插件选择对话循环、工作循环或两者。未分配的插件默认仅工作循环可用;选择两者会保存显式覆盖,重新选择工作循环会移除覆盖。关闭 BTW 后保留普通工具可用性。 +在配置档中启用 BTW 后,可通过 **更多功能 → BTW 双循环 → 插件工具循环分配** 为每个已启用的非系统插件选择对话循环、工作循环或两者。未分配的插件默认仅工作循环可用;选择两者会保存显式覆盖,重新选择工作循环会移除覆盖。关闭 BTW 后保留普通工具可用性。 主 Agent 与其子 Agent handoff 应用相同分配,并继续遵守 Persona、配置档与授权限制。循环分配不会授予工具执行权限。插件事件处理器和显式命令保留原有执行路径;此设置不会把整个插件转换为后台任务。 diff --git a/docs/zh/use/webui.md b/docs/zh/use/webui.md index 86e9a29a65..ed3abe1099 100644 --- a/docs/zh/use/webui.md +++ b/docs/zh/use/webui.md @@ -19,9 +19,12 @@ AstrBot 管理面板具有管理插件、查看日志、可视化配置、查看 | 配置文件 → 系统配置 | 左下角设置 → 常规、外观、网络或安全 | | 指令管理 | 插件 → 管理行为 → 指令 | | 独立 MCP / 技能入口 | 插件 → MCP / 技能 | +| 配置文件 → AI 配置 → 能力 → BTW 双循环 | 更多功能 → BTW 双循环(`/btw`) | 旧日志、追踪、对话和统计页面地址仍会跳转到对应的新标签页。Agent 执行器现在随配置文件保存,不再通过新增模型提供商创建;操作步骤见 [Agent 执行器](./agent-runner.md)。 +BTW 双循环的设置从配置文件页的 AI 面板移到了 `更多功能 → BTW 双循环`(`/btw`)。页面按配置文件加载和保存,内容不变:两个循环的开关与模型、工作循环的只读边界、Computer Use 运行时、委派任务的目录与结果汇报,以及编码代理列表。 + ## 管理面板的访问 当启动 AstrBot 之后,本机可以通过 `http://localhost:6185` 访问管理面板。 diff --git a/tests/unit/test_dashboard_config_service.py b/tests/unit/test_dashboard_config_service.py index 1e61c14de3..031a031890 100644 --- a/tests/unit/test_dashboard_config_service.py +++ b/tests/unit/test_dashboard_config_service.py @@ -121,6 +121,132 @@ def test_sensitive_config_changed_ignores_redacted_placeholders() -> None: ) +def test_restore_matches_list_entries_by_id_not_position() -> None: + current = { + "coding_agents": [ + {"id": "a", "providers": [{"id": "p1", "api_key": "key-a"}]}, + {"id": "b", "providers": [{"id": "p2", "api_key": "key-b"}]}, + ] + } + posted = copy.deepcopy(current) + posted["coding_agents"].reverse() + for agent in posted["coding_agents"]: + for provider in agent["providers"]: + provider["api_key"] = config_service.REDACTED_SECRET_PLACEHOLDER + + restored = config_service._restore_redacted_sensitive_config(posted, current) + + moved = restored["coding_agents"] + assert [agent["id"] for agent in moved] == ["b", "a"] + assert [agent["providers"][0]["api_key"] for agent in moved] == [ + "key-b", + "key-a", + ] + + +def test_sensitive_config_changed_matches_list_entries_by_id() -> None: + current = { + "providers": [ + {"id": "a", "api_key": "key-a"}, + {"id": "b", "api_key": "key-b"}, + ] + } + posted = { + "providers": [ + {"id": "b", "api_key": config_service.REDACTED_SECRET_PLACEHOLDER}, + {"id": "a", "api_key": config_service.REDACTED_SECRET_PLACEHOLDER}, + ] + } + + assert not config_service.sensitive_config_changed( + current, posted, missing_is_change=False + ) + + posted["providers"][0]["api_key"] = "replacement" + assert config_service.sensitive_config_changed( + current, posted, missing_is_change=False + ) + + +def test_restore_blanks_a_placeholder_with_nothing_to_restore_from() -> None: + current = {"providers": [{"id": "p1", "api_key": "key-a"}]} + posted = { + "providers": [ + {"id": "p1", "api_key": config_service.REDACTED_SECRET_PLACEHOLDER}, + {"id": "p1-copy", "api_key": config_service.REDACTED_SECRET_PLACEHOLDER}, + ] + } + + restored = config_service._restore_redacted_sensitive_config(posted, current) + + assert restored["providers"][0]["api_key"] == "key-a" + assert restored["providers"][1]["api_key"] == "" + + +def test_restore_follows_an_entry_the_client_renamed() -> None: + current = {"providers": [{"id": "p1", "api_key": "key-a"}]} + posted = { + "providers": [ + {"id": "p1-renamed", "api_key": config_service.REDACTED_SECRET_PLACEHOLDER} + ] + } + + restored = config_service._restore_redacted_sensitive_config(posted, current) + + assert restored["providers"][0]["api_key"] == "key-a" + + +def test_restore_never_hands_one_stored_key_to_two_entries() -> None: + current = { + "providers": [ + {"id": "a", "api_key": "key-a"}, + {"id": "b", "api_key": "key-b"}, + ] + } + posted = { + "providers": [ + {"id": "b", "api_key": config_service.REDACTED_SECRET_PLACEHOLDER}, + {"id": "new", "api_key": config_service.REDACTED_SECRET_PLACEHOLDER}, + ] + } + + restored = config_service._restore_redacted_sensitive_config(posted, current) + + assert [item["api_key"] for item in restored["providers"]] == ["key-b", ""] + + +def test_restore_keeps_position_when_ids_do_not_name_the_entries() -> None: + current = {"items": [{"name": "x", "api_key": "key-a"}]} + posted = { + "items": [{"name": "x", "api_key": config_service.REDACTED_SECRET_PLACEHOLDER}] + } + + restored = config_service._restore_redacted_sensitive_config(posted, current) + + assert restored["items"][0]["api_key"] == "key-a" + + repeated = { + "items": [ + {"id": "same", "api_key": "key-a"}, + {"id": "same", "api_key": "key-b"}, + ] + } + repeated_posted = { + "items": [ + {"id": "same", "api_key": config_service.REDACTED_SECRET_PLACEHOLDER}, + {"id": "same", "api_key": config_service.REDACTED_SECRET_PLACEHOLDER}, + ] + } + restored_repeated = config_service._restore_redacted_sensitive_config( + repeated_posted, repeated + ) + + assert [item["api_key"] for item in restored_repeated["items"]] == [ + "key-a", + "key-b", + ] + + def test_profile_and_system_config_responses_redact_secrets() -> None: current = { "dashboard": {"jwt_secret": "jwt-secret"},