feat: add verified manual pipeline migration for plugin runners

This commit is contained in:
RockChinQ
2026-09-16 18:13:11 +00:00
parent 273b1ea3cf
commit 8c119bc4b6
97 changed files with 16662 additions and 123 deletions
+504
View File
@@ -0,0 +1,504 @@
# 旧流水线迁移:配置去向与语义变更
本文对应迁移规划器 v3。原生行为基线:`9b7ba0d64708496ace30a82866f6dbc185f089dc`。
## 迁移边界
- 迁移只由用户在 UI 选择流水线并确认后执行。打开页面、预览、刷新、安装插件都不触发自动迁移。
- 流水线 ID、名称、Bot 绑定以及非 AI 配置保持不变。插件凭证、应用 ID、端点和模型配置仍属于本流水线,不迁入共享插件全局设置。
- 活动 AI 配置规范化为 `ai.runner` 与 `ai.runner_config`,避免编辑器把迁移结果判为混合旧配置。所有原始 AI 段(包括未选中的 Runner 配置)与完整原始配置一起保存到同一事务的持久化备份;预览会列出被归档的字段。
- 备份不是公开下载,也不意味着已有一键恢复 UI。未知提交结果不显示为已回滚;激活待重试只在新预览、重新选择并确认后执行,且不重复转换配置。
- “配置迁移”不等于“运行状态无损迁移”。原有远端会话、历史、Box 文件、待处理表单令牌不自动导入;UI 必须在确认前说明新会话/状态重置。自定义 Box 共享作用域与未结束的交互不能被静默清除。
## 功能与默认值的取舍
- LocalAgent 保留日期提示、结构化提示词、模型回退、模型专属推理配置、工具/知识库/沙箱能力;保留现代上下文预算、检查点和摘要机制,不恢复旧的 `max-round` 截断算法。
- `max-round` 退役,不把“10 轮”伪装成“50 条消息”等价换算;新上下文参数使用下面列明的默认值。工具执行仍显式采用 `serial`,不偷偷改变有副作用工具的执行顺序。
- Dify 旧保存项 `timeout` 并非原生 Runner 实际读取的超时控制。迁移采用新插件 30 秒默认值,并提示用户;旧任意数值只保存在备份中。
- Dify/Coze/N8n 迁移显式选择 `user-id-source=legacy-session`,Tbox 选择 `legacy-bot`;新建插件配置仍默认 `sender`。旧身份来自 Host 的可信会话/事件/机器人上下文,缺失时失败,不猜测为发送者。
- Dify/N8n 的旧身份使用原 `query.session`,Coze 使用原 `query`;不能在群聊、不同会话策略或恢复交互时把这两者混同。
- Coze、Langflow 等现代持久化远端会话机制保留;旧远端会话 ID 不跨账户、端点、Workspace 或 Runner 导入。
- DashScope 引用字段别名、Langflow 输入输出字段别名、Coze 历史开关别名分别归一化;冲突值阻断,而非靠字段出现顺序决定。
- N8n 的 ignore 响应模式、认证和保留身份字段要维持明确语义。普通业务变量保留支持的 JSON 值,内部/权限/敏感变量不无条件转发。
- `remove-think` 保留原输出策略,并与支持该字段的 Runner 参数同步;仅用户实际修改才传播,不把表单挂载默认值视为修改。
- 空字符串、缺失和显式 null 区分处理。WeKnora 的 agent-id 不得在打开/保存其他字段时被默认值替换;未知字段/类型不能靠 truthiness 静默接受。
- 工具、KB、MCP 的授权由 Host 校验;表单未显示的授权字段仍必须保留,不能因保存而删除或扩大权限。
## 插件版本要求
以下是本次本地源码的目标版本;本表不代表插件已经发布。旧版本即使名称相同,也不能作为新迁移能力的证明。
- `local-agent` → `LocalAgent` **0.1.6**。
- `dify-service-api` → `DifyAgent` **0.1.7**。
- `coze-api` → `CozeAgent` **0.1.7**。
- `dashscope-app-api` → `DashScopeAgent` **0.1.7**。
- `n8n-service-api` → `N8nAgent` **0.1.7**。
- `langflow-api` → `LangflowAgent` **0.1.7**。
- `deerflow-api` → `DeerFlowAgent` **0.1.7**。
- `tbox-app-api` → `TboxAgent` **0.1.5**。
- `weknora-api` → `WeKnoraAgent` **0.1.7**。
## 逐字段完整清单
共 9 类 Runner、104 条字段条目;按消费者展开共享字段。未知部署自定义键不冒充已穷举字段,遇到时阻断并保留原值。
目标路径缩写 `R` 表示 `config.ai.runner_config[当前插件 Runner ID]`;`extensions_preferences` 是流水线的 Host 扩展策略,不是插件全局配置。
### LocalAgent(28 项)
- **`ai.local-agent.box-session-id-template`** → `Host execution_context.build_host_box_scope + Box service`。
- 缺失、空值或原生默认 {launcher_type}_{launcher_id} 不写入新 Runner;用户确认明确的 Box 状态重置警告后创建新隔离会话。任何其他自定义共享模板仍阻断,不自动复制文件或跨作用域授权。
- 所有权:`host_tenancy_and_existing_file_state`;原生依据:`src/langbot/pkg/box/service.py:641`。
- **`ai.local-agent.enable-all-tools`** → `R.enable-all-tools`。
- 原样复制有效授权;mcp 字段缺失时取 extensions_preferences 对应值;显式 null/错误类型不回退扩大权限。
- 所有权:`host_policy_in_pipeline_runner_config`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:34`。
- **`ai.local-agent.knowledge-base`** → `R.knowledge-bases`。
- 有效非空 plural 列表优先;plural 缺失/有效空列表且 singular 非空且非__none__→[原UUID];非法容器/null单独校验,不以truthiness宽容;其他为空→[]。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:280`。
- **`ai.local-agent.knowledge-bases`** → `R.knowledge-bases`。
- 有效非空 plural 列表优先;plural 缺失/有效空列表且 singular 非空且非__none__→[原UUID];非法容器/null单独校验,不以truthiness宽容;其他为空→[]。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:278`。
- **`ai.local-agent.max-round`** → `退役;不生成同名活动参数`。
- 活动目标省略 max-round;保留回滚备份和弃用通知;不把10轮映射成50条。
- 所有权:`obsolete_native_context_control`;原生依据:`src/langbot/pkg/pipeline/msgtrun/truncators/round.py:13`。
- **`ai.local-agent.mcp-resource-agent-read-enabled`** → `R.mcp-resource-agent-read-enabled`。
- 原样复制有效授权;mcp 字段缺失时取 extensions_preferences 对应值;显式 null/错误类型不回退扩大权限。
- 所有权:`host_policy_in_pipeline_runner_config`;原生依据:`src/langbot/pkg/pipeline/pipelinemgr.py:133`。
- **`ai.local-agent.mcp-resources`** → `R.mcp-resources`。
- 原样复制有效授权;mcp 字段缺失时取 extensions_preferences 对应值;显式 null/错误类型不回退扩大权限。
- 所有权:`host_policy_in_pipeline_runner_config`;原生依据:`src/langbot/pkg/pipeline/pipelinemgr.py:129`。
- **`ai.local-agent.model`** → `R.model`。
- 旧字符串UUID→{primary:原UUID,fallbacks:[],reasoning:{}};对象深拷贝 primary/fallbacks/reasoning;不替换失效UUID、不扩大授权。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/localagent.py:283`。
- **`ai.local-agent.model.fallbacks`** → `R.model.fallbacks`。
- 保留有序UUID列表,缺失=[];runtime 去重不是迁移重排。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:93`。
- **`ai.local-agent.model.primary`** → `R.model.primary`。
- 逐字复制非空UUID;Host 所有权交集校验,缺失不得选无关模型。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:92`。
- **`ai.local-agent.model.reasoning`** → `R.model.reasoning`。
- 复制 UUID→level 映射,缺失={};缺项保留模型持久默认;显式 provider_default 覆盖持久级别。
- 所有权:`pipeline_selector_with_host_provider_policy`;原生依据:`src/langbot/pkg/provider/runners/localagent.py:284`。
- **`ai.local-agent.prompt`** → `R.prompt`。
- 保留有序消息、合法SDK结构化内容、空消息、name/provider metadata;缺失不拿UI短提示词覆盖seed长提示词;空Host提示列表有权威性。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:122`。
- **`ai.local-agent.prompt[0].content`** → `R.prompt[0].content`。
- 保留所有prompt记录的content,这里[0]仅默认记录形状;不把seed较长content替换为UI短默认,合法空content保留。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/templates/metadata/pipeline/ai.yaml:121`。
- **`ai.local-agent.prompt[0].role`** → `R.prompt[0].role`。
- 保留所有prompt记录的role,这里[0]仅默认记录形状;不把seed较长content替换为UI短默认,合法空content保留。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/templates/metadata/pipeline/ai.yaml:120`。
- **`ai.local-agent.rerank-model`** → `R.rerank-model`。
- 复制UUID;空字符串或__none__代表禁用;Host验证 rerank 授权。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/localagent.py:442`。
- **`ai.local-agent.rerank-top-k`** → `R.rerank-top-k`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/localagent.py:456`。
- **`ai.local-agent.tools`** → `R.tools`。
- 原样复制有效授权;mcp 字段缺失时取 extensions_preferences 对应值;显式 null/错误类型不回退扩大权限。
- 所有权:`host_policy_in_pipeline_runner_config`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:37`。
- **`ai.runner.expire-time`** → `config.ai.runner.expire-time`。
- 原样保留非负整数;缺失=0,禁止用 token budget 替换。
- 所有权:`pipeline_host_lifecycle`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:131`。
- **`ai.runner.runner`** → `config.ai.runner.id`。
- 所有权:`pipeline_selector`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:73`。
- **`extensions_preferences.enable_all_mcp_servers`** → `extensions_preferences.enable_all_mcp_servers`。
- 按原存在性和值保留;显式 false/空名单=不授权;无字段的旧有效缺省只有在同一所有权范围内可保留。
- 所有权:`host_resource_authorization_shared_all_nine`;原生依据:`src/langbot/pkg/pipeline/pipelinemgr.py:127`。
- **`extensions_preferences.enable_all_plugins`** → `extensions_preferences.enable_all_plugins`。
- 按原存在性和值保留;显式 false/空名单=不授权;无字段的旧有效缺省只有在同一所有权范围内可保留。
- 所有权:`host_resource_authorization_shared_all_nine`;原生依据:`src/langbot/pkg/pipeline/pipelinemgr.py:126`。
- **`extensions_preferences.enable_all_skills`** → `extensions_preferences.enable_all_skills`。
- 按原存在性和值保留;显式 false/空名单=不授权;无字段的旧有效缺省只有在同一所有权范围内可保留。
- 所有权:`host_resource_authorization_shared_all_nine`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:339`。
- **`extensions_preferences.mcp_resource_agent_read_enabled`** → `extensions_preferences.mcp_resource_agent_read_enabled`。
- 按原存在性和值保留;显式 false/空名单=不授权;无字段的旧有效缺省只有在同一所有权范围内可保留。
- 所有权:`host_resource_authorization_shared_all_nine`;原生依据:`src/langbot/pkg/pipeline/pipelinemgr.py:135`。
- **`extensions_preferences.mcp_resources`** → `extensions_preferences.mcp_resources`。
- 按原存在性和值保留;显式 false/空名单=不授权;无字段的旧有效缺省只有在同一所有权范围内可保留。
- 所有权:`host_resource_authorization_shared_all_nine`;原生依据:`src/langbot/pkg/pipeline/pipelinemgr.py:131`。
- **`extensions_preferences.mcp_servers`** → `extensions_preferences.mcp_servers`。
- 按原存在性和值保留;显式 false/空名单=不授权;无字段的旧有效缺省只有在同一所有权范围内可保留。
- 所有权:`host_resource_authorization_shared_all_nine`;原生依据:`src/langbot/pkg/pipeline/pipelinemgr.py:149`。
- **`extensions_preferences.plugins`** → `extensions_preferences.plugins`。
- 按原存在性和值保留;显式 false/空名单=不授权;无字段的旧有效缺省只有在同一所有权范围内可保留。
- 所有权:`host_resource_authorization_shared_all_nine`;原生依据:`src/langbot/pkg/pipeline/pipelinemgr.py:142`。
- **`extensions_preferences.skills`** → `extensions_preferences.skills`。
- 按原存在性和值保留;显式 false/空名单=不授权;无字段的旧有效缺省只有在同一所有权范围内可保留。
- 所有权:`host_resource_authorization_shared_all_nine`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:344`。
- **`output.misc.remove-think`** → `R.remove-think`。
- 复制严格 bool 到所选 Runner remove-think;同时保留 config.output.misc.remove-think;缺失=false。
- 所有权:`pipeline_runner_parameters_plus_retained_host_output`;原生依据:`src/langbot/pkg/provider/runners/localagent.py:539`。
### DifyAgent(8 项)
- **`ai.dify-service-api.api-key`** → `R.api-key`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/difysvapi.py:838`。
- **`ai.dify-service-api.app-type`** → `R.app-type`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/difysvapi.py:833`。
- **`ai.dify-service-api.base-prompt`** → `R.base-prompt`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/difysvapi.py:984`。
- **`ai.dify-service-api.base-url`** → `R.base-url`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/difysvapi.py:842`。
- **`ai.dify-service-api.timeout`** → `R.timeout`。
- 旧seed timeout=30没有被native Runner读取;native请求显式120。采用新插件30秒默认,旧任意保存值只留备份,不悄悄激活;用户可显式配置新timeout。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/templates/default-pipeline-config.json:65`。
- **`ai.runner.expire-time`** → `config.ai.runner.expire-time`。
- 原样保留非负整数;缺失=0,禁止用 token budget 替换。
- 所有权:`pipeline_host_lifecycle`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:131`。
- **`ai.runner.runner`** → `config.ai.runner.id`。
- 所有权:`pipeline_selector`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:73`。
- **`output.misc.remove-think`** → `R.remove-think`。
- 复制严格 bool 到所选 Runner remove-think;同时保留 config.output.misc.remove-think;缺失=false。
- 所有权:`pipeline_runner_parameters_plus_retained_host_output`;原生依据:`src/langbot/pkg/provider/runners/difysvapi.py:859`。
### CozeAgent(9 项)
- **`ai.coze-api.api-base`** → `R.api-base`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/cozeapi.py:35`。
- **`ai.coze-api.api-key`** → `R.api-key`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/cozeapi.py:31`。
- **`ai.coze-api.auto-save-history`** → `R.auto-save-history`。
- 有效 underscore bool 为旧runtime值;仅hyphen有效bool时明确修复UI旧bug并取该值;两者有效且冲突需用户选择;缺失/null旧值明确弃用后采用目标 true;非bool(含空白字符串)拒绝,不bool()。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/templates/metadata/pipeline/ai.yaml:566`。
- **`ai.coze-api.auto_save_history`** → `R.auto-save-history`。
- 有效 underscore bool 为旧runtime值;仅hyphen有效bool时明确修复UI旧bug并取该值;两者有效且冲突需用户选择;缺失/null旧值明确弃用后采用目标 true;非bool(含空白字符串)拒绝,不bool()。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/cozeapi.py:34`。
- **`ai.coze-api.bot-id`** → `R.bot-id`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/cozeapi.py:32`。
- **`ai.coze-api.timeout`** → `R.timeout`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/cozeapi.py:33`。
- **`ai.runner.expire-time`** → `config.ai.runner.expire-time`。
- 原样保留非负整数;缺失=0,禁止用 token budget 替换。
- 所有权:`pipeline_host_lifecycle`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:131`。
- **`ai.runner.runner`** → `config.ai.runner.id`。
- 所有权:`pipeline_selector`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:73`。
- **`output.misc.remove-think`** → `R.remove-think`。
- 复制严格 bool 到所选 Runner remove-think;同时保留 config.output.misc.remove-think;缺失=false。
- 所有权:`pipeline_runner_parameters_plus_retained_host_output`;原生依据:`src/langbot/pkg/provider/runners/cozeapi.py:50`。
### DashScopeAgent(8 项)
- **`ai.dashscope-app-api.api-key`** → `R.api-key`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/dashscopeapi.py:58`。
- **`ai.dashscope-app-api.app-id`** → `R.app-id`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/dashscopeapi.py:57`。
- **`ai.dashscope-app-api.app-type`** → `R.app-type`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/dashscopeapi.py:51`。
- **`ai.dashscope-app-api.references-quote`** → `R.references_quote`。
- 有效 underscore 字符串优先;只有 hyphen 字符串则明确重命名修复;两者冲突需选择;都缺失采用“参考资料来自:”;显式空串和空白原样保留,null非字符串拒绝或用户明确选择默认。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/templates/default-pipeline-config.json:71`。
- **`ai.dashscope-app-api.references_quote`** → `R.references_quote`。
- 有效 underscore 字符串优先;只有 hyphen 字符串则明确重命名修复;两者冲突需选择;都缺失采用“参考资料来自:”;显式空串和空白原样保留,null非字符串拒绝或用户明确选择默认。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/dashscopeapi.py:59`。
- **`ai.runner.expire-time`** → `config.ai.runner.expire-time`。
- 原样保留非负整数;缺失=0,禁止用 token budget 替换。
- 所有权:`pipeline_host_lifecycle`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:131`。
- **`ai.runner.runner`** → `config.ai.runner.id`。
- 所有权:`pipeline_selector`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:73`。
- **`output.misc.remove-think`** → `R.remove-think`。
- 复制严格 bool 到所选 Runner remove-think;同时保留 config.output.misc.remove-think;缺失=false。
- 所有权:`pipeline_runner_parameters_plus_retained_host_output`;原生依据:`src/langbot/pkg/provider/runners/dashscopeapi.py:121`。
### N8nAgent(13 项)
- **`ai.n8n-service-api.auth-type`** → `R.auth-type`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:47`。
- **`ai.n8n-service-api.basic-password`** → `R.basic-password`。
- 凭证字节原样复制;选用basic时同时显式 basic-encoding="latin1"(native aiohttp默认),插件新建默认utf-8不改;不支持编码字符应报安全错误而非改写。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:52`。
- **`ai.n8n-service-api.basic-username`** → `R.basic-username`。
- 凭证字节原样复制;选用basic时同时显式 basic-encoding="latin1"(native aiohttp默认),插件新建默认utf-8不改;不支持编码字符应报安全错误而非改写。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:51`。
- **`ai.n8n-service-api.header-name`** → `R.header-name`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:57`。
- **`ai.n8n-service-api.header-value`** → `R.header-value`。
- 原样复制包括空字符串;active header必须有合法header-name,不省略合法空value;隐藏/inactive字段仍保留。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:58`。
- **`ai.n8n-service-api.jwt-algorithm`** → `R.jwt-algorithm`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:55`。
- **`ai.n8n-service-api.jwt-secret`** → `R.jwt-secret`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:54`。
- **`ai.n8n-service-api.output-key`** → `R.output-key`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:41`。
- **`ai.n8n-service-api.response-handling`** → `R.response-handling`。
- reply/ignore逐字复制;缺失reply;ignore现已实现2xx只读header、不读body、不输出;非2xx仍失败;不得留旧缺特性blocker。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:42`。
- **`ai.n8n-service-api.timeout`** → `R.timeout`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:38`。
- **`ai.n8n-service-api.webhook-url`** → `R.webhook-url`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/n8nsvapi.py:35`。
- **`ai.runner.expire-time`** → `config.ai.runner.expire-time`。
- 原样保留非负整数;缺失=0,禁止用 token budget 替换。
- 所有权:`pipeline_host_lifecycle`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:131`。
- **`ai.runner.runner`** → `config.ai.runner.id`。
- 所有权:`pipeline_selector`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:73`。
### LangflowAgent(10 项)
- **`ai.langflow-api.api-key`** → `R.api-key`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/langflowapi.py:119`。
- **`ai.langflow-api.base-url`** → `R.base-url`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/langflowapi.py:118`。
- **`ai.langflow-api.flow-id`** → `R.flow-id`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/langflowapi.py:120`。
- **`ai.langflow-api.input-type`** → `R.input-type`。
- underscore存在且合法字符串→对应hyphen;underscore缺失且hyphen缺失/chat→chat;仅hyphen非默认属于旧UI被忽略的意图,用户选修复为该值或维持chat;两者冲突需选择;underscore显式null/空白不能被or chat吞掉。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/templates/metadata/pipeline/ai.yaml:692`。
- **`ai.langflow-api.input_type`** → `R.input-type`。
- underscore存在且合法字符串→对应hyphen;underscore缺失且hyphen缺失/chat→chat;仅hyphen非默认属于旧UI被忽略的意图,用户选修复为该值或维持chat;两者冲突需选择;underscore显式null/空白不能被or chat吞掉。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/langflowapi.py:81`。
- **`ai.langflow-api.output-type`** → `R.output-type`。
- underscore存在且合法字符串→对应hyphen;underscore缺失且hyphen缺失/chat→chat;仅hyphen非默认属于旧UI被忽略的意图,用户选修复为该值或维持chat;两者冲突需选择;underscore显式null/空白不能被or chat吞掉。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/templates/metadata/pipeline/ai.yaml:702`。
- **`ai.langflow-api.output_type`** → `R.output-type`。
- underscore存在且合法字符串→对应hyphen;underscore缺失且hyphen缺失/chat→chat;仅hyphen非默认属于旧UI被忽略的意图,用户选修复为该值或维持chat;两者冲突需选择;underscore显式null/空白不能被or chat吞掉。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/langflowapi.py:82`。
- **`ai.langflow-api.tweaks`** → `R.tweaks`。
- 目标接受dict或严格JSON object(递归保留null/false/0);缺失/null/空串/JSON null→{};空白串按新便利默认{}并记录;false/0/list拒绝;JSON重复键/NaN/Infinity/过深结构拒绝;坏JSON绝不降级{}。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/langflowapi.py:93`。
- **`ai.runner.expire-time`** → `config.ai.runner.expire-time`。
- 原样保留非负整数;缺失=0,禁止用 token budget 替换。
- 所有权:`pipeline_host_lifecycle`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:131`。
- **`ai.runner.runner`** → `config.ai.runner.id`。
- 所有权:`pipeline_selector`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:73`。
### DeerFlowAgent(13 项)
- **`ai.deerflow-api.api-base`** → `R.api-base`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:61`。
- **`ai.deerflow-api.api-key`** → `R.api-key`。
- 逐字保留字符串包括空白/空串;null或非string拒绝;仅缺失采用target_default。auth-header是完整Authorization值,非空优先于api-key。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:68`。
- **`ai.deerflow-api.assistant-id`** → `R.assistant-id`。
- 逐字保留字符串包括空白/空串;null或非string拒绝;仅缺失采用target_default。auth-header是完整Authorization值,非空优先于api-key。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:70`。
- **`ai.deerflow-api.auth-header`** → `R.auth-header`。
- 逐字保留字符串包括空白/空串;null或非string拒绝;仅缺失采用target_default。auth-header是完整Authorization值,非空优先于api-key。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:69`。
- **`ai.deerflow-api.max-concurrent-subagents`** → `R.max-concurrent-subagents`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:75`。
- **`ai.deerflow-api.model-name`** → `R.model-name`。
- 逐字保留字符串包括空白/空串;null或非string拒绝;仅缺失采用target_default。auth-header是完整Authorization值,非空优先于api-key。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:71`。
- **`ai.deerflow-api.plan-mode`** → `R.plan-mode`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:73`。
- **`ai.deerflow-api.recursion-limit`** → `R.recursion-limit`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:77`。
- **`ai.deerflow-api.subagent-enabled`** → `R.subagent-enabled`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:74`。
- **`ai.deerflow-api.thinking-enabled`** → `R.thinking-enabled`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:72`。
- **`ai.deerflow-api.timeout`** → `R.timeout`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/deerflowapi.py:76`。
- **`ai.runner.expire-time`** → `config.ai.runner.expire-time`。
- 原样保留非负整数;缺失=0,禁止用 token budget 替换。
- 所有权:`pipeline_host_lifecycle`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:131`。
- **`ai.runner.runner`** → `config.ai.runner.id`。
- 所有权:`pipeline_selector`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:73`。
### TboxAgent(5 项)
- **`ai.runner.expire-time`** → `config.ai.runner.expire-time`。
- 原样保留非负整数;缺失=0,禁止用 token budget 替换。
- 所有权:`pipeline_host_lifecycle`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:131`。
- **`ai.runner.runner`** → `config.ai.runner.id`。
- 所有权:`pipeline_selector`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:73`。
- **`ai.tbox-app-api.api-key`** → `R.api-key`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/tboxapi.py:59`。
- **`ai.tbox-app-api.app-id`** → `R.app-id`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/tboxapi.py:58`。
- **`output.misc.remove-think`** → `R.remove-think`。
- 复制严格 bool 到所选 Runner remove-think;同时保留 config.output.misc.remove-think;缺失=false。
- 所有权:`pipeline_runner_parameters_plus_retained_host_output`;原生依据:`src/langbot/pkg/provider/runners/tboxapi.py:114`。
### WeKnoraAgent(10 项)
- **`ai.runner.expire-time`** → `config.ai.runner.expire-time`。
- 原样保留非负整数;缺失=0,禁止用 token budget 替换。
- 所有权:`pipeline_host_lifecycle`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:131`。
- **`ai.runner.runner`** → `config.ai.runner.id`。
- 所有权:`pipeline_selector`;原生依据:`src/langbot/pkg/pipeline/preproc/preproc.py:73`。
- **`ai.weknora-api.agent-id`** → `R.agent-id`。
- 原键缺失:明确写入按旧app-type的默认(chat quick-answer,agent smart-reasoning);显式null/空串保持(线端省略agent_id);其余字符串含空白逐字保留,不能因chat而覆盖历史smart ID。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/weknoraapi.py:89`。
- **`ai.weknora-api.api-key`** → `R.api-key`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/weknoraapi.py:38`。
- **`ai.weknora-api.app-type`** → `R.app-type`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/weknoraapi.py:336`。
- **`ai.weknora-api.base-prompt`** → `R.base-prompt`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/weknoraapi.py:65`。
- **`ai.weknora-api.base-url`** → `R.base-url`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/weknoraapi.py:45`。
- **`ai.weknora-api.knowledge-base-ids`** → `R.knowledge-base-ids`。
- 保留远端ID顺序/重复/字节;缺失或null→[];无效元素/空白ID拒绝,不静默丢弃。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/weknoraapi.py:90`。
- **`ai.weknora-api.timeout`** → `R.timeout`。
- 源键存在时深拷贝原值;缺失时按下面 native_defaults 的实际读取语义或显式新默认处理。不得把 null/空字符串统一当作缺失。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/weknoraapi.py:92`。
- **`ai.weknora-api.web-search-enabled`** → `R.web-search-enabled`。
- 复制严格bool,缺失false;仅agent线端使用,chat保存true仍不激活。
- 所有权:`pipeline_runner_parameters`;原生依据:`src/langbot/pkg/provider/runners/weknoraapi.py:91`。
## 新增参数与明确采用的默认值
这些值是新机制的默认值,不声称与旧行为一一等价。已存在且受支持的有效值按映射规则保留;必填凭证/端点不能由模板伪造。
### LocalAgent
```json
{
"advanced-settings": false,
"date-grounding": true,
"timeout": 300,
"retrieval-top-k": 5,
"rerank-model": "",
"rerank-top-k": 5,
"max-tool-iterations": 100,
"tool-execution-mode": "serial",
"max-tool-result-chars": 20000,
"context-history-fetch-limit": 50,
"context-window-tokens": 200000,
"context-reserve-tokens": 16384,
"context-keep-recent-tokens": 20000,
"context-summary-tokens": 8000,
"enable-all-tools": true,
"tools": [],
"knowledge-bases": []
}
```
### DifyAgent
```json
{
"timeout": 30,
"advanced-settings": false
}
```
### CozeAgent
```json
{
"auto-save-history": true,
"advanced-settings": false
}
```
### DashScopeAgent
```json
{
"references_quote": "参考资料来自:",
"timeout": 120,
"advanced-settings": false
}
```
### N8nAgent
```json
{
"auth-type": "none",
"basic-username": "",
"basic-password": "",
"jwt-secret": "",
"jwt-algorithm": "HS256",
"header-name": "",
"header-value": "",
"timeout": 120,
"output-key": "response",
"response-handling": "reply",
"basic-encoding": "latin1",
"advanced-settings": false
}
```
### LangflowAgent
```json
{
"input-type": "chat",
"output-type": "chat",
"tweaks": {},
"advanced-settings": false
}
```
### DeerFlowAgent
```json
{
"api-key": "",
"auth-header": "",
"assistant-id": "lead_agent",
"model-name": "",
"thinking-enabled": false,
"plan-mode": false,
"subagent-enabled": false,
"max-concurrent-subagents": 3,
"timeout": 300,
"recursion-limit": 1000
}
```
### TboxAgent
```json
{
"timeout": 120
}
```
### WeKnoraAgent
```json
{
"knowledge-base-ids": [],
"web-search-enabled": false,
"timeout": 120,
"base-prompt": "请回答用户的问题。",
"advanced-settings": false
}
```
## 验证与交付状态
本清单负责说明所有已识别旧字段的去向和语义;不替代最终的集成验收。源码修改、插件打包、OSS/Cloud 持久化与权限、真实 SDK 调用、浏览器测试、独立审查和发布状态必须分别记录,不能以其中一项通过宣称全部完成。
源码库存与每条字段的多处原生证据、默认值/表达式及哈希见工作区的 `411-completion-config-audit.json` 和其标注的原始库存;最终验证以 `411-completion-*` 的实际日志和源码哈希为准。
@@ -249,6 +249,20 @@ class RunnerContextBuilder:
'timestamp': event.event_time or int(time.time()),
}
# A legacy Tbox resume must use the persisted original bot, not a
# newly reconstructed callback envelope. Ordinary sender mode is unchanged.
bot_id = event.bot_id
if event.event_type == 'interaction.submitted' and binding.runner_config.get('user-id-source') == 'legacy-bot':
identity = event.legacy_identity
bot_id = (
identity.bot_id
if identity is not None
and identity.workspace_id == event.workspace_id
and identity.bot_id == event.bot_id
and identity.pipeline_id == (binding.processor_id or binding.agent_id)
else None
)
# Build conversation context from event
conversation: ConversationContext | None = None
if event.conversation_id:
@@ -256,13 +270,39 @@ class RunnerContextBuilder:
'session_id': None,
'conversation_id': event.conversation_id,
'thread_id': event.thread_id,
'launcher_type': None, # Will be filled from actor/subject if needed
'launcher_type': None,
'launcher_id': None,
'sender_id': event.actor.actor_id if event.actor else None,
'bot_id': event.bot_id,
'bot_id': bot_id,
'workspace_id': event.workspace_id,
}
identity = event.legacy_identity
if (
binding.runner_config.get('user-id-source') == 'legacy-session'
and identity is not None
and identity.workspace_id == event.workspace_id
and identity.bot_id == event.bot_id
and identity.pipeline_id == (binding.processor_id or binding.agent_id)
and binding.processor_type == 'pipeline'
):
# Coze used Query itself; Dify/n8n used Query.session, which may
# deliberately differ (e.g. group per-member session isolation).
if binding.runner_id == 'plugin:langbot-team/CozeAgent/default':
launcher_type = identity.query_launcher_type
launcher_id = identity.query_launcher_id
elif binding.runner_id in {
'plugin:langbot-team/DifyAgent/default',
'plugin:langbot-team/N8nAgent/default',
}:
launcher_type = identity.session_launcher_type
launcher_id = identity.session_launcher_id
else:
launcher_type = launcher_id = None
if launcher_type in ('group', 'person') and launcher_id:
conversation['launcher_type'] = launcher_type
conversation['launcher_id'] = launcher_id
# Build event context (Protocol v1 event-first)
event_context = {
'event_id': event.event_id,
@@ -322,7 +362,7 @@ class RunnerContextBuilder:
'trace_id': run_id,
'deadline_at': self._build_deadline_from_binding(binding),
'metadata': {
'bot_id': event.bot_id,
'bot_id': bot_id,
'workspace_id': event.workspace_id,
'streaming_supported': event.delivery.supports_streaming,
'model_context_window_tokens': model_context_window_tokens,
@@ -17,6 +17,18 @@ from langbot_plugin.api.entities.builtin.runner.input import AgentInput
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
class LegacyRunnerIdentity(pydantic.BaseModel):
"""Host-captured native Query identity; never populated from event data/params."""
workspace_id: str | None = None
bot_id: str | None = None
pipeline_id: str | None = None
query_launcher_type: str | None = None
query_launcher_id: str | None = None
session_launcher_type: str | None = None
session_launcher_id: str | None = None
class AgentEventEnvelope(pydantic.BaseModel):
"""Event envelope for LangBot Host event gateway.
@@ -69,6 +81,9 @@ class AgentEventEnvelope(pydantic.BaseModel):
data: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
"""Small structured event payload. Large payloads should be referenced via raw_ref."""
legacy_identity: LegacyRunnerIdentity | None = None
"""Host-only provenance, separate from untrusted event payload and adapter params."""
# Binding scope types
class BindingScope(pydantic.BaseModel):
@@ -4,6 +4,7 @@ from __future__ import annotations
from langbot.pkg.telemetry import diagnostics
import copy
import json
import time
import typing
@@ -14,7 +15,7 @@ from langbot_plugin.api.entities.builtin.platform import message as platform_mes
from .descriptor import RunnerDescriptor
from .errors import RunnerProtocolError
from .host_models import AgentBinding, AgentEventEnvelope
from .host_models import AgentBinding, AgentEventEnvelope, LegacyRunnerIdentity
from .interaction_store import InteractionStore
@@ -152,7 +153,12 @@ class InteractionManager:
runner_id=descriptor.id,
processor_type=binding.processor_type,
processor_id=processor_id,
request=request_data,
request={
**request_data,
'_host_legacy_identity': event.legacy_identity.model_dump(mode='json')
if event.legacy_identity is not None
else None,
},
delivery_target=reply_target,
replaces_interaction_id=(update_target or {}).get('interaction_id'),
bot_id=event.bot_id,
@@ -161,6 +167,7 @@ class InteractionManager:
thread_id=event.thread_id,
actor_id=event.actor.actor_id if event.actor else None,
expires_at=expires_at,
**self._pipeline_admission(binding, descriptor, adapter_context),
)
try:
@@ -204,6 +211,50 @@ class InteractionManager:
diagnostics.set_outcome('waiting', reason_code='waiting')
return True
@staticmethod
def _pipeline_admission(binding, descriptor, adapter_context):
if binding.processor_type != 'pipeline':
return {}
query = (adapter_context or {}).get('_query')
expected = copy.deepcopy((adapter_context or {}).get('_pipeline_expected_config'))
session = getattr(query, 'session', None)
conversation = (adapter_context or {}).get('_pipeline_conversation')
def authority():
from .config_resolver import RunnerConfigResolver
return (
query is not None
and session is not None
and conversation is not None
and isinstance(expected, dict)
and session.using_conversation is conversation
and getattr(query, 'pipeline_uuid', None) == binding.processor_id
and RunnerConfigResolver.resolve_runner_id(expected) == descriptor.id == binding.runner_id
and RunnerConfigResolver.resolve_runner_config(expected, descriptor.id) == binding.runner_config
)
return {'expected_config': expected, 'authority_check': authority}
async def restore_legacy_identity(self, event: AgentEventEnvelope, binding: AgentBinding) -> None:
"""Resume only from the scoped, consumed Host record, never callback fields."""
event.legacy_identity = None
submission = getattr(event.input, 'interaction', None)
if submission is None:
return
if hasattr(submission, 'model_dump'):
submission = submission.model_dump(mode='json')
record = await self.store.find_resume_request(event=event, binding=binding, submission=submission)
if record is None:
return
identity = record['request'].get('_host_legacy_identity')
if not isinstance(identity, dict):
return
try:
event.legacy_identity = LegacyRunnerIdentity.model_validate(identity)
except pydantic.ValidationError:
return
@diagnostics.observe('interaction', 'interaction.acknowledge', source='platform', stage='ack')
async def acknowledge_submission(self, record: dict[str, typing.Any], adapter: typing.Any) -> None:
"""Best-effort transition of submitted controls into a read-only state."""
@@ -14,6 +14,8 @@ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from sqlalchemy.orm import sessionmaker
from ...entity.persistence.agent_interaction import AgentInteraction
from ...persistence.tenant_uow import TenantUnitOfWork
from ...persistence.pipeline_admission import lock_pipeline_admission
UTC = datetime.timezone.utc
@@ -49,6 +51,12 @@ def _utc_now() -> datetime.datetime:
return datetime.datetime.now(UTC)
def _db_datetime(value):
# Existing schema is TIMESTAMP WITHOUT TIME ZONE; persist UTC naive on
# both backends and normalize to aware UTC only for application comparisons.
return _as_utc(value).replace(tzinfo=None) if value is not None else None
def _as_utc(value: datetime.datetime | None) -> datetime.datetime | None:
if value is None:
return None
@@ -113,6 +121,8 @@ class InteractionStore:
thread_id: str | None = None,
actor_id: str | None = None,
expires_at: int | float | None = None,
expected_config: dict | None = None,
authority_check: typing.Callable[[], bool] | None = None,
) -> tuple[dict[str, typing.Any], str]:
"""Persist a request and return its record plus one-time callback token."""
if not interaction_id or not run_id or not binding_id or not runner_id or not processor_id:
@@ -145,11 +155,32 @@ class InteractionStore:
delivery_target_json=delivery_target_json,
replaces_interaction_id=replaces_interaction_id,
callback_token_hash=_token_hash(callback_token),
expires_at=expires_at_dt,
created_at=now,
updated_at=now,
expires_at=_db_datetime(expires_at_dt),
created_at=_db_datetime(now),
updated_at=_db_datetime(now),
)
if processor_type == 'pipeline':
# Require Host-captured configuration and live conversation authority;
# runner output must never supply either of these admission inputs.
if not workspace_id or not isinstance(expected_config, dict) or authority_check is None:
raise InteractionScopeError('Pipeline interaction authority unavailable')
try:
async with TenantUnitOfWork(self.engine, workspace_id) as uow:
current = await lock_pipeline_admission(uow.session, workspace_id, processor_id)
if current != expected_config or authority_check() is not True:
raise InteractionScopeError('Pipeline interaction authority changed')
existing = await self._get_by_run_interaction(uow.session, run_id, interaction_id)
if existing is not None:
raise DuplicateInteractionError('Interaction already exists')
uow.session.add(row)
await uow.session.flush()
return self._to_dict(row), callback_token
except ValueError as exc:
raise InteractionScopeError('Pipeline interaction authority unavailable') from exc
except IntegrityError as exc:
raise DuplicateInteractionError('Interaction already exists') from exc
async with self._session_factory() as session:
existing = await self._get_by_run_interaction(session, run_id, interaction_id)
if existing is not None:
@@ -182,7 +213,7 @@ class InteractionStore:
AgentInteraction.interaction_id == interaction_id,
AgentInteraction.status.in_(['pending', 'submitted']),
)
.values(delivery_result_json=payload, updated_at=_utc_now())
.values(delivery_result_json=payload, updated_at=_db_datetime(_utc_now()))
)
await session.commit()
return result.rowcount == 1
@@ -228,6 +259,38 @@ class InteractionStore:
row = result.scalar_one_or_none()
return self._to_dict(row) if row is not None else None
async def find_resume_request(self, *, event, binding, submission) -> dict[str, typing.Any] | None:
"""Resolve persisted provenance with exact tenant/processor/callback scope.
Ambiguous identities fail closed instead of picking the newest run.
"""
if not isinstance(submission, dict) or not submission.get('interaction_id'):
return None
target = event.delivery.reply_target or {}
target_type, target_id = target.get('target_type'), target.get('target_id')
conversation_id = f'{target_type}_{target_id}' if target_type and target_id else event.conversation_id
conditions = [
AgentInteraction.interaction_id == submission['interaction_id'],
AgentInteraction.status == 'submitted',
AgentInteraction.binding_id == binding.binding_id,
AgentInteraction.runner_id == binding.runner_id,
AgentInteraction.processor_type == binding.processor_type,
AgentInteraction.processor_id == (binding.processor_id or binding.agent_id),
]
for column, value in (
(AgentInteraction.workspace_id, event.workspace_id),
(AgentInteraction.bot_id, event.bot_id),
(AgentInteraction.conversation_id, conversation_id),
(AgentInteraction.thread_id, event.thread_id),
(AgentInteraction.actor_id, event.actor.actor_id if event.actor else None),
):
conditions.append(column.is_(None) if value is None else column == value)
async with self._session_factory() as session:
result = await session.execute(sqlalchemy.select(AgentInteraction).where(*conditions))
records = [self._to_dict(row) for row in result.scalars()]
matches = [record for record in records if record['submission'] == submission]
return matches[0] if len(matches) == 1 else None
async def get_request(self, run_id: str, interaction_id: str) -> dict[str, typing.Any] | None:
"""Return a request by the runner-visible identity."""
async with self._session_factory() as session:
@@ -283,7 +346,7 @@ class InteractionStore:
if expires_at is not None and expires_at <= now:
row.status = 'expired'
row.status_reason = 'interaction expired before submission'
row.updated_at = now
row.updated_at = _db_datetime(now)
await session.commit()
raise InteractionExpiredError('Interaction has expired')
@@ -297,9 +360,9 @@ class InteractionStore:
)
.values(
status='submitted',
submitted_at=submission_time,
submitted_at=_db_datetime(submission_time),
submission_json=_json_dumps(submission),
updated_at=now,
updated_at=_db_datetime(now),
)
)
if update_result.rowcount != 1:
@@ -323,7 +386,7 @@ class InteractionStore:
AgentInteraction.interaction_id == interaction_id,
AgentInteraction.status == 'pending',
)
.values(status='delivery_failed', status_reason=reason, updated_at=now)
.values(status='delivery_failed', status_reason=reason, updated_at=_db_datetime(now))
)
await session.commit()
return result.rowcount == 1
@@ -337,12 +400,12 @@ class InteractionStore:
.where(
AgentInteraction.status == 'pending',
AgentInteraction.expires_at.is_not(None),
AgentInteraction.expires_at <= cutoff,
AgentInteraction.expires_at <= _db_datetime(cutoff),
)
.values(
status='expired',
status_reason='interaction expired',
updated_at=cutoff,
updated_at=_db_datetime(cutoff),
)
)
await session.commit()
@@ -0,0 +1,85 @@
"""Host-owned, run-scoped reasoning policy for schema-declared model selectors.
This policy is not an SDK resource or a provider kwarg. Only the Host binding
assembler supplies it; model actions consult it after resource authorization.
"""
from __future__ import annotations
import copy
import typing
from ...provider.modelmgr.reasoning import normalize_reasoning_config, validate_reasoning_config
from .config_schema import NONE_SENTINELS, iter_schema_items
from .descriptor import RunnerDescriptor
if typing.TYPE_CHECKING:
from ...provider.modelmgr.requester import RuntimeLLMModel
ModelReasoningOverrides = dict[str, dict[str, str]]
def extract_model_reasoning_overrides(
descriptor: RunnerDescriptor,
runner_config: dict[str, typing.Any],
resources: typing.Mapping[str, typing.Any],
) -> ModelReasoningOverrides:
"""Normalize explicit UUID-to-level mappings, intersecting selection and grants.
An absent entry preserves persisted model defaults. Explicit provider_default
overrides them using the native requester's semantics. Descriptor defaults and
undeclared config fields cannot silently introduce reasoning overrides.
"""
authorized = {model.get('model_id') for model in resources.get('models', [])}
overrides: ModelReasoningOverrides = {}
for item in iter_schema_items(descriptor, {'model-fallback-selector'}):
field_name = item.get('name')
if not isinstance(field_name, str):
continue
selection = runner_config.get(field_name)
if not isinstance(selection, dict) or 'reasoning' not in selection:
continue
configured = selection['reasoning']
if not isinstance(configured, dict):
raise ValueError('Invalid runner model reasoning configuration')
candidates = [selection.get('primary')]
fallbacks = selection.get('fallbacks')
if isinstance(fallbacks, list):
candidates.extend(fallbacks)
selected = {value for value in candidates if isinstance(value, str) and value not in NONE_SENTINELS}
for model_id, level in configured.items():
# Validate with Core, but do not echo arbitrary config/secret values.
try:
if not isinstance(model_id, str) or not isinstance(level, str):
raise ValueError
config = normalize_reasoning_config({'level': level})
except (TypeError, ValueError):
raise ValueError('Invalid runner model reasoning configuration') from None
if model_id not in selected or model_id not in authorized:
continue
if model_id in overrides and overrides[model_id] != config:
raise ValueError('Conflicting runner model reasoning overrides')
overrides[model_id] = config
return overrides
def model_with_reasoning_override(
model: RuntimeLLMModel,
model_id: str,
session: typing.Mapping[str, typing.Any] | None,
) -> RuntimeLLMModel:
"""Clone only the runtime wrapper, after the caller has authorized the model.
Do not mutate shared model entities or providers. Requesters retain ownership
of ability/capability validation and provider-specific argument translation.
"""
if session is None:
return model
overrides = session.get('authorization', {}).get('model_reasoning_overrides', {})
if model_id not in overrides:
return model
config = validate_reasoning_config(overrides[model_id], model.model_entity.abilities, model.model_entity.extra_args)
scoped_model = copy.copy(model)
scoped_model.reasoning_config_override = copy.deepcopy(config)
return scoped_model
@@ -29,6 +29,7 @@ from .execution_context import (
)
from .host_models import AgentBinding, AgentEventEnvelope
from .invoker import RunnerInvoker
from .model_reasoning import extract_model_reasoning_overrides
from .interaction_manager import InteractionManager
from .query_bridge import QueryRunBridge
from .registry import RunnerRegistry
@@ -121,6 +122,12 @@ class AgentRunOrchestrator:
project_mcp_resource_config(execution_query, binding.runner_config)
object.__setattr__(execution_query, '_execution_context', execution_context)
if event.event_type == 'interaction.submitted' and binding.runner_config.get('user-id-source') in (
'legacy-session',
'legacy-bot',
):
await self.interaction_manager.restore_legacy_identity(event, binding)
execution_event = event
resource_addition = await build_mcp_resource_context_addition(self.ap, execution_query)
if resource_addition:
@@ -133,6 +140,7 @@ class AgentRunOrchestrator:
binding=binding,
descriptor=descriptor,
)
model_reasoning_overrides = extract_model_reasoning_overrides(descriptor, binding.runner_config, resources)
context = await self.context_builder.build_context_from_event(
event=execution_event,
@@ -192,6 +200,7 @@ class AgentRunOrchestrator:
'state_scopes': list(binding.state_policy.state_scopes),
},
'state_context': state_context,
'model_reasoning_overrides': model_reasoning_overrides,
}
seen_sequences: set[int] = set()
@@ -228,6 +237,7 @@ class AgentRunOrchestrator:
execution_query=execution_query,
platform_context=freeze_platform_context(event),
reply_streams=reply_streams,
model_reasoning_overrides=model_reasoning_overrides,
)
event_log_id = await self.journal.write_event_log(
@@ -412,6 +422,10 @@ class AgentRunOrchestrator:
plan = self.query_bridge.build_plan(query)
adapter_context = dict(plan.adapter_context)
adapter_context['_query'] = query
import copy
adapter_context['_pipeline_expected_config'] = copy.deepcopy(query.pipeline_config)
adapter_context['_pipeline_conversation'] = getattr(getattr(query, 'session', None), 'using_conversation', None)
adapter_context['_execution_context'] = get_query_execution_context(query)
# Inbound files and subsequent runner tools must share one Host scope.
@@ -25,6 +25,7 @@ from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
from .host_models import (
AgentConfig,
AgentEventEnvelope,
LegacyRunnerIdentity,
StatePolicy,
DeliveryPolicy,
)
@@ -100,6 +101,24 @@ class QueryEntryAdapter:
delivery=delivery,
raw_ref=raw_ref,
data=event.data,
legacy_identity=cls._build_legacy_identity(query, execution_context.workspace_uuid),
)
@staticmethod
def _build_legacy_identity(query, workspace_id: str) -> LegacyRunnerIdentity:
def exact(value):
value = getattr(value, 'value', value)
return value if isinstance(value, str) and value else None
session = getattr(query, 'session', None)
return LegacyRunnerIdentity(
workspace_id=workspace_id,
bot_id=exact(getattr(query, 'bot_uuid', None)),
pipeline_id=exact(getattr(query, 'pipeline_uuid', None)),
query_launcher_type=exact(getattr(query, 'launcher_type', None)),
query_launcher_id=exact(getattr(query, 'launcher_id', None)),
session_launcher_type=exact(getattr(session, 'launcher_type', None)),
session_launcher_id=exact(getattr(session, 'launcher_id', None)),
)
@classmethod
@@ -13,6 +13,7 @@ import threading
from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
from .context_builder import AgentResources
from .model_reasoning import ModelReasoningOverrides
from ...provider.tools.toolmgr import ToolSourceRef
@@ -52,6 +53,7 @@ class RunAuthorizationSnapshot(typing.TypedDict):
platform_context: dict[str, typing.Any]
authorized_ids: dict[str, set[str]]
authorized_operations: dict[str, dict[str, set[str]]]
model_reasoning_overrides: ModelReasoningOverrides
SteeringQueueItem = dict[str, typing.Any]
@@ -119,6 +121,7 @@ class AgentRunSessionRegistry:
execution_query: pipeline_query.Query | None = None,
platform_context: dict[str, typing.Any] | None = None,
reply_streams: typing.Any = None,
model_reasoning_overrides: ModelReasoningOverrides | None = None,
) -> None:
"""Register a new agent run session.
@@ -164,6 +167,7 @@ class AgentRunSessionRegistry:
'platform_context': copy.deepcopy(platform_context or {}),
'authorized_ids': self._build_authorized_ids(resources_snapshot),
'authorized_operations': self._build_authorized_operations(resources_snapshot),
'model_reasoning_overrides': copy.deepcopy(model_reasoning_overrides or {}),
}
session: AgentRunSession = {
@@ -0,0 +1,33 @@
"""User-confirmed migration only; deliberately not API-key/MCP accessible."""
import quart
from ... import group
from ....authz import Permission
from ....context import RequestContext
from ....service.pipeline_migration import PipelineMigrationService, MigrationError
@group.group_class('pipeline_migration', '/api/v1/pipelines/_/migration')
class PipelineMigrationRouterGroup(group.RouterGroup):
async def initialize(self):
self.service = PipelineMigrationService(self.ap)
@self.route(
'/preview', methods=['GET'], auth_type=group.AuthType.USER_TOKEN, permission=Permission.RESOURCE_VIEW
)
async def preview(request_context: RequestContext):
try:
return self.success(data=await self.service.preview(request_context))
except MigrationError as exc:
return self.http_status(exc.status_code, -1, exc.code)
@self.route(
'/execute', methods=['POST'], auth_type=group.AuthType.USER_TOKEN, permission=Permission.RESOURCE_MANAGE
)
async def execute(request_context: RequestContext):
try:
body = await quart.request.get_json(silent=True)
return self.success(data=await self.service.execute(request_context, body))
except MigrationError as exc:
return self.http_status(exc.status_code, -1, exc.code)
+19 -7
View File
@@ -13,7 +13,7 @@ from ....pipeline.extension_preferences import (
validate_extension_preferences,
)
from ....workspace.errors import WorkspaceNotFoundError
from .secrets import contains_secret_placeholder, redact_secrets, restore_secret_placeholders
from .secrets import redact_secrets, restore_secret_placeholders
from .tenant import TenantContext, require_workspace_uuid, scope_statement
@@ -213,12 +213,24 @@ class PipelineService:
pipeline_data.pop(protected_field, None)
if 'config' in pipeline_data:
current_config = None
if contains_secret_placeholder(pipeline_data['config']):
current_pipeline = await self.get_pipeline(context, pipeline_uuid, include_secret=True)
if current_pipeline is None:
raise WorkspaceNotFoundError('Pipeline not found')
current_config = current_pipeline.get('config', {})
current_pipeline = await self.get_pipeline(context, pipeline_uuid, include_secret=True)
if current_pipeline is None:
raise WorkspaceNotFoundError('Pipeline not found')
current_config = current_pipeline.get('config', {})
old_ai = current_config.get('ai', {}) if isinstance(current_config, dict) else {}
old_runner = old_ai.get('runner') if isinstance(old_ai, dict) else None
legacy_binding = (
isinstance(old_runner, str)
and bool(old_runner)
or isinstance(old_runner, dict)
and bool(old_runner.get('runner'))
and not old_runner.get('id')
)
new_config = pipeline_data['config']
new_ai = new_config.get('ai', {}) if isinstance(new_config, dict) else {}
new_runner = new_ai.get('runner') if isinstance(new_ai, dict) else None
if legacy_binding and isinstance(new_runner, dict) and new_runner.get('id'):
raise ValueError('manual_migration_required')
pipeline_data['config'] = restore_secret_placeholders(
pipeline_data['config'],
current_config if current_config is not None else {},
@@ -0,0 +1,685 @@
"""Explicit, tenant-scoped migration. Preview never contacts plugin runtimes.
The task handle is process-local. Original snapshots and pending activation are
persistent; a refreshed preview is required after observation loss or restart.
"""
from __future__ import annotations
import asyncio
import copy
import hashlib
import hmac
import json
import secrets
import sys
import uuid
import sqlalchemy as sa
from ..authz import Permission, permissions_for_role, require_permission
from ..context import ExecutionContext, PrincipalType, RequestContext
from ....agent.runner.config_resolver import RunnerConfigResolver
from ....agent.runner import config_schema
from ....agent.runner.resource_builder import AgentResourceBuilder
from ....agent.runner.resource_policy import ResourcePolicyProjector
from ....agent.runner.model_reasoning import extract_model_reasoning_overrides
from ....entity.persistence.model import LLMModel, RerankModel, EmbeddingModel
from ....entity.persistence.rag import KnowledgeBase
from ....entity.persistence.mcp import MCPServer
from ....core.taskmgr import TaskContext
from ....entity.persistence.pipeline import LegacyPipeline
from ....entity.persistence.agent_interaction import AgentInteraction
from ....persistence.pipeline_admission import lock_pipeline_admission
from ....entity.persistence.pipeline_migration import PipelineMigrationSnapshot
from ....entity.persistence.plugin import PluginSetting
from ....entity.persistence.user import User
from ....entity.persistence.workspace import Workspace, WorkspaceMembership, WorkspaceExecutionState
from ....pipeline.legacy_config_migration import PLANNER_VERSION, plan_legacy_pipeline
class MigrationError(ValueError):
"""Only constant, credential-free codes cross the API/task boundary."""
def __init__(self, code: str, status_code: int = 409):
super().__init__(code)
self.code = code
self.status_code = status_code
def validate_execute_request(body) -> list[dict]:
if not isinstance(body, dict) or set(body) != {'confirmed', 'items'} or body['confirmed'] is not True:
raise MigrationError('confirmation_required', 400)
items = body['items']
if not isinstance(items, list) or not 1 <= len(items) <= 50:
raise MigrationError('invalid_selection', 400)
seen = set()
for item in items:
if not isinstance(item, dict) or set(item) != {'pipeline_uuid', 'preview_token'}:
raise MigrationError('invalid_selection', 400)
for key, maximum in [('pipeline_uuid', 255), ('preview_token', 256)]:
value = item[key]
if not isinstance(value, str) or not value.strip() or len(value) > maximum or value != value.strip():
raise MigrationError('invalid_selection', 400)
if item['pipeline_uuid'] in seen:
raise MigrationError('invalid_selection', 400)
seen.add(item['pipeline_uuid'])
return copy.deepcopy(items)
def _json(value):
return json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=False, default=str)
def _fingerprint(row):
# SQL timestamps are neither monotonic revisions nor portable CAS values.
return hashlib.sha256(
_json(
{k: v for k, v in row.items() if not k.startswith('_') and k not in ('created_at', 'updated_at')}
).encode()
).hexdigest()
class PipelineMigrationService:
def __init__(self, ap):
self.ap = ap
self.pm = ap.persistence_mgr
# Loss of this process key only invalidates previews, never snapshots.
self._token_key = secrets.token_bytes(32)
async def _binding(self, ctx, session=None):
binding = await self.ap.workspace_service.get_execution_binding(
ctx.workspace_uuid, expected_generation=ctx.placement_generation, session=session
)
if binding.instance_uuid != ctx.instance_uuid:
raise MigrationError('placement_changed')
async def _authorize(self, ctx, session=None):
require_permission(ctx, Permission.RESOURCE_MANAGE)
if ctx.principal.principal_type != PrincipalType.ACCOUNT or not ctx.account_uuid:
raise MigrationError('authorization_changed', 403)
status = (await self.pm.execute_async(sa.select(User.status).where(User.uuid == ctx.account_uuid))).scalar()
if status != 'active':
raise MigrationError('authorization_changed', 403)
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
ctx.account_uuid, ctx.workspace_uuid, session=session
)
if (
access.workspace.uuid != ctx.workspace_uuid
or access.membership.uuid != ctx.workspace.membership_uuid
or access.membership.projection_revision != ctx.workspace.membership_revision
or Permission.RESOURCE_MANAGE.value not in permissions_for_role(access.membership.role)
or access.execution.instance_uuid != ctx.instance_uuid
or access.execution.placement_generation != ctx.placement_generation
):
raise MigrationError('authorization_changed', 403)
await self._binding(ctx, session)
async def _rows(self, ctx, pipeline_uuid=None):
# Read DB-native text for JSON CAS: never compare PostgreSQL JSON using =,
# and never assume Python's JSON whitespace/key order matches stored text.
table = LegacyPipeline.__table__
stmt = sa.select(
table,
*[sa.cast(table.c[k], sa.Text).label('_raw_' + k) for k in ('config', 'stages', 'extensions_preferences')],
)
stmt = stmt.where(table.c.workspace_uuid == ctx.workspace_uuid)
if pipeline_uuid is not None:
stmt = stmt.where(table.c.uuid == pipeline_uuid)
return [dict(r._mapping) for r in (await self.pm.execute_async(stmt.order_by(table.c.uuid))).all()]
async def _plugin(self, ctx, plan):
target = plan.get('target_plugin')
if not target:
return None
t = PluginSetting.__table__
row = (
await self.pm.execute_async(
sa.select(
t.c.enabled, t.c.installation_uuid, t.c.artifact_digest, t.c.runtime_revision, t.c.install_info
).where(
t.c.workspace_uuid == ctx.workspace_uuid,
t.c.plugin_author == target['author'],
t.c.plugin_name == target['name'],
)
)
).first()
return dict(row._mapping) if row else None
def _native_interactions(self, ctx, pipeline_uuid):
"""Inspect the old in-process cache without importing/pruning it.
Native forms have no durable handoff to the plugin runner. Operators
must finish them in the original runtime before migrating; never infer
completion from the cache TTL or submit/cancel a form here.
"""
module = sys.modules.get('langbot.pkg.provider.runners.difysvapi')
if module is None:
return []
cache = getattr(module, '_PENDING_FORMS', None)
if not isinstance(cache, dict):
return ['unavailable']
observed = []
for key, forms in cache.items():
if not isinstance(key, tuple) or len(key) != 8:
if forms:
observed.append('unscoped')
continue
instance, workspace, _, _, pipeline, *_ = key
# Inspect ownership before touching any form contents. Empty scope
# cannot prove this is another tenant; block without disclosing it.
if instance and instance != ctx.instance_uuid or workspace and workspace != ctx.workspace_uuid:
continue
if pipeline and pipeline != pipeline_uuid:
continue
if forms:
observed.append(hashlib.sha256(_json(key).encode()).hexdigest())
return sorted(observed)
async def _interaction_state(self, ctx, pipeline_uuid):
"""Read only correlation/status, never request/submission/token data.
The real table has workspace_id (not workspace_uuid), and no instance
column. Execution placement is verified by the caller; processor_id is
the globally unique pipeline UUID. NULL ownership on that exact UUID
is uncertainty, not permission to query other Workspaces.
"""
if self.pm.get_db_engine().dialect.name not in {'sqlite', 'postgresql'}:
return {'boundary': 'unavailable'}, True
t = AgentInteraction.__table__
try:
records = (
await self.pm.execute_async(
sa.select(t.c.id, t.c.status, t.c.updated_at, t.c.workspace_id)
.where(
t.c.processor_type == 'pipeline',
t.c.processor_id == pipeline_uuid,
sa.or_(
t.c.workspace_id == ctx.workspace_uuid, t.c.workspace_id.is_(None), t.c.workspace_id == ''
),
)
.order_by(t.c.id)
)
).all()
durable = [tuple(r) for r in records]
blocked = any(r.status not in {'submitted', 'cancelled', 'expired', 'delivery_failed'} for r in records)
except Exception:
# Missing schema, permissions, or a failed store must not be treated
# as an empty store. No original diagnostic crosses the API.
durable, blocked = ['unavailable'], True
native = self._native_interactions(ctx, pipeline_uuid)
return {'durable': durable, 'native': native}, blocked or bool(native)
async def _plan(self, ctx, row):
plan = plan_legacy_pipeline(row['config'], row['extensions_preferences'])
pending = None
if plan['state'] == 'already_current':
t = PipelineMigrationSnapshot.__table__
snapshots = (
await self.pm.execute_async(
sa.select(t).where(
t.c.workspace_uuid == ctx.workspace_uuid,
t.c.pipeline_uuid == row['uuid'],
t.c.state == 'activation_pending',
t.c.target_fingerprint == _fingerprint(row),
t.c.planner_version == str(PLANNER_VERSION),
)
)
).all()
if snapshots:
pending = dict(snapshots[0]._mapping)
source = pending['source_snapshot']
plan = plan_legacy_pipeline(source['config'], source['extensions_preferences'])
candidate = {**row, 'config': plan.get('config')}
if plan['state'] != 'ready' or _fingerprint(candidate) != _fingerprint(row):
raise MigrationError('planner_changed')
facts = await self._plugin(ctx, plan)
state = 'activation_pending' if pending else plan['state']
blockers = list(plan['blockers'])
if state in ('ready', 'activation_pending'):
if facts is None:
state = 'needs_plugin'
blockers.append({'code': 'plugin_missing'})
elif facts['enabled'] is not True:
state = 'blocked'
blockers.append({'code': 'plugin_disabled'})
if plan['state'] == 'ready':
observation, interaction_blocked = await self._interaction_state(ctx, row['uuid'])
plan['_interaction_state'] = observation
if interaction_blocked:
state = 'blocked'
blockers.append({'code': 'runtime.pending_interaction'})
return plan, facts, pending, state, blockers
def _token(self, ctx, row, plan, facts, pending):
claim = [
ctx.instance_uuid,
ctx.workspace_uuid,
ctx.placement_generation,
ctx.account_uuid,
ctx.workspace.membership_uuid,
ctx.workspace.membership_revision,
str(PLANNER_VERSION),
_fingerprint(row),
plan,
facts,
pending['uuid'] if pending else None,
]
# An authenticated opaque digest discloses neither credentials nor configs.
return hmac.new(self._token_key, _json(claim).encode(), hashlib.sha256).hexdigest()
async def preview(self, ctx: RequestContext):
require_permission(ctx, Permission.RESOURCE_VIEW)
async with self.pm.tenant_scope(ctx.workspace_uuid):
await self._binding(ctx)
items = []
for row in await self._rows(ctx):
plan, facts, pending, state, blockers = await self._plan(ctx, row)
item = {
k: copy.deepcopy(plan[k])
for k in ('legacy_runner', 'target_runner_id', 'target_plugin', 'changed_paths', 'warnings')
}
item.update(
pipeline_uuid=row['uuid'],
name=row['name'],
state=state,
blockers=blockers,
preview_token=self._token(ctx, row, plan, facts, pending)
if state in ('ready', 'activation_pending')
else None,
)
items.append(item)
return {
'planner_version': str(PLANNER_VERSION),
'workspace_uuid': ctx.workspace_uuid,
'items': items,
'total': len(items),
}
async def _selected(self, ctx, selection):
rows = await self._rows(ctx, selection['pipeline_uuid'])
if not rows:
raise MigrationError('pipeline_not_found', 404)
row = rows[0]
plan, facts, pending, state, _ = await self._plan(ctx, row)
token = self._token(ctx, row, plan, facts, pending)
if not hmac.compare_digest(token, selection['preview_token']):
raise MigrationError('preview_stale')
if state not in ('ready', 'activation_pending'):
raise MigrationError('migration_blocked')
return row, plan, facts, pending
async def execute(self, ctx: RequestContext, body):
require_permission(ctx, Permission.RESOURCE_MANAGE)
items = validate_execute_request(body)
async with self.pm.tenant_scope(ctx.workspace_uuid):
await self._authorize(ctx)
# Preflight the entire selection before creating tasks or contacting runtimes.
for selection in items:
await self._selected(ctx, selection)
task_context = TaskContext.new()
task_context.metadata = {
'kind': 'pipeline_migration',
'results': [{'pipeline_uuid': i['pipeline_uuid'], 'state': 'pending', 'code': None} for i in items],
}
task = self.ap.task_mgr.create_user_task(
self._run(ctx, ExecutionContext.from_request(ctx), items, task_context),
kind='pipeline_migration',
name='pipeline_migration',
context=task_context,
instance_uuid=ctx.instance_uuid,
workspace_uuid=ctx.workspace_uuid,
placement_generation=ctx.placement_generation,
)
return {'task_id': task.id}
async def _verify_runtime(self, execution, plan):
runners = await self.ap.runner_registry.list_runners(execution, use_cache=False, usage='agent')
descriptor = next((r for r in runners if r.id == plan['target_runner_id']), None)
if descriptor is None or 'agent' not in descriptor.usages:
raise MigrationError('runner_unavailable')
if descriptor.plugin_version != plan['target_plugin']['version']:
raise MigrationError('plugin_version_incompatible')
config = plan['config']
RunnerConfigResolver.validate_pipeline_config(config)
runner_config = config['ai']['runner_config'][plan['target_runner_id']]
schema = {item['name']: item for item in descriptor.config_schema}
# Host fields have their own scope/capability checks below; plugin
# schema defaults must never implicitly authorize a foreign resource.
host_fields = {
'enable-all-tools': 'boolean',
'tools': 'array',
'knowledge-bases': 'knowledge-base-multi-selector',
'mcp-resources': 'array',
'mcp-resource-agent-read-enabled': 'boolean',
}
for name, value in runner_config.items():
field = schema.get(name)
if field is None and name in host_fields:
field = {'type': host_fields[name]}
if field is None:
raise MigrationError('runner_schema_incompatible')
if value is None and field.get('nullable') is True:
continue
kind = config_schema.normalize_schema_item_type(field.get('type'))
valid = (
(
kind
in (
'string',
'text',
'password',
'secret',
'select',
'llm-model-selector',
'embedding-model-selector',
'rerank-model-selector',
)
and isinstance(value, str)
)
or (kind == 'boolean' and type(value) is bool)
or (kind in ('integer', 'int') and type(value) is int)
or (kind in ('float', 'number') and type(value) in (int, float))
or (
kind
in (
'array',
'multi-select',
'knowledge-base-selector',
'knowledge-base-multi-selector',
'prompt-editor',
)
and isinstance(value, list)
)
or (kind in ('object', 'json') and type(value) is dict)
or (kind == 'array[string]' and type(value) is list and all(type(v) is str for v in value))
or (
kind == 'model-fallback-selector'
and (
isinstance(value, str)
or isinstance(value, dict)
and set(value) <= {'primary', 'fallbacks', 'reasoning'}
and isinstance(value.get('primary'), str)
and isinstance(value.get('fallbacks', []), list)
and all(isinstance(v, str) for v in value.get('fallbacks', []))
and isinstance(value.get('reasoning', {}), dict)
)
)
)
if not valid:
raise MigrationError('runner_schema_incompatible')
if kind == 'select' and field.get('options'):
if value not in [o.get('value', o.get('name')) for o in field['options']]:
raise MigrationError('runner_schema_incompatible')
if any(
f.get('required') and name not in runner_config and f.get('default') is None for name, f in schema.items()
):
raise MigrationError('runner_schema_incompatible')
await self._verify_resources(execution, descriptor, runner_config)
return _json(
[
descriptor.id,
descriptor.plugin_version,
descriptor.usages,
descriptor.config_schema,
getattr(descriptor, 'capabilities', None),
getattr(descriptor, 'permissions', None),
]
)
async def _verify_resources(self, execution, descriptor, runner_config):
"""Fail closed on references the scoped Host cannot authorize.
This runs only on explicit execution, outside the mutation transaction.
Resource use is still reauthorized by the Host on every eventual run.
"""
async def require_row(model, identifier, *, column=None):
column = model.uuid if column is None else column
found = (
await self.pm.execute_async(
sa.select(model.uuid).where(
model.workspace_uuid == execution.workspace_uuid,
column == identifier,
)
)
).first()
if found is None:
raise MigrationError('runner_resource_unavailable')
permissions = getattr(descriptor, 'permissions', None)
model_resources = []
for model_type, model_uuid in config_schema.iter_config_model_refs(descriptor, runner_config):
allowed = set(getattr(permissions, 'models', []))
if not (allowed & ({'rerank'} if model_type == 'rerank' else {'invoke', 'stream'})):
raise MigrationError('runner_resource_unavailable')
await require_row(RerankModel if model_type == 'rerank' else LLMModel, model_uuid)
model_resources.append({'model_id': model_uuid})
for field in config_schema.iter_schema_items(descriptor, {'embedding-model-selector'}):
value = runner_config.get(field['name'])
if value and value not in config_schema.NONE_SENTINELS:
await require_row(EmbeddingModel, value)
extract_model_reasoning_overrides(descriptor, runner_config, {'models': model_resources})
kb_ids = runner_config.get('knowledge-bases', [])
if not isinstance(kb_ids, list) or any(not isinstance(v, str) or not v for v in kb_ids):
raise MigrationError('runner_resource_unavailable')
if kb_ids:
if not config_schema.uses_host_knowledge_bases(descriptor) or 'retrieve' not in getattr(
permissions, 'knowledge_bases', []
):
raise MigrationError('runner_resource_unavailable')
for kb_id in kb_ids:
await require_row(KnowledgeBase, kb_id)
tool_names = runner_config.get('tools', [])
if not isinstance(tool_names, list) or any(not isinstance(v, str) or not v for v in tool_names):
raise MigrationError('runner_resource_unavailable')
tool_grant = (
runner_config.get('enable-all-tools', False)
or tool_names
or runner_config.get('mcp-resource-agent-read-enabled', False)
)
if tool_grant:
if not config_schema.uses_host_tools(descriptor) or 'call' not in getattr(permissions, 'tools', []):
raise MigrationError('runner_resource_unavailable')
policy = ResourcePolicyProjector.from_runner_config(runner_config)
tools = await AgentResourceBuilder(self.ap)._build_tools_from_binding(
execution,
permissions,
policy,
descriptor,
runner_config,
)
if not set(tool_names) <= {tool['tool_name'] for tool in tools}:
raise MigrationError('runner_resource_unavailable')
for attachment in runner_config.get('mcp-resources', []):
if not isinstance(attachment, dict):
raise MigrationError('runner_resource_unavailable')
identifier = attachment.get('server_uuid') or attachment.get('server_id')
name = attachment.get('server_name')
if identifier:
await require_row(MCPServer, identifier)
elif name:
await require_row(MCPServer, name, column=MCPServer.name)
else:
raise MigrationError('runner_resource_unavailable')
async def _lock(self, ctx, pipeline_uuid, session):
# Every pending producer shares this row and revalidates its captured
# configuration/conversation authority after acquiring admission.
if self.pm.get_db_engine().dialect.name not in {'sqlite', 'postgresql'}:
raise MigrationError('runtime.pending_interaction')
await lock_pipeline_admission(session, ctx.workspace_uuid, pipeline_uuid)
# Hold revocable directory rows through commit (no network in this UoW).
await session.execute(sa.select(User.uuid).where(User.uuid == ctx.account_uuid).with_for_update())
for model, condition in [
(Workspace, Workspace.uuid == ctx.workspace_uuid),
(WorkspaceExecutionState, WorkspaceExecutionState.workspace_uuid == ctx.workspace_uuid),
(
WorkspaceMembership,
sa.and_(
WorkspaceMembership.workspace_uuid == ctx.workspace_uuid,
WorkspaceMembership.account_uuid == ctx.account_uuid,
),
),
(PluginSetting, PluginSetting.workspace_uuid == ctx.workspace_uuid),
]:
await session.execute(sa.select(model.__table__).where(condition).with_for_update())
async def _commit(self, ctx, selection, expected_facts, candidate):
async with self.pm.tenant_uow(ctx.workspace_uuid) as uow:
await self._lock(ctx, selection['pipeline_uuid'], uow.session)
await self._authorize(ctx, uow.session)
row, plan, facts, pending = await self._selected(ctx, selection)
if facts != expected_facts:
raise MigrationError('plugin_changed')
if pending:
return pending['uuid'], pending['target_fingerprint']
target = {**row, 'config': candidate['config']}
t = LegacyPipeline.__table__
cas = [t.c.workspace_uuid == ctx.workspace_uuid, t.c.uuid == row['uuid']]
for key in ('config', 'stages', 'extensions_preferences'):
cas.append(sa.cast(t.c[key], sa.Text) == row['_raw_' + key])
result = await self.pm.execute_async(sa.update(t).where(*cas).values(config=candidate['config']))
if result.rowcount != 1:
raise MigrationError('preview_stale')
snapshot_uuid = str(uuid.uuid4())
target_fingerprint = _fingerprint(target)
await self.pm.execute_async(
sa.insert(PipelineMigrationSnapshot).values(
uuid=snapshot_uuid,
workspace_uuid=ctx.workspace_uuid,
pipeline_uuid=row['uuid'],
source_fingerprint=_fingerprint(row),
target_fingerprint=target_fingerprint,
source_snapshot=json.loads(_json({k: v for k, v in row.items() if not k.startswith('_')})),
planner_version=str(PLANNER_VERSION),
state='activation_pending',
)
)
if self._native_interactions(ctx, row['uuid']):
raise MigrationError('runtime.pending_interaction')
return snapshot_uuid, target_fingerprint
async def _activate(self, ctx, selection, snapshot_uuid, target_fingerprint, candidate, plan, expected_facts):
# Re-read the committed target under a short lock before synchronous
# publication. A later editor must never be replaced by our old candidate.
async with self.pm.tenant_uow(ctx.workspace_uuid) as uow:
await self._lock(ctx, selection['pipeline_uuid'], uow.session)
await self._authorize(ctx, uow.session)
rows = await self._rows(ctx, selection['pipeline_uuid'])
if not rows or _fingerprint(rows[0]) != target_fingerprint:
raise MigrationError('activation_source_changed')
if await self._plugin(ctx, plan) != expected_facts:
raise MigrationError('plugin_changed')
_, interaction_blocked = await self._interaction_state(ctx, selection['pipeline_uuid'])
if interaction_blocked:
raise MigrationError('runtime.pending_interaction')
# Native cache inspection above is the last await before publication
# and session invalidation; no native producer can interleave here.
self.ap.pipeline_mgr.publish_pipeline(candidate)
for session in self.ap.sess_mgr.session_list:
conversation = session.using_conversation
if (
conversation is not None
and conversation.pipeline_uuid == selection['pipeline_uuid']
and getattr(session, 'workspace_uuid', None) == ctx.workspace_uuid
):
session.using_conversation = None
t = PipelineMigrationSnapshot.__table__
await self.pm.execute_async(
sa.update(t)
.where(t.c.workspace_uuid == ctx.workspace_uuid, t.c.uuid == snapshot_uuid)
.values(state='active')
)
async def _run(self, ctx, execution, items, task_context):
async with self.pm.tenant_scope(execution.workspace_uuid):
for selection, result in zip(items, task_context.metadata['results']):
committed = False
commit_attempted = False
try:
await self._authorize(ctx)
row, plan, facts, pending = await self._selected(ctx, selection)
runtime_schema = await self._verify_runtime(execution, plan)
candidate_entity = {k: copy.deepcopy(v) for k, v in row.items() if not k.startswith('_')}
candidate_entity['config'] = copy.deepcopy(plan['config'])
runtime = await self.ap.pipeline_mgr.prepare_pipeline(execution, copy.deepcopy(candidate_entity))
if await self._verify_runtime(execution, plan) != runtime_schema:
raise MigrationError('runner_schema_changed')
# Runtime awaits are over. Recheck authorization/source/plugin
# facts under database locks, then atomically journal and CAS.
commit_attempted = True
snapshot_uuid, target_fingerprint = await self._commit(ctx, selection, facts, candidate_entity)
committed = True
await self._activate(ctx, selection, snapshot_uuid, target_fingerprint, runtime, plan, facts)
result.update(state='migrated', code=None)
except (Exception, asyncio.CancelledError) as exc:
cancelled = isinstance(exc, asyncio.CancelledError)
reconciliation_cancel = None
code = (
'operation_cancelled'
if cancelled
else exc.code
if isinstance(exc, MigrationError)
else 'migration_failed'
)
if commit_attempted and not committed and not isinstance(exc, MigrationError):
# A commit can succeed while acknowledgement/connection
# cleanup fails, including cancellation. Make one read
# after the UoW has unwound, in this task's tenant scope;
# never shield/retry through another cancellation.
try:
t = PipelineMigrationSnapshot.__table__
conditions = [
t.c.workspace_uuid == ctx.workspace_uuid,
t.c.pipeline_uuid == selection['pipeline_uuid'],
t.c.source_fingerprint
== (pending['source_fingerprint'] if pending else _fingerprint(row)),
t.c.target_fingerprint == _fingerprint(candidate_entity),
t.c.planner_version == str(PLANNER_VERSION),
]
if pending:
# A retry starts from the target, not the legacy
# source. Only the original snapshot proves this
# durable migration; an unrelated journal cannot.
conditions.extend(
[
t.c.uuid == pending['uuid'],
t.c.target_fingerprint == pending['target_fingerprint'],
]
)
found = (await self.pm.execute_async(sa.select(t.c.uuid).where(*conditions))).first()
committed = found is not None
except asyncio.CancelledError as cancel_exc:
reconciliation_cancel = cancel_exc
cancelled = True
code = 'commit_outcome_unknown'
except Exception:
code = 'commit_outcome_unknown'
state = (
# Unknown is deliberately recovery-needed, not a claim
# of rollback or proof that a snapshot was committed.
'activation_pending'
if committed or code == 'commit_outcome_unknown'
else 'stale'
if code == 'preview_stale'
else 'blocked'
if isinstance(exc, MigrationError)
else 'failed'
)
result.update(state=state, code=code)
if cancelled:
for unstarted in task_context.metadata['results']:
if unstarted['state'] == 'pending':
unstarted.update(state='failed', code='operation_cancelled')
if reconciliation_cancel is not None:
# Preserve cancellation propagation during recovery,
# but record safe outcomes before leaving the task.
raise reconciliation_cancel
return task_context.metadata
# Never attach the original exception, traceback or plugin
# diagnostic to TaskContext: upstream errors can contain keys.
return task_context.metadata
@@ -0,0 +1,33 @@
"""Tenant-owned original configurations; never expose through public serialization."""
import sqlalchemy as sa
from .base import Base
class PipelineMigrationSnapshot(Base):
__tablename__ = 'pipeline_migration_snapshots'
uuid = sa.Column(sa.String(36), primary_key=True)
workspace_uuid = sa.Column(sa.String(36), sa.ForeignKey('workspaces.uuid', ondelete='CASCADE'), nullable=False)
pipeline_uuid = sa.Column(sa.String(255), nullable=False)
source_fingerprint = sa.Column(sa.String(64), nullable=False)
target_fingerprint = sa.Column(sa.String(64), nullable=False)
planner_version = sa.Column(sa.String(64), nullable=False)
source_snapshot = sa.Column(sa.JSON, nullable=False)
state = sa.Column(sa.String(32), nullable=False)
created_at = sa.Column(sa.DateTime, nullable=False, server_default=sa.func.now())
__table_args__ = (
sa.ForeignKeyConstraint(
['workspace_uuid', 'pipeline_uuid'],
['legacy_pipelines.workspace_uuid', 'legacy_pipelines.uuid'],
name='fk_pipeline_migration_workspace_pipeline',
ondelete='CASCADE',
),
sa.UniqueConstraint(
'workspace_uuid', 'pipeline_uuid', 'source_fingerprint', name='uq_pipeline_migration_source'
),
sa.CheckConstraint("state IN ('activation_pending', 'active')", name='ck_pipeline_migration_state'),
sa.Index('ix_pipeline_migration_workspace_pipeline', 'workspace_uuid', 'pipeline_uuid'),
)
@@ -0,0 +1,53 @@
"""Create the manual migration journal only; never convert pipeline JSON."""
from alembic import op
import sqlalchemy as sa
revision = '0027_pipeline_migration'
down_revision = '0026_merge_master_beta'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
table = 'pipeline_migration_snapshots'
if not sa.inspect(bind).has_table(table):
op.create_table(
table,
sa.Column('uuid', sa.String(36), primary_key=True),
sa.Column(
'workspace_uuid', sa.String(36), sa.ForeignKey('workspaces.uuid', ondelete='CASCADE'), nullable=False
),
sa.Column('pipeline_uuid', sa.String(255), nullable=False),
sa.Column('source_fingerprint', sa.String(64), nullable=False),
sa.Column('target_fingerprint', sa.String(64), nullable=False),
sa.Column('planner_version', sa.String(64), nullable=False),
sa.Column('source_snapshot', sa.JSON(), nullable=False),
sa.Column('state', sa.String(32), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(
['workspace_uuid', 'pipeline_uuid'],
['legacy_pipelines.workspace_uuid', 'legacy_pipelines.uuid'],
name='fk_pipeline_migration_workspace_pipeline',
ondelete='CASCADE',
),
sa.UniqueConstraint(
'workspace_uuid', 'pipeline_uuid', 'source_fingerprint', name='uq_pipeline_migration_source'
),
sa.CheckConstraint("state IN ('activation_pending', 'active')", name='ck_pipeline_migration_state'),
)
op.create_index('ix_pipeline_migration_workspace_pipeline', table, ['workspace_uuid', 'pipeline_uuid'])
if bind.dialect.name == 'postgresql':
op.execute(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY')
op.execute(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY')
op.execute(f'DROP POLICY IF EXISTS langbot_workspace_isolation ON {table}')
expression = "workspace_uuid = NULLIF(current_setting('langbot.workspace_uuid', true), '')"
op.execute(
f'CREATE POLICY langbot_workspace_isolation ON {table} FOR ALL TO PUBLIC '
f'USING ({expression}) WITH CHECK ({expression})'
)
def downgrade():
op.drop_table('pipeline_migration_snapshots')
+1
View File
@@ -71,6 +71,7 @@ _ALEMBIC_TENANT_TABLES = {
'rerank_models',
'legacy_pipelines',
'pipeline_run_records',
'pipeline_migration_snapshots',
'plugin_settings',
'knowledge_bases',
'knowledge_base_files',
@@ -0,0 +1,27 @@
"""Transaction-held, tenant/pipeline-scoped admission for form producers.
Every pending producer must hold this row until its INSERT commits. Migration
holds the same row for both CAS and activation. No table/process/advisory lock.
The no-op UPDATE also acquires SQLite's write reservation before any reads.
"""
import sqlalchemy as sa
from ..entity.persistence.pipeline import LegacyPipeline
async def lock_pipeline_admission(session, workspace_uuid: str, pipeline_uuid: str):
if not workspace_uuid or not pipeline_uuid:
raise ValueError('Pipeline admission requires exact ownership')
t = LegacyPipeline.__table__
result = await session.execute(
sa.update(t)
.where(t.c.workspace_uuid == workspace_uuid, t.c.uuid == pipeline_uuid)
.values(updated_at=t.c.updated_at)
)
if result.rowcount != 1:
raise ValueError('Pipeline admission authority unavailable')
return (
await session.execute(
sa.select(t.c.config).where(t.c.workspace_uuid == workspace_uuid, t.c.uuid == pipeline_uuid)
)
).scalar_one()
+15 -2
View File
@@ -58,6 +58,7 @@ TENANT_TABLE_COLUMNS: dict[str, str] = {
'rerank_models': 'workspace_uuid',
'legacy_pipelines': 'workspace_uuid',
'pipeline_run_records': 'workspace_uuid',
'pipeline_migration_snapshots': 'workspace_uuid',
'plugin_settings': 'workspace_uuid',
'knowledge_bases': 'workspace_uuid',
'knowledge_base_files': 'workspace_uuid',
@@ -467,9 +468,21 @@ def _validate_scoped_statement_call(args: tuple[typing.Any, ...], kwargs: dict[s
raise ScopedSessionTransactionError('TenantUnitOfWork does not allow literal-execute SQL parameters')
if isinstance(element, sqlalchemy.sql.elements.Cast) and type(element.type) not in {Vector, HALFVEC}:
raise ScopedSessionTransactionError(
'TenantUnitOfWork only allows the trusted pgvector cast used by tenant vector search'
# Manual Pipeline migration compares DB-native JSON text, because
# PostgreSQL JSON has no equality operator. Permit only these fixed
# mapped columns and a built-in Text target, never arbitrary casts.
source = element.clause
pipeline_json_cas = (
type(element.type) is sqlalchemy.Text
and isinstance(source, sqlalchemy.Column)
and type(source.type) is sqlalchemy.JSON
and getattr(getattr(source, 'table', None), 'name', None) == 'legacy_pipelines'
and source.name in {'config', 'stages', 'extensions_preferences'}
)
if not pipeline_json_cas:
raise ScopedSessionTransactionError(
'TenantUnitOfWork only allows trusted pgvector and Pipeline JSON CAS casts'
)
if isinstance(element, sqlalchemy.sql.functions.FunctionElement):
function_name = str(getattr(element, 'name', '')).casefold()
@@ -0,0 +1,692 @@
"""Pure planning for explicitly requested legacy Pipeline migrations.
The candidate config is internal-only: never serialize it in a preview or log
it. ``ready`` means a config candidate, NOT permission to execute: installation,
resource ownership and existing conversation-state checks belong to the caller.
No IO, registry access, defaults from UI schemas, or runtime mutation occurs here.
Reviewed native source: 9b7ba0d64708496ace30a82866f6dbc185f089dc.
Reviewed official plugins: local parity patches over 678b18fd65af98230806c3b97b649514c83acea9.
Patch manifest versions below are required; version alone is not artifact proof.
"""
from __future__ import annotations
import copy
import json
import math
import re
from urllib.parse import urlsplit
PLANNER_VERSION = '3'
_TARGETS = {
'local-agent': ('LocalAgent', '0.1.6'),
'dify-service-api': ('DifyAgent', '0.1.7'),
'coze-api': ('CozeAgent', '0.1.7'),
'dashscope-app-api': ('DashScopeAgent', '0.1.7'),
'n8n-service-api': ('N8nAgent', '0.1.7'),
'langflow-api': ('LangflowAgent', '0.1.7'),
'deerflow-api': ('DeerFlowAgent', '0.1.7'),
'tbox-app-api': ('TboxAgent', '0.1.5'),
'weknora-api': ('WeKnoraAgent', '0.1.7'),
}
_DEERFLOW_FIELDS = {
'api-base',
'api-key',
'auth-header',
'assistant-id',
'model-name',
'thinking-enabled',
'plan-mode',
'subagent-enabled',
'max-concurrent-subagents',
'timeout',
'recursion-limit',
}
_FIELDS = {
'local-agent': {
'model',
'max-round',
'prompt',
'box-session-id-template',
'rerank-model',
'rerank-top-k',
'enable-all-tools',
'tools',
'knowledge-bases',
'knowledge-base',
'mcp-resources',
'mcp-resource-agent-read-enabled',
},
'dify-service-api': {'base-url', 'api-key', 'app-type', 'base-prompt', 'timeout'},
'coze-api': {'api-key', 'bot-id', 'api-base', 'timeout', 'auto-save-history', 'auto_save_history'},
'dashscope-app-api': {'app-type', 'api-key', 'app-id', 'references_quote', 'references-quote'},
'n8n-service-api': {
'webhook-url',
'auth-type',
'basic-username',
'basic-password',
'jwt-secret',
'jwt-algorithm',
'header-name',
'header-value',
'timeout',
'output-key',
'response-handling',
},
'langflow-api': {
'base-url',
'api-key',
'flow-id',
'input-type',
'output-type',
'input_type',
'output_type',
'tweaks',
},
'deerflow-api': _DEERFLOW_FIELDS,
'tbox-app-api': {'api-key', 'app-id'},
'weknora-api': {
'base-url',
'api-key',
'app-type',
'agent-id',
'knowledge-base-ids',
'web-search-enabled',
'timeout',
'base-prompt',
},
}
_REQUIRED = {
'local-agent': ('model', 'prompt'),
'dify-service-api': ('base-url', 'api-key', 'app-type', 'base-prompt'),
'coze-api': ('api-key', 'bot-id', 'api-base', 'timeout'),
'dashscope-app-api': ('app-type', 'api-key', 'app-id'),
'n8n-service-api': ('webhook-url',),
'langflow-api': ('base-url', 'api-key', 'flow-id'),
'deerflow-api': ('api-base',),
'tbox-app-api': ('api-key', 'app-id'),
'weknora-api': ('base-url', 'api-key', 'app-type'),
}
_BOOLEANS = {
'enable-all-tools',
'mcp-resource-agent-read-enabled',
'auto-save-history',
'auto_save_history',
'thinking-enabled',
'plan-mode',
'subagent-enabled',
'web-search-enabled',
}
_INTEGERS = {'max-round', 'rerank-top-k', 'max-concurrent-subagents', 'recursion-limit'}
_NAME_LISTS = {'tools', 'knowledge-bases', 'knowledge-base-ids'}
_DEERFLOW_DEFAULTS = {
'api-key': '',
'auth-header': '',
'assistant-id': 'lead_agent',
'model-name': '',
'thinking-enabled': False,
'plan-mode': False,
'subagent-enabled': False,
'max-concurrent-subagents': 3,
'timeout': 300,
'recursion-limit': 1000,
}
# Reviewed local patch manifests and implementations explicitly preserve native
# provider identity using trusted Host conversation fields; absent context fails
# closed in the plugin. Caller must verify the installed artifact/Host contract.
_IDENTITY_SOURCES = {
'dify-service-api': 'legacy-session',
'coze-api': 'legacy-session',
'n8n-service-api': 'legacy-session',
'tbox-app-api': 'legacy-bot',
}
_LOCAL_DEFAULTS = {
'advanced-settings': False,
'date-grounding': True,
'timeout': 300,
'retrieval-top-k': 5,
'rerank-model': '',
'rerank-top-k': 5,
'max-tool-iterations': 100,
'tool-execution-mode': 'serial',
'max-tool-result-chars': 20000,
'context-history-fetch-limit': 50,
'context-window-tokens': 200000,
'context-reserve-tokens': 16384,
'context-keep-recent-tokens': 20000,
'context-summary-tokens': 8000,
'enable-all-tools': True,
'tools': [],
'knowledge-bases': [],
}
_DEFAULTS = {
'local-agent': _LOCAL_DEFAULTS,
'dify-service-api': {'timeout': 30, 'advanced-settings': False},
'coze-api': {'auto-save-history': True, 'advanced-settings': False},
'dashscope-app-api': {'references_quote': '参考资料来自:', 'timeout': 120, 'advanced-settings': False},
'n8n-service-api': {
'auth-type': 'none',
'basic-username': '',
'basic-password': '',
'jwt-secret': '',
'jwt-algorithm': 'HS256',
'header-name': '',
'header-value': '',
'timeout': 120,
'output-key': 'response',
'response-handling': 'reply',
'basic-encoding': 'latin1',
'advanced-settings': False,
},
'langflow-api': {'input-type': 'chat', 'output-type': 'chat', 'tweaks': {}, 'advanced-settings': False},
'deerflow-api': _DEERFLOW_DEFAULTS,
'tbox-app-api': {'timeout': 120},
'weknora-api': {
'knowledge-base-ids': [],
'web-search-enabled': False,
'timeout': 120,
'base-prompt': '请回答用户的问题。',
'advanced-settings': False,
},
}
_ASSET_RUNNERS = {'dify-service-api', 'coze-api', 'dashscope-app-api', 'n8n-service-api', 'langflow-api'}
_REMOVE_THINK_RUNNERS = {'local-agent', 'dify-service-api', 'coze-api', 'dashscope-app-api', 'tbox-app-api'}
_REASONING_LEVELS = {'provider_default', 'disabled', 'enabled', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'}
def _strict_json(value, ancestors=None, depth=0):
"""Reject cycles, custom objects, nonfinite numbers and non-string keys."""
if depth > 100:
return False
kind = type(value)
if value is None or kind in (str, bool, int):
return True
if kind is float:
return math.isfinite(value)
if kind not in (dict, list):
return False
ancestors = set() if ancestors is None else ancestors
identity = id(value)
if identity in ancestors:
return False
ancestors.add(identity)
try:
if kind is dict:
return all(type(k) is str and _strict_json(v, ancestors, depth + 1) for k, v in value.items())
return all(_strict_json(v, ancestors, depth + 1) for v in value)
finally:
ancestors.remove(identity)
def _block(result, code, field):
"""Only developer-owned codes and paths may reach the public projection."""
diagnostic = {'code': code, 'field': field}
if diagnostic not in result['blockers']:
result['blockers'].append(diagnostic)
result['state'] = 'blocked'
return result
def _names(value):
return type(value) is list and all(type(item) is str and bool(item) for item in value)
def _attachments(value):
return type(value) is list and all(
type(item) is dict and ('enabled' not in item or type(item['enabled']) is bool) for item in value
)
def _validate_preferences(result, preferences, name):
if preferences is None:
return
if type(preferences) is not dict or not _strict_json(preferences):
_block(result, 'invalid_extension_preferences', 'extensions_preferences')
return
for field in (
'enable_all_plugins',
'enable_all_mcp_servers',
'enable_all_skills',
'mcp_resource_agent_read_enabled',
):
if field in preferences and type(preferences[field]) is not bool:
_block(result, 'invalid_extension_preferences', f'extensions_preferences.{field}')
for field in ('mcp_servers', 'skills'):
if field in preferences and not _names(preferences[field]):
_block(result, 'invalid_extension_preferences', f'extensions_preferences.{field}')
if 'mcp_resources' in preferences and not _attachments(preferences['mcp_resources']):
_block(result, 'invalid_extension_preferences', 'extensions_preferences.mcp_resources')
plugins = preferences.get('plugins', [])
if type(plugins) is not list or not all(
type(item) is dict and all(type(item.get(k)) is str and item[k] for k in ('author', 'name')) for item in plugins
):
_block(result, 'invalid_extension_preferences', 'extensions_preferences.plugins')
elif preferences.get('enable_all_plugins', True) is False and not any(
item['author'] == 'langbot-team' and item['name'] == name for item in plugins
):
# The immutable result contract contains no extension-preference patch.
# Require explicit binding instead of silently flipping allow-all.
_block(result, 'extensions.runner_excluded', 'extensions_preferences.plugins')
def _warn(result, code, field):
diagnostic = {'code': code, 'field': field}
if diagnostic not in result['warnings']:
result['warnings'].append(diagnostic)
def _message_shape(value, shape, required=()):
# SDK Message schemas, without importing runtime services or coercing values.
return (
type(value) is dict
and not (set(value) - set(shape))
and all(key in value for key in required)
and all(check(value[key]) for key, check in shape.items() if key in value)
)
def _valid_prompt(prompt):
def string(value):
return type(value) is str
def optional_string(value):
return value is None or string(value)
def metadata(value):
return value is None or type(value) is dict
def content(value):
if value is None or string(value):
return True
fields = {key: optional_string for key in ('text', 'image_base64', 'file_url', 'file_base64', 'file_name')}
fields.update(type=string, image_url=lambda v: v is None or _message_shape(v, {'url': string}, ('url',)))
return type(value) is list and all(_message_shape(v, fields, ('type',)) for v in value)
def tools(value):
fields = {
'id': string,
'type': string,
'function': lambda v: _message_shape(v, {'name': string, 'arguments': string}, ('name', 'arguments')),
'provider_specific_fields': metadata,
}
return value is None or (
type(value) is list and all(_message_shape(v, fields, ('id', 'type', 'function')) for v in value)
)
fields = {key: optional_string for key in ('name', 'tool_call_id', 'resp_message_id')}
fields.update(role=string, content=content, tool_calls=tools, provider_specific_fields=metadata)
return type(prompt) is list and all(_message_shape(item, fields, ('role',)) for item in prompt)
def _validate_local(result, section):
prefix = 'ai.local-agent'
model = section.get('model')
if type(model) is dict:
if set(model) - {'primary', 'fallbacks', 'reasoning'}:
_block(result, 'unknown_field', f'{prefix}.model')
if type(model.get('primary')) is not str or not model['primary'] or not _names(model.get('fallbacks', [])):
_block(result, 'invalid_type', f'{prefix}.model')
reasoning = model.get('reasoning', {})
if type(reasoning) is not dict or not all(type(v) is str for v in reasoning.values()):
_block(result, 'invalid_type', f'{prefix}.model.reasoning')
elif any(value not in _REASONING_LEVELS for value in reasoning.values()):
_block(result, 'local.reasoning_value', f'{prefix}.model')
elif reasoning:
_warn(result, 'local.model_reasoning', f'{prefix}.model.reasoning')
elif type(model) is not str or not model:
_block(result, 'invalid_type', f'{prefix}.model')
if not _valid_prompt(section.get('prompt')):
_block(result, 'local.prompt_shape', f'{prefix}.prompt')
if section.get('box-session-id-template') not in (None, '', '{launcher_type}_{launcher_id}'):
_block(result, 'local.box_scope', f'{prefix}.box-session-id-template')
_warn(result, 'local.context_defaults', f'{prefix}.max-round')
_warn(result, 'local.serial_tools_preserved', f'{prefix}.tools')
_warn(result, 'local.retrieval_defaults', f'{prefix}.knowledge-bases')
_warn(result, 'local.box_state_reset', f'{prefix}.box-session-id-template')
def _unique_json_object(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ValueError('duplicate_key')
result[key] = value
return result
def _parse_tweaks(raw):
if raw is None or (type(raw) is str and not raw.strip()):
return {}
decoded = json.loads(raw, object_pairs_hook=_unique_json_object) if type(raw) is str else raw
if decoded is None:
return {}
if type(decoded) is not dict or not _strict_json(decoded):
raise ValueError('invalid_tweaks')
return decoded
def _validate_external(result, legacy, section):
prefix = f'ai.{legacy}'
app_types = {
'dify-service-api': ('chat', 'agent', 'workflow', 'chatflow'),
'dashscope-app-api': ('agent', 'workflow'),
'weknora-api': ('chat', 'agent'),
}
required_strings = {
'dify-service-api': ('api-key', 'base-url'),
'coze-api': ('api-key', 'bot-id'),
'dashscope-app-api': ('api-key', 'app-id'),
'n8n-service-api': ('webhook-url',),
'langflow-api': ('base-url', 'api-key', 'flow-id'),
'tbox-app-api': ('api-key', 'app-id'),
'weknora-api': ('api-key', 'base-url'),
}
for field in required_strings.get(legacy, ()):
value = section.get(field)
if type(value) is str and (not value or (legacy == 'weknora-api' and not value.strip())):
_block(result, 'invalid_value', f'{prefix}.{field}')
if legacy in app_types and 'app-type' in section and section['app-type'] not in app_types[legacy]:
_block(result, 'invalid_value', f'{prefix}.app-type')
if legacy == 'coze-api':
actual, alias = section.get('auto_save_history'), section.get('auto-save-history')
if type(actual) is bool and type(alias) is bool and actual != alias:
_block(result, 'coze.history_alias', f'{prefix}.auto_save_history')
if 'auto_save_history' in section or 'auto-save-history' in section:
_warn(result, 'migration.alias_repaired', f'{prefix}.auto_save_history')
if actual is None and alias is None:
_warn(result, 'migration.null_default', f'{prefix}.auto_save_history')
base = section.get('api-base')
try:
parsed = urlsplit(base) if type(base) is str else None
if (
parsed is None
or parsed.scheme not in ('http', 'https')
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
or any(c.isspace() or ord(c) < 32 for c in base)
):
raise ValueError
_ = parsed.port
except ValueError:
_block(result, 'coze.custom_endpoint', f'{prefix}.api-base')
_warn(result, 'coze.persistent_history', prefix)
elif legacy == 'dashscope-app-api':
if 'references-quote' in section:
if 'references_quote' in section and section['references_quote'] != section['references-quote']:
_block(result, 'dashscope.references_alias', f'{prefix}.references_quote')
else:
_warn(result, 'migration.alias_repaired', f'{prefix}.references-quote')
elif legacy == 'langflow-api':
for axis in ('input', 'output'):
actual, ui = f'{axis}_type', f'{axis}-type'
if ui in section and section[ui] != section.get(actual, 'chat'):
_block(result, 'langflow.io_alias', f'{prefix}.{actual}')
if actual in section:
_warn(result, 'migration.alias_repaired', f'{prefix}.{actual}')
try:
_parse_tweaks(section.get('tweaks'))
except (ValueError, RecursionError):
_block(result, 'langflow.invalid_tweaks', f'{prefix}.tweaks')
if section.get('tweaks') is None or (
type(section.get('tweaks')) is str
and (not section['tweaks'].strip() or section['tweaks'].strip() == 'null')
):
_warn(result, 'langflow.tweaks_default', f'{prefix}.tweaks')
_warn(result, 'langflow.persistent_history', prefix)
elif legacy == 'dify-service-api':
_warn(result, 'dify.timeout_default', f'{prefix}.timeout')
elif legacy == 'n8n-service-api':
mode = section.get('auth-type', 'none')
auth_fields = {
'none': (),
'basic': ('basic-username', 'basic-password'),
'jwt': ('jwt-secret',),
'header': ('header-name', 'header-value'),
}
if type(mode) is not str or mode not in auth_fields:
_block(result, 'invalid_value', f'{prefix}.auth-type')
else:
for field in auth_fields[mode]:
if field not in section:
_block(result, 'missing_field', f'{prefix}.{field}')
response = section.get('response-handling', 'reply')
if response not in ('reply', 'ignore'):
_block(result, 'invalid_value', f'{prefix}.response-handling')
if mode == 'basic':
for field in ('basic-username', 'basic-password'):
value = section.get(field)
if type(value) is str:
try:
value.encode('latin1')
if field == 'basic-username' and ':' in value:
raise ValueError
except (UnicodeEncodeError, ValueError):
_block(result, 'n8n.basic_encoding', f'{prefix}.{field}')
if mode == 'header' and type(section.get('header-name')) is str:
if not re.fullmatch(r"[!#$%&'*+.^_`|~0-9A-Za-z-]+", section['header-name']):
_block(result, 'n8n.header_name', f'{prefix}.header-name')
_warn(result, 'n8n.session_ids_reset', prefix)
elif legacy == 'weknora-api':
knowledge = section.get('knowledge-base-ids')
if type(knowledge) is list and any(type(v) is str and not v.strip() for v in knowledge):
_block(result, 'invalid_value', f'{prefix}.knowledge-base-ids')
if legacy in ('coze-api', 'n8n-service-api', 'weknora-api'):
timeout = section.get('timeout', 120)
if type(timeout) in (int, float) and timeout <= 0:
_block(result, 'invalid_value', f'{prefix}.timeout')
def _validate_section(result, legacy, section):
prefix = f'ai.{legacy}'
if set(section) - _FIELDS[legacy]:
_block(result, 'unknown_field', prefix)
for field in _REQUIRED[legacy]:
if field not in section:
_block(result, 'missing_field', f'{prefix}.{field}')
# Iterate only known keys, never put an arbitrary saved key in diagnostics.
for field in sorted(_FIELDS[legacy] & section.keys()):
value = section[field]
if field in ('model', 'prompt', 'tweaks') or (legacy == 'dify-service-api' and field == 'timeout'):
continue
if value is None and (
(legacy == 'coze-api' and field in ('auto_save_history', 'auto-save-history'))
or (legacy == 'weknora-api' and field in ('agent-id', 'knowledge-base-ids'))
):
continue
if field in _BOOLEANS:
valid = type(value) is bool
elif field in _INTEGERS or (field == 'timeout' and legacy == 'deerflow-api'):
valid = type(value) is int
elif field == 'timeout':
valid = type(value) in (int, float)
elif field in _NAME_LISTS:
valid = _names(value)
elif field == 'mcp-resources':
valid = _attachments(value)
else:
valid = type(value) is str
if not valid:
_block(result, 'invalid_type', f'{prefix}.{field}')
if legacy == 'local-agent':
_validate_local(result, section)
else:
_validate_external(result, legacy, section)
if legacy == 'deerflow-api':
base = section.get('api-base')
if type(base) is str and not base.strip().startswith(('http://', 'https://')):
_block(result, 'invalid_value', f'{prefix}.api-base')
def _validate_output(result, config, legacy):
output = config.get('output', {})
if type(output) is not dict:
_block(result, 'invalid_type', 'output')
return
misc = output.get('misc', {})
if type(misc) is not dict:
_block(result, 'invalid_type', 'output.misc')
return
value = misc.get('remove-think', False)
if type(value) is not bool:
_block(result, 'invalid_type', 'output.misc.remove-think')
def _assemble(result, legacy, section, config, preferences):
selected = {**copy.deepcopy(_DEFAULTS[legacy]), **copy.deepcopy(section)}
prefix = f'ai.{legacy}'
if legacy in _IDENTITY_SOURCES:
selected['user-id-source'] = _IDENTITY_SOURCES[legacy]
if legacy == 'local-agent':
selected.pop('max-round', None)
selected.pop('box-session-id-template', None)
singular = selected.pop('knowledge-base', '')
if not selected['knowledge-bases'] and singular and singular != '__none__':
selected['knowledge-bases'] = [singular]
_warn(result, 'migration.alias_repaired', f'{prefix}.knowledge-base')
model = selected['model']
selected['model'] = (
{'primary': model, 'fallbacks': [], 'reasoning': {}}
if type(model) is str
else {'fallbacks': [], 'reasoning': {}, **model}
)
preferences = preferences or {}
for field, default in (('mcp-resources', []), ('mcp-resource-agent-read-enabled', True)):
selected[field] = copy.deepcopy(section.get(field, preferences.get(field.replace('-', '_'), default)))
else:
selected.update(
{
'enable-all-tools': False,
'tools': [],
'knowledge-bases': [],
'mcp-resources': [],
'mcp-resource-agent-read-enabled': False,
}
)
if legacy in _ASSET_RUNNERS:
selected['langbot-assets-enabled'] = False
if legacy in _REMOVE_THINK_RUNNERS:
selected['remove-think'] = config.get('output', {}).get('misc', {}).get('remove-think', False)
_warn(result, 'migration.output_policy_copied', 'output.misc.remove-think')
if legacy == 'dify-service-api':
selected['timeout'] = 30
elif legacy == 'coze-api':
actual = selected.pop('auto_save_history', None)
selected['auto-save-history'] = actual if type(actual) is bool else section.get('auto-save-history')
if selected['auto-save-history'] is None:
selected['auto-save-history'] = True
elif legacy == 'dashscope-app-api':
if 'references-quote' in selected:
selected['references_quote'] = selected.pop('references-quote')
elif legacy == 'langflow-api':
for axis in ('input', 'output'):
selected[f'{axis}-type'] = selected.pop(f'{axis}_type', selected[f'{axis}-type'])
selected['tweaks'] = copy.deepcopy(_parse_tweaks(section.get('tweaks')))
elif legacy == 'weknora-api':
selected.setdefault(
'agent-id', 'builtin-quick-answer' if selected['app-type'] == 'chat' else 'builtin-smart-reasoning'
)
if selected['knowledge-base-ids'] is None:
selected['knowledge-base-ids'] = []
_warn(result, 'weknora.session_title_changed', prefix)
return selected
def plan_legacy_pipeline(config, extensions_preferences=None) -> dict:
"""Return a detached config candidate or safe, value-free diagnostics."""
result = {
'state': 'not_legacy',
'legacy_runner': None,
'target_runner_id': None,
'target_plugin': None,
'config': None,
'changed_paths': [],
'blockers': [],
'warnings': [],
}
if type(config) is not dict:
return _block(result, 'invalid_type', 'config')
if not _strict_json(config):
return _block(result, 'invalid_json_value', 'config')
ai = config.get('ai', {})
if type(ai) is not dict:
return _block(result, 'invalid_type', 'ai')
selection = ai.get('runner', {})
if type(selection) is not dict:
return _block(result, 'invalid_type', 'ai.runner')
if 'id' in selection and 'runner' in selection:
return _block(result, 'mixed_runner_selection', 'ai.runner')
if 'id' in selection:
current = selection['id']
if type(current) is not str or not re.fullmatch(r'plugin:[^/\s]+/[^/\s]+/[^/\s]+', current):
return _block(result, 'invalid_runner_id', 'ai.runner.id')
result['state'] = 'already_current'
return result
if 'runner' not in selection:
return result
legacy = selection['runner']
if type(legacy) is not str:
return _block(result, 'invalid_type', 'ai.runner.runner')
if legacy not in _TARGETS:
return result
name, version = _TARGETS[legacy]
target = f'plugin:langbot-team/{name}/default'
result.update(
legacy_runner=legacy,
target_runner_id=target,
target_plugin={'author': 'langbot-team', 'name': name, 'version': version},
)
if 'runner_config' in ai:
if type(ai['runner_config']) is not dict:
return _block(result, 'invalid_type', 'ai.runner_config')
if ai['runner_config']:
return _block(result, 'mixed_runner_config', 'ai.runner_config')
if 'expire-time' in selection:
expiry = selection['expire-time']
if type(expiry) is not int or expiry < 0:
_block(result, 'invalid_expiry', 'ai.runner.expire-time')
if set(ai) - set(_TARGETS) - {'runner', 'runner_config'}:
_block(result, 'unknown_field', 'ai')
if set(selection) - {'runner', 'expire-time'}:
_block(result, 'unknown_field', 'ai.runner')
if legacy not in ai:
return _block(result, 'missing_field', f'ai.{legacy}')
section = ai[legacy]
if type(section) is not dict:
return _block(result, 'invalid_type', f'ai.{legacy}')
_validate_section(result, legacy, section)
_validate_preferences(result, extensions_preferences, name)
_validate_output(result, config, legacy)
_warn(result, 'external.state_validation_required', 'ai.runner')
_warn(result, 'migration.history_reset', 'ai.runner')
_warn(result, 'migration.new_defaults', f'ai.{legacy}')
_warn(result, 'migration.legacy_sections_archived', 'ai')
if legacy in ('dify-service-api', 'dashscope-app-api', 'n8n-service-api'):
_warn(result, 'migration.filtered_variables', f'ai.{legacy}')
if legacy in _IDENTITY_SOURCES:
_warn(result, 'migration.identity_preserved', f'ai.{legacy}')
if result['blockers']:
return result
candidate = copy.deepcopy(config)
candidate['ai']['runner'].pop('runner')
candidate['ai']['runner']['id'] = target
selected = _assemble(result, legacy, section, config, extensions_preferences)
candidate['ai']['runner_config'] = {target: selected}
# The durable transaction stores the full source, including inactive legacy
# credentials. The active configuration must be canonical so the guarded
# editor never needs to interpret mixed legacy/current containers.
archived_paths = []
for old_runner in _TARGETS:
if old_runner in candidate['ai']:
candidate['ai'].pop(old_runner)
archived_paths.append(f'ai.{old_runner}')
result.update(
state='ready',
config=candidate,
changed_paths=['ai.runner.runner', 'ai.runner.id', 'ai.runner_config', *archived_paths],
)
return result
+21 -5
View File
@@ -636,6 +636,16 @@ class PipelineManager:
return dataclasses.replace(context, pipeline_uuid=pipeline_uuid)
async def load_pipeline(
self,
context: ExecutionContext | RequestContext,
pipeline_entity,
*,
_binding_validated: bool = False,
):
candidate = await self.prepare_pipeline(context, pipeline_entity, _binding_validated=_binding_validated)
self.publish_pipeline(candidate)
async def prepare_pipeline(
self,
context: ExecutionContext | RequestContext,
pipeline_entity: persistence_pipeline.LegacyPipeline
@@ -657,8 +667,6 @@ class PipelineManager:
execution_context.workspace_uuid,
expected_generation=execution_context.placement_generation,
)
self._observe_execution_context(execution_context)
coerce_pipeline_config(
pipeline_entity.config,
getattr(self.ap, 'pipeline_config_meta_trigger', {'name': 'trigger', 'stages': []}),
@@ -685,17 +693,25 @@ class PipelineManager:
execution_context.workspace_uuid,
expected_generation=execution_context.placement_generation,
)
self._observe_execution_context(execution_context)
runtime_pipeline = RuntimePipeline(
return RuntimePipeline(
self.ap,
pipeline_entity,
stage_containers,
execution_context,
)
def publish_pipeline(self, runtime_pipeline: RuntimePipeline) -> None:
"""Publish a prepared candidate without yielding.
Callers preparing before a database commit must recheck the committed
source and execution binding before invoking this synchronous seam.
"""
execution_context = runtime_pipeline.execution_context
self._observe_execution_context(execution_context)
key = (
execution_context.instance_uuid,
execution_context.workspace_uuid,
pipeline_entity.uuid,
runtime_pipeline.pipeline_entity.uuid,
)
self._pipelines_by_key[key] = runtime_pipeline
self._pipeline_keys_by_scope.setdefault(key[:2], set()).add(key)
+37 -4
View File
@@ -52,6 +52,7 @@ from ..entity.persistence import model as persistence_model
from ..core import app
from ..utils import constants
from ..agent.runner.session_registry import get_session_registry
from ..agent.runner.model_reasoning import model_with_reasoning_override
from ..agent.runner.config_resolver import RunnerConfigResolver
from ..agent.runner import config_schema
from ..agent.runner.platform_tools import execute_platform_tool, get_platform_tool_detail, resolve_platform_api_call
@@ -204,6 +205,7 @@ async def _validate_run_authorization(
ap: app.Application,
caller_plugin_identity: str | None = None,
operation: str | None = None,
workspace_uuid: str | None = None,
) -> Union[tuple[None, handler.ActionResponse], tuple[Any, None]]:
"""Validate run_id authorization for a resource access.
@@ -250,6 +252,9 @@ async def _validate_run_authorization(
message=f'Plugin identity mismatch: caller {caller_plugin_identity} is not authorized for run_id {run_id}',
)
if workspace_uuid is not None and session['authorization'].get('workspace_id') not in (None, workspace_uuid):
return None, handler.ActionResponse.error(message='Run session belongs to another Workspace')
if not session_registry.is_resource_allowed(session, resource_type, resource_id, operation):
ap.logger.warning(
f'{resource_type.upper()}: {resource_id} operation {operation or "*"} not allowed for run_id {run_id}'
@@ -1341,11 +1346,19 @@ class RuntimeConnectionHandler(handler.Handler):
caller_plugin_identity = data.get('caller_plugin_identity')
if run_id:
_session, error = await _validate_run_authorization(
run_id, 'model', llm_model_uuid, self.ap, caller_plugin_identity, operation='count_tokens'
session, error = await _validate_run_authorization(
run_id,
'model',
llm_model_uuid,
self.ap,
caller_plugin_identity,
operation='count_tokens',
workspace_uuid=action_context.workspace_uuid,
)
if error:
return error
else:
session = None
if not await self._resource_exists(
persistence_model.LLMModel,
@@ -1366,6 +1379,9 @@ class RuntimeConnectionHandler(handler.Handler):
message=f'LLM model with llm_model_uuid {llm_model_uuid} not found',
)
if getattr(llm_model.model_entity, 'workspace_uuid', None) not in (None, action_context.workspace_uuid):
return handler.ActionResponse.error(message='LLM model belongs to another Workspace')
llm_model = model_with_reasoning_override(llm_model, llm_model_uuid, session)
messages_obj = [provider_message.Message.model_validate(message) for message in messages]
async def _placeholder_func(**kwargs):
@@ -1407,7 +1423,13 @@ class RuntimeConnectionHandler(handler.Handler):
# Permission validation for Runner calls
if run_id:
session, error = await _validate_run_authorization(
run_id, 'model', llm_model_uuid, self.ap, caller_plugin_identity, operation='invoke'
run_id,
'model',
llm_model_uuid,
self.ap,
caller_plugin_identity,
operation='invoke',
workspace_uuid=action_context.workspace_uuid,
)
if error:
return error
@@ -1441,6 +1463,7 @@ class RuntimeConnectionHandler(handler.Handler):
message=f'LLM model with llm_model_uuid {llm_model_uuid} not found',
)
llm_model = model_with_reasoning_override(llm_model, llm_model_uuid, session)
messages_obj = [provider_message.Message.model_validate(message) for message in messages]
# The func field is excluded during model_dump() in plugin side (marked as exclude=True),
@@ -1500,7 +1523,13 @@ class RuntimeConnectionHandler(handler.Handler):
# Permission validation for Runner calls
if run_id:
session, error = await _validate_run_authorization(
run_id, 'model', llm_model_uuid, self.ap, caller_plugin_identity, operation='stream'
run_id,
'model',
llm_model_uuid,
self.ap,
caller_plugin_identity,
operation='stream',
workspace_uuid=action_context.workspace_uuid,
)
if error:
yield error
@@ -1528,6 +1557,10 @@ class RuntimeConnectionHandler(handler.Handler):
)
return
if getattr(llm_model.model_entity, 'workspace_uuid', None) not in (None, action_context.workspace_uuid):
yield handler.ActionResponse.error(message='LLM model belongs to another Workspace')
return
llm_model = model_with_reasoning_override(llm_model, llm_model_uuid, session)
messages_obj = [provider_message.Message.model_validate(message) for message in messages]
# The func field is excluded during model_dump() in plugin side
@@ -235,7 +235,10 @@ class CodexRequester(requester.ProviderAPIRequester):
'stream': True,
'include': ['reasoning.encrypted_content'],
}
level = reasoning.normalize_reasoning_config(getattr(model.model_entity, 'reasoning_config', None))['level']
reasoning_config = getattr(model, 'reasoning_config_override', None)
if reasoning_config is None:
reasoning_config = getattr(model.model_entity, 'reasoning_config', None)
level = reasoning.normalize_reasoning_config(reasoning_config)['level']
if level != 'provider_default':
reasoning.validate_reasoning_capabilities(
{'level': level}, self.get_reasoning_capabilities(model), model.model_entity.name
+11
View File
@@ -0,0 +1,11 @@
{
"local-agent": {"model": {"primary": "synthetic-model", "fallbacks": []}, "max-round": 10, "prompt": [{"role": "system", "content": "Synthetic prompt"}], "enable-all-tools": false, "tools": []},
"dify-service-api": {"base-url": "https://dify.invalid/v1", "api-key": "synthetic-key", "app-type": "chat", "base-prompt": "Synthetic prompt"},
"coze-api": {"api-key": "synthetic-key", "bot-id": "synthetic-bot", "api-base": "https://api.coze.cn", "auto_save_history": false, "timeout": 120},
"dashscope-app-api": {"api-key": "synthetic-key", "app-id": "synthetic-app", "app-type": "agent", "references_quote": "Synthetic references"},
"n8n-service-api": {"webhook-url": "https://n8n.invalid/hook?token=synthetic", "auth-type": "none", "timeout": 120, "output-key": "response", "response-handling": "reply"},
"langflow-api": {"base-url": "https://langflow.invalid", "api-key": "synthetic-key", "flow-id": "synthetic-flow", "input_type": "chat", "output_type": "chat", "tweaks": "{}"},
"deerflow-api": {"api-base": "https://deerflow.invalid", "api-key": "synthetic-key", "auth-header": "", "assistant-id": "lead_agent", "model-name": "synthetic-model", "thinking-enabled": false, "plan-mode": false, "subagent-enabled": false, "max-concurrent-subagents": 3, "timeout": 300, "recursion-limit": 1000},
"tbox-app-api": {"api-key": "synthetic-key", "app-id": "synthetic-app"},
"weknora-api": {"base-url": "https://weknora.invalid/api/v1", "api-key": "synthetic-key", "app-type": "agent", "knowledge-base-ids": ["remote-kb"], "web-search-enabled": false}
}
@@ -0,0 +1,226 @@
"""Disposable PostgreSQL only: real row-lock admission, rollback, and FORCE RLS."""
import asyncio
import os
import copy
import pytest
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import create_async_engine
from tests.unit_tests.api.service import test_pipeline_migration as base
from langbot.pkg.agent.runner.interaction_store import InteractionStore, InteractionScopeError
from langbot.pkg.persistence.pipeline_admission import lock_pipeline_admission
from langbot.pkg.entity.persistence.agent_interaction import AgentInteraction
URL = os.environ.get('LANGBOT_ADMISSION_TEST_URL')
pytestmark = [pytest.mark.asyncio, pytest.mark.skipif(not URL, reason='disposable PostgreSQL URL required')]
@pytest.fixture
async def env(tmp_path, monkeypatch):
admin = create_async_engine(URL)
async with admin.begin() as conn:
await conn.execute(sa.text('DROP SCHEMA public CASCADE'))
await conn.execute(sa.text('CREATE SCHEMA public'))
await conn.execute(
sa.text(
'DO $$ BEGIN CREATE ROLE admission_runtime LOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$'
)
)
monkeypatch.setattr(base, 'create_async_engine', lambda *a, **kw: admin)
async for env in base.env.__wrapped__(tmp_path, monkeypatch):
async with admin.begin() as conn:
for table in ['legacy_pipelines', 'pipeline_migration_snapshots']:
await conn.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY'))
await conn.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY'))
await conn.execute(
sa.text(
f"CREATE POLICY isolation ON {table} USING (workspace_uuid = current_setting('langbot.workspace_uuid', true)) WITH CHECK (workspace_uuid = current_setting('langbot.workspace_uuid', true))"
)
)
await conn.execute(sa.text('GRANT USAGE ON SCHEMA public TO admission_runtime'))
await conn.execute(
sa.text('GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO admission_runtime')
)
await conn.execute(sa.text('GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO admission_runtime'))
runtime = create_async_engine(URL.replace('postgres@', 'admission_runtime@'))
env.pm.db = base.NS(get_engine=lambda: runtime)
env.runtime = runtime
yield env
await runtime.dispose()
async def write(env, workspace=base.WS, pipeline='one', expected=None, check=lambda: True):
return await InteractionStore(env.runtime).create_request(
interaction_id='form',
run_id='run',
binding_id='binding',
runner_id=base.RID,
processor_type='pipeline',
processor_id=pipeline,
workspace_id=workspace,
request={},
delivery_target={},
expected_config=copy.deepcopy(base.SOURCE if expected is None else expected),
authority_check=check,
)
async def blocked(env):
# Observe PostgreSQL's lock waiter, not an arbitrary scheduling sleep.
async with asyncio.timeout(5):
while True:
async with env.engine.connect() as conn:
n = (
await conn.execute(
sa.text(
"SELECT count(*) FROM pg_stat_activity WHERE usename='admission_runtime' AND wait_event_type='Lock'"
)
)
).scalar()
if n:
return
await asyncio.sleep(0)
@pytest.mark.parametrize('outcome', ['commit', 'rollback', 'cancel'])
async def test_real_writer_waits_and_revalidates_after_transaction(env, outcome):
writer = None
try:
async with env.pm.tenant_uow(base.WS) as uow:
await lock_pipeline_admission(uow.session, base.WS, 'one')
writer = asyncio.create_task(write(env))
await blocked(env)
assert not writer.done()
async with env.engine.connect() as conn:
assert not (await conn.execute(sa.select(AgentInteraction))).all()
await uow.session.execute(
sa.update(base.LegacyPipeline)
.where(base.LegacyPipeline.uuid == 'one')
.values(config=base.planner(base.SOURCE)['config'])
)
if outcome == 'rollback':
raise RuntimeError('rollback')
if outcome == 'cancel':
raise asyncio.CancelledError()
except (RuntimeError, asyncio.CancelledError):
assert outcome != 'commit'
if outcome == 'commit':
with pytest.raises(InteractionScopeError):
await writer
else:
assert (await writer)[0]['status'] == 'pending'
async def test_real_writer_first_blocks_migration_and_other_tenant_isolated(env):
await write(env)
async with env.pm.tenant_uow(base.WS) as uow:
await lock_pipeline_admission(uow.session, base.WS, 'one')
assert (
await uow.session.execute(
sa.select(AgentInteraction.status).where(AgentInteraction.workspace_id == base.WS)
)
).scalar_one() == 'pending'
with pytest.raises(InteractionScopeError):
await write(env, workspace=base.OTHER)
async with env.runtime.connect() as conn:
assert not (await conn.execute(sa.select(base.LegacyPipeline))).all()
async with env.pm.tenant_uow(base.OTHER) as uow:
assert (await uow.session.execute(sa.select(base.LegacyPipeline.uuid))).scalars().all() == ['foreign']
async def test_real_migration_commit_activation_and_stale_writer(env):
original = env.svc._activate
async def activate(*args):
with pytest.raises(InteractionScopeError):
await write(env)
return await original(*args)
env.svc._activate = activate
task = await base.execute(env)
assert task.task_context.metadata['results'][0]['state'] == 'migrated'
with pytest.raises(InteractionScopeError):
await write(env)
_, snapshots = await base.rows(env)
assert snapshots[0]['state'] == 'active'
@pytest.mark.parametrize('boundary', ['cas', 'activation'])
async def test_real_no_phantom_between_check_and_publication(env, monkeypatch, boundary):
writer = None
sql = env.pm.execute_async
state = env.svc._interaction_state
entered_activation = False
activate = env.svc._activate
async def activation(*args):
nonlocal entered_activation
entered_activation = True
return await activate(*args)
async def start_waiter():
nonlocal writer
writer = asyncio.create_task(write(env))
await blocked(env)
assert not writer.done()
async def execute_sql(statement, *args, **kwargs):
result = await sql(statement, *args, **kwargs)
if (
boundary == 'cas'
and isinstance(statement, sa.sql.dml.Insert)
and statement.table.name == 'pipeline_migration_snapshots'
):
await start_waiter()
return result
async def interaction_state(*args):
result = await state(*args)
if boundary == 'activation' and entered_activation:
await start_waiter()
return result
monkeypatch.setattr(env.pm, 'execute_async', execute_sql)
monkeypatch.setattr(env.svc, '_activate', activation)
monkeypatch.setattr(env.svc, '_interaction_state', interaction_state)
task = await base.execute(env)
assert task.task_context.metadata['results'][0]['state'] == 'migrated'
with pytest.raises(InteractionScopeError):
await writer
async with env.engine.connect() as conn:
assert not (await conn.execute(sa.select(AgentInteraction))).all()
async def test_real_conversation_revoked_while_waiting(env):
live = True
async with env.pm.tenant_uow(base.WS) as uow:
await lock_pipeline_admission(uow.session, base.WS, 'one')
writer = asyncio.create_task(write(env, check=lambda: live))
await blocked(env)
live = False
with pytest.raises(InteractionScopeError):
await writer
async def test_real_other_tenant_writer_is_not_serialized(env):
async with env.pm.tenant_uow(base.WS) as uow:
await lock_pipeline_admission(uow.session, base.WS, 'one')
record, _ = await asyncio.wait_for(write(env, workspace=base.OTHER, pipeline='foreign'), 3)
assert record['workspace_id'] == base.OTHER
async def test_real_pending_before_commit_prevents_migration(env):
body = await base.selection(env)
await write(env)
with pytest.raises(env.m.MigrationError, match='preview_stale'):
await base.execute(env, body)
configs, snapshots = await base.rows(env)
assert configs['one'] == base.SOURCE and not snapshots
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
# Run the unchanged cancellation/ambiguous-acknowledgement contracts on real PG.
test_real_cancel_commit_outcome = base.test_cancel_commit_reports_durable_outcome_and_stops_batch
test_real_cancel_activation_retry = base.test_cancel_activation_retry_reconciles_original_snapshot
test_real_unavailable_reconciliation = base.test_unavailable_commit_reconciliation_is_conservative
test_real_cancel_prepare = base.test_cancel_during_prepare_keeps_original_and_stops_batch
@@ -6,6 +6,7 @@ from types import SimpleNamespace
import time
import pytest
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import create_async_engine
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
@@ -26,6 +27,27 @@ from langbot.pkg.agent.runner.host_models import (
from langbot.pkg.agent.runner.interaction_manager import InteractionManager
from langbot.pkg.agent.runner.interaction_store import InteractionStore
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.entity.persistence.pipeline import LegacyPipeline
CONFIG = {
'ai': {
'runner': {'id': 'plugin:test/ApprovalRunner/default'},
'runner_config': {'plugin:test/ApprovalRunner/default': {}},
}
}
def _context(adapter):
conversation = SimpleNamespace(pipeline_uuid='pipeline-1')
query = SimpleNamespace(
pipeline_uuid='pipeline-1', pipeline_config=CONFIG, session=SimpleNamespace(using_conversation=conversation)
)
return {
'_delivery_adapter': adapter,
'_query': query,
'_pipeline_expected_config': CONFIG,
'_pipeline_conversation': conversation,
}
class FakeAdapter:
@@ -63,6 +85,18 @@ async def store(tmp_path):
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "manager.db"}', echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await conn.execute(
sa.insert(LegacyPipeline).values(
uuid='pipeline-1',
workspace_uuid='workspace-1',
name='test',
description='',
for_version='4.11',
stages=[],
config=CONFIG,
extensions_preferences={},
)
)
yield InteractionStore(engine)
await engine.dispose()
@@ -74,6 +108,7 @@ def event():
event_type='message.received',
source='platform',
bot_id='bot-1',
workspace_id='workspace-1',
conversation_id='group_chat-1',
actor=ActorContext(actor_type='user', actor_id='user-1'),
input=AgentInput(text='start'),
@@ -135,7 +170,7 @@ async def test_structured_delivery_uses_frozen_target_and_persists_request(store
binding=binding,
descriptor=_descriptor(),
run_id='run-1',
adapter_context={'_delivery_adapter': adapter},
adapter_context=_context(adapter),
)
assert consumed is True
@@ -167,7 +202,7 @@ async def test_continuous_interaction_reuses_submitted_platform_presentation(sto
binding=binding,
descriptor=_descriptor(),
run_id='run-1',
adapter_context={'_delivery_adapter': adapter},
adapter_context=_context(adapter),
)
first_token = adapter.actions[0][1]['callback_token']
submitted = await manager.consume_callback(
@@ -192,7 +227,7 @@ async def test_continuous_interaction_reuses_submitted_platform_presentation(sto
binding=binding,
descriptor=_descriptor(),
run_id='run-2',
adapter_context={'_delivery_adapter': adapter},
adapter_context=_context(adapter),
)
assert [action for action, _ in adapter.actions] == [
@@ -221,7 +256,7 @@ async def test_interaction_update_failure_falls_back_to_new_presentation(store,
binding=binding,
descriptor=_descriptor(),
run_id='run-1',
adapter_context={'_delivery_adapter': adapter},
adapter_context=_context(adapter),
)
first_token = adapter.actions[0][1]['callback_token']
await manager.consume_callback(
@@ -241,7 +276,7 @@ async def test_interaction_update_failure_falls_back_to_new_presentation(store,
binding=binding,
descriptor=_descriptor(),
run_id='run-2',
adapter_context={'_delivery_adapter': adapter},
adapter_context=_context(adapter),
)
update_attempt = adapter.actions[-2][1]
@@ -263,7 +298,7 @@ async def test_callback_scope_uses_frozen_delivery_conversation(store, event, bi
binding=binding,
descriptor=_descriptor(),
run_id='run-1',
adapter_context={'_delivery_adapter': adapter},
adapter_context=_context(adapter),
)
record = await store.get_request('run-1', 'form-1')
@@ -281,7 +316,7 @@ async def test_adapter_without_interactions_receives_fallback_text(store, event,
binding=binding,
descriptor=_descriptor(),
run_id='run-1',
adapter_context={'_delivery_adapter': adapter},
adapter_context=_context(adapter),
)
assert adapter.actions == []
@@ -301,7 +336,7 @@ async def test_runner_without_interaction_permission_is_rejected(store, event, b
binding=binding,
descriptor=_descriptor(permitted=False),
run_id='run-1',
adapter_context={'_delivery_adapter': FakeAdapter(supports_interactions=True)},
adapter_context=_context(FakeAdapter(supports_interactions=True)),
)
assert await store.get_request('run-1', 'form-1') is None
@@ -319,7 +354,7 @@ async def test_binding_policy_can_disable_interactions(store, event, binding):
binding=binding,
descriptor=_descriptor(),
run_id='run-1',
adapter_context={'_delivery_adapter': FakeAdapter(supports_interactions=True)},
adapter_context=_context(FakeAdapter(supports_interactions=True)),
)
@@ -344,7 +379,7 @@ async def test_interaction_expiry_is_bounded(store, event, binding, expires_at,
binding=binding,
descriptor=_descriptor(),
run_id='run-1',
adapter_context={'_delivery_adapter': FakeAdapter(supports_interactions=True)},
adapter_context=_context(FakeAdapter(supports_interactions=True)),
)
@@ -361,7 +396,7 @@ async def test_interaction_rejects_duplicate_protocol_ids(store, event, binding)
binding=binding,
descriptor=_descriptor(),
run_id='run-1',
adapter_context={'_delivery_adapter': FakeAdapter(supports_interactions=True)},
adapter_context=_context(FakeAdapter(supports_interactions=True)),
)
@@ -392,7 +427,7 @@ async def test_interaction_request_payload_is_bounded(store, event, binding):
binding=binding,
descriptor=_descriptor(),
run_id='run-1',
adapter_context={'_delivery_adapter': FakeAdapter(supports_interactions=True)},
adapter_context=_context(FakeAdapter(supports_interactions=True)),
)
@@ -408,7 +443,7 @@ async def test_non_interaction_action_is_not_consumed(store, event, binding):
binding=binding,
descriptor=_descriptor(),
run_id='run-1',
adapter_context={'_delivery_adapter': FakeAdapter(supports_interactions=True)},
adapter_context=_context(FakeAdapter(supports_interactions=True)),
)
@@ -421,7 +456,7 @@ async def _deliver_interaction(store, event, binding, result=None):
binding=binding,
descriptor=_descriptor(),
run_id='run-1',
adapter_context={'_delivery_adapter': adapter},
adapter_context=_context(adapter),
)
return manager, adapter.actions[0][1]['callback_token']
@@ -18,6 +18,7 @@ from langbot.pkg.agent.runner.interaction_store import (
)
from langbot.pkg.entity.persistence.agent_interaction import AgentInteraction
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.entity.persistence.pipeline import LegacyPipeline
UTC = datetime.timezone.utc
@@ -28,6 +29,18 @@ async def db_engine(tmp_path):
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "interactions.db"}', echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await conn.execute(
sqlalchemy.insert(LegacyPipeline).values(
uuid='pipeline-1',
workspace_uuid='workspace-1',
name='test',
description='',
for_version='4.11',
stages=[],
config={},
extensions_preferences={},
)
)
yield engine
await engine.dispose()
@@ -45,6 +58,9 @@ async def _create(store: InteractionStore, **overrides):
'runner_id': 'plugin:test/ApprovalRunner/default',
'processor_type': 'pipeline',
'processor_id': 'pipeline-1',
'workspace_id': 'workspace-1',
'expected_config': {},
'authority_check': lambda: True,
'request': {'interaction_id': 'form-1', 'title': 'Approve?', 'fallback_text': 'Reply yes or no.'},
'delivery_target': {'chat_id': 'chat-1'},
'bot_id': 'bot-1',
@@ -0,0 +1,241 @@
"""Native identity provenance across the Host/SDK and persisted resume boundary."""
import ast
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from langbot_plugin.api.entities.builtin.runner.context import RunnerContext
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
from langbot.pkg.agent.runner.context_builder import RunnerContextBuilder
from langbot.pkg.agent.runner.host_models import AgentEventEnvelope
from langbot.pkg.agent.runner.interaction_manager import InteractionManager
from langbot.pkg.agent.runner.interaction_store import InteractionStore
from langbot.pkg.entity.persistence.base import Base
from sqlalchemy.ext.asyncio import create_async_engine
from tests.unit_tests.agent import test_event_first_protocol as event_fixtures
from tests.unit_tests.agent.test_context_validation import TestContextValidation as Helpers
@pytest.fixture
def mock_query():
return event_fixtures.mock_query.__wrapped__()
async def build(event, runner, source='legacy-session'):
helper = Helpers()
binding = helper._make_binding()
binding.runner_id = f'plugin:langbot-team/{runner}/default'
binding.processor_type = 'pipeline'
binding.processor_id = 'pipeline-uuid-456'
binding.runner_config = {'user-id-source': source}
builder = RunnerContextBuilder(helper._make_mock_app())
builder._build_context_access = AsyncMock(return_value={})
with patch('langbot.pkg.agent.runner.context_builder.get_persistent_state_store') as store:
store.return_value.build_snapshot_from_event = AsyncMock(return_value={})
context = await builder.build_context_from_event(
event, binding, helper._make_descriptor(), helper._make_resources()
)
return context, binding
@pytest.mark.asyncio
@pytest.mark.parametrize('runner', ['DifyAgent', 'N8nAgent', 'CozeAgent'])
@pytest.mark.parametrize('kind', ['group', 'person'])
async def test_exact_native_identity_survives_serialization(mock_query, runner, kind):
mock_query.launcher_type.value = kind
mock_query.launcher_id = ' Event_007 '
mock_query.session.launcher_type.value = 'person' if kind == 'group' else 'group'
mock_query.session.launcher_id = ' Session_008 '
event = AgentEventEnvelope.model_validate_json(QueryEntryAdapter.query_to_event(mock_query).model_dump_json())
context, _ = await build(event, runner)
expected = (
(kind, ' Event_007 ') if runner == 'CozeAgent' else (mock_query.session.launcher_type.value, ' Session_008 ')
)
assert (context['conversation']['launcher_type'], context['conversation']['launcher_id']) == expected
assert context['actor']['actor_id'] == 'sender-123'
assert context['conversation']['sender_id'] == 'sender-123'
@pytest.mark.asyncio
@pytest.mark.parametrize('runner', ['DifyAgent', 'N8nAgent', 'CozeAgent'])
async def test_missing_authoritative_fields_never_fall_back_to_raw_data(mock_query, runner):
mock_query.session.launcher_id = None
mock_query.launcher_id = None
mock_query.variables.update({'launcher_id': 'spoof', 'session_id': 'group_spoof'})
event = QueryEntryAdapter.query_to_event(mock_query)
event.data.update({'launcher_type': 'group', 'launcher_id': 'spoof'})
context, _ = await build(event, runner)
assert context['conversation']['launcher_id'] is None
@pytest.mark.asyncio
async def test_tbox_and_sender_defaults_unchanged(mock_query):
event = QueryEntryAdapter.query_to_event(mock_query)
context, _ = await build(event, 'TboxAgent', 'legacy-bot')
assert context['conversation']['bot_id'] == mock_query.bot_uuid
context, _ = await build(event, 'DifyAgent', 'sender')
assert context['actor']['actor_id'] == 'sender-123'
assert context['conversation']['launcher_id'] is None
@pytest.mark.asyncio
@pytest.mark.parametrize('field', ['workspace_id', 'bot_id'])
async def test_provenance_cannot_cross_scope(mock_query, field):
event = QueryEntryAdapter.query_to_event(mock_query)
setattr(event, field, 'different')
context, _ = await build(event, 'DifyAgent')
assert context['conversation']['launcher_id'] is None
@pytest.mark.asyncio
async def test_provenance_cannot_cross_pipeline(mock_query):
event = QueryEntryAdapter.query_to_event(mock_query)
event.legacy_identity.pipeline_id = 'other-pipeline'
context, _ = await build(event, 'DifyAgent')
assert context['conversation']['launcher_id'] is None
@pytest.mark.asyncio
@pytest.mark.parametrize('changed', [None, 'workspace_id', 'bot_id', 'processor_id', 'actor_id'])
async def test_resume_uses_persisted_identity_not_new_session(tmp_path, mock_query, changed):
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
try:
store = InteractionStore(engine)
event = QueryEntryAdapter.query_to_event(mock_query)
context, binding = await build(event, 'DifyAgent')
manager = InteractionManager(SimpleNamespace(), store)
descriptor = Helpers()._make_descriptor()
descriptor.id = binding.runner_id
descriptor.capabilities.interactions = True
descriptor.permissions.interactions = ['request']
binding.delivery_policy.enable_interactions = True
import sqlalchemy as sa
from langbot.pkg.entity.persistence.pipeline import LegacyPipeline
config = {
'ai': {'runner': {'id': binding.runner_id}, 'runner_config': {binding.runner_id: binding.runner_config}}
}
mock_query.pipeline_config = config
conversation = mock_query.session.using_conversation
async with engine.begin() as conn:
await conn.execute(
sa.insert(LegacyPipeline).values(
uuid=binding.processor_id,
workspace_uuid=event.workspace_id,
name='test',
description='',
for_version='4.11',
stages=[],
config=config,
extensions_preferences={},
)
)
adapter = SimpleNamespace(
get_supported_apis=lambda: ['interaction.request'], call_platform_api=AsyncMock(return_value={})
)
mock_query.adapter = adapter
await manager.handle_result(
result_dict={
'data': {
'action': 'interaction.requested',
'payload': {'interaction_id': 'form', 'title': 'Approve', 'fallback_text': 'Approve'},
}
},
event=event,
binding=binding,
descriptor=descriptor,
run_id='original',
adapter_context={
'_delivery_adapter': adapter,
'_query': mock_query,
'_pipeline_expected_config': config,
'_pipeline_conversation': conversation,
},
)
token = adapter.call_platform_api.call_args.args[1]['callback_token']
record = await manager.consume_callback(
callback_token=token,
submission={'interaction_id': 'form', 'values': {}},
bot_id=event.bot_id,
conversation_id='person_launcher-123',
actor_id='sender-123',
)
mock_query.variables['_interaction_submission'] = record['submission']
mock_query.session.launcher_id = 'wrong-new-session'
resumed = QueryEntryAdapter.query_to_event(mock_query)
if changed in ('workspace_id', 'bot_id'):
setattr(resumed, changed, 'other')
elif changed == 'processor_id':
binding.processor_id = 'other'
elif changed == 'actor_id':
resumed.actor.actor_id = 'other'
# A fresh manager/store represents a process restart, not an in-memory cache.
manager = InteractionManager(SimpleNamespace(), InteractionStore(engine))
await manager.restore_legacy_identity(resumed, binding)
result, _ = await build(resumed, 'DifyAgent')
assert result['conversation']['launcher_id'] == (None if changed else 'launcher-123')
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_tbox_resume_without_persisted_identity_fails_closed(mock_query):
event = QueryEntryAdapter.query_to_event(mock_query)
event.event_type = 'interaction.submitted'
event.legacy_identity = None
context, _ = await build(event, 'TboxAgent', 'legacy-bot')
assert context['conversation']['bot_id'] is None
assert context['runtime']['metadata']['bot_id'] is None
@pytest.mark.asyncio
@pytest.mark.parametrize(
'runner,folder,method',
[
('DifyAgent', 'dify-agent', '_get_user_tag'),
('N8nAgent', 'n8n-agent', '_get_user_tag'),
('CozeAgent', 'coze-agent', '_get_user_id'),
('TboxAgent', 'tbox-agent', '_get_user_id'),
],
)
@pytest.mark.parametrize('missing', [False, True])
async def test_real_plugin_identity_helper_with_host_payload(mock_query, runner, folder, method, missing):
# Execute the actual pure helper AST without loading vendor clients or making network calls.
root = Path(__file__).resolve().parents[3].parent / 'langbot-plugins-411-migration'
path = root / 'Runner' / folder / 'components/runner/default.py'
if not path.exists():
pytest.skip('sibling plugin source checkout is required for cross-repository contract gate')
tree = ast.parse(path.read_text())
function = next(node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) and node.name == method)
class ConfigError(Exception):
def __init__(self, message, **kwargs):
super().__init__(message)
namespace = {
'RunnerContext': RunnerContext,
**{name: ConfigError for name in ('DifyConfigError', 'N8nConfigError', 'CozeConfigError', 'TboxConfigError')},
}
exec(compile(ast.Module(body=[function], type_ignores=[]), str(path), 'exec'), namespace)
mock_query.session.launcher_id = 'Session_007'
event = QueryEntryAdapter.query_to_event(mock_query)
if missing:
event.legacy_identity = None
event.bot_id = None
source = 'legacy-bot' if runner == 'TboxAgent' else 'legacy-session'
context, _ = await build(event, runner, source)
sdk = RunnerContext.model_validate(context)
if missing:
with pytest.raises(ConfigError, match='trusted Host identity'):
namespace[method](None, sdk)
else:
expected = (
mock_query.bot_uuid
if runner == 'TboxAgent'
else ('person_launcher-123' if runner == 'CozeAgent' else 'person_Session_007')
)
assert namespace[method](None, sdk) == expected
@@ -469,6 +469,23 @@ async def test_orchestrator_consumes_interaction_request_before_message_output(c
call_platform_api=AsyncMock(return_value={'ok': True}),
)
import sqlalchemy as sa
from langbot.pkg.entity.persistence.pipeline import LegacyPipeline
async with db_engine.begin() as conn:
await conn.execute(
sa.insert(LegacyPipeline).values(
uuid=query.pipeline_uuid,
workspace_uuid=TEST_CONTEXT.workspace_uuid,
name='test',
description='',
for_version='4.11',
stages=[],
config=query.pipeline_config,
extensions_preferences={},
)
)
messages = [message async for message in orchestrator.run_from_query(query)]
assert [message.content for message in messages] == ['Waiting for approval']
@@ -0,0 +1,268 @@
"""Host-only, descriptor-driven Runner reasoning policy and durable snapshot tests."""
from __future__ import annotations
import importlib
from types import SimpleNamespace
import pytest
from langbot.pkg.agent.runner.session_registry import AgentRunSessionRegistry
from langbot.pkg.provider.modelmgr import errors, reasoning
from tests.unit_tests.provider.test_reasoning_control import _requester, _runtime_model
PRIMARY = '00000000-0000-4000-8000-000000000011'
FALLBACK = '00000000-0000-4000-8000-000000000012'
OTHER = '00000000-0000-4000-8000-000000000013'
def policy():
return importlib.import_module('langbot.pkg.agent.runner.model_reasoning')
def descriptor(*names):
return SimpleNamespace(config_schema=[{'name': name, 'type': 'model-fallback-selector'} for name in names])
def resources(*ids):
return {'models': [{'model_id': model_id} for model_id in ids]}
def selection(level='high'):
return {'primary': PRIMARY, 'fallbacks': [FALLBACK], 'reasoning': {PRIMARY: level, FALLBACK: 'low'}}
def test_generic_descriptor_extracts_only_selected_authorized_models():
value = selection()
value['reasoning'][OTHER] = 'max'
result = policy().extract_model_reasoning_overrides(
descriptor('arbitrary'), {'arbitrary': value}, resources(PRIMARY, FALLBACK, OTHER)
)
assert result == {PRIMARY: {'level': 'high'}, FALLBACK: {'level': 'low'}}
assert policy().extract_model_reasoning_overrides(
descriptor('arbitrary'), {'arbitrary': value}, resources(FALLBACK)
) == {FALLBACK: {'level': 'low'}}
value['reasoning'][PRIMARY] = 'disabled'
assert result[PRIMARY] == {'level': 'high'}
@pytest.mark.parametrize('level', reasoning.REASONING_LEVELS)
def test_all_canonical_levels_use_core_normalization(level):
result = policy().extract_model_reasoning_overrides(
descriptor('models'), {'models': selection(level)}, resources(PRIMARY)
)
assert result == {PRIMARY: reasoning.normalize_reasoning_config({'level': level})}
@pytest.mark.parametrize('value', ['plain-model', {}, {'primary': PRIMARY}, {'primary': PRIMARY, 'reasoning': {}}])
def test_absent_map_does_not_create_default_override(value):
assert policy().extract_model_reasoning_overrides(descriptor('models'), {'models': value}, resources(PRIMARY)) == {}
def test_undeclared_fields_and_descriptor_defaults_do_not_supply_overrides():
desc = descriptor('declared')
desc.config_schema[0]['default'] = selection()
assert policy().extract_model_reasoning_overrides(desc, {'model': selection()}, resources(PRIMARY)) == {}
@pytest.mark.parametrize(
'value', [None, [], 'high', {PRIMARY: None}, {PRIMARY: {}}, {PRIMARY: 'secret-invalid-level'}, {PRIMARY: ['high']}]
)
def test_invalid_explicit_maps_fail_with_safe_error(value):
with pytest.raises(ValueError, match='Invalid runner model reasoning configuration') as exc:
policy().extract_model_reasoning_overrides(
descriptor('models'), {'models': {'primary': PRIMARY, 'reasoning': value}}, resources(PRIMARY)
)
assert 'secret-invalid-level' not in str(exc.value)
assert PRIMARY not in str(exc.value)
@pytest.mark.parametrize('reverse', [False, True])
def test_multiple_selectors_reject_conflicting_overrides_deterministically(reverse):
names = ['one', 'two']
if reverse:
names.reverse()
config = {'one': selection('high'), 'two': selection('provider_default')}
with pytest.raises(ValueError, match='Conflicting runner model reasoning overrides'):
policy().extract_model_reasoning_overrides(descriptor(*names), config, resources(PRIMARY))
config['two'] = selection('high')
assert policy().extract_model_reasoning_overrides(descriptor(*names), config, resources(PRIMARY)) == {
PRIMARY: {'level': 'high'}
}
@pytest.mark.asyncio
async def test_session_deepcopies_reasoning_without_granting_models():
registry = AgentRunSessionRegistry()
overrides = {PRIMARY: {'level': 'high'}, OTHER: {'level': 'max'}}
await registry.register(
run_id='frozen',
runner_id='plugin:test/runner/main',
query_id=None,
plugin_identity='test/runner',
resources=resources(PRIMARY),
model_reasoning_overrides=overrides,
)
overrides[PRIMARY]['level'] = 'low'
session = await registry.get('frozen')
assert session['authorization']['model_reasoning_overrides'][PRIMARY] == {'level': 'high'}
assert not registry.is_resource_allowed(session, 'model', OTHER, 'invoke')
def test_request_local_clone_preserves_shared_model_and_absent_default():
model = _runtime_model(_requester('openai'), 'high', name='gpt-5')
assert policy().model_with_reasoning_override(model, PRIMARY, None) is model
assert policy().model_with_reasoning_override(model, PRIMARY, {'authorization': {}}) is model
overrides = {PRIMARY: {'level': 'provider_default'}}
clone = policy().model_with_reasoning_override(
model, PRIMARY, {'authorization': {'model_reasoning_overrides': overrides}}
)
assert clone is not model
assert clone.provider is model.provider
assert clone.model_entity is model.model_entity
assert clone.reasoning_config_override == {'level': 'provider_default'}
clone.reasoning_config_override['level'] = 'disabled'
assert overrides[PRIMARY]['level'] == 'provider_default'
assert model.reasoning_config_override is None
assert model.model_entity.reasoning_config == {'level': 'high'}
@pytest.mark.parametrize(
('provider', 'name', 'expected'),
[
('openai', 'gpt-5', {'reasoning_effort': 'high'}),
('anthropic', 'claude-sonnet-4-6', {'reasoning_effort': 'high'}),
('gemini', 'gemini-3-pro', {'reasoning_effort': 'high'}),
],
)
def test_real_requester_reasoning_boundary(provider, name, expected, monkeypatch):
request = _requester(provider)
monkeypatch.setattr(request, '_supports_reasoning', lambda _: True)
monkeypatch.setattr(request, '_safe_model_info', lambda _: {})
model = _runtime_model(request, 'low', name=name)
clone = policy().model_with_reasoning_override(
model, PRIMARY, {'authorization': {'model_reasoning_overrides': {PRIMARY: {'level': 'high'}}}}
)
assert request._build_reasoning_args(clone) == expected
clone.reasoning_config_override = {'level': 'provider_default'}
assert request._build_reasoning_args(clone) == {}
assert model.model_entity.reasoning_config == {'level': 'low'}
@pytest.mark.parametrize(
('name', 'abilities', 'level'), [('gpt-5', [], 'high'), ('gemini-3-pro', ['reasoning'], 'disabled')]
)
def test_real_requester_still_rejects_ability_and_capability_mismatches(name, abilities, level, monkeypatch):
request = _requester('gemini' if name.startswith('gemini') else 'openai')
monkeypatch.setattr(request, '_supports_reasoning', lambda _: True)
monkeypatch.setattr(request, '_safe_model_info', lambda _: {})
model = _runtime_model(request, name=name, abilities=abilities)
session = {'authorization': {'model_reasoning_overrides': {PRIMARY: {'level': level}}}}
if not abilities:
with pytest.raises(ValueError, match='reasoning ability'):
policy().model_with_reasoning_override(model, PRIMARY, session)
else:
clone = policy().model_with_reasoning_override(model, PRIMARY, session)
with pytest.raises(errors.RequesterError):
request._build_reasoning_args(clone)
@pytest.mark.asyncio
async def test_orchestrator_freezes_host_policy_and_persistent_reload(tmp_path):
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.agent.runner.orchestrator import AgentRunOrchestrator
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.plugin.agent_run_support import _load_persistent_agent_run_session
from tests.unit_tests.agent.test_orchestrator_integration import (
FakeApplication,
FakePluginConnector,
FakeRegistry,
make_descriptor,
make_query,
)
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "reasoning.db"}')
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
connector = FakePluginConnector(results=[{'type': 'run.completed', 'data': {}}])
ap = FakeApplication(connector, engine)
desc = make_descriptor()
query = make_query()
query.pipeline_config['ai']['runner_config'][desc.id]['model']['reasoning'] = {
'model_primary': 'high',
'model_fallback': 'low',
}
expected = {'model_primary': {'level': 'high'}, 'model_fallback': {'level': 'low'}}
try:
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(desc))
_ = [value async for value in orchestrator.run_from_query(query)]
session = connector.sessions_during_run[0]
assert session['authorization']['model_reasoning_overrides'] == expected
wire = connector.contexts[0]
assert 'model_reasoning_overrides' not in wire
assert 'model_reasoning_overrides' not in wire['resources']
query.pipeline_config['ai']['runner_config'][desc.id]['model']['reasoning']['model_primary'] = 'disabled'
assert session['authorization']['model_reasoning_overrides'] == expected
restored = await _load_persistent_agent_run_session(wire['run_id'], ap, 'test')
assert restored['authorization']['model_reasoning_overrides'] == expected
finally:
await engine.dispose()
@pytest.mark.asyncio
@pytest.mark.parametrize(
('provider', 'name', 'level', 'expected'),
[
('openai', 'gpt-5', 'high', {'reasoning_effort': 'high'}),
('anthropic', 'claude-sonnet-4-6', 'disabled', {'thinking': {'type': 'disabled'}}),
('gemini', 'gemini-3-pro', 'low', {'reasoning_effort': 'low'}),
],
)
async def test_real_completion_and_count_tokens_build_boundary(provider, name, level, expected, monkeypatch):
from unittest.mock import AsyncMock
from langbot_plugin.api.entities.builtin.provider import message as provider_message
from langbot.pkg.provider.modelmgr.requesters import litellmchat
request = _requester(provider)
monkeypatch.setattr(request, '_supports_reasoning', lambda _: True)
monkeypatch.setattr(request, '_safe_model_info', lambda _: {})
model = _runtime_model(request, name=name)
model.provider.token_mgr.get_token = lambda: 'test-only-not-a-secret'
clone = policy().model_with_reasoning_override(
model, PRIMARY, {'authorization': {'model_reasoning_overrides': {PRIMARY: {'level': level}}}}
)
messages = [provider_message.Message(role='user', content='hello')]
for stream in (False, True):
built = await request._build_completion_args(clone, messages, extra_args={'temperature': 0.7}, stream=stream)
for key, value in expected.items():
assert built[key] == value
assert 'model_reasoning_overrides' not in built
assert 'reasoning_config_override' not in built
assert built['temperature'] == 0.7
assert built.get('stream', False) is stream
build = AsyncMock(wraps=request._build_completion_args)
monkeypatch.setattr(request, '_build_completion_args', build)
# Only the tokenizer is stubbed; count_tokens and completion construction are real.
monkeypatch.setattr(litellmchat.litellm, 'token_counter', lambda **kwargs: 37)
assert await request.count_tokens(clone, messages) == 37
assert build.await_args.args[0] is clone
assert model.reasoning_config_override is None
@pytest.mark.asyncio
async def test_real_requester_rejects_caller_reasoning_conflicts(monkeypatch):
from langbot_plugin.api.entities.builtin.provider import message as provider_message
request = _requester('openai')
monkeypatch.setattr(request, '_supports_reasoning', lambda _: True)
monkeypatch.setattr(request, '_safe_model_info', lambda _: {})
model = _runtime_model(request, name='gpt-5')
model.provider.token_mgr.get_token = lambda: 'test-only-not-a-secret'
clone = policy().model_with_reasoning_override(
model, PRIMARY, {'authorization': {'model_reasoning_overrides': {PRIMARY: {'level': 'high'}}}}
)
with pytest.raises(errors.RequesterError, match='conflicts with advanced parameters'):
await request._build_completion_args(
clone, [provider_message.Message(role='user', content='hello')], extra_args={'reasoning_effort': 'low'}
)
+10 -6
View File
@@ -171,14 +171,18 @@ class TestPersistentStateStore:
engine = create_async_engine(f'sqlite+aiosqlite:///{db_path}', echo=False)
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.entity.persistence.runner_state import RunnerState
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
try:
async with engine.begin() as conn:
# This unit fixture owns one table, not the application's entire
# schema. Unrelated DDL/fsync work must not scale every test.
await conn.run_sync(Base.metadata.create_all, tables=[RunnerState.__table__])
yield engine
await engine.dispose()
os.unlink(db_path)
yield engine
finally:
await engine.dispose()
os.unlink(db_path)
@pytest.fixture
async def persistent_store(self, db_engine):
@@ -0,0 +1,31 @@
"""Regression guard against unrelated schema setup in state-store tests."""
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from unit_tests.agent import test_state_store as state_tests
from langbot.pkg.entity.persistence.runner_state import RunnerState
@pytest.mark.asyncio
async def test_state_store_fixture_creates_only_its_owned_table(monkeypatch):
created_tables = []
async def run_sync(create_all, **kwargs):
created_tables.extend(kwargs.get('tables', create_all.__self__.sorted_tables))
@asynccontextmanager
async def begin():
yield SimpleNamespace(run_sync=run_sync)
engine = SimpleNamespace(begin=begin, dispose=AsyncMock())
monkeypatch.setattr(state_tests, 'create_async_engine', lambda *args, **kwargs: engine)
fixture = state_tests.TestPersistentStateStore.db_engine.__wrapped__(state_tests.TestPersistentStateStore())
try:
assert await anext(fixture) is engine
assert created_tables == [RunnerState.__table__]
finally:
await fixture.aclose()
@@ -0,0 +1,176 @@
[
{
"name": "base-url",
"label": {
"en_US": "Base URL",
"zh_Hans": "\u57fa\u7840 URL"
},
"type": "string",
"required": true,
"default": "http://localhost:7860"
},
{
"name": "api-key",
"label": {
"en_US": "API Key",
"zh_Hans": "API \u5bc6\u94a5"
},
"type": "secret",
"required": true,
"default": ""
},
{
"name": "flow-id",
"label": {
"en_US": "Flow ID",
"zh_Hans": "\u6d41\u7a0b ID"
},
"type": "string",
"required": true,
"default": ""
},
{
"name": "advanced-settings",
"label": {
"en_US": "Advanced Settings",
"zh_Hans": "\u9ad8\u7ea7\u8bbe\u7f6e"
},
"description": {
"en_US": "Show input, output, and flow tweak controls.",
"zh_Hans": "\u663e\u793a\u8f93\u5165\u3001\u8f93\u51fa\u548c\u6d41\u7a0b tweak \u8c03\u4f18\u9009\u9879\u3002"
},
"type": "boolean",
"required": false,
"default": false
},
{
"name": "input-type",
"label": {
"en_US": "Input Type",
"zh_Hans": "\u8f93\u5165\u7c7b\u578b"
},
"type": "string",
"required": false,
"default": "chat",
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "output-type",
"label": {
"en_US": "Output Type",
"zh_Hans": "\u8f93\u51fa\u7c7b\u578b"
},
"type": "string",
"required": false,
"default": "chat",
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "tweaks",
"label": {
"en_US": "Tweaks",
"zh_Hans": "\u8c03\u6574\u53c2\u6570"
},
"type": "json",
"required": false,
"default": "{}",
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "langbot-assets-enabled",
"label": {
"en_US": "Enable LangBot Assets",
"zh_Hans": "\u542f\u7528 LangBot \u8d44\u4ea7"
},
"type": "boolean",
"required": false,
"default": false
},
{
"name": "langbot-assets-gateway-host",
"label": {
"en_US": "LangBot Asset Gateway Host",
"zh_Hans": "LangBot \u8d44\u4ea7\u7f51\u5173\u76d1\u542c\u5730\u5740"
},
"type": "string",
"required": false,
"default": "0.0.0.0",
"show_if": {
"field": "langbot-assets-enabled",
"operator": "eq",
"value": true
}
},
{
"name": "langbot-assets-gateway-port",
"label": {
"en_US": "LangBot Asset Gateway Port",
"zh_Hans": "LangBot \u8d44\u4ea7\u7f51\u5173\u7aef\u53e3"
},
"type": "integer",
"required": false,
"default": 8765,
"show_if": {
"field": "langbot-assets-enabled",
"operator": "eq",
"value": true
}
},
{
"name": "langbot-assets-gateway-request-timeout",
"label": {
"en_US": "LangBot Asset Gateway Request Timeout",
"zh_Hans": "LangBot \u8d44\u4ea7\u7f51\u5173\u8bf7\u6c42\u8d85\u65f6"
},
"type": "integer",
"required": false,
"default": 60,
"show_if": {
"field": "langbot-assets-enabled",
"operator": "eq",
"value": true
}
},
{
"name": "langbot-assets-token-ttl",
"label": {
"en_US": "LangBot Asset Token TTL",
"zh_Hans": "LangBot \u8d44\u4ea7\u4ee4\u724c\u6709\u6548\u671f"
},
"type": "integer",
"required": false,
"default": 3600,
"show_if": {
"field": "langbot-assets-enabled",
"operator": "eq",
"value": true
}
},
{
"name": "langbot-assets-input-name",
"label": {
"en_US": "LangBot Asset Token Input Name",
"zh_Hans": "LangBot \u8d44\u4ea7\u4ee4\u724c\u8f93\u5165\u7ec4\u4ef6\u540d"
},
"type": "string",
"required": false,
"default": "langbot_asset_run_token",
"show_if": {
"field": "langbot-assets-enabled",
"operator": "eq",
"value": true
}
}
]
@@ -0,0 +1,94 @@
[
{
"name": "api-base",
"type": "string",
"required": true,
"default": "http://127.0.0.1:2026"
},
{
"name": "api-key",
"type": "secret",
"required": false,
"default": ""
},
{
"name": "auth-header",
"type": "secret",
"required": false,
"default": ""
},
{
"name": "assistant-id",
"type": "string",
"required": true,
"default": "lead_agent"
},
{
"name": "advanced-settings",
"type": "boolean",
"required": false,
"default": false
},
{
"name": "model-name",
"type": "string",
"required": false,
"default": "",
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "thinking-enabled",
"type": "boolean",
"required": false,
"default": false
},
{
"name": "plan-mode",
"type": "boolean",
"required": false,
"default": false
},
{
"name": "subagent-enabled",
"type": "boolean",
"required": false,
"default": false
},
{
"name": "max-concurrent-subagents",
"type": "integer",
"required": false,
"default": 3,
"show_if": {
"field": "subagent-enabled",
"operator": "eq",
"value": true
}
},
{
"name": "timeout",
"type": "integer",
"required": false,
"default": 300,
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "recursion-limit",
"type": "integer",
"required": false,
"default": 1000,
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
}
]
@@ -0,0 +1,326 @@
{
"usages": [
"agent"
],
"config": [
{
"name": "model",
"description": {
"en_US": "Primary/fallback model UUIDs and per-model reasoning levels. Host validates and applies reasoning for every request; this is per-pipeline configuration.",
"zh_Hans": "\u4e3b\u6a21\u578b/\u5907\u7528\u6a21\u578b UUID \u4e0e\u5404\u6a21\u578b\u63a8\u7406\u7b49\u7ea7\u3002\u7531 Host \u5bf9\u6bcf\u6b21\u8bf7\u6c42\u6821\u9a8c\u5e76\u5e94\u7528\uff0c\u5c5e\u4e8e\u6d41\u6c34\u7ebf\u72ec\u7acb\u914d\u7f6e\u3002"
},
"label": {
"en_US": "Model",
"zh_Hans": "\u6a21\u578b"
},
"type": "model-fallback-selector",
"required": true,
"default": {
"primary": "",
"fallbacks": [],
"reasoning": {}
}
},
{
"name": "prompt",
"label": {
"en_US": "Prompt",
"zh_Hans": "\u63d0\u793a\u8bcd"
},
"type": "prompt-editor",
"required": true,
"default": [
{
"role": "system",
"content": "You are a helpful assistant."
}
]
},
{
"name": "knowledge-bases",
"label": {
"en_US": "Knowledge Bases",
"zh_Hans": "\u77e5\u8bc6\u5e93"
},
"type": "knowledge-base-multi-selector",
"required": false,
"default": []
},
{
"name": "advanced-settings",
"label": {
"en_US": "Advanced Settings",
"zh_Hans": "\u9ad8\u7ea7\u8bbe\u7f6e"
},
"description": {
"en_US": "Show tuning controls for retrieval, tools, timeouts, and context management.",
"zh_Hans": "\u663e\u793a\u68c0\u7d22\u3001\u5de5\u5177\u3001\u8d85\u65f6\u548c\u4e0a\u4e0b\u6587\u7ba1\u7406\u7684\u8c03\u4f18\u9009\u9879\u3002"
},
"type": "boolean",
"required": false,
"default": false
},
{
"name": "date-grounding",
"label": {
"en_US": "Current Date Grounding",
"zh_Hans": "\u5f53\u524d\u65e5\u671f\u951a\u5b9a"
},
"description": {
"en_US": "Add the current UTC date and a reminder to verify time-sensitive facts with available search tools.",
"zh_Hans": "\u6ce8\u5165\u5f53\u524d UTC \u65e5\u671f\uff0c\u5e76\u63d0\u9192\u4f7f\u7528\u53ef\u7528\u641c\u7d22\u5de5\u5177\u6838\u5b9e\u6709\u65f6\u6548\u6027\u7684\u4fe1\u606f\u3002"
},
"type": "boolean",
"required": false,
"default": true,
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "timeout",
"label": {
"en_US": "Timeout",
"zh_Hans": "\u6267\u884c\u8d85\u65f6"
},
"type": "integer",
"required": false,
"default": 300,
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "remove-think",
"label": {
"en_US": "Remove Thinking Output",
"zh_Hans": "\u79fb\u9664\u601d\u8003\u5185\u5bb9"
},
"type": "boolean",
"required": false,
"default": false,
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "retrieval-top-k",
"label": {
"en_US": "Retrieval Top K",
"zh_Hans": "\u77e5\u8bc6\u5e93\u68c0\u7d22\u6570\u91cf"
},
"type": "integer",
"required": false,
"default": 5,
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "rerank-model",
"label": {
"en_US": "Rerank Model",
"zh_Hans": "\u91cd\u6392\u5e8f\u6a21\u578b"
},
"type": "rerank-model-selector",
"required": false,
"default": "",
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "rerank-top-k",
"label": {
"en_US": "Rerank Top K",
"zh_Hans": "\u91cd\u6392\u5e8f\u4fdd\u7559\u6570\u91cf"
},
"type": "integer",
"required": false,
"default": 5,
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "max-tool-iterations",
"label": {
"en_US": "Max Tool Iterations",
"zh_Hans": "\u6700\u5927\u5de5\u5177\u8c03\u7528\u8f6e\u6570"
},
"type": "integer",
"required": false,
"default": 100,
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "tool-execution-mode",
"description": {
"en_US": "Parallel is faster for independent tools. Use serial for dependent or side-effecting actions; result order does not guarantee execution order.",
"zh_Hans": "\u5e76\u884c\u9002\u5408\u72ec\u7acb\u5de5\u5177\uff1b\u6709\u4f9d\u8d56\u6216\u526f\u4f5c\u7528\u7684\u64cd\u4f5c\u8bf7\u9009\u62e9\u4e32\u884c\u3002\u7ed3\u679c\u6392\u5217\u4e0d\u4fdd\u8bc1\u6267\u884c\u987a\u5e8f\u3002"
},
"label": {
"en_US": "Tool Execution Mode",
"zh_Hans": "\u5de5\u5177\u6267\u884c\u6a21\u5f0f"
},
"type": "select",
"required": false,
"default": "parallel",
"options": [
{
"name": "parallel",
"label": {
"en_US": "Parallel",
"zh_Hans": "\u5e76\u884c"
}
},
{
"name": "serial",
"label": {
"en_US": "Serial",
"zh_Hans": "\u4e32\u884c"
}
}
],
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "max-tool-result-chars",
"label": {
"en_US": "Max Tool Result Characters",
"zh_Hans": "\u6700\u5927\u5de5\u5177\u7ed3\u679c\u5b57\u7b26\u6570"
},
"type": "integer",
"required": false,
"default": 20000,
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "context-history-fetch-limit",
"label": {
"en_US": "History Fetch Limit",
"zh_Hans": "\u5386\u53f2\u6d88\u606f\u62c9\u53d6\u6570\u91cf"
},
"type": "integer",
"required": false,
"default": 50,
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "context-window-tokens",
"label": {
"en_US": "Context Window Tokens",
"zh_Hans": "\u4e0a\u4e0b\u6587\u7a97\u53e3 Token \u6570"
},
"type": "integer",
"required": false,
"default": 200000,
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "context-reserve-tokens",
"label": {
"en_US": "Reserved Output Tokens",
"zh_Hans": "\u8f93\u51fa\u4fdd\u7559 Token \u6570"
},
"type": "integer",
"required": false,
"default": 16384,
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "context-keep-recent-tokens",
"label": {
"en_US": "Recent Context Tokens",
"zh_Hans": "\u6700\u8fd1\u4e0a\u4e0b\u6587 Token \u6570"
},
"type": "integer",
"required": false,
"default": 20000,
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "context-summary-tokens",
"label": {
"en_US": "Summary Tokens",
"zh_Hans": "\u6458\u8981 Token \u6570"
},
"type": "integer",
"required": false,
"default": 8000,
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
}
],
"capabilities": {
"streaming": true,
"tool_calling": true,
"knowledge_retrieval": true,
"multimodal_input": true,
"skill_authoring": true,
"interrupt": true,
"steering": true
},
"permissions": {
"models": [
"count_tokens",
"invoke",
"stream",
"rerank"
],
"tools": [
"detail",
"call"
],
"knowledge_bases": [
"list",
"retrieve"
],
"history": [
"page"
]
}
}
@@ -0,0 +1,154 @@
[
{
"name": "base-url",
"label": {
"en_US": "Base URL",
"zh_Hans": "\u57fa\u7840 URL"
},
"description": {
"en_US": "The WeKnora API base URL, including /api/v1.",
"zh_Hans": "WeKnora API \u57fa\u7840 URL\uff0c\u5305\u542b /api/v1\u3002"
},
"type": "string",
"required": true,
"default": "http://localhost:8080/api/v1"
},
{
"name": "api-key",
"label": {
"en_US": "API Key",
"zh_Hans": "API \u5bc6\u94a5"
},
"description": {
"en_US": "API key generated from WeKnora Settings -> API Keys.",
"zh_Hans": "\u4ece WeKnora \u8bbe\u7f6e -> API Keys \u751f\u6210\u7684 API \u5bc6\u94a5\u3002"
},
"type": "secret",
"required": true,
"default": ""
},
{
"name": "app-type",
"label": {
"en_US": "App Type",
"zh_Hans": "\u5e94\u7528\u7c7b\u578b"
},
"type": "select",
"required": true,
"default": "agent",
"options": [
{
"name": "agent",
"label": {
"en_US": "Agent (Smart Reasoning)",
"zh_Hans": "Agent\uff08\u667a\u80fd\u63a8\u7406\uff09"
}
},
{
"name": "chat",
"label": {
"en_US": "Chat (Knowledge Base RAG)",
"zh_Hans": "\u804a\u5929\uff08\u77e5\u8bc6\u5e93 RAG\uff09"
}
}
]
},
{
"name": "agent-id",
"label": {
"en_US": "Agent ID",
"zh_Hans": "\u667a\u80fd\u4f53 ID"
},
"description": {
"en_US": "When omitted, chat uses builtin-quick-answer and agent uses builtin-smart-reasoning. An explicit empty value lets the server choose. Saved IDs apply in both modes.",
"zh_Hans": "\u672a\u8bbe\u7f6e\u65f6\uff0c\u804a\u5929\u4f7f\u7528 builtin-quick-answer\uff0c\u667a\u80fd\u4f53\u4f7f\u7528 builtin-smart-reasoning\u3002\u663e\u5f0f\u7559\u7a7a\u5219\u7531\u670d\u52a1\u7aef\u9009\u62e9\u3002\u5df2\u4fdd\u5b58\u7684 ID \u5728\u4e24\u79cd\u6a21\u5f0f\u4e0b\u5747\u751f\u6548\u3002"
},
"type": "string",
"required": false
},
{
"name": "knowledge-base-ids",
"label": {
"en_US": "Knowledge Base IDs",
"zh_Hans": "\u77e5\u8bc6\u5e93 ID \u5217\u8868"
},
"description": {
"en_US": "Remote WeKnora knowledge base IDs used in both modes, not LangBot knowledge base UUIDs. Values and order are preserved.",
"zh_Hans": "\u4e24\u79cd\u6a21\u5f0f\u5747\u53ef\u4f7f\u7528\u7684\u8fdc\u7aef WeKnora \u77e5\u8bc6\u5e93 ID\uff0c\u4e0d\u662f LangBot \u77e5\u8bc6\u5e93 UUID\u3002\u4fdd\u7559\u539f\u503c\u548c\u987a\u5e8f\u3002"
},
"type": "array[string]",
"required": false,
"default": []
},
{
"name": "web-search-enabled",
"label": {
"en_US": "Enable Web Search",
"zh_Hans": "\u542f\u7528\u7f51\u7edc\u641c\u7d22"
},
"description": {
"en_US": "Whether to enable web search in agent mode.",
"zh_Hans": "\u662f\u5426\u5728 Agent \u6a21\u5f0f\u4e0b\u542f\u7528\u7f51\u7edc\u641c\u7d22\u3002"
},
"type": "boolean",
"required": false,
"default": false,
"show_if": {
"field": "app-type",
"operator": "eq",
"value": "agent"
}
},
{
"name": "advanced-settings",
"label": {
"en_US": "Advanced Settings",
"zh_Hans": "\u9ad8\u7ea7\u8bbe\u7f6e"
},
"description": {
"en_US": "Show timeout and fallback prompt controls.",
"zh_Hans": "\u663e\u793a\u8d85\u65f6\u548c\u56de\u9000\u63d0\u793a\u8bcd\u8c03\u4f18\u9009\u9879\u3002"
},
"type": "boolean",
"required": false,
"default": false
},
{
"name": "timeout",
"label": {
"en_US": "Timeout",
"zh_Hans": "\u8d85\u65f6\u65f6\u95f4"
},
"description": {
"en_US": "Request timeout in seconds.",
"zh_Hans": "\u8bf7\u6c42\u8d85\u65f6\u65f6\u95f4\uff08\u79d2\uff09\u3002"
},
"type": "integer",
"required": false,
"default": 120,
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
},
{
"name": "base-prompt",
"label": {
"en_US": "Base Prompt",
"zh_Hans": "\u57fa\u7840\u63d0\u793a\u8bcd"
},
"description": {
"en_US": "Default prompt when the user message is empty.",
"zh_Hans": "\u7528\u6237\u6d88\u606f\u4e3a\u7a7a\u65f6\u4f7f\u7528\u7684\u9ed8\u8ba4\u63d0\u793a\u8bcd\u3002"
},
"type": "string",
"required": false,
"default": "\u8bf7\u56de\u7b54\u7528\u6237\u7684\u95ee\u9898\u3002",
"show_if": {
"field": "advanced-settings",
"operator": "eq",
"value": true
}
}
]
@@ -0,0 +1,734 @@
"""Manual migration boundary tests; all configs are synthetic."""
import asyncio
import copy
import importlib
import importlib.util
from types import SimpleNamespace as NS
from unittest.mock import AsyncMock, Mock
import pytest
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.api.http.authz import PermissionDeniedError, permissions_for_role
from langbot.pkg.api.http.context import RequestContext, PrincipalContext, PrincipalType, WorkspaceContext
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.entity.persistence.pipeline import LegacyPipeline
from langbot.pkg.entity.persistence.plugin import PluginSetting
from langbot.pkg.entity.persistence.user import User
from langbot.pkg.entity.persistence.workspace import Workspace
from langbot.pkg.persistence.mgr import PersistenceManager
from langbot.pkg.core.taskmgr import AsyncTaskManager
MODULE = 'langbot.pkg.api.http.service.pipeline_migration'
RID = 'plugin:langbot-team/TestAgent/default'
WS = '00000000-0000-0000-0000-000000000001'
OTHER = '00000000-0000-0000-0000-000000000002'
SOURCE = {'ai': {'runner': 'test', 'secret': 'synthetic-secret'}, 'output': {'keep': True}}
def module():
assert importlib.util.find_spec(MODULE) is not None, 'manual migration service is missing'
return importlib.import_module(MODULE)
def context(role='developer'):
return RequestContext(
'instance',
1,
'request',
'user_token',
PrincipalContext(PrincipalType.ACCOUNT, account_uuid='account'),
WorkspaceContext(WS, 'membership', role, permissions_for_role(role)),
)
def planner(config, extensions_preferences=None):
base = dict(
state='already_current',
legacy_runner=None,
target_runner_id=None,
target_plugin=None,
changed_paths=[],
blockers=[],
warnings=[],
)
if isinstance(config.get('ai', {}).get('runner'), str):
target = copy.deepcopy(config)
target['ai'] = {'runner': {'id': RID}, 'runner_config': {RID: {'api_key': config['ai']['secret']}}}
base.update(
state='ready',
legacy_runner='test',
target_runner_id=RID,
target_plugin={'author': 'langbot-team', 'name': 'TestAgent', 'version': '1.0'},
changed_paths=['ai.runner', 'ai.runner_config'],
config=target,
)
return base
class PM(PersistenceManager):
def __init__(self, engine):
super().__init__(NS())
self.db = NS(get_engine=lambda: engine)
@pytest.fixture
async def env(tmp_path, monkeypatch):
m = module()
monkeypatch.setattr(m, 'plan_legacy_pipeline', planner)
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "migration.db"}')
async with engine.begin() as conn:
names = {
'users',
'workspaces',
'workspace_memberships',
'workspace_execution_states',
'legacy_pipelines',
'plugin_settings',
'pipeline_migration_snapshots',
'agent_interaction',
}
await conn.run_sync(lambda c: Base.metadata.create_all(c, tables=[Base.metadata.tables[n] for n in names]))
await conn.execute(
sa.insert(User).values(
uuid='account',
user='test@example.invalid',
normalized_email='test@example.invalid',
password='synthetic-not-a-password',
)
)
await conn.execute(
sa.insert(Workspace),
[
dict(uuid=ws, instance_uuid='instance', name=ws, slug=ws, source='cloud_projection')
for ws in (WS, OTHER)
],
)
await conn.execute(
sa.insert(LegacyPipeline),
[
dict(
uuid=pid,
workspace_uuid=ws,
name=pid,
description='kept',
for_version='4.10',
stages=[],
config=SOURCE,
extensions_preferences={'enable_all_plugins': True},
)
for pid, ws in [('one', WS), ('two', WS), ('foreign', OTHER)]
],
)
await conn.execute(
sa.insert(PluginSetting).values(
workspace_uuid=WS,
plugin_author='langbot-team',
plugin_name='TestAgent',
enabled=True,
install_info={'version': '1.0'},
)
)
pm = PM(engine)
binding = NS(instance_uuid='instance', workspace_uuid=WS, placement_generation=1)
access = NS(
workspace=NS(uuid=WS),
membership=NS(uuid='membership', role='developer', projection_revision=0),
execution=binding,
)
ap = NS(
persistence_mgr=pm,
event_loop=asyncio.get_running_loop(),
instance_config=NS(data={}),
workspace_service=NS(get_execution_binding=AsyncMock(return_value=binding)),
workspace_collaboration_service=NS(resolve_account_workspace=AsyncMock(return_value=access)),
pipeline_mgr=NS(prepare_pipeline=AsyncMock(return_value='candidate'), publish_pipeline=Mock()),
sess_mgr=NS(session_list=[]),
runner_registry=NS(
list_runners=AsyncMock(
return_value=[
NS(
id=RID,
usages=['agent'],
plugin_version='1.0',
config_schema=[{'name': 'api_key', 'type': 'string', 'required': True}],
)
]
)
),
plugin_connector=NS(require_workspace_context=AsyncMock()),
)
ap.task_mgr = AsyncTaskManager(ap)
svc = m.PipelineMigrationService(ap)
yield NS(m=m, ap=ap, svc=svc, engine=engine, pm=pm, access=access)
await ap.task_mgr.wait_all()
await engine.dispose()
async def selection(env, ids=('one',)):
preview = await env.svc.preview(context())
return {
'confirmed': True,
'items': [
{k: item[k] for k in ('pipeline_uuid', 'preview_token')}
for item in preview['items']
if item['pipeline_uuid'] in ids
],
}
async def execute(env, body=None):
result = await env.svc.execute(context(), body or await selection(env))
task = env.ap.task_mgr.get_task_by_id(result['task_id'])
await task.task
return task
async def rows(env):
async with env.engine.connect() as conn:
configs = dict((await conn.execute(sa.select(LegacyPipeline.uuid, LegacyPipeline.config))).all())
backups = (await conn.execute(sa.select(env.m.PipelineMigrationSnapshot))).mappings().all()
return configs, backups
def test_strict_confirmation_and_selection():
m = module()
valid = {'confirmed': True, 'items': [{'pipeline_uuid': 'one', 'preview_token': 'token'}]}
assert m.validate_execute_request(valid) == valid['items']
for body in [
None,
{},
{**valid, 'confirmed': 'true'},
{**valid, 'confirmed': 1},
{**valid, 'confirmed': False},
{**valid, 'workspace_uuid': WS},
{**valid, 'items': []},
{**valid, 'items': valid['items'] * 2},
{**valid, 'items': [{'pipeline_uuid': 'one', 'preview_token': 'x', 'config': {}}]},
{**valid, 'items': [{'pipeline_uuid': str(i), 'preview_token': 'x'} for i in range(51)]},
]:
with pytest.raises(m.MigrationError):
m.validate_execute_request(body)
@pytest.mark.asyncio
async def test_preview_is_scoped_secret_free_and_read_only(env):
writes = []
def observe(_conn, _cursor, statement, *_args):
if statement.lstrip().upper().startswith(('INSERT', 'UPDATE', 'DELETE')):
writes.append(statement)
sa.event.listen(env.engine.sync_engine, 'before_cursor_execute', observe)
result = await env.svc.preview(context('viewer'))
assert result['total'] == 2
assert {i['pipeline_uuid'] for i in result['items']} == {'one', 'two'}
assert all(i['state'] == 'ready' and i['preview_token'] for i in result['items'])
assert 'synthetic-secret' not in str(result)
assert writes == []
env.ap.runner_registry.list_runners.assert_not_awaited()
env.ap.plugin_connector.require_workspace_context.assert_not_awaited()
env.ap.pipeline_mgr.prepare_pipeline.assert_not_awaited()
assert not env.ap.task_mgr.tasks
@pytest.mark.asyncio
async def test_viewer_and_foreign_ids_rejected_before_runtime(env):
body = await selection(env)
with pytest.raises(PermissionDeniedError):
await env.svc.execute(context('viewer'), body)
body['items'].append({'pipeline_uuid': 'foreign', 'preview_token': 'x'})
with pytest.raises(env.m.MigrationError) as err:
await env.svc.execute(context(), body)
assert err.value.status_code == 404
assert not env.ap.task_mgr.tasks
env.ap.runner_registry.list_runners.assert_not_awaited()
@pytest.mark.asyncio
async def test_success_snapshot_atomic_preserves_source_and_task_scope(env):
task = await execute(env)
configs, backups = await rows(env)
assert configs['one'] == planner(SOURCE)['config']
assert configs['two'] == SOURCE
assert len(backups) == 1 and backups[0]['source_snapshot']['config'] == SOURCE
assert backups[0]['state'] == 'active'
assert (task.instance_uuid, task.workspace_uuid, task.placement_generation) == ('instance', WS, 1)
assert task.task_context.metadata == {
'kind': 'pipeline_migration',
'results': [{'pipeline_uuid': 'one', 'state': 'migrated', 'code': None}],
}
assert 'synthetic-secret' not in str(task.to_dict())
env.ap.pipeline_mgr.publish_pipeline.assert_called_once()
@pytest.mark.asyncio
async def test_stale_preview_and_plugin_change_are_rejected(env):
body = await selection(env)
async with env.engine.begin() as conn:
await conn.execute(sa.update(LegacyPipeline).where(LegacyPipeline.uuid == 'one').values(config={'edit': True}))
with pytest.raises(env.m.MigrationError) as err:
await env.svc.execute(context(), body)
assert err.value.status_code == 409
body = await selection(env, ('two',))
async with env.engine.begin() as conn:
await conn.execute(sa.update(PluginSetting).values(enabled=False))
with pytest.raises(env.m.MigrationError):
await env.svc.execute(context(), body)
assert not env.ap.task_mgr.tasks
@pytest.mark.asyncio
@pytest.mark.parametrize('failure', ['prepare', 'schema', 'membership', 'generation', 'edit'])
async def test_precommit_failures_keep_original_without_backup(env, failure):
body = await selection(env)
if failure == 'prepare':
env.ap.pipeline_mgr.prepare_pipeline.side_effect = RuntimeError('synthetic-secret')
elif failure == 'schema':
env.ap.runner_registry.list_runners.return_value[0].config_schema = []
async def prepare(*args, **kwargs):
assert env.pm.current_session() is None
if failure == 'membership':
env.access.membership.role = 'viewer'
if failure == 'generation':
env.ap.workspace_service.get_execution_binding.side_effect = RuntimeError('synthetic-secret')
if failure == 'edit':
async with env.engine.begin() as conn:
await conn.execute(
sa.update(LegacyPipeline)
.where(LegacyPipeline.uuid == 'one')
.values(extensions_preferences={'enable_all_plugins': False})
)
return 'candidate'
if failure not in ('prepare', 'schema'):
env.ap.pipeline_mgr.prepare_pipeline.side_effect = prepare
task = await execute(env, body)
configs, backups = await rows(env)
assert configs['one'] == SOURCE and not backups
assert task.task_context.metadata['results'][0]['state'] in ('blocked', 'failed', 'stale')
assert 'synthetic-secret' not in str(task.to_dict())
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
@pytest.mark.asyncio
async def test_activation_pending_explicit_retry_without_second_backup(env):
env.ap.pipeline_mgr.publish_pipeline.side_effect = RuntimeError('synthetic-secret')
task = await execute(env)
assert task.task_context.metadata['results'][0]['state'] == 'activation_pending'
configs, backups = await rows(env)
assert configs['one'] != SOURCE and len(backups) == 1
preview = await env.svc.preview(context())
assert preview['items'][0]['state'] == 'activation_pending'
env.ap.pipeline_mgr.publish_pipeline.side_effect = None
task = await execute(env)
assert task.task_context.metadata['results'][0]['state'] == 'migrated'
_, backups = await rows(env)
assert len(backups) == 1 and backups[0]['state'] == 'active'
@pytest.mark.asyncio
async def test_double_execute_commits_once_and_partial_results(env):
body = await selection(env, ('one', 'two'))
original_prepare = env.ap.pipeline_mgr.prepare_pipeline
async def prepare(_ctx, entity):
assert env.pm.current_session() is None
if entity['uuid'] == 'two':
raise RuntimeError('synthetic-secret')
await asyncio.sleep(0)
return 'candidate'
original_prepare.side_effect = prepare
first, second = await asyncio.gather(env.svc.execute(context(), body), env.svc.execute(context(), body))
await env.ap.task_mgr.wait_all()
configs, backups = await rows(env)
assert len(backups) == 1
assert configs['two'] == SOURCE
results = [env.ap.task_mgr.get_task_by_id(r['task_id']).task_context.metadata['results'] for r in (first, second)]
assert sum(item['state'] == 'migrated' for batch in results for item in batch) == 1
assert all(batch[1]['state'] == 'failed' for batch in results)
@pytest.mark.asyncio
async def test_ordinary_update_requires_manual_migration_but_allows_metadata(env):
from langbot.pkg.api.http.service.pipeline import PipelineService
service = PipelineService(env.ap)
env.ap.pipeline_mgr.remove_pipeline = AsyncMock()
env.ap.pipeline_mgr.load_pipeline = AsyncMock()
async with env.pm.tenant_scope(WS):
with pytest.raises(ValueError, match='manual_migration_required'):
await service.update_pipeline(context(), 'one', {'config': planner(SOURCE)['config']})
await service.update_pipeline(context(), 'one', {'name': 'renamed'})
configs, backups = await rows(env)
assert configs['one'] == SOURCE and not backups
@pytest.mark.asyncio
async def test_snapshot_commit_failure_rolls_back_config_and_backup(env, monkeypatch):
from sqlalchemy.ext.asyncio import AsyncSessionTransaction
original = AsyncSessionTransaction.commit
async def fail_write_commit(transaction):
# Fail the actual transaction after both statements have run.
result = await transaction.session.execute(
sa.select(sa.func.count()).select_from(env.m.PipelineMigrationSnapshot)
)
if result.scalar() > 0:
raise RuntimeError('synthetic-secret')
await original(transaction)
monkeypatch.setattr(AsyncSessionTransaction, 'commit', fail_write_commit)
task = await execute(env)
configs, backups = await rows(env)
assert configs['one'] == SOURCE and not backups
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
assert task.task_context.metadata['results'][0]['state'] == 'failed'
assert 'synthetic-secret' not in str(task.to_dict())
@pytest.mark.asyncio
async def test_plugin_changes_while_preparing_prevent_commit(env):
async def prepare(*args):
async with env.engine.begin() as conn:
await conn.execute(sa.update(PluginSetting).values(runtime_revision=2))
return 'candidate'
env.ap.pipeline_mgr.prepare_pipeline.side_effect = prepare
task = await execute(env)
configs, backups = await rows(env)
assert configs['one'] == SOURCE and not backups
assert task.task_context.metadata['results'][0]['state'] == 'stale'
@pytest.mark.asyncio
async def test_scope_token_cannot_be_reused_by_other_identity_or_generation(env):
import dataclasses
body = await selection(env)
ctx = dataclasses.replace(context(), placement_generation=2)
env.access.execution.placement_generation = 2
with pytest.raises(env.m.MigrationError):
await env.svc.execute(ctx, body)
assert not env.ap.task_mgr.tasks
@pytest.mark.asyncio
async def test_conversation_invalidation_is_workspace_scoped(env):
good = NS(workspace_uuid=WS, using_conversation=NS(pipeline_uuid='one'))
foreign = NS(workspace_uuid=OTHER, using_conversation=NS(pipeline_uuid='one'))
env.ap.sess_mgr.session_list = [good, foreign]
await execute(env)
assert good.using_conversation is None
assert foreign.using_conversation is not None
@pytest.mark.asyncio
async def test_runtime_schema_changes_during_prepare_prevent_commit(env):
async def prepare(*args):
env.ap.runner_registry.list_runners.return_value[0].config_schema = []
return 'candidate'
env.ap.pipeline_mgr.prepare_pipeline.side_effect = prepare
task = await execute(env)
configs, backups = await rows(env)
assert configs['one'] == SOURCE and not backups
assert task.task_context.metadata['results'][0]['state'] == 'blocked'
@pytest.mark.asyncio
async def test_real_planner_deerflow_schema_integration(env, monkeypatch):
import json
from pathlib import Path
from langbot.pkg.pipeline.legacy_config_migration import plan_legacy_pipeline
monkeypatch.setattr(env.m, 'plan_legacy_pipeline', plan_legacy_pipeline)
source = {
'ai': {
'runner': {'runner': 'deerflow-api', 'expire-time': 60},
'deerflow-api': {'api-base': 'https://synthetic.invalid', 'api-key': 'synthetic-secret'},
},
'output': {'misc': {'remove-think': False}},
}
plan = plan_legacy_pipeline(source, {'enable_all_plugins': True})
assert plan['state'] == 'ready'
async with env.engine.begin() as conn:
await conn.execute(sa.update(LegacyPipeline).where(LegacyPipeline.uuid == 'one').values(config=source))
await conn.execute(
sa.insert(PluginSetting).values(
workspace_uuid=WS, plugin_author='langbot-team', plugin_name='DeerFlowAgent', enabled=True
)
)
schema = json.loads((Path(__file__).parent / 'fixtures/pipeline_migration_deerflow_schema.json').read_text())
env.ap.runner_registry.list_runners.return_value = [
NS(
id=plan['target_runner_id'],
usages=['agent'],
plugin_version=plan['target_plugin']['version'],
config_schema=schema,
)
]
task = await execute(env)
assert task.task_context.metadata['results'][0]['state'] == 'migrated'
configs, backups = await rows(env)
assert configs['one'] == plan['config'] and backups[0]['source_snapshot']['config'] == source
@pytest.mark.asyncio
async def test_account_disabled_during_prepare_blocks_commit(env):
async def prepare(*args):
async with env.engine.begin() as conn:
await conn.execute(sa.update(User).where(User.uuid == 'account').values(status='disabled'))
return 'candidate'
env.ap.pipeline_mgr.prepare_pipeline.side_effect = prepare
task = await execute(env)
configs, backups = await rows(env)
assert configs['one'] == SOURCE and not backups
assert task.task_context.metadata['results'][0]['state'] == 'blocked'
@pytest.mark.asyncio
async def test_plugin_disabled_after_commit_defers_activation(env, monkeypatch):
original = env.svc._commit
async def commit(*args):
result = await original(*args)
async with env.engine.begin() as conn:
await conn.execute(sa.update(PluginSetting).values(enabled=False))
return result
monkeypatch.setattr(env.svc, '_commit', commit)
task = await execute(env)
assert task.task_context.metadata['results'][0]['state'] == 'activation_pending'
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
@pytest.mark.asyncio
async def test_ambiguous_commit_acknowledgement_reports_pending(env, monkeypatch):
from sqlalchemy.ext.asyncio import AsyncSessionTransaction
original = AsyncSessionTransaction.commit
failed = False
async def fail_ack(transaction):
nonlocal failed
count = (
await transaction.session.execute(sa.select(sa.func.count()).select_from(env.m.PipelineMigrationSnapshot))
).scalar()
await original(transaction)
if count and not failed:
failed = True
raise RuntimeError('synthetic-secret')
monkeypatch.setattr(AsyncSessionTransaction, 'commit', fail_ack)
task = await execute(env)
configs, backups = await rows(env)
assert configs['one'] != SOURCE and len(backups) == 1
assert task.task_context.metadata['results'][0]['state'] == 'activation_pending'
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize('durable', [False, True], ids=['before_commit', 'after_durable_commit'])
async def test_cancel_commit_reports_durable_outcome_and_stops_batch(env, monkeypatch, durable):
from sqlalchemy.ext.asyncio import AsyncSessionTransaction
original = AsyncSessionTransaction.commit
cancelled = False
async def cancel_commit(transaction):
nonlocal cancelled
count = (
await transaction.session.execute(sa.select(sa.func.count()).select_from(env.m.PipelineMigrationSnapshot))
).scalar()
if count and not cancelled:
cancelled = True
if durable:
await original(transaction)
asyncio.current_task().cancel()
await asyncio.sleep(0)
await original(transaction)
monkeypatch.setattr(AsyncSessionTransaction, 'commit', cancel_commit)
task = await execute(env, await selection(env, ('one', 'two')))
configs, backups = await rows(env)
assert cancelled
assert configs['one'] == (planner(SOURCE)['config'] if durable else SOURCE)
assert len(backups) == int(durable)
if durable:
assert backups[0]['state'] == 'activation_pending'
assert configs['two'] == SOURCE
assert task.task_context.metadata['results'] == [
{'pipeline_uuid': 'one', 'state': 'activation_pending' if durable else 'failed', 'code': 'operation_cancelled'},
{'pipeline_uuid': 'two', 'state': 'failed', 'code': 'operation_cancelled'},
]
env.ap.pipeline_mgr.prepare_pipeline.assert_awaited_once()
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize('durable', [False, True])
@pytest.mark.parametrize('initial_cancel', [False, True])
@pytest.mark.parametrize('reconcile_cancel', [False, True])
async def test_unavailable_commit_reconciliation_is_conservative(
env, monkeypatch, durable, initial_cancel, reconcile_cancel
):
from sqlalchemy.ext.asyncio import AsyncSessionTransaction
original_commit = AsyncSessionTransaction.commit
original_execute = env.pm.execute_async
interrupted = False
reconciliation_attempts = 0
async def interrupt_commit(transaction):
nonlocal interrupted
count = (
await transaction.session.execute(sa.select(sa.func.count()).select_from(env.m.PipelineMigrationSnapshot))
).scalar()
if count and not interrupted:
interrupted = True
if durable:
await original_commit(transaction)
if initial_cancel:
asyncio.current_task().cancel()
await asyncio.sleep(0)
raise RuntimeError('synthetic-secret')
await original_commit(transaction)
async def unavailable_reconciliation(statement, *args, **kwargs):
nonlocal reconciliation_attempts
if interrupted:
reconciliation_attempts += 1
if reconcile_cancel:
# Repeated real cancellation must stop the batch, not start a
# shield/retry loop or leave the current result as pending.
asyncio.current_task().cancel()
asyncio.current_task().cancel()
await asyncio.sleep(0)
raise RuntimeError('synthetic-secret')
return await original_execute(statement, *args, **kwargs)
monkeypatch.setattr(AsyncSessionTransaction, 'commit', interrupt_commit)
monkeypatch.setattr(env.pm, 'execute_async', unavailable_reconciliation)
task_id = (await env.svc.execute(context(), await selection(env, ('one', 'two'))))['task_id']
task = env.ap.task_mgr.get_task_by_id(task_id)
await asyncio.gather(task.task, return_exceptions=True)
assert task.task.cancelled() is reconcile_cancel
configs, backups = await rows(env)
assert configs['one'] == (planner(SOURCE)['config'] if durable else SOURCE)
assert len(backups) == int(durable)
assert task.task_context.metadata['results'][0] == {
'pipeline_uuid': 'one',
'state': 'activation_pending',
'code': 'commit_outcome_unknown',
}
if initial_cancel or reconcile_cancel:
assert reconciliation_attempts == 1
assert task.task_context.metadata['results'][1] == {
'pipeline_uuid': 'two',
'state': 'failed',
'code': 'operation_cancelled',
}
env.ap.pipeline_mgr.prepare_pipeline.assert_awaited_once()
assert configs['two'] == SOURCE
assert 'synthetic-secret' not in str(task.task_context.metadata)
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize('durable_retry_ack', [False, True])
@pytest.mark.parametrize('reconcile_failure', [None, 'error', 'cancel'])
async def test_cancel_activation_retry_reconciles_original_snapshot(
env, monkeypatch, durable_retry_ack, reconcile_failure
):
from sqlalchemy.ext.asyncio import AsyncSessionTransaction
env.ap.pipeline_mgr.publish_pipeline.side_effect = RuntimeError('synthetic-secret')
await execute(env)
before_configs, before_backups = await rows(env)
body = await selection(env, ('one', 'two'))
env.ap.pipeline_mgr.publish_pipeline.reset_mock(side_effect=True)
env.ap.pipeline_mgr.prepare_pipeline.reset_mock()
original_commit = AsyncSessionTransaction.commit
original_service_commit = env.svc._commit
original_execute = env.pm.execute_async
interrupted = False
in_commit = False
reconciliation_attempts = 0
async def service_commit(*args):
nonlocal in_commit
in_commit = True
try:
return await original_service_commit(*args)
finally:
in_commit = False
async def cancel_ack(transaction):
nonlocal interrupted
if in_commit and not interrupted:
interrupted = True
if durable_retry_ack:
await original_commit(transaction)
asyncio.current_task().cancel()
await asyncio.sleep(0)
await original_commit(transaction)
async def reconcile(statement, *args, **kwargs):
nonlocal reconciliation_attempts
if interrupted and not in_commit:
reconciliation_attempts += 1
if reconcile_failure == 'cancel':
asyncio.current_task().cancel()
await asyncio.sleep(0)
if reconcile_failure == 'error':
raise RuntimeError('synthetic-secret')
return await original_execute(statement, *args, **kwargs)
monkeypatch.setattr(env.svc, '_commit', service_commit)
monkeypatch.setattr(AsyncSessionTransaction, 'commit', cancel_ack)
monkeypatch.setattr(env.pm, 'execute_async', reconcile)
task_id = (await env.svc.execute(context(), body))['task_id']
task = env.ap.task_mgr.get_task_by_id(task_id)
await asyncio.gather(task.task, return_exceptions=True)
configs, backups = await rows(env)
assert interrupted and configs == before_configs
assert backups == before_backups
assert backups[0]['state'] == 'activation_pending'
assert reconciliation_attempts == 1
assert task.task.cancelled() is (reconcile_failure == 'cancel')
assert task.task_context.metadata['results'] == [
{
'pipeline_uuid': 'one',
'state': 'activation_pending',
'code': 'commit_outcome_unknown' if reconcile_failure else 'operation_cancelled',
},
{'pipeline_uuid': 'two', 'state': 'failed', 'code': 'operation_cancelled'},
]
env.ap.pipeline_mgr.prepare_pipeline.assert_awaited_once()
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
assert 'synthetic-secret' not in str(task.task_context.metadata)
@pytest.mark.asyncio
async def test_cancel_during_prepare_keeps_original_and_stops_batch(env):
async def cancel_prepare(*args):
asyncio.current_task().cancel()
await asyncio.sleep(0)
env.ap.pipeline_mgr.prepare_pipeline.side_effect = cancel_prepare
task = await execute(env, await selection(env, ('one', 'two')))
configs, backups = await rows(env)
assert configs['one'] == configs['two'] == SOURCE and not backups
assert all(
r['state'] == 'failed' and r['code'] == 'operation_cancelled' for r in task.task_context.metadata['results']
)
env.ap.pipeline_mgr.prepare_pipeline.assert_awaited_once()
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
@@ -0,0 +1,119 @@
"""Actual planner + artifact descriptors + migration service + SQLite snapshots.
Vendor calls and runtime publication are fixtures. Database writes, snapshots,
scoped service validation, and converter output are real. Browser tests consume
this same synthetic contract, without requiring Python or a sibling checkout.
"""
import copy
import json
from pathlib import Path
from types import SimpleNamespace as NS
from unittest.mock import AsyncMock, Mock
import pytest
import sqlalchemy as sa
import yaml
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
from langbot.pkg.entity.persistence.model import LLMModel, ModelProvider
from langbot.pkg.pipeline.legacy_config_migration import PLANNER_VERSION, plan_legacy_pipeline
from tests.unit_tests.api.service import test_pipeline_migration as base
ROOT = Path(__file__).parents[4]
CONTRACT = json.loads((ROOT / 'web/tests/e2e/fixtures/runner-migration-contract.json').read_text())
env = base.env
@pytest.mark.parametrize('case', CONTRACT['cases'], ids=lambda case: case['legacy_runner'])
def test_portable_browser_contract_matches_actual_converter(case):
assert CONTRACT['planner_version'] == PLANNER_VERSION
assert plan_legacy_pipeline(case['source']) == case['plan']
assert len(CONTRACT['cases']) == len(CONTRACT['plugins']) == 9
for name in ('ai', 'output'):
schema = yaml.safe_load((ROOT / f'src/langbot/templates/metadata/pipeline/{name}.yaml').read_text())
assert CONTRACT[f'{name}_schema'] == schema
@pytest.mark.asyncio
@pytest.mark.parametrize('source_mode', ['local', 'cloud_projection'])
@pytest.mark.parametrize('case', CONTRACT['cases'], ids=lambda case: case['legacy_runner'])
async def test_all_runner_service_roundtrip_retains_complete_backup(env, monkeypatch, case, source_mode):
monkeypatch.setattr(env.m, 'plan_legacy_pipeline', plan_legacy_pipeline)
source = copy.deepcopy(case['source'])
plan = plan_legacy_pipeline(source)
target = plan['target_plugin']
artifact = next(
item for item in CONTRACT['plugins'].values() if item['manifest']['metadata']['name'] == target['name']
)
schema = artifact['component']['spec']
env.ap.runner_registry.list_runners.return_value = [
RunnerDescriptor(
id=plan['target_runner_id'],
source='plugin',
label={},
plugin_author=target['author'],
plugin_name=target['name'],
plugin_version=target['version'],
runner_name='default',
usages=schema['usages'],
config_schema=schema['config'],
capabilities=schema.get('capabilities', {}),
permissions=schema.get('permissions', {}),
)
]
env.ap.logger = Mock()
env.ap.tool_mgr = NS(
get_resolved_tool_catalog=AsyncMock(return_value=[]), get_tool_schema=AsyncMock(return_value=('', {}))
)
async with env.engine.begin() as conn:
await conn.run_sync(
lambda c: ModelProvider.metadata.create_all(c, tables=[ModelProvider.__table__, LLMModel.__table__])
)
await conn.execute(
sa.insert(ModelProvider).values(
uuid='fixture-provider',
workspace_uuid=base.WS,
name='fixture',
requester='test',
base_url='https://synthetic.invalid',
)
)
await conn.execute(
sa.insert(LLMModel).values(
uuid='synthetic-model', workspace_uuid=base.WS, name='fixture', provider_uuid='fixture-provider'
)
)
await conn.execute(sa.update(base.Workspace).where(base.Workspace.uuid == base.WS).values(source=source_mode))
await conn.execute(
sa.update(base.LegacyPipeline).where(base.LegacyPipeline.uuid == 'one').values(config=source)
)
await conn.execute(
sa.insert(base.PluginSetting).values(
workspace_uuid=base.WS,
plugin_author=target['author'],
plugin_name=target['name'],
enabled=True,
install_info={'version': target['version']},
)
)
before, snapshots = await base.rows(env)
assert not snapshots
preview = await env.svc.preview(base.context())
selected = next(row for row in preview['items'] if row['pipeline_uuid'] == 'one')
assert selected['state'] == 'ready', selected['blockers']
assert selected['preview_token']
assert await base.rows(env) == (before, snapshots)
task = await base.execute(
env, {'confirmed': True, 'items': [{key: selected[key] for key in ('pipeline_uuid', 'preview_token')}]}
)
assert task.task_context.metadata['results'] == [{'pipeline_uuid': 'one', 'state': 'migrated', 'code': None}]
configs, backups = await base.rows(env)
assert configs['one'] == plan['config']
assert set(configs['one']['ai']) == {'runner', 'runner_config'}
assert configs['two'] == before['two'] and configs['foreign'] == before['foreign']
assert len(backups) == 1
assert backups[0]['source_snapshot']['config'] == source
assert set(backups[0]['source_snapshot']['config']['ai']) == {'runner', *CONTRACT['legacy_sections']}
assert backups[0]['state'] == 'active'
env.ap.pipeline_mgr.publish_pipeline.assert_called_once()
@@ -0,0 +1,121 @@
"""Shared admission and real embedded artifact schema regressions."""
import copy
import json
from pathlib import Path
from types import SimpleNamespace as NS
import pytest
import sqlalchemy as sa
from tests.unit_tests.api.service.test_pipeline_migration import (
env as migration_env,
execute,
WS,
SOURCE,
RID,
planner,
)
from langbot.pkg.agent.runner.interaction_store import InteractionStore, InteractionScopeError
from langbot.pkg.entity.persistence.agent_interaction import AgentInteraction
@pytest.fixture
async def env(tmp_path, monkeypatch):
async for value in migration_env.__wrapped__(tmp_path, monkeypatch):
yield value
async def request(env, **kwargs):
return await InteractionStore(env.pm.get_db_engine()).create_request(
interaction_id='form',
run_id='run',
binding_id='binding',
runner_id=RID,
processor_type='pipeline',
processor_id='one',
workspace_id=WS,
request={},
delivery_target={},
expected_config=copy.deepcopy(SOURCE),
authority_check=lambda: True,
**kwargs,
)
@pytest.mark.asyncio
async def test_stale_writer_after_commit_rejected(env):
original = env.svc._activate
async def activate(*args):
with pytest.raises(InteractionScopeError):
await request(env)
return await original(*args)
env.svc._activate = activate
task = await execute(env)
assert task.task_context.metadata['results'][0]['state'] == 'migrated'
with pytest.raises(InteractionScopeError):
await request(env)
async with env.engine.connect() as conn:
assert not (await conn.execute(sa.select(AgentInteraction))).all()
@pytest.mark.asyncio
async def test_writer_requires_pipeline_authority(env):
with pytest.raises(InteractionScopeError):
await InteractionStore(env.engine).create_request(
interaction_id='form',
run_id='run',
binding_id='binding',
runner_id=RID,
processor_type='pipeline',
processor_id='one',
workspace_id=WS,
request={},
delivery_target={},
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
'folder,field,good,bad',
[
('LangflowAgent', 'tweaks', {'node': {'value': 1}}, ['not-an-object']),
('WeKnoraAgent', 'knowledge-base-ids', ['kb-a'], [1]),
],
)
async def test_embedded_artifact_schema_types(env, folder, field, good, bad):
root = Path(__file__).parent / 'fixtures'
artifact = 'langflow-agent' if folder == 'LangflowAgent' else 'weknora-agent'
spec = {'config': json.loads((root / (artifact + '-artifact-schema.json')).read_text())}
kind = 'json' if folder == 'LangflowAgent' else 'array[string]'
schema = next(s for s in spec['config'] if s['type'] == kind)
descriptor = env.ap.runner_registry.list_runners.return_value[0]
descriptor.config_schema = [schema]
plan = planner(SOURCE)
plan['config']['ai']['runner_config'][RID] = {schema['name']: good}
await env.svc._verify_runtime(NS(workspace_uuid=WS), plan)
plan['config']['ai']['runner_config'][RID][schema['name']] = bad
with pytest.raises(env.m.MigrationError, match='runner_schema_incompatible'):
await env.svc._verify_runtime(NS(workspace_uuid=WS), plan)
def test_conversation_authority_is_captured_before_runner_returns():
from langbot.pkg.agent.runner.interaction_manager import InteractionManager
old, new = NS(), NS()
config = planner(SOURCE)['config']
query = NS(pipeline_config=config, pipeline_uuid='one', session=NS(using_conversation=new))
binding = NS(
processor_type='pipeline', processor_id='one', runner_id=RID, runner_config=config['ai']['runner_config'][RID]
)
authority = InteractionManager._pipeline_admission(
binding,
NS(id=RID),
{
'_query': query,
'_pipeline_expected_config': copy.deepcopy(config),
'_pipeline_conversation': old,
},
)
assert authority['authority_check']() is False
@@ -0,0 +1,234 @@
"""Migration must not abandon native or durable human-input requests."""
import copy
import sys
from types import SimpleNamespace as NS
import pytest
import sqlalchemy as sa
from tests.unit_tests.api.service import test_pipeline_migration as existing
from tests.unit_tests.api.service.test_pipeline_migration import (
context,
selection,
execute,
rows,
WS,
OTHER,
SOURCE,
)
from langbot.pkg.entity.persistence.agent_interaction import AgentInteraction
env = existing.env
NATIVE = 'langbot.pkg.provider.runners.difysvapi'
@pytest.fixture(autouse=True)
async def interaction_schema(env):
async with env.engine.begin() as conn:
await conn.run_sync(lambda c: AgentInteraction.__table__.create(c, checkfirst=True))
async def add_request(env, status='pending', workspace=WS, pipeline='one'):
async with env.engine.begin() as conn:
await conn.execute(
sa.insert(AgentInteraction).values(
interaction_id='form',
run_id=f'{workspace}-{pipeline}-{status}',
binding_id='binding',
runner_id='plugin:langbot-team/DifyAgent/default',
processor_type='pipeline',
processor_id=pipeline,
workspace_id=workspace,
status=status,
request_json='{"secret":"private-form"}',
callback_token_hash=f'{workspace}-{pipeline}-{status}',
)
)
def native(monkeypatch, workspace=WS, pipeline='one', instance='instance'):
forms = {
(instance, workspace, 1, 'bot', pipeline, 'adapter', 'person', 'actor'): {
'private-token': {'inputs': {'secret': 'private-form'}}
}
}
monkeypatch.setitem(sys.modules, NATIVE, NS(_PENDING_FORMS=forms))
return forms
@pytest.mark.asyncio
@pytest.mark.parametrize('source', ['native', 'durable'])
async def test_pending_preview_readonly_secret_free(env, monkeypatch, source):
cache = native(monkeypatch) if source == 'native' else None
if source == 'durable':
await add_request(env)
before = copy.deepcopy(cache)
writes = []
def observe(_conn, _cursor, statement, *_args):
if statement.lstrip().upper().startswith(('INSERT', 'UPDATE', 'DELETE')):
writes.append(statement)
sa.event.listen(env.engine.sync_engine, 'before_cursor_execute', observe)
result = await env.svc.preview(context())
item = result['items'][0]
assert item['state'] == 'blocked'
assert {'code': 'runtime.pending_interaction'} in item['blockers']
assert item['preview_token'] is None
assert result['items'][1]['state'] == 'ready'
assert 'private-' not in str(result)
assert cache == before and not writes
env.ap.pipeline_mgr.prepare_pipeline.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize('status', ['submitted', 'cancelled', 'expired', 'delivery_failed'])
async def test_terminal_requests_allow_migration(env, status):
await add_request(env, status)
task = await execute(env)
assert task.task_context.metadata['results'][0]['state'] == 'migrated'
@pytest.mark.asyncio
@pytest.mark.parametrize('source', ['native', 'durable'])
async def test_other_scope_does_not_block(env, monkeypatch, source):
if source == 'native':
native(monkeypatch, workspace=OTHER)
else:
await add_request(env, workspace=OTHER)
await add_request(env, pipeline='two')
task = await execute(env)
assert task.task_context.metadata['results'][0]['state'] == 'migrated'
@pytest.mark.asyncio
@pytest.mark.parametrize('source', ['native', 'durable'])
async def test_request_after_preview_stales_token(env, monkeypatch, source):
body = await selection(env)
if source == 'native':
native(monkeypatch)
else:
await add_request(env)
with pytest.raises(env.m.MigrationError, match='preview_stale'):
await env.svc.execute(context(), body)
assert not env.ap.task_mgr.tasks
@pytest.mark.asyncio
@pytest.mark.parametrize('source', ['native', 'durable'])
async def test_new_request_during_prepare_prevents_snapshot_and_reset(env, monkeypatch, source):
conversation = NS(pipeline_uuid='one')
session = NS(workspace_uuid=WS, instance_uuid='instance', using_conversation=conversation)
env.ap.sess_mgr.session_list = [session]
async def prepare(*args):
if source == 'native':
native(monkeypatch)
else:
await add_request(env)
return 'candidate'
env.ap.pipeline_mgr.prepare_pipeline.side_effect = prepare
task = await execute(env)
configs, snapshots = await rows(env)
assert configs['one'] == SOURCE and not snapshots
assert session.using_conversation is conversation
assert task.task_context.metadata['results'][0]['state'] == 'stale'
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
@pytest.mark.asyncio
async def test_activation_boundary_catches_new_request(env, monkeypatch):
original = env.svc._commit
async def commit(*args):
result = await original(*args)
await add_request(env)
return result
monkeypatch.setattr(env.svc, '_commit', commit)
task = await execute(env)
assert task.task_context.metadata['results'][0] == {
'pipeline_uuid': 'one',
'state': 'activation_pending',
'code': 'runtime.pending_interaction',
}
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
_, snapshots = await rows(env)
assert snapshots[0]['state'] == 'activation_pending'
@pytest.mark.asyncio
async def test_activation_retry_cannot_erase_pending_form(env):
env.ap.pipeline_mgr.publish_pipeline.side_effect = RuntimeError('activation failed')
await execute(env)
body = await selection(env)
before = await rows(env)
await add_request(env)
with pytest.raises(env.m.MigrationError, match='preview_stale'):
await env.svc.execute(context(), body)
assert await rows(env) == before
assert (await env.svc.preview(context()))['items'][0]['state'] == 'blocked'
@pytest.mark.asyncio
async def test_missing_interaction_table_fails_closed(env):
async with env.engine.begin() as conn:
await conn.run_sync(lambda c: AgentInteraction.__table__.drop(c))
result = await env.svc.preview(context())
assert result['items'][0]['state'] == 'blocked'
assert {'code': 'runtime.pending_interaction'} in result['items'][0]['blockers']
@pytest.mark.asyncio
async def test_unknown_native_scope_fails_closed_without_payload(env, monkeypatch):
native(monkeypatch, workspace='')
result = await env.svc.preview(context())
assert result['items'][0]['state'] == 'blocked'
assert 'private-' not in str(result)
@pytest.mark.asyncio
async def test_terminal_state_change_requires_fresh_preview(env):
await add_request(env, 'submitted')
body = await selection(env)
async with env.engine.begin() as conn:
await conn.execute(sa.update(AgentInteraction).values(status='cancelled'))
with pytest.raises(env.m.MigrationError, match='preview_stale'):
await env.svc.execute(context(), body)
@pytest.mark.asyncio
async def test_unsupported_atomic_boundary_not_advertised_ready(env, monkeypatch):
from unittest.mock import AsyncMock
monkeypatch.setattr(env.pm, 'get_db_engine', lambda: NS(dialect=NS(name='unsupported')))
monkeypatch.setattr(env.pm, 'execute_async', AsyncMock(return_value=NS(all=lambda: [])))
_, blocked = await env.svc._interaction_state(context(), 'one')
assert blocked
@pytest.mark.asyncio
async def test_native_form_arriving_during_cas_rolls_back(env, monkeypatch):
original = env.pm.execute_async
async def execute_sql(statement, *args, **kwargs):
result = await original(statement, *args, **kwargs)
if isinstance(statement, sa.sql.dml.Insert) and statement.table.name == 'pipeline_migration_snapshots':
native(monkeypatch)
return result
monkeypatch.setattr(env.pm, 'execute_async', execute_sql)
await execute(env)
configs, snapshots = await rows(env)
assert configs['one'] == SOURCE and not snapshots
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
# Also exercise the unchanged regression assertions with this module's
# interaction-schema fixture. The original tests remain in their own module.
for _name, _test in vars(existing).items():
if _name.startswith('test_') and callable(_test):
globals()['test_existing_' + _name[5:]] = _test
@@ -0,0 +1,170 @@
"""Real scoped database checks against the reviewed LocalAgent descriptor."""
import copy
import json
from pathlib import Path
from types import SimpleNamespace as NS
from unittest.mock import AsyncMock, Mock
import pytest
import sqlalchemy as sa
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.entity.persistence.model import ModelProvider, LLMModel
from langbot.pkg.entity.persistence.rag import KnowledgeBase
from langbot.pkg.entity.persistence.mcp import MCPServer
from tests.unit_tests.api.service import test_pipeline_migration as base
from tests.unit_tests.api.service.test_pipeline_migration import context, WS, OTHER
env = base.env
@pytest.fixture
async def local(env):
spec = json.loads((Path(__file__).parent / 'fixtures/pipeline_migration_local_schema.json').read_text())
rid = 'plugin:langbot-team/LocalAgent/default'
descriptor = RunnerDescriptor(
id=rid,
source='plugin',
label={},
plugin_author='langbot-team',
plugin_name='LocalAgent',
runner_name='default',
plugin_version='reviewed',
usages=['agent'],
config_schema=spec['config'],
capabilities=spec['capabilities'],
permissions=spec['permissions'],
)
env.ap.runner_registry.list_runners.return_value = [descriptor]
env.ap.logger = Mock()
env.ap.tool_mgr = NS(
get_resolved_tool_catalog=AsyncMock(
return_value=[{'name': 'scoped_tool', 'source': 'mcp', 'source_id': 'mcp'}]
),
get_tool_schema=AsyncMock(return_value=('', {})),
)
async with env.engine.begin() as conn:
await conn.run_sync(
lambda c: ModelProvider.metadata.create_all(
c, tables=[ModelProvider.__table__, LLMModel.__table__, KnowledgeBase.__table__, MCPServer.__table__]
)
)
for ws, suffix in [(WS, ''), (OTHER, '_foreign')]:
await conn.execute(
sa.insert(ModelProvider).values(
uuid='provider' + suffix,
workspace_uuid=ws,
name='provider',
requester='test',
base_url='https://synthetic.invalid',
)
)
await conn.execute(
sa.insert(LLMModel).values(
uuid='model' + suffix, workspace_uuid=ws, name='model', provider_uuid='provider' + suffix
)
)
await conn.execute(sa.insert(KnowledgeBase).values(uuid='kb' + suffix, workspace_uuid=ws, name='kb'))
await conn.execute(
sa.insert(MCPServer).values(
uuid='mcp' + suffix, workspace_uuid=ws, name='mcp' + suffix, mode='remote', enable=True
)
)
config = {item['name']: copy.deepcopy(item['default']) for item in spec['config'] if 'default' in item}
config.update(
{
'model': {'primary': 'model', 'fallbacks': [], 'reasoning': {}},
'knowledge-bases': ['kb'],
'tools': ['scoped_tool'],
'enable-all-tools': False,
'mcp-resources': [{'server_uuid': 'mcp', 'uri': 'test://document', 'enabled': True}],
'mcp-resource-agent-read-enabled': True,
}
)
plan = {
'target_runner_id': rid,
'target_plugin': {'version': 'reviewed'},
'config': {'ai': {'runner': {'id': rid}, 'runner_config': {rid: config}}},
}
return env, descriptor, config, plan
@pytest.mark.asyncio
async def test_valid_local_resources_and_real_descriptor_options(local):
env, _, _, plan = local
async with env.pm.tenant_scope(WS):
await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan)
env.ap.tool_mgr.get_resolved_tool_catalog.assert_awaited_once()
ctx = env.ap.tool_mgr.get_resolved_tool_catalog.call_args.args[0]
assert ctx.workspace_uuid == WS
assert env.pm.current_session() is None
@pytest.mark.asyncio
@pytest.mark.parametrize('resource', ['model', 'kb', 'mcp', 'tool'])
async def test_foreign_or_unresolved_resource_is_denied(local, resource):
env, _, config, plan = local
if resource == 'model':
config['model']['primary'] = 'model_foreign'
elif resource == 'kb':
config['knowledge-bases'] = ['kb_foreign']
elif resource == 'mcp':
config['mcp-resources'][0]['server_uuid'] = 'mcp_foreign'
else:
config['tools'] = ['foreign_tool']
async with env.pm.tenant_scope(WS):
with pytest.raises(env.m.MigrationError, match='runner_resource_unavailable'):
await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan)
@pytest.mark.asyncio
@pytest.mark.parametrize('declared', [False, True])
async def test_null_requires_explicit_descriptor_nullable(local, declared):
env, descriptor, config, plan = local
config['timeout'] = None
field = next(f for f in descriptor.config_schema if f['name'] == 'timeout')
field['nullable'] = declared
async with env.pm.tenant_scope(WS):
if declared:
await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan)
else:
with pytest.raises(env.m.MigrationError, match='runner_schema_incompatible'):
await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan)
@pytest.mark.asyncio
async def test_old_plugin_version_is_rejected_before_resource_resolution(local):
env, descriptor, _, plan = local
descriptor.plugin_version = 'old'
async with env.pm.tenant_scope(WS):
with pytest.raises(env.m.MigrationError, match='plugin_version_incompatible'):
await env.svc._verify_runtime(ExecutionContext.from_request(context()), plan)
env.ap.tool_mgr.get_resolved_tool_catalog.assert_not_awaited()
@pytest.mark.asyncio
async def test_local_descriptor_migrates_through_detached_task(local, monkeypatch):
env, _, _, plan = local
def planner(config, extensions_preferences=None):
result = base.planner(config, extensions_preferences)
if result['state'] == 'ready':
result.update(copy.deepcopy(plan))
result['target_plugin'].update(author='langbot-team', name='LocalAgent')
return result
monkeypatch.setattr(env.m, 'plan_legacy_pipeline', planner)
async with env.engine.begin() as conn:
await conn.execute(
sa.insert(base.PluginSetting).values(
workspace_uuid=WS, plugin_author='langbot-team', plugin_name='LocalAgent', enabled=True
)
)
task = await base.execute(env)
assert task.task_context.metadata['results'][0]['state'] == 'migrated'
configs, snapshots = await base.rows(env)
assert configs['one'] == plan['config']
assert len(snapshots) == 1 and snapshots[0]['source_snapshot']['config'] == base.SOURCE
env.ap.pipeline_mgr.publish_pipeline.assert_called_once()
@@ -0,0 +1,60 @@
import importlib
import importlib.util
from types import SimpleNamespace as NS
from unittest.mock import AsyncMock
import pytest
import quart
from langbot.pkg.api.http.context import RequestContext, WorkspaceContext, PrincipalContext, PrincipalType
from langbot.pkg.api.http.authz import permissions_for_role
@pytest.mark.asyncio
async def test_manual_routes_user_token_permissions_and_strict_request():
path = 'langbot.pkg.api.http.controller.groups.pipelines.migration'
assert importlib.util.find_spec(path), 'manual migration routes missing'
cls = importlib.import_module(path).PipelineMigrationRouterGroup
from langbot.pkg.api.http.service.pipeline_migration import MigrationError
app = quart.Quart(__name__)
router = cls(NS(persistence_mgr=NS()), app)
router._authenticate_support_admin = AsyncMock(return_value=None)
router._authenticate_account = AsyncMock(return_value=(NS(uuid='account'), 'test@example.invalid'))
viewer = RequestContext(
'instance',
1,
'request',
'user_token',
PrincipalContext(PrincipalType.ACCOUNT, account_uuid='account'),
WorkspaceContext('workspace', 'membership', 'viewer', permissions_for_role('viewer')),
)
router._resolve_account_context = AsyncMock(return_value=viewer)
await router.initialize()
router.service = NS(
preview=AsyncMock(return_value={'items': [], 'total': 0}), execute=AsyncMock(return_value={'task_id': 1})
)
client = app.test_client()
base = '/api/v1/pipelines/_/migration'
assert (await client.get(base + '/preview')).status_code == 401
assert (await client.get(base + '/preview', headers={'X-API-Key': 'synthetic'})).status_code == 401
headers = {'Authorization': 'Bearer synthetic'}
assert (await client.get(base + '/preview', headers=headers)).status_code == 200
assert (await client.post(base + '/execute', headers=headers, json={})).status_code == 403
router.service.execute.assert_not_awaited()
manager = RequestContext(
'instance',
1,
'request',
'user_token',
viewer.principal,
WorkspaceContext('workspace', 'membership', 'developer', permissions_for_role('developer')),
)
router._resolve_account_context.return_value = manager
router.service.execute.side_effect = MigrationError('confirmation_required', 400)
response = await client.post(base + '/execute', headers=headers, json={'confirmed': 'true'})
assert response.status_code == 400
assert 'confirmation_required' in await response.get_data(as_text=True)
router.service.execute.side_effect = None
response = await client.post(base + '/execute', headers=headers, json={'confirmed': True, 'items': []})
assert (await response.get_json())['data']['task_id'] == 1
@@ -0,0 +1,47 @@
import importlib.util
from pathlib import Path
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.entity.persistence.pipeline import LegacyPipeline
from langbot.pkg.persistence.mgr import _ALEMBIC_TENANT_TABLES
from langbot.pkg.persistence.tenant_uow import TENANT_TABLE_COLUMNS
def test_snapshot_schema_registered_composite_fk_and_schema_only_migration(tmp_path):
assert 'pipeline_migration_snapshots' in Base.metadata.tables, 'durable snapshot table missing'
table = Base.metadata.tables['pipeline_migration_snapshots']
assert isinstance(table.c.source_snapshot.type, sa.JSON)
assert TENANT_TABLE_COLUMNS[table.name] == 'workspace_uuid'
assert table.name in _ALEMBIC_TENANT_TABLES
assert any(
tuple(f.column.name for f in c.elements) == ('workspace_uuid', 'uuid') for c in table.foreign_key_constraints
)
assert any(
isinstance(c, sa.UniqueConstraint)
and [col.name for col in c.columns] == ['workspace_uuid', 'pipeline_uuid', 'source_fingerprint']
for c in table.constraints
)
path = (
Path(__file__).parents[3] / 'src/langbot/pkg/persistence/alembic/versions/0027_pipeline_migration_snapshots.py'
)
spec = importlib.util.spec_from_file_location('migration0027', path)
migration = importlib.util.module_from_spec(spec)
spec.loader.exec_module(migration)
assert migration.down_revision == '0026_merge_master_beta'
assert len(migration.revision) <= 32, 'Alembic version_num is VARCHAR(32)'
engine = sa.create_engine(f'sqlite:///{tmp_path / "schema.db"}')
with engine.begin() as conn:
# Upgrade an existing schema that has not yet created the journal.
Base.metadata.create_all(conn, tables=[LegacyPipeline.__table__])
statements = []
sa.event.listen(conn, 'before_cursor_execute', lambda _c, _u, sql, *_a: statements.append(sql))
with Operations.context(MigrationContext.configure(conn)):
migration.upgrade()
migration.upgrade() # fresh-metadata boot and retry are idempotent
assert table.name in sa.inspect(conn).get_table_names()
assert not any(s.lstrip().upper().startswith(('UPDATE ', 'INSERT ', 'DELETE ')) for s in statements)
engine.dispose()
@@ -0,0 +1,986 @@
"""Behavioral tests for the pure, fail-closed legacy migration planner."""
import copy
import importlib
import importlib.util
import json
from pathlib import Path
import pytest
FIXTURES = json.loads((Path(__file__).parents[2] / 'fixtures/pipeline_migration/synthetic_legacy.json').read_text())
TARGETS = {
'local-agent': ('LocalAgent', '0.1.6', None),
'dify-service-api': ('DifyAgent', '0.1.7', None),
'coze-api': ('CozeAgent', '0.1.7', None),
'dashscope-app-api': ('DashScopeAgent', '0.1.7', None),
'n8n-service-api': ('N8nAgent', '0.1.7', None),
'langflow-api': ('LangflowAgent', '0.1.7', None),
'deerflow-api': ('DeerFlowAgent', '0.1.7', None),
'tbox-app-api': ('TboxAgent', '0.1.5', None),
'weknora-api': ('WeKnoraAgent', '0.1.7', None),
}
def source_for(runner='deerflow-api'):
return {
'uuid': 'synthetic-pipeline',
'bot_uuid': 'synthetic-bot',
'stages': [{'stage': 'synthetic', 'config': {'nested': [False, 0, None]}}],
'output': {'misc': {'remove-think': False}},
'ai': {'runner': {'runner': runner, 'expire-time': 0}, runner: copy.deepcopy(FIXTURES[runner])},
}
def plan(source, preferences=None):
return planner().plan_legacy_pipeline(source, preferences)
def assert_block(result, code, field=None):
assert result['state'] == 'blocked'
assert result['config'] is None
assert result['changed_paths'] == []
expected = {'code': code}
if field is not None:
expected['field'] = field
assert expected in result['blockers']
@pytest.mark.parametrize('runner', TARGETS)
def test_recognizes_all_nine_exact_official_identities(runner):
result = plan(source_for(runner))
name, version, blocker = TARGETS[runner]
assert result['legacy_runner'] == runner
assert result['target_runner_id'] == f'plugin:langbot-team/{name}/default'
assert result['target_plugin'] == {'author': 'langbot-team', 'name': name, 'version': version}
if blocker:
assert result['state'] == 'blocked'
assert blocker in {item['code'] for item in result['blockers']}
else:
assert result['state'] == 'ready'
def test_supported_deerflow_candidate_preserves_source_and_denies_new_resources():
source = source_for()
source['ai']['local-agent'] = {
'enable-all-tools': True,
'tools': ['private-tool'],
'knowledge-bases': ['private-kb'],
'mcp-resources': [{'uri': 'private'}],
}
preferences = {
'enable_all_plugins': False,
'plugins': [{'author': 'langbot-team', 'name': 'DeerFlowAgent'}],
'enable_all_mcp_servers': False,
'mcp_servers': ['restricted-server'],
'enable_all_skills': False,
'skills': [],
'mcp_resource_agent_read_enabled': True,
'mcp_resources': [{'uri': 'private'}],
}
before = copy.deepcopy((source, preferences))
result = plan(source, preferences)
assert result['state'] == 'ready'
candidate = result['config']
assert candidate is not source
assert candidate['ai']['runner'] == {'id': result['target_runner_id'], 'expire-time': 0}
selected = candidate['ai']['runner_config'][result['target_runner_id']]
assert selected == {
**FIXTURES['deerflow-api'],
'enable-all-tools': False,
'tools': [],
'knowledge-bases': [],
'mcp-resources': [],
'mcp-resource-agent-read-enabled': False,
}
for key in ('uuid', 'bot_uuid', 'stages', 'output'):
assert candidate[key] == source[key]
assert set(candidate['ai']) == {'runner', 'runner_config'}
assert source['ai']['local-agent'] == before[0]['ai']['local-agent']
assert source['ai']['deerflow-api'] == before[0]['ai']['deerflow-api']
assert (source, preferences) == before
candidate['stages'][0]['config']['nested'].append('mutated')
selected['api-key'] = 'mutated'
assert (source, preferences) == before
assert result['changed_paths'] == [
'ai.runner.runner',
'ai.runner.id',
'ai.runner_config',
'ai.local-agent',
'ai.deerflow-api',
]
assert {'code': 'external.state_validation_required', 'field': 'ai.runner'} in result['warnings']
@pytest.mark.parametrize('runner', TARGETS)
def test_current_identifiers_are_not_migrated_twice(runner):
name = TARGETS[runner][0]
source = source_for(runner)
source['ai']['runner'] = {'id': f'plugin:langbot-team/{name}/default', 'expire-time': 0}
result = plan(source)
assert result['state'] == 'already_current'
assert result['config'] is None
assert result['changed_paths'] == []
@pytest.mark.parametrize('value', [None, [], False, 0, 'secret'])
def test_malformed_roots_are_safe_blockers(value):
assert_block(plan(value), 'invalid_type', 'config')
@pytest.mark.parametrize('field', ['ai', 'runner'])
@pytest.mark.parametrize('value', [None, [], False, 0, 'secret'])
def test_malformed_envelopes_are_safe_blockers(field, value):
source = source_for()
if field == 'ai':
source['ai'] = value
else:
source['ai']['runner'] = value
assert_block(plan(source), 'invalid_type', 'ai' if field == 'ai' else 'ai.runner')
@pytest.mark.parametrize('current', [None, '', False, {}, 'plugin:langbot-team/DeerFlowAgent/default'])
def test_mixed_selections_block_even_empty_current_id(current):
source = source_for()
source['ai']['runner']['id'] = current
assert_block(plan(source), 'mixed_runner_selection', 'ai.runner')
@pytest.mark.parametrize('value', [None, False, 0, '', [], 'secret'])
def test_malformed_active_runner_section_is_not_defaulted(value):
source = source_for()
source['ai']['deerflow-api'] = value
assert_block(plan(source), 'invalid_type', 'ai.deerflow-api')
def test_existing_runner_config_is_not_overwritten():
source = source_for()
source['ai']['runner_config'] = {'plugin:custom/Runner/default': {'secret': 'value'}}
assert_block(plan(source), 'mixed_runner_config', 'ai.runner_config')
def test_unknown_selection_is_not_echoed_as_public_legacy_id():
source = source_for()
source['ai']['runner']['runner'] = 'secret-value'
result = plan(source)
assert result['state'] == 'not_legacy'
assert 'secret-value' not in json.dumps(result)
@pytest.mark.parametrize('value', [None, False, -1, 1.5, '0'])
def test_expiry_is_strict_nonnegative_integer(value):
source = source_for()
source['ai']['runner']['expire-time'] = value
assert_block(plan(source), 'invalid_expiry', 'ai.runner.expire-time')
def test_unknown_active_field_name_and_value_never_leak():
source = source_for()
source['ai']['deerflow-api']['secret-field-name'] = 'secret-field-value'
result = plan(source)
assert_block(result, 'unknown_field', 'ai.deerflow-api')
assert 'secret-field' not in json.dumps(result)
@pytest.mark.parametrize('value', [float('nan'), float('inf'), {1: 'secret'}, ('tuple',), b'secret'])
def test_non_json_values_block_without_serialization_or_coercion(value):
source = source_for()
source['opaque'] = value
assert_block(plan(source), 'invalid_json_value', 'config')
def test_cyclic_input_blocks_without_recursion_error():
source = source_for()
source['opaque'] = source
assert_block(plan(source), 'invalid_json_value', 'config')
@pytest.mark.parametrize('runner', TARGETS)
@pytest.mark.parametrize('value', [None, False, 0, '', [], {}])
def test_unknown_active_keys_block_even_when_falsey(runner, value):
source = source_for(runner)
source['ai'][runner]['secret-unknown-key'] = value
assert_block(plan(source), 'unknown_field', f'ai.{runner}')
@pytest.mark.parametrize('runner', TARGETS)
def test_missing_active_section_is_not_seeded(runner):
source = source_for(runner)
del source['ai'][runner]
assert_block(plan(source), 'missing_field', f'ai.{runner}')
@pytest.mark.parametrize('container', ['ai', 'selection'])
def test_unknown_active_envelope_keys_are_not_echoed(container):
source = source_for()
target = source['ai'] if container == 'ai' else source['ai']['runner']
target['secret-unknown-key'] = 'secret-value'
result = plan(source)
assert_block(result, 'unknown_field', 'ai' if container == 'ai' else 'ai.runner')
assert 'secret-' not in json.dumps(result)
@pytest.mark.parametrize(
'preferences',
[
False,
[],
'secret',
{'enable_all_plugins': 1},
{'enable_all_mcp_servers': None},
{'enable_all_skills': 'false'},
{'plugins': ['secret']},
{'mcp_servers': [1]},
{'skills': [None]},
{'mcp_resource_agent_read_enabled': 0},
{'mcp_resources': [False]},
],
)
def test_malformed_extension_preferences_block_without_expanding_access(preferences):
result = plan(source_for(), preferences)
assert result['state'] == 'blocked'
assert any(item['code'] == 'invalid_extension_preferences' for item in result['blockers'])
assert 'secret' not in json.dumps(result)
def test_explicit_plugin_denylist_is_not_silently_broadened():
preferences = {'enable_all_plugins': False, 'plugins': []}
assert_block(plan(source_for(), preferences), 'extensions.runner_excluded', 'extensions_preferences.plugins')
assert preferences == {'enable_all_plugins': False, 'plugins': []}
@pytest.mark.parametrize('field', ['api-key', 'auth-header'])
@pytest.mark.parametrize('secret', [' synthetic ', '\tsynthetic', 'synthetic\n'])
def test_deerflow_secret_trim_drift_blocks_without_leaking(field, secret):
source = source_for()
source['ai']['deerflow-api'][field] = secret
result = plan(source)
assert result['state'] == 'ready'
assert result['config']['ai']['runner_config'][result['target_runner_id']][field] == secret
assert secret not in json.dumps({k: v for k, v in result.items() if k != 'config'})
assert source['ai']['deerflow-api'][field] == secret
@pytest.mark.parametrize(
'field,value', [('assistant-id', ''), ('assistant-id', ' padded '), ('model-name', ' padded ')]
)
def test_deerflow_option_trim_drift_is_not_repaired(field, value):
source = source_for()
source['ai']['deerflow-api'][field] = value
result = plan(source)
assert result['state'] == 'ready'
assert result['config']['ai']['runner_config'][result['target_runner_id']][field] == value
@pytest.mark.parametrize('field', ['thinking-enabled', 'plan-mode', 'subagent-enabled'])
@pytest.mark.parametrize('value', [None, 0, 1, 'false', []])
def test_deerflow_requires_real_booleans(field, value):
source = source_for()
source['ai']['deerflow-api'][field] = value
assert_block(plan(source), 'invalid_type', f'ai.deerflow-api.{field}')
@pytest.mark.parametrize('field', ['timeout', 'max-concurrent-subagents', 'recursion-limit'])
@pytest.mark.parametrize('value', [None, False, 1.5, '3'])
def test_deerflow_requires_real_integers_without_coercion(field, value):
source = source_for()
source['ai']['deerflow-api'][field] = value
assert_block(plan(source), 'invalid_type', f'ai.deerflow-api.{field}')
@pytest.mark.parametrize('field', ['timeout', 'max-concurrent-subagents', 'recursion-limit'])
@pytest.mark.parametrize('value', [0, -1, 1])
def test_deerflow_does_not_invent_numeric_clamps(field, value):
source = source_for()
source['ai']['deerflow-api'][field] = value
result = plan(source)
assert result['state'] == 'ready'
assert result['config']['ai']['runner_config'][result['target_runner_id']][field] == value
@pytest.mark.parametrize('value', [None, False, 0, [], ''])
def test_deerflow_requires_url_not_ui_default(value):
source = source_for()
source['ai']['deerflow-api']['api-base'] = value
result = plan(source)
assert result['state'] == 'blocked'
assert result['blockers'][0]['field'] == 'ai.deerflow-api.api-base'
def test_deerflow_native_runtime_defaults_are_materialized_not_ui_defaults():
source = source_for()
source['ai']['deerflow-api'] = {'api-base': ' https://example.invalid/path/?signed=synthetic '}
del source['ai']['runner']['expire-time']
result = plan(source)
assert result['state'] == 'ready'
selected = result['config']['ai']['runner_config'][result['target_runner_id']]
assert selected == {
'api-base': ' https://example.invalid/path/?signed=synthetic ',
'api-key': '',
'auth-header': '',
'assistant-id': 'lead_agent',
'model-name': '',
'thinking-enabled': False,
'plan-mode': False,
'subagent-enabled': False,
'max-concurrent-subagents': 3,
'timeout': 300,
'recursion-limit': 1000,
'enable-all-tools': False,
'tools': [],
'knowledge-bases': [],
'mcp-resources': [],
'mcp-resource-agent-read-enabled': False,
}
assert 'expire-time' not in result['config']['ai']['runner']
def test_deerflow_missing_endpoint_blocks_instead_of_using_schema_localhost():
source = source_for()
del source['ai']['deerflow-api']['api-base']
assert_block(plan(source), 'missing_field', 'ai.deerflow-api.api-base')
def test_ready_public_projection_contains_no_secrets_or_credentials_in_urls():
source = source_for()
section = source['ai']['deerflow-api']
section.update(
{
'api-key': 'SECRET-KEY',
'auth-header': 'Bearer SECRET-HEADER',
'api-base': 'https://name:SECRET-PASSWORD@example.invalid/?token=SECRET-TOKEN',
}
)
result = plan(source)
assert result['state'] == 'ready'
selected = result['config']['ai']['runner_config'][result['target_runner_id']]
for key, value in section.items():
assert selected[key] == value
public = {key: value for key, value in result.items() if key != 'config'}
assert 'SECRET' not in json.dumps(public)
@pytest.mark.parametrize('value', [None, 0, 'false'])
def test_remove_think_flag_is_not_coerced(value):
source = source_for('tbox-app-api')
source['output']['misc']['remove-think'] = value
assert_block(plan(source), 'invalid_type', 'output.misc.remove-think')
@pytest.mark.parametrize(
'runner,code',
[
('dify-service-api', 'dify.remove_think_schema'),
('coze-api', 'coze.remove_think'),
('dashscope-app-api', 'dashscope.remove_think'),
('tbox-app-api', 'tbox.remove_think'),
],
)
def test_unsupported_thinking_suppression_has_precise_blocker(runner, code):
source = source_for(runner)
source['output']['misc']['remove-think'] = True
result = plan(source)
assert code not in {b['code'] for b in result['blockers']}
if result['state'] == 'ready':
assert result['config']['ai']['runner_config'][result['target_runner_id']]['remove-think'] is True
@pytest.mark.parametrize('rounds', [0, 1, 10, -1])
def test_local_rounds_are_never_translated_into_transcript_item_counts(rounds):
source = source_for('local-agent')
source['ai']['local-agent']['max-round'] = rounds
result = plan(source)
assert result['state'] == 'ready'
selected = result['config']['ai']['runner_config'][result['target_runner_id']]
assert 'max-round' not in selected
assert selected['context-history-fetch-limit'] == 50
assert selected['context-window-tokens'] == 200000
assert selected['tool-execution-mode'] == 'serial'
assert {'code': 'local.context_defaults', 'field': 'ai.local-agent.max-round'} in result['warnings']
@pytest.mark.parametrize(
'field,value,code',
[
(
'model',
{'primary': 'model', 'fallbacks': [], 'reasoning': {'SECRET-model': 'invalid-level'}},
'local.reasoning_value',
),
('prompt', [{'role': 'system', 'content': 42}], 'local.prompt_shape'),
('prompt', [{'role': 'system', 'content': 'text', 'SECRET-key': 'SECRET-value'}], 'local.prompt_shape'),
('prompt', [{'role': 'system', 'content': [{'type': 'text', 'text': 42}]}], 'local.prompt_shape'),
('box-session-id-template', '{global}', 'local.box_scope'),
],
)
def test_local_unsupported_behaviors_have_specific_safe_blockers(field, value, code):
source = source_for('local-agent')
source['ai']['local-agent'][field] = value
result = plan(source)
assert_block(result, code, f'ai.local-agent.{field}')
assert 'SECRET' not in json.dumps(result)
@pytest.mark.parametrize(
'field,value',
[
('enable-all-tools', None),
('enable-all-tools', 0),
('tools', None),
('tools', [42]),
('mcp-resources', None),
('mcp-resources', [False]),
('mcp-resource-agent-read-enabled', 'false'),
],
)
def test_local_host_policy_malformed_values_block(field, value):
source = source_for('local-agent')
source['ai']['local-agent'][field] = value
assert_block(plan(source), 'invalid_type', f'ai.local-agent.{field}')
@pytest.mark.parametrize(
'runner,field',
[
('dify-service-api', 'api-key'),
('dify-service-api', 'base-prompt'),
('coze-api', 'timeout'),
('coze-api', 'api-base'),
('langflow-api', 'flow-id'),
('n8n-service-api', 'webhook-url'),
('tbox-app-api', 'app-id'),
('weknora-api', 'app-type'),
],
)
def test_defaultless_native_fields_are_not_filled_from_target_schema(runner, field):
source = source_for(runner)
del source['ai'][runner][field]
assert_block(plan(source), 'missing_field', f'ai.{runner}.{field}')
@pytest.mark.parametrize('value', [None, False, 0, [], {}])
@pytest.mark.parametrize('runner', [r for r in TARGETS if r not in ('local-agent', 'n8n-service-api')])
def test_external_api_keys_require_strings_even_if_target_coerces(runner, value):
source = source_for(runner)
source['ai'][runner]['api-key'] = value
assert_block(plan(source), 'invalid_type', f'ai.{runner}.api-key')
@pytest.mark.parametrize(
'section',
[
{'auto_save_history': False, 'auto-save-history': True},
{'auto_save_history': True, 'auto-save-history': False},
],
)
def test_coze_actual_underscore_history_key_missing_null_or_conflict_blocks(section):
source = source_for('coze-api')
source['ai']['coze-api'].pop('auto_save_history')
source['ai']['coze-api'].update(section)
assert_block(plan(source), 'coze.history_alias', 'ai.coze-api.auto_save_history')
@pytest.mark.parametrize('value', [False, True])
def test_coze_equal_aliases_are_unambiguous_but_statefulness_still_blocks(value):
source = source_for('coze-api')
source['ai']['coze-api'].update({'auto_save_history': value, 'auto-save-history': value})
result = plan(source)
assert selected_config(result)['auto-save-history'] is value
assert 'coze.history_alias' not in {b['code'] for b in result['blockers']}
def test_coze_custom_endpoint_not_replaced_with_schema_region():
source = source_for('coze-api')
source['ai']['coze-api']['api-base'] = 'https://secret-user:secret-pass@proxy.invalid'
result = plan(source)
assert_block(result, 'coze.custom_endpoint', 'ai.coze-api.api-base')
assert 'secret-' not in json.dumps(result)
@pytest.mark.parametrize('underscore,hyphen', [('one', 'two')])
def test_dashscope_seed_hyphen_is_not_actual_native_runtime_key(underscore, hyphen):
source = source_for('dashscope-app-api')
section = source['ai']['dashscope-app-api']
section.pop('references_quote')
if underscore is not None:
section['references_quote'] = underscore
section['references-quote'] = hyphen
assert_block(plan(source), 'dashscope.references_alias', 'ai.dashscope-app-api.references_quote')
@pytest.mark.parametrize('axis', ['input', 'output'])
@pytest.mark.parametrize('underscore,hyphen', [(None, 'text'), ('chat', 'text'), ('text', 'chat')])
def test_langflow_conflicting_or_nondefault_ui_only_alias_blocks(axis, underscore, hyphen):
source = source_for('langflow-api')
section = source['ai']['langflow-api']
section.pop(f'{axis}_type')
if underscore is not None:
section[f'{axis}_type'] = underscore
section[f'{axis}-type'] = hyphen
assert_block(plan(source), 'langflow.io_alias', f'ai.langflow-api.{axis}_type')
@pytest.mark.parametrize(
'raw',
[
'{broken-secret',
'[]',
'0',
'false',
'{"key": NaN}',
'{"key": Infinity}',
'{"key": 1e999}',
'{"key":1,"key":2}',
'{"nested":{"key":1,"key":2}}',
[],
False,
0,
],
)
def test_langflow_tweaks_require_strict_object_json_without_fallback_loss(raw):
source = source_for('langflow-api')
source['ai']['langflow-api']['tweaks'] = raw
result = plan(source)
assert_block(result, 'langflow.invalid_tweaks', 'ai.langflow-api.tweaks')
assert 'broken-secret' not in json.dumps(result)
@pytest.mark.parametrize('raw', [None, '', '{}', '{"nested":{"token":"SECRET","values":[null,false,0]}}'])
def test_langflow_native_empty_and_valid_tweaks_not_misreported(raw):
source = source_for('langflow-api')
source['ai']['langflow-api']['tweaks'] = raw
original = copy.deepcopy(source)
result = plan(source)
assert result['state'] == 'ready'
assert 'langflow.invalid_tweaks' not in {item['code'] for item in result['blockers']}
assert 'SECRET' not in json.dumps({k: v for k, v in result.items() if k != 'config'})
assert source == original
def test_n8n_ignore_cannot_be_encoded_as_ignored_plugin_field():
source = source_for('n8n-service-api')
source['ai']['n8n-service-api']['response-handling'] = 'ignore'
result = plan(source)
assert 'n8n.ignore_missing_0_1_6' not in {b['code'] for b in result['blockers']}
@pytest.mark.parametrize(
'mode,missing',
[
('basic', 'basic-username'),
('basic', 'basic-password'),
('jwt', 'jwt-secret'),
('header', 'header-name'),
('header', 'header-value'),
],
)
def test_n8n_missing_active_auth_material_has_precise_blocker(mode, missing):
source = source_for('n8n-service-api')
source['ai']['n8n-service-api'].update(
{
'auth-type': mode,
'basic-username': 'synthetic-user',
'basic-password': 'SECRET-pass',
'jwt-secret': 'SECRET-jwt',
'header-name': 'X-Synthetic',
'header-value': 'SECRET-header',
}
)
source['ai']['n8n-service-api'].pop(missing)
result = plan(source)
assert_block(result, 'missing_field', f'ai.n8n-service-api.{missing}')
assert 'SECRET' not in json.dumps(result)
@pytest.mark.parametrize('mode', ['none', 'basic', 'jwt', 'header'])
def test_n8n_secrets_remain_raw_and_not_public_even_for_inactive_modes(mode):
source = source_for('n8n-service-api')
section = source['ai']['n8n-service-api']
section.update(
{
'auth-type': mode,
'basic-username': 'user',
'basic-password': ' SECRET-pass ',
'jwt-secret': '\tSECRET-jwt',
'header-name': 'Authorization',
'header-value': 'Bearer SECRET-header ',
}
)
original = copy.deepcopy(source)
result = plan(source)
selected = selected_config(result)
for field in ('basic-password', 'jwt-secret', 'header-value'):
assert selected[field] == section[field]
assert 'SECRET' not in json.dumps({k: v for k, v in result.items() if k != 'config'})
assert source == original
@pytest.mark.parametrize(
'runner,field,value',
[
('dify-service-api', 'app-type', 'unknown'),
('dashscope-app-api', 'app-type', 'chat'),
('weknora-api', 'app-type', 'workflow'),
('n8n-service-api', 'auth-type', 'SECRET-unknown'),
],
)
def test_invalid_enum_values_are_not_echoed(runner, field, value):
source = source_for(runner)
source['ai'][runner][field] = value
result = plan(source)
assert_block(result, 'invalid_value', f'ai.{runner}.{field}')
assert 'SECRET' not in json.dumps(result)
@pytest.mark.parametrize('timeout', [None, 0, 30, 301])
def test_dify_saved_timeout_is_not_blindly_activated(timeout):
source = source_for('dify-service-api')
source['ai']['dify-service-api']['timeout'] = timeout
result = plan(source)
assert 'dify.timeout_semantics' not in {b['code'] for b in result['blockers']}
assert {'code': 'dify.timeout_default', 'field': 'ai.dify-service-api.timeout'} in result['warnings']
@pytest.mark.parametrize('current', [None, '', False, [], 'plugin:bad', ' plugin:a/b/c', 'plugin:a/b/c/extra'])
def test_malformed_current_id_is_not_already_current(current):
source = {'ai': {'runner': {'id': current}}}
assert_block(plan(source), 'invalid_runner_id', 'ai.runner.id')
@pytest.mark.parametrize('runner', TARGETS)
def test_deterministic_results_and_input_nonmutation_for_all_nine(runner):
source = source_for(runner)
original = copy.deepcopy(source)
first = plan(source)
second = plan(source)
assert first == second
assert source == original
assert all(set(item) <= {'code', 'field'} for item in first['blockers'] + first['warnings'])
def test_ready_candidate_is_idempotently_recognized_current():
first = plan(source_for())
second = plan(first['config'])
assert second['state'] == 'already_current'
assert second['config'] is None
def planner():
name = 'langbot.pkg.pipeline.legacy_config_migration'
assert importlib.util.find_spec(name) is not None, 'Pure migration planner is not implemented'
return importlib.import_module(name)
def selected_config(result):
assert result['state'] == 'ready', result['blockers']
return result['config']['ai']['runner_config'][result['target_runner_id']]
@pytest.mark.parametrize(
'prompt',
[
[],
[{'role': 'system', 'content': ''}],
[{'role': 'tool', 'content': None, 'tool_call_id': 'call'}],
[
{
'role': 'user',
'name': 'name',
'content': [
{'type': 'text', 'text': ''},
{'type': 'image_url', 'image_url': {'url': 'https://image.invalid'}},
],
'provider_specific_fields': {'opaque': [False, None]},
}
],
[
{
'role': 'assistant',
'tool_calls': [
{
'id': 'call',
'type': 'function',
'function': {'name': 'tool', 'arguments': '{}'},
'provider_specific_fields': {'signature': 'opaque'},
}
],
}
],
],
)
def test_sdk_valid_prompts_are_preserved(prompt):
from langbot_plugin.api.entities.builtin.provider.message import Message
for message in prompt:
Message.model_validate(message, strict=True)
source = source_for('local-agent')
source['ai']['local-agent']['prompt'] = prompt
assert selected_config(plan(source))['prompt'] == prompt
@pytest.mark.parametrize(
'level', ['provider_default', 'disabled', 'enabled', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
)
def test_reasoning_and_local_authorizations_survive(level):
source = source_for('local-agent')
section = source['ai']['local-agent']
section.update(
{
'model': {'primary': 'm', 'fallbacks': ['f'], 'reasoning': {'m': level}},
'knowledge-base': 'kb',
'enable-all-tools': False,
'tools': ['tool'],
'mcp-resources': [],
'mcp-resource-agent-read-enabled': False,
}
)
preferences = {'mcp_resources': [{'uri': 'not-granted'}], 'mcp_resource_agent_read_enabled': True}
selected = selected_config(plan(source, preferences))
assert selected['model'] == section['model']
assert selected['knowledge-bases'] == ['kb']
assert selected['tools'] == ['tool']
assert selected['enable-all-tools'] is False
assert selected['mcp-resources'] == []
assert selected['mcp-resource-agent-read-enabled'] is False
assert selected['tool-execution-mode'] == 'serial'
assert 'knowledge-base' not in selected
assert 'assistant-id' not in selected
def test_missing_local_host_policy_uses_native_defaults_and_preferences():
source = source_for('local-agent')
section = source['ai']['local-agent']
del section['enable-all-tools']
del section['tools']
section['model'] = 'm'
selected = selected_config(
plan(source, {'mcp_resources': [{'uri': 'granted'}], 'mcp_resource_agent_read_enabled': False})
)
assert selected['enable-all-tools'] is True
assert selected['tools'] == []
assert selected['mcp-resources'] == [{'uri': 'granted'}]
assert selected['mcp-resource-agent-read-enabled'] is False
assert selected['model'] == {'primary': 'm', 'fallbacks': [], 'reasoning': {}}
@pytest.mark.parametrize('raw', [None, '', ' ', 'null', {}, '{"nested":[null,false,0]}'])
def test_langflow_convenience_inputs_are_explicitly_normalized(raw):
source = source_for('langflow-api')
source['ai']['langflow-api']['tweaks'] = raw
selected = selected_config(plan(source))
assert selected['tweaks'] == ({'nested': [None, False, 0]} if isinstance(raw, str) and raw.startswith('{') else {})
assert selected['input-type'] == 'chat'
assert 'input_type' not in selected
assert selected['langbot-assets-enabled'] is False
@pytest.mark.parametrize('value', ['', ' ', 'references'])
def test_dashscope_unambiguous_seed_alias_is_repaired(value):
source = source_for('dashscope-app-api')
section = source['ai']['dashscope-app-api']
del section['references_quote']
section['references-quote'] = value
result = plan(source)
selected = selected_config(result)
assert selected['references_quote'] == value
assert 'references-quote' not in selected
assert selected['timeout'] == 120
assert {'code': 'migration.alias_repaired', 'field': 'ai.dashscope-app-api.references-quote'} in result['warnings']
@pytest.mark.parametrize('mode', ['agent', 'chat'])
@pytest.mark.parametrize('value', ['ABSENT', None, '', ' ', 'saved-agent'])
def test_weknora_agent_absence_is_not_null_or_empty(mode, value):
source = source_for('weknora-api')
section = source['ai']['weknora-api']
section['app-type'] = mode
section['knowledge-base-ids'] = None
if value != 'ABSENT':
section['agent-id'] = value
selected = selected_config(plan(source))
assert selected['agent-id'] == (
('builtin-quick-answer' if mode == 'chat' else 'builtin-smart-reasoning') if value == 'ABSENT' else value
)
assert selected['knowledge-base-ids'] == []
assert selected['knowledge-bases'] == []
@pytest.mark.parametrize('runner', ['dashscope-app-api', 'langflow-api', 'deerflow-api', 'weknora-api'])
def test_every_ready_external_denies_dormant_host_resources(runner):
source = source_for(runner)
source['ai']['local-agent'] = {'tools': ['forbidden'], 'knowledge-bases': ['forbidden'], 'enable-all-tools': True}
selected = selected_config(plan(source, {'enable_all_mcp_servers': True, 'mcp_resources': [{'uri': 'forbidden'}]}))
assert selected['enable-all-tools'] is False
assert selected['tools'] == selected['knowledge-bases'] == selected['mcp-resources'] == []
assert selected['mcp-resource-agent-read-enabled'] is False
if runner != 'deerflow-api':
assert 'assistant-id' not in selected
@pytest.mark.parametrize(
'runner,identity',
[
('dify-service-api', 'legacy-session'),
('coze-api', 'legacy-session'),
('n8n-service-api', 'legacy-session'),
('tbox-app-api', 'legacy-bot'),
],
)
def test_verified_plugin_identity_modes_are_explicit(runner, identity):
result = plan(source_for(runner))
assert selected_config(result)['user-id-source'] == identity
assert {'code': 'migration.identity_preserved', 'field': f'ai.{runner}'} in result['warnings']
@pytest.mark.parametrize(
'values,expected',
[
({}, True),
({'auto_save_history': None}, True),
({'auto-save-history': False}, False),
({'auto_save_history': False}, False),
({'auto_save_history': None, 'auto-save-history': False}, False),
],
)
def test_coze_history_aliases_and_null_defaults(values, expected):
source = source_for('coze-api')
section = source['ai']['coze-api']
section.pop('auto_save_history')
section.update(values)
section['api-base'] = 'https://custom.invalid/prefix'
selected = selected_config(plan(source))
assert selected['auto-save-history'] is expected
assert 'auto_save_history' not in selected
assert selected['api-base'] == section['api-base']
@pytest.mark.parametrize(
'values,code,field',
[
(
{'basic-username': '姓名', 'basic-password': 'p', 'auth-type': 'basic'},
'n8n.basic_encoding',
'basic-username',
),
(
{'basic-username': 'a:b', 'basic-password': 'p', 'auth-type': 'basic'},
'n8n.basic_encoding',
'basic-username',
),
({'header-name': '', 'header-value': '', 'auth-type': 'header'}, 'n8n.header_name', 'header-name'),
],
)
def test_n8n_invalid_auth_is_not_reencoded_or_silently_dropped(values, code, field):
source = source_for('n8n-service-api')
source['ai']['n8n-service-api'].update(values)
assert_block(plan(source), code, f'ai.n8n-service-api.{field}')
def test_n8n_ignore_and_native_latin1_are_selected():
source = source_for('n8n-service-api')
source['ai']['n8n-service-api'].update(
{'auth-type': 'basic', 'basic-username': 'café', 'basic-password': 'p', 'response-handling': 'ignore'}
)
selected = selected_config(plan(source))
assert selected['response-handling'] == 'ignore'
assert selected['basic-encoding'] == 'latin1'
def test_weknora_blank_remote_kb_is_rejected():
source = source_for('weknora-api')
source['ai']['weknora-api']['knowledge-base-ids'] = [' ']
assert_block(plan(source), 'invalid_value', 'ai.weknora-api.knowledge-base-ids')
@pytest.mark.parametrize('runner', ['coze-api', 'n8n-service-api', 'weknora-api'])
@pytest.mark.parametrize('value', [0, -1])
def test_external_target_requires_positive_timeout(runner, value):
source = source_for(runner)
source['ai'][runner]['timeout'] = value
assert_block(plan(source), 'invalid_value', f'ai.{runner}.timeout')
@pytest.mark.parametrize(
'runner,field',
[
('dify-service-api', 'api-key'),
('dify-service-api', 'base-url'),
('coze-api', 'bot-id'),
('coze-api', 'api-key'),
('dashscope-app-api', 'api-key'),
('dashscope-app-api', 'app-id'),
('n8n-service-api', 'webhook-url'),
('langflow-api', 'base-url'),
('langflow-api', 'api-key'),
('langflow-api', 'flow-id'),
('tbox-app-api', 'api-key'),
('tbox-app-api', 'app-id'),
('weknora-api', 'api-key'),
('weknora-api', 'base-url'),
],
)
def test_required_target_strings_cannot_be_empty(runner, field):
source = source_for(runner)
source['ai'][runner][field] = ''
assert_block(plan(source), 'invalid_value', f'ai.{runner}.{field}')
@pytest.mark.parametrize('runner', TARGETS)
def test_ready_all9_nested_candidates_are_detached_and_no_io(runner, monkeypatch):
module = planner()
source = source_for(runner)
before = copy.deepcopy(source)
def forbidden(*args, **kwargs):
raise AssertionError('planner performed IO')
monkeypatch.setattr('builtins.open', forbidden)
result = module.plan_legacy_pipeline(source)
selected = selected_config(result)
selected['nested-test'] = ['changed']
result['config']['stages'][0]['config']['nested'].append('changed')
result['config']['ai']['runner']['expire-time'] = 999
assert source == before
assert set(result) == {
'state',
'legacy_runner',
'target_runner_id',
'target_plugin',
'config',
'changed_paths',
'blockers',
'warnings',
}
def test_empty_config_is_not_legacy():
result = planner().plan_legacy_pipeline({})
assert result == {
'state': 'not_legacy',
'legacy_runner': None,
'target_runner_id': None,
'target_plugin': None,
'config': None,
'changed_paths': [],
'blockers': [],
'warnings': [],
}
def test_default_local_agent_blocks_instead_of_inventing_round_translation():
source = {
'ai': {
'runner': {'runner': 'local-agent', 'expire-time': 0},
'local-agent': {'model': 'model-id', 'max-round': 10},
}
}
original = copy.deepcopy(source)
result = planner().plan_legacy_pipeline(source)
assert planner().PLANNER_VERSION == '3'
assert result['state'] == 'blocked'
assert result['target_runner_id'] == 'plugin:langbot-team/LocalAgent/default'
assert result['target_plugin'] == {'author': 'langbot-team', 'name': 'LocalAgent', 'version': '0.1.6'}
assert {'code': 'missing_field', 'field': 'ai.local-agent.prompt'} in result['blockers']
assert result['config'] is None
assert source == original
@@ -0,0 +1,67 @@
"""Migration output must be accepted by the guarded current editor."""
import copy
import json
from pathlib import Path
import pytest
from langbot.pkg.pipeline.legacy_config_migration import plan_legacy_pipeline
FIXTURES = json.loads((Path(__file__).parents[2] / 'fixtures/pipeline_migration/synthetic_legacy.json').read_text())
@pytest.mark.parametrize('runner', FIXTURES)
def test_migrated_ai_is_canonical_and_complete_source_remains_available_for_snapshot(runner):
source = {
'ai': {'runner': {'runner': runner, 'expire-time': 0}, **copy.deepcopy(FIXTURES)},
'output': {'misc': {'remove-think': False}},
'trigger': {'untouched': [False, 0, None]},
}
original = copy.deepcopy(source)
result = plan_legacy_pipeline(source)
assert result['state'] == 'ready', result['blockers']
assert set(result['config']['ai']) == {'runner', 'runner_config'}
assert {'code': 'migration.legacy_sections_archived', 'field': 'ai'} in result['warnings']
assert result['config']['trigger'] == original['trigger']
assert source == original
assert set(result['changed_paths']) == {
'ai.runner.runner',
'ai.runner.id',
'ai.runner_config',
*[f'ai.{name}' for name in FIXTURES],
}
def test_sdk_valid_omitted_prompt_content_is_preserved_exactly():
from langbot_plugin.api.entities.builtin.provider.message import Message
prompt = [{'role': 'assistant', 'tool_calls': [], 'name': 'edited', 'provider_specific_fields': {'cache': False}}]
Message.model_validate(prompt[0])
source = {'ai': {'runner': {'runner': 'local-agent'}, 'local-agent': copy.deepcopy(FIXTURES['local-agent'])}}
source['ai']['local-agent']['prompt'] = prompt
result = plan_legacy_pipeline(source)
assert result['state'] == 'ready', result['blockers']
migrated = result['config']['ai']['runner_config'][result['target_runner_id']]['prompt']
assert migrated == prompt
assert 'content' not in migrated[0]
@pytest.mark.parametrize('template', ['', '{launcher_type}_{launcher_id}'])
def test_standard_box_template_is_explicit_reset_not_custom_scope_block(template):
source = {'ai': {'runner': {'runner': 'local-agent'}, 'local-agent': copy.deepcopy(FIXTURES['local-agent'])}}
source['ai']['local-agent']['box-session-id-template'] = template
result = plan_legacy_pipeline(source)
assert result['state'] == 'ready', result['blockers']
assert {'code': 'local.box_state_reset', 'field': 'ai.local-agent.box-session-id-template'} in result['warnings']
assert 'box-session-id-template' not in result['config']['ai']['runner_config'][result['target_runner_id']]
@pytest.mark.parametrize('template', ['global', '{launcher_id}', '{workspace}', ' {launcher_type}_{launcher_id}'])
def test_custom_box_sharing_stays_blocked(template):
source = {'ai': {'runner': {'runner': 'local-agent'}, 'local-agent': copy.deepcopy(FIXTURES['local-agent'])}}
source['ai']['local-agent']['box-session-id-template'] = template
result = plan_legacy_pipeline(source)
assert result['state'] == 'blocked'
assert {'code': 'local.box_scope', 'field': 'ai.local-agent.box-session-id-template'} in result['blockers']
@@ -0,0 +1,47 @@
"""Preparation must not publish or evict a previously usable runtime."""
import copy
from types import SimpleNamespace as NS
from unittest.mock import AsyncMock, Mock
import pytest
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.pipeline.pipelinemgr import PipelineManager
@pytest.mark.asyncio
async def test_prepare_is_unpublished_and_failure_retains_old_runtime(monkeypatch):
assert hasattr(PipelineManager, 'prepare_pipeline'), 'runtime preparation seam missing'
ctx = ExecutionContext('instance', 'workspace', 1, pipeline_uuid='pipeline')
ap = NS(workspace_service=NS(get_execution_binding=AsyncMock()), logger=Mock())
mgr = PipelineManager(ap)
mgr.stage_dict = {}
source = dict(
uuid='pipeline',
workspace_uuid='workspace',
name='old',
description='',
stages=[],
config={},
extensions_preferences={},
)
await mgr.load_pipeline(ctx, copy.deepcopy(source))
old = mgr.pipelines[0]
candidate = await mgr.prepare_pipeline(ctx, {**copy.deepcopy(source), 'name': 'new'})
assert mgr.pipelines == [old]
mgr.publish_pipeline(candidate)
assert mgr.pipelines == [candidate]
assert candidate.pipeline_entity.name == 'new'
class BrokenStage:
def __init__(self, ap):
pass
async def initialize(self, config):
raise RuntimeError('preparation failed')
mgr.stage_dict = {'broken': BrokenStage}
with pytest.raises(RuntimeError, match='preparation failed'):
await mgr.prepare_pipeline(ctx, {**copy.deepcopy(source), 'stages': ['broken']})
assert mgr.pipelines == [candidate]
@@ -0,0 +1,245 @@
"""Actual secured Host actions consume frozen policy, not plugin payload hints."""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from langbot_plugin.api.entities.builtin.provider import message as provider_message
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
from langbot.pkg.agent.runner.session_registry import AgentRunSessionRegistry
from langbot.pkg.plugin import handler as handler_module
from tests.unit_tests.agent.test_runner_model_reasoning import PRIMARY, FALLBACK, OTHER
from tests.unit_tests.plugin.test_handler_actions import make_handler, make_result
from tests.unit_tests.provider.test_reasoning_control import _requester, _runtime_model
ACTIONS = [
PluginToRuntimeAction.INVOKE_LLM,
PluginToRuntimeAction.INVOKE_LLM_STREAM,
PluginToRuntimeAction.COUNT_TOKENS,
]
class RecordingProvider:
"""Network-free boundary; reasoning translation remains the real requester."""
def __init__(self, request):
self.requester = request
self.calls = []
async def record(self, kwargs):
await asyncio.sleep(0)
built = self.requester._build_reasoning_args(kwargs['model'])
self.calls.append((kwargs, built))
async def invoke_llm(self, **kwargs):
await self.record(kwargs)
return provider_message.Message(role='assistant', content='ok')
async def invoke_llm_stream(self, **kwargs):
await self.record(kwargs)
yield provider_message.MessageChunk(role='assistant', content='ok')
async def count_tokens(self, **kwargs):
await self.record(kwargs)
return 37
@pytest.fixture
async def host(monkeypatch):
registry = AgentRunSessionRegistry()
monkeypatch.setattr(handler_module, 'get_session_registry', lambda: registry)
request = _requester('openai')
monkeypatch.setattr(request, '_supports_reasoning', lambda _: True)
monkeypatch.setattr(request, '_safe_model_info', lambda _: {})
provider = RecordingProvider(request)
monkeypatch.setattr(request, 'count_tokens', provider.count_tokens)
models = {}
for model_id in (PRIMARY, FALLBACK):
model = _runtime_model(request, 'medium', name='gpt-5')
model.model_entity.uuid = model_id
model.model_entity.workspace_uuid = 'workspace-a'
model.provider = provider
models[model_id] = model
ap = SimpleNamespace(
logger=Mock(),
model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(side_effect=lambda context, model_id: models[model_id])),
persistence_mgr=SimpleNamespace(
execute_async=AsyncMock(return_value=make_result(SimpleNamespace(uuid=PRIMARY)))
),
)
runtime = make_handler(ap)
async def register(
run_id='run', overrides=None, workspace='workspace-a', plugin='test-author/test-plugin', operations=None
):
await registry.register(
run_id=run_id,
runner_id='plugin:test-author/test-plugin/arbitrary',
query_id=None,
plugin_identity=plugin,
workspace_id=workspace,
resources={
'models': [
{'model_id': model_id, **({'operations': operations} if operations else {})}
for model_id in (PRIMARY, FALLBACK)
]
},
model_reasoning_overrides=overrides,
)
return await registry.get(run_id)
return SimpleNamespace(
registry=registry, models=models, provider=provider, runtime=runtime, register=register, ap=ap
)
async def call(host, action, model_id=PRIMARY, run_id='run', **extra):
payload = {
'llm_model_uuid': model_id,
'messages': [{'role': 'user', 'content': 'hello'}],
'extra_args': {'temperature': 0.7},
**extra,
}
if run_id is not None:
payload['run_id'] = run_id
if action == PluginToRuntimeAction.INVOKE_LLM_STREAM:
return [response async for response in host.runtime.actions[action.value](payload)]
return [await host.runtime.actions[action.value](payload)]
@pytest.mark.asyncio
@pytest.mark.parametrize('action', ACTIONS)
async def test_primary_fallback_and_repeated_tool_round_use_frozen_per_model_policy(host, action):
await host.register(overrides={PRIMARY: {'level': 'high'}, FALLBACK: {'level': 'low'}})
for model_id, level in [(PRIMARY, 'high'), (FALLBACK, 'low'), (FALLBACK, 'low')]:
responses = await call(host, action, model_id)
assert all(response.code == 0 for response in responses)
kwargs, built = host.provider.calls[-1]
assert built == {'reasoning_effort': level}
assert kwargs['model'] is not host.models[model_id]
assert kwargs['model'].reasoning_config_override == {'level': level}
assert kwargs['extra_args'] == {'temperature': 0.7}
assert 'model_reasoning_overrides' not in kwargs
assert all(model.reasoning_config_override is None for model in host.models.values())
@pytest.mark.asyncio
@pytest.mark.parametrize('action', ACTIONS)
@pytest.mark.parametrize('level', [None, 'provider_default'])
async def test_absent_and_explicit_provider_default_are_distinct(host, action, level):
await host.register(overrides={PRIMARY: {'level': level}} if level else None)
assert all(response.code == 0 for response in await call(host, action))
kwargs, built = host.provider.calls[-1]
assert built == ({} if level else {'reasoning_effort': 'medium'})
assert (kwargs['model'] is host.models[PRIMARY]) is (level is None)
assert host.models[PRIMARY].model_entity.reasoning_config == {'level': 'medium'}
@pytest.mark.asyncio
@pytest.mark.parametrize('action', ACTIONS)
async def test_regular_plugin_without_run_keeps_model_defaults_and_ignores_forged_map(host, action):
responses = await call(
host,
action,
run_id=None,
model_reasoning_overrides={PRIMARY: {'level': 'max'}},
reasoning_config_override={'level': 'disabled'},
)
assert all(response.code == 0 for response in responses)
kwargs, built = host.provider.calls[-1]
assert kwargs['model'] is host.models[PRIMARY]
assert built == {'reasoning_effort': 'medium'}
@pytest.mark.asyncio
@pytest.mark.parametrize('action', ACTIONS)
async def test_plugin_cannot_replace_host_map(host, action):
await host.register(overrides={PRIMARY: {'level': 'high'}})
responses = await call(
host,
action,
model_reasoning_overrides={PRIMARY: {'level': 'disabled'}},
reasoning_config_override={'level': 'disabled'},
)
assert all(response.code == 0 for response in responses)
assert host.provider.calls[-1][1] == {'reasoning_effort': 'high'}
@pytest.mark.asyncio
@pytest.mark.parametrize('action', ACTIONS)
@pytest.mark.parametrize('denial', ['workspace', 'plugin', 'unselected', 'operation', 'expired'])
async def test_authorization_denial_happens_before_model_access(host, action, denial):
await host.register(
overrides={PRIMARY: {'level': 'high'}, OTHER: {'level': 'max'}},
workspace='workspace-b' if denial == 'workspace' else 'workspace-a',
plugin='other/plugin' if denial == 'plugin' else 'test-author/test-plugin',
operations=['rerank'] if denial == 'operation' else None,
)
responses = await call(
host, action, OTHER if denial == 'unselected' else PRIMARY, run_id='expired' if denial == 'expired' else 'run'
)
assert all(response.code != 0 for response in responses)
assert not host.provider.calls
host.ap.model_mgr.get_model_by_uuid.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize('action', ACTIONS)
async def test_concurrent_runs_share_model_without_cross_run_or_round_leakage(host, action):
await host.register('high-run', {PRIMARY: {'level': 'high'}})
await host.register('low-run', {PRIMARY: {'level': 'low'}})
results = await asyncio.gather(*(call(host, action, run_id=run_id) for run_id in ['high-run', 'low-run'] * 3))
assert all(response.code == 0 for result in results for response in result)
assert sorted(built['reasoning_effort'] for _, built in host.provider.calls) == ['high'] * 3 + ['low'] * 3
assert len({id(kwargs['model']) for kwargs, _ in host.provider.calls}) == 6
assert host.models[PRIMARY].reasoning_config_override is None
@pytest.mark.asyncio
@pytest.mark.parametrize('action', ACTIONS)
async def test_model_runtime_workspace_mismatch_denies(host, action):
await host.register(overrides={PRIMARY: {'level': 'high'}})
host.models[PRIMARY].model_entity.workspace_uuid = 'workspace-b'
responses = await call(host, action)
assert all(response.code != 0 for response in responses)
assert not host.provider.calls
@pytest.mark.asyncio
@pytest.mark.parametrize('action', ACTIONS)
async def test_host_reuses_core_ability_validation_before_provider_call(host, action):
await host.register(overrides={PRIMARY: {'level': 'high'}})
host.models[PRIMARY].model_entity.abilities = []
with pytest.raises(ValueError, match='reasoning ability'):
await call(host, action)
assert not host.provider.calls
@pytest.mark.asyncio
@pytest.mark.parametrize('action', ACTIONS)
async def test_snapshot_survives_configuration_edits_and_tool_followup(host, action):
config = {PRIMARY: {'level': 'high'}, FALLBACK: {'level': 'low'}}
await host.register(overrides=config)
config[PRIMARY]['level'] = 'disabled'
config[FALLBACK]['level'] = 'max'
responses = await call(
host,
action,
FALLBACK,
messages=[
{'role': 'user', 'content': 'search'},
{
'role': 'assistant',
'content': '',
'tool_calls': [{'id': 'call-1', 'type': 'function', 'function': {'name': 'search', 'arguments': '{}'}}],
},
{'role': 'tool', 'content': 'search result', 'tool_call_id': 'call-1'},
],
)
assert all(response.code == 0 for response in responses)
assert host.provider.calls[-1][1] == {'reasoning_effort': 'low'}
@@ -0,0 +1,208 @@
"""Request-local reasoning must reach the native Codex Responses builder."""
from copy import copy, deepcopy
from types import SimpleNamespace
import pytest
import langbot_plugin.api.entities.builtin.provider.message as pm
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.entity.persistence import model as persistence_model
from langbot.pkg.provider.modelmgr.requester import RuntimeLLMModel
from tests.unit_tests.provider.test_codex import TOKENS, requester, stream
@pytest.fixture
def codex(monkeypatch):
def unexpected_request(request):
pytest.fail('Builder tests must not send HTTP requests')
return requester(monkeypatch, unexpected_request)
def runtime_model(codex, name='primary', level='high'):
context = ExecutionContext(instance_uuid='instance', workspace_uuid='w', placement_generation=1)
provider = SimpleNamespace(
execution_context=context,
provider_entity=persistence_model.ModelProvider(
workspace_uuid='w', uuid='p', name='codex', requester='codex', api_keys=[]
),
requester=codex,
)
entity = persistence_model.LLMModel(
workspace_uuid='w',
uuid=name,
name=name,
provider_uuid='p',
abilities=['reasoning', 'func_call'],
reasoning_config={'level': level},
extra_args={},
)
return RuntimeLLMModel(context, entity, provider)
def entity_values(model):
return {
column.name: deepcopy(getattr(model.model_entity, column.name))
for column in model.model_entity.__table__.columns
}
def build(codex, model):
return codex._body(None, model, [pm.Message(role='user', content='Hi')], None, None, TOKENS)
@pytest.mark.parametrize('level', ['provider_default', 'low', 'medium', 'high', 'xhigh'])
def test_canonical_codex_override_levels(codex, level):
model = runtime_model(codex, level='medium')
before = deepcopy(entity_values(model))
model.reasoning_config_override = {'level': level}
assert codex.get_reasoning_capabilities(model)['levels'] == ['provider_default', 'low', 'medium', 'high', 'xhigh']
body = build(codex, model)
if level == 'provider_default':
assert 'reasoning' not in body
else:
assert body['reasoning'] == {'effort': level, 'summary': 'auto'}
assert model.reasoning_config_override == {'level': level}
assert entity_values(model) == before
assert 'reasoning_config_override' not in body
def test_primary_fallback_and_shared_entity_clones_are_isolated(codex):
primary = runtime_model(codex)
fallback = runtime_model(codex, name='fallback', level='medium')
primary_run, fallback_run, another_run = copy(primary), copy(fallback), copy(primary)
primary_run.reasoning_config_override = {'level': 'low'}
fallback_run.reasoning_config_override = {'level': 'xhigh'}
another_run.reasoning_config_override = {'level': 'medium'}
originals = [primary, fallback]
before = [deepcopy(entity_values(model)) for model in originals]
for scoped, expected in [(primary_run, 'low'), (fallback_run, 'xhigh'), (another_run, 'medium')]:
assert build(codex, scoped)['reasoning']['effort'] == expected
assert primary_run.model_entity is another_run.model_entity is primary.model_entity
assert primary_run.provider is another_run.provider is primary.provider
assert build(codex, primary)['reasoning']['effort'] == 'high'
assert build(codex, fallback)['reasoning']['effort'] == 'medium'
assert [entity_values(model) for model in originals] == before
assert all(model.reasoning_config_override is None for model in originals)
def test_explicit_provider_default_overrides_persisted_high(codex):
model = runtime_model(codex)
model.reasoning_config_override = {'level': 'provider_default'}
assert 'reasoning' not in build(codex, model)
assert model.model_entity.reasoning_config == {'level': 'high'}
@pytest.mark.parametrize('missing_attribute', [False, True])
@pytest.mark.parametrize('level', ['provider_default', 'high'])
def test_absent_override_preserves_persisted_config(codex, missing_attribute, level):
model = runtime_model(codex, level=level)
if missing_attribute:
del model.reasoning_config_override
body = build(codex, model)
assert body.get('reasoning') == (None if level == 'provider_default' else {'effort': level, 'summary': 'auto'})
@pytest.mark.parametrize(
'config',
[
{'level': 'turbo'},
{'level': 'disabled'},
{'level': 'enabled'},
{'level': 'minimal'},
{'level': 'max'},
{'unknown': True},
'high',
],
)
def test_override_uses_same_validation_errors_as_persisted_config(codex, config):
persisted = runtime_model(codex)
persisted.model_entity.reasoning_config = deepcopy(config)
with pytest.raises(ValueError) as expected:
build(codex, persisted)
scoped = runtime_model(codex)
scoped.reasoning_config_override = deepcopy(config)
before = deepcopy(entity_values(scoped))
with pytest.raises(ValueError) as actual:
build(codex, scoped)
assert str(actual.value) == str(expected.value)
assert TOKENS['access_token'] not in str(actual.value)
assert TOKENS['account_id'] not in str(actual.value)
assert entity_values(scoped) == before
def test_advanced_args_remain_unchanged(codex):
model = runtime_model(codex, level='provider_default')
model.model_entity.extra_args = {'reasoning': {'effort': 'low'}, 'parallel_tool_calls': False}
before = deepcopy(entity_values(model))
baseline = build(codex, model)
model.reasoning_config_override = {'level': 'provider_default'}
assert build(codex, model) == baseline
assert entity_values(model) == before
@pytest.mark.asyncio
@pytest.mark.parametrize('streaming', [False, True])
async def test_override_preserves_stream_tools_usage_and_replay(monkeypatch, streaming):
import json
call = {'type': 'function_call', 'call_id': 'call_1', 'name': 'lookup', 'arguments': '{}'}
output = [{'type': 'reasoning', 'encrypted_content': 'opaque-secret'}, call]
requests = []
def handler(request):
requests.append(request)
return stream(
[
{'type': 'response.output_text.delta', 'delta': 'Hello'},
{'type': 'response.output_item.done', 'item': call, 'output_index': 1},
{
'type': 'response.completed',
'response': {
'id': 'resp_1',
'status': 'completed',
'output': output,
'usage': {'input_tokens': 4, 'output_tokens': 3},
},
},
]
)
codex = requester(monkeypatch, handler)
model = runtime_model(codex)
scoped = copy(model)
scoped.reasoning_config_override = {'level': 'low'}
before = deepcopy(entity_values(model))
provider_before = dict(vars(model.provider))
query = SimpleNamespace(query_id='q', variables={})
messages = [pm.Message(role='user', content='Hi')]
funcs = [SimpleNamespace(name='lookup', description='Look up', parameters={'type': 'object'})]
baseline = codex._body(query, model, messages, funcs, None, TOKENS)
if streaming:
chunks = [chunk async for chunk in codex.invoke_llm_stream(query, scoped, messages, funcs)]
assert ''.join(chunk.content or '' for chunk in chunks) == 'Hello'
assert sum(len(chunk.tool_calls or []) for chunk in chunks) == 1
assert next(chunk.tool_calls[0] for chunk in chunks if chunk.tool_calls).function.arguments == '{}'
assert chunks[-1].is_final
result = pm.Message(
role='assistant', content='Hello', provider_specific_fields=chunks[-1].provider_specific_fields
)
else:
result, usage = await codex.invoke_llm(query, scoped, messages, funcs)
assert result.content == 'Hello'
assert len(result.tool_calls) == 1
assert result.tool_calls[0].function.arguments == '{}'
assert usage['total_tokens'] == 7
body = json.loads(requests[0].content)
assert body.pop('reasoning') == {'effort': 'low', 'summary': 'auto'}
baseline.pop('reasoning')
assert body == baseline
assert query.variables['_stream_usage']['total_tokens'] == 7
assert 'opaque-secret' not in result.model_dump_json()
assert codex._body(query, scoped, [result], None, None, TOKENS)['input'] == output
assert requests[0].headers['authorization'] == 'Bearer ' + TOKENS['access_token']
assert entity_values(model) == before
assert vars(model.provider) == provider_before
assert model.reasoning_config_override is None
codex.auth.access.assert_awaited_once_with('w', 'p')
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from '@playwright/test';
import base from './playwright.config';
export default defineConfig({
...base,
retries: 0,
outputDir: 'test-results-completion-migration',
use: { ...base.use, baseURL: 'http://127.0.0.1:4198' },
webServer: {
command: 'pnpm exec vite --host 127.0.0.1 --port 4198 --strictPort',
url: 'http://127.0.0.1:4198',
reuseExistingServer: false,
timeout: 120000,
},
});
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
workers: 1,
retries: 0,
timeout: 30000,
reporter: 'list',
outputDir: '/tmp/411-reasoning-results',
use: { baseURL: 'http://127.0.0.1:4196' },
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
command:
'taskset -c 0,1 node node_modules/vite/bin/vite.js --config vite.reasoning.config.ts --host 127.0.0.1 --port 4196',
url: 'http://127.0.0.1:4196',
reuseExistingServer: true,
},
});
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from '@playwright/test';
import base from './playwright.config';
export default defineConfig({
...base,
retries: 0,
outputDir: 'test-results-structured',
use: { ...base.use, baseURL: 'http://127.0.0.1:4197' },
webServer: {
command:
'taskset -c 0,1 node node_modules/vite/bin/vite.js --config vite.structured.config.ts --host 127.0.0.1 --port 4197 --strictPort',
url: 'http://127.0.0.1:4197',
reuseExistingServer: false,
timeout: 120000,
},
});
+14 -2
View File
@@ -32,7 +32,13 @@ import AgentFormComponent, {
RunnerStatus,
} from './components/AgentFormComponent';
export default function AgentDetailContent({ id }: { id: string }) {
export default function AgentDetailContent({
id,
pipelineRevision,
}: {
id: string;
pipelineRevision?: string;
}) {
const isCreateMode = id === 'new';
const navigate = useNavigate();
const { t } = useTranslation();
@@ -130,7 +136,13 @@ export default function AgentDetailContent({ id }: { id: string }) {
if (loading || !agent) return <EntityLoadState />;
if (agent.kind === 'pipeline') {
return <PipelineDetailContent id={id} routeBase="/home/agents" />;
return (
<PipelineDetailContent
key={pipelineRevision}
id={id}
routeBase="/home/agents"
/>
);
}
async function saveBasicInfo(values: EntityBasicInfoValues) {
+34 -6
View File
@@ -1,19 +1,47 @@
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useCurrentWorkspace } from '@/app/infra/http';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import PipelineMigration, {
migrationWorkspaceKey,
} from '@/app/home/pipelines/PipelineMigration';
import AgentDetailContent from './AgentDetailContent';
export default function AgentsPage() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const detailId = searchParams.get('id');
if (detailId) {
return <AgentDetailContent key={detailId} id={detailId} />;
}
const workspace = useCurrentWorkspace();
const { refreshPipelines } = useSidebarData();
const [revision, setRevision] = useState(0);
const scopeKey = migrationWorkspaceKey(workspace);
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
<p>{t('agents.selectFromSidebar')}</p>
<div className="flex h-full min-h-0 flex-col">
{workspace && workspace.permissions.includes('resource.view') && (
<PipelineMigration
key={scopeKey}
workspace={workspace}
onComplete={() => {
void refreshPipelines();
setRevision((current) => current + 1);
}}
/>
)}
<div className="flex-1 min-h-0">
{detailId ? (
<AgentDetailContent
key={detailId}
id={detailId}
pipelineRevision={`${scopeKey}:${revision}`}
/>
) : (
<div className="flex h-full items-center justify-center text-muted-foreground">
<p>{t('agents.selectFromSidebar')}</p>
</div>
)}
</div>
</div>
);
}
@@ -6,6 +6,7 @@ import {
import { useForm } from 'react-hook-form';
import type { ControllerRenderProps } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { isJsonValue, isPromptValue } from './StructuredFieldValue';
import { z } from 'zod';
import {
Form,
@@ -133,13 +134,10 @@ function getValueSchema(spec: DynamicFormValueSpec) {
fallbacks: z.array(z.string()),
reasoning: z.record(z.string()),
});
case DynamicFormItemType.JSON:
return z.custom(isJsonValue);
case DynamicFormItemType.PROMPT_EDITOR:
return z.array(
z.object({
content: z.string(),
role: z.string(),
}),
);
return z.custom(isPromptValue);
default:
return z.string();
}
@@ -656,6 +654,7 @@ export default function DynamicFormComponent({
};
const fieldKey = config.id || config.name || `field-${index}`;
let isHiddenByCondition = false;
if (config.show_if) {
const dependValue = resolveShowIfValue(
config.show_if.field,
@@ -668,23 +667,32 @@ export default function DynamicFormComponent({
config.show_if.operator === 'eq' &&
dependValue !== config.show_if.value
) {
return null;
isHiddenByCondition = true;
}
if (
config.show_if.operator === 'neq' &&
dependValue === config.show_if.value
) {
return null;
isHiddenByCondition = true;
}
if (
config.show_if.operator === 'in' &&
Array.isArray(config.show_if.value) &&
!config.show_if.value.includes(dependValue)
) {
return null;
isHiddenByCondition = true;
}
}
// Keep structured drafts mounted across conditional hiding so invalid
// JSON cannot disappear from validation when Advanced Settings closes.
if (
isHiddenByCondition &&
normalizedConfig.type !== DynamicFormItemType.JSON &&
normalizedConfig.type !== DynamicFormItemType.PROMPT_EDITOR
)
return null;
// Keep locked fields visible and resolve only the applicable reason.
const { isDisabledByCondition, disabledTooltip: tooltip } =
resolveDisabledState(
@@ -957,7 +965,10 @@ export default function DynamicFormComponent({
setFormValue,
});
return (
<FormItem className="min-w-0">
<FormItem
className={cn('min-w-0', isHiddenByCondition && 'hidden')}
hidden={isHiddenByCondition}
>
<FormLabel className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 break-words">
{extractI18nObject(config.label)}{' '}
@@ -3,6 +3,8 @@ import {
IDynamicFormItemSchema,
IFileConfig,
} from '@/app/infra/entities/form/dynamic';
import StructuredFieldEditor from './StructuredFieldEditor';
import { isSimplePrompt } from './StructuredFieldValue';
import { Input } from '@/components/ui/input';
import {
Select,
@@ -490,14 +492,7 @@ export default function DynamicFormItemComponent({
);
case DynamicFormItemType.JSON:
return (
<Textarea
{...field}
value={field.value ?? ''}
className="min-h-[200px] font-mono text-sm"
placeholder='{"key": "value"}'
/>
);
return <StructuredFieldEditor field={field} />;
case DynamicFormItemType.BOOLEAN:
return (
@@ -1261,11 +1256,11 @@ export default function DynamicFormItemComponent({
const updateModelReasoning = (
modelUuid: string,
level: ReasoningLevel,
level: ReasoningLevel | undefined,
) => {
if (!modelUuid) return;
const updated = { ...modelValue.reasoning };
if (level === 'provider_default') {
if (level === undefined) {
delete updated[modelUuid];
} else {
updated[modelUuid] = level;
@@ -1311,6 +1306,8 @@ export default function DynamicFormItemComponent({
<ReasoningLevelPicker
value={currentLevel}
levels={levels}
inherited={!Object.hasOwn(modelValue.reasoning, modelUuid)}
onInherit={() => updateModelReasoning(modelUuid, undefined)}
onChange={(level) => updateModelReasoning(modelUuid, level)}
/>
);
@@ -1872,6 +1869,9 @@ export default function DynamicFormItemComponent({
);
case DynamicFormItemType.PROMPT_EDITOR: {
if (!isSimplePrompt(field.value)) {
return <StructuredFieldEditor field={field} prompt />;
}
// Guard: field.value may be undefined when the form resets or
// initialValues haven't propagated yet. Fall back to a default
// single system-prompt entry to prevent the .map() crash.
@@ -56,7 +56,6 @@ export function parseDynamicFormItemType(value: string): DynamicFormItemType {
[DynamicFormItemType.SELECT_KNOWLEDGE_BASES]:
DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR,
[DynamicFormItemType.NUMBER]: DynamicFormItemType.FLOAT,
[DynamicFormItemType.JSON]: DynamicFormItemType.TEXT,
};
if (value in typeMap) {
return typeMap[value];
@@ -80,6 +80,7 @@ export function normalizeDynamicFormFieldValue(
}
const reasoningLevels = new Set([
'provider_default',
'disabled',
'enabled',
'minimal',
@@ -0,0 +1,73 @@
import { useEffect, useRef, useState } from 'react';
import type { ControllerRenderProps } from 'react-hook-form';
import { useFormContext } from 'react-hook-form';
import { Textarea } from '@/components/ui/textarea';
import { useTranslation } from 'react-i18next';
import { parseStructuredDraft } from './StructuredFieldValue';
/** Draft text stays local: only explicitly parsed edits enter persisted state. */
export default function StructuredFieldEditor({
field,
prompt = false,
}: {
field: ControllerRenderProps<any, any>;
prompt?: boolean;
}) {
const { t } = useTranslation();
const form = useFormContext();
const [draft, setDraft] = useState(
() => JSON.stringify(field.value, null, 2) ?? '',
);
const [invalid, setInvalid] = useState(false);
const emitted = useRef(field.value);
const input = useRef<HTMLTextAreaElement | null>(null);
const error = t('agents.debugData.invalidField', {
field: `${field.name} (JSON)`,
});
useEffect(() => {
if (JSON.stringify(field.value) !== JSON.stringify(emitted.current)) {
emitted.current = field.value;
setDraft(JSON.stringify(field.value, null, 2) ?? '');
setInvalid(false);
input.current?.setCustomValidity('');
}
}, [field.value]);
return (
<div className="space-y-2">
<Textarea
ref={(element) => {
input.current = element;
field.ref(element);
}}
name={field.name}
aria-label={`${field.name} (JSON)`}
data-testid={`structured-editor-${field.name}`}
aria-invalid={invalid}
className="min-h-48 font-mono"
value={draft}
onBlur={field.onBlur}
onChange={(event) => {
const next = event.target.value;
setDraft(next);
try {
const value = parseStructuredDraft(next, prompt);
event.target.setCustomValidity('');
setInvalid(false);
form.clearErrors(field.name);
emitted.current = value;
field.onChange(value);
} catch {
event.target.setCustomValidity(error);
setInvalid(true);
form.setError(field.name, { type: 'json', message: error });
}
}}
/>
{invalid && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
</div>
);
}
@@ -0,0 +1,67 @@
export type JsonValue =
| null
| boolean
| number
| string
| JsonValue[]
| { [key: string]: JsonValue };
export type PromptMessage = {
role: string;
content?: string | null | JsonValue[];
[key: string]: JsonValue | undefined;
};
export function isJsonValue(value: unknown): value is JsonValue {
if (value === null || typeof value === 'string' || typeof value === 'boolean')
return true;
if (typeof value === 'number') return Number.isFinite(value);
if (Array.isArray(value)) return value.every(isJsonValue);
return (
typeof value === 'object' &&
value !== null &&
Object.values(value).every(isJsonValue)
);
}
export function isPromptValue(value: unknown): value is PromptMessage[] {
return (
Array.isArray(value) &&
value.every(
(item) =>
item !== null &&
typeof item === 'object' &&
!Array.isArray(item) &&
typeof item.role === 'string' &&
(!Object.prototype.hasOwnProperty.call(item, 'content') ||
typeof item.content === 'string' ||
item.content === null ||
Array.isArray(item.content)) &&
isJsonValue(item),
)
);
}
export function parseStructuredDraft(
draft: string,
prompt: boolean,
): JsonValue {
const value: unknown = JSON.parse(draft);
if (!(prompt ? isPromptValue(value) : isJsonValue(value)))
throw new TypeError('JSON');
return value as JsonValue;
}
export function isSimplePrompt(
value: unknown,
): value is { role: string; content: string }[] {
return (
Array.isArray(value) &&
value.length > 0 &&
value.every(
(item, index) =>
item &&
typeof item.content === 'string' &&
(index === 0
? item.role === 'system'
: ['user', 'assistant'].includes(item.role)) &&
Object.keys(item).every((key) => key === 'role' || key === 'content'),
)
);
}
@@ -37,6 +37,8 @@ interface ReasoningLevelPickerProps {
value: ReasoningLevel;
levels: ReasoningLevel[];
disabled?: boolean;
inherited?: boolean;
onInherit?: () => void;
onChange: (value: ReasoningLevel) => void;
}
@@ -44,6 +46,8 @@ export default function ReasoningLevelPicker({
value,
levels,
disabled = false,
inherited = false,
onInherit,
onChange,
}: ReasoningLevelPickerProps) {
const { t } = useTranslation();
@@ -52,8 +56,14 @@ export default function ReasoningLevelPicker({
const safeValue: ReasoningLevel = safeLevels.includes(value)
? value
: safeLevels[0];
const currentLabel = t(REASONING_LEVEL_LABEL_KEYS[safeValue]);
const isExplicit = safeValue !== 'provider_default';
const isInherited = Boolean(onInherit && inherited);
const currentLabel = t(
isInherited
? 'models.reasoningLevels.useModelSetting'
: REASONING_LEVEL_LABEL_KEYS[safeValue],
);
const isExplicit =
!isInherited && (Boolean(onInherit) || safeValue !== 'provider_default');
const currentIndex = Math.max(0, safeLevels.indexOf(safeValue));
return (
@@ -63,7 +73,7 @@ export default function ReasoningLevelPicker({
type="button"
variant="outline"
size="sm"
disabled={disabled || safeLevels.length <= 1}
disabled={disabled || (safeLevels.length <= 1 && !onInherit)}
aria-label={`${t('models.reasoningLevel')}: ${currentLabel}`}
className="h-9 w-9 shrink-0 gap-1.5 px-2.5 text-xs font-normal sm:w-auto sm:max-w-36"
>
@@ -77,6 +87,30 @@ export default function ReasoningLevelPicker({
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[272px] p-4">
{onInherit && (
<div className="mb-3 flex flex-col gap-1">
<Button
type="button"
variant="outline"
size="sm"
aria-pressed={isInherited}
onClick={onInherit}
>
{t('models.reasoningLevels.useModelSetting')}
</Button>
{safeLevels.includes('provider_default') && (
<Button
type="button"
variant="outline"
size="sm"
aria-pressed={!isInherited && safeValue === 'provider_default'}
onClick={() => onChange('provider_default')}
>
{t(REASONING_LEVEL_LABEL_KEYS.provider_default)}
</Button>
)}
</div>
)}
<div className="flex h-5 items-center gap-0.5 text-sm text-muted-foreground">
<span>{currentLabel}</span>
<ChevronRight className="size-3.5" />
@@ -1,8 +1,10 @@
import EntityLoadState from '@/components/EntityLoadState';
import { isCurrentPipelineConfig } from './pipeline-config-safety';
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import PipelineFormComponent, {
PipelineFormHandle,
} from '@/app/home/pipelines/components/pipeline-form/PipelineFormComponent';
@@ -150,6 +152,25 @@ export default function PipelineDetailContent({
);
if (!pipelineDetails) return <EntityLoadState />;
// Never hydrate plugin defaults or mount debug autosave for legacy bindings.
if (!isCurrentPipelineConfig(pipelineDetails.config)) {
return (
<div className="space-y-4">
<h1 className="text-xl font-semibold">
{pipelineDetails.emoji || '⚙️'} {pipelineDetails.name}
</h1>
<p className="text-sm text-muted-foreground">
{pipelineDetails.description}
</p>
<Alert>
<AlertDescription>
{t('pipelineMigration.legacyGate')}
</AlertDescription>
</Alert>
</div>
);
}
// ==================== Edit Mode ====================
const pipelineName =
pipelineDetails?.name ||
@@ -178,6 +199,7 @@ export default function PipelineDetailContent({
<fieldset className="contents" disabled={!canManage}>
<PipelineFormComponent
ref={pipelineFormRef}
onLegacyPipeline={setPipelineDetails}
pipelineId={id}
isEditMode={true}
disableForm={!canManage}
@@ -0,0 +1,540 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { httpClient } from '@/app/infra/http/HttpClient';
import { getCurrentWorkspaceSnapshot } from '@/app/infra/http/currentWorkspaceStore';
import { migrationIssueKey } from './pipeline-migration-issues';
import type { CurrentWorkspace } from '@/app/infra/entities/workspace';
import type {
PipelineMigrationIssue,
PipelineMigrationItem,
PipelineMigrationPreview,
PipelineMigrationResult,
} from '@/app/infra/entities/api/pipeline-migration';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
export function migrationWorkspaceKey(workspace: CurrentWorkspace | null) {
return workspace
? `${workspace.workspace.instance_uuid}:${workspace.workspace.uuid}:${workspace.placement_generation}:${workspace.permissions.join(',')}`
: '';
}
function safeField(field?: string | null) {
return field && field.length <= 180 && /^[a-zA-Z0-9_.\-[\]]+$/.test(field)
? field
: null;
}
const resultStates = new Set([
'pending',
'migrated',
'already_current',
'blocked',
'stale',
'failed',
'activation_pending',
]);
type Status =
| 'idle'
| 'submitting'
| 'running'
| 'finished'
| 'failed'
| 'lost'
| 'requestError';
export default function PipelineMigration({
workspace,
onComplete,
}: {
workspace: CurrentWorkspace;
onComplete: () => void;
}) {
const { t } = useTranslation();
const scopeKey = migrationWorkspaceKey(workspace);
const canManage = workspace.permissions.includes('resource.manage');
const [open, setOpen] = useState(false);
const [preview, setPreview] = useState<PipelineMigrationPreview | null>(null);
const [loading, setLoading] = useState(false);
const [previewError, setPreviewError] = useState(false);
const [valid, setValid] = useState(false);
const [selected, setSelected] = useState<string[]>([]);
const [confirmed, setConfirmed] = useState(false);
const [status, setStatus] = useState<Status>('idle');
const [results, setResults] = useState<PipelineMigrationResult[]>([]);
const active = useRef(false);
const submitting = useRef(false);
const previewGeneration = useRef(0);
const controller = useRef<AbortController | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const onCompleteRef = useRef(onComplete);
onCompleteRef.current = onComplete;
const isCurrent = useCallback(
() =>
active.current &&
migrationWorkspaceKey(getCurrentWorkspaceSnapshot()) === scopeKey,
[scopeKey],
);
const refresh = useCallback(
async (allowSelection = true) => {
if (!isCurrent()) return;
const generation = ++previewGeneration.current;
setSelected([]);
setConfirmed(false);
setValid(false);
setLoading(true);
setPreviewError(false);
try {
const response = await httpClient.getPipelineMigrationPreview({
signal: controller.current?.signal,
});
if (!isCurrent() || generation !== previewGeneration.current) return;
if (
response.workspace_uuid !== workspace.workspace.uuid ||
!Array.isArray(response.items) ||
response.total !== response.items.length
)
throw new Error('Invalid preview');
setPreview(response);
setValid(allowSelection);
} catch {
if (isCurrent() && generation === previewGeneration.current) {
setPreviewError(true);
setPreview(null);
}
} finally {
if (isCurrent() && generation === previewGeneration.current)
setLoading(false);
}
},
[isCurrent, workspace.workspace.uuid],
);
useEffect(() => {
active.current = true;
controller.current = new AbortController();
void refresh();
const onFocus = () => {
if (!submitting.current) void refresh();
};
window.addEventListener('focus', onFocus);
return () => {
active.current = false;
controller.current?.abort();
if (timer.current) clearTimeout(timer.current);
window.removeEventListener('focus', onFocus);
};
}, [refresh]);
const busy = status === 'submitting' || status === 'running';
const eligible = (item: PipelineMigrationItem) =>
['ready', 'activation_pending'].includes(item.state) &&
!!item.preview_token;
const rows = preview?.items ?? [];
const count = rows.filter(
(item) => !['already_current', 'not_legacy'].includes(item.state),
).length;
function renderIssue(issue: PipelineMigrationIssue, warning = false) {
// Unknown server codes use localized fallbacks, never raw upstream messages.
const key = migrationIssueKey(issue.code);
const fallback = t(
warning
? 'pipelineMigration.warningFallback'
: 'pipelineMigration.blockerFallback',
);
const message = key
? t(`pipelineMigration.notices.${key}`, { defaultValue: fallback })
: fallback;
const field = safeField(issue.field);
return (
<span>
{message}
{field && (
<>
{' '}
— <code className="text-xs">{field}</code>
</>
)}
</span>
);
}
async function execute() {
if (
submitting.current ||
!isCurrent() ||
!canManage ||
!valid ||
loading ||
!confirmed ||
selected.length === 0 ||
selected.length > 50
)
return;
const items = rows
.filter((item) => selected.includes(item.pipeline_uuid) && eligible(item))
.map((item) => ({
pipeline_uuid: item.pipeline_uuid,
preview_token: item.preview_token!,
}));
if (items.length !== selected.length) return;
submitting.current = true;
++previewGeneration.current;
setStatus('submitting');
setValid(false);
setConfirmed(false);
setResults(
items.map((item) => ({
pipeline_uuid: item.pipeline_uuid,
state: 'pending',
code: null,
})),
);
const loseObservation = () => {
if (!isCurrent()) return;
submitting.current = false;
setStatus('lost');
void refresh(false);
onCompleteRef.current();
};
try {
const { task_id } = await httpClient.executePipelineMigration(
{ confirmed: true, items },
{ signal: controller.current?.signal },
);
if (!isCurrent()) return;
setStatus('running');
const poll = async () => {
if (!isCurrent()) return;
try {
const task = await httpClient.getAsyncTask(task_id, {
signal: controller.current?.signal,
});
if (!isCurrent()) return;
const metadata = task.task_context?.metadata;
if (
task.id !== task_id ||
metadata?.kind !== 'pipeline_migration' ||
!Array.isArray(metadata.results)
) {
loseObservation();
return;
}
const outcome = metadata.results as PipelineMigrationResult[];
if (
outcome.some(
(item) =>
!item ||
!resultStates.has(item.state) ||
!items.some(
(submitted) => submitted.pipeline_uuid === item.pipeline_uuid,
),
) ||
(task.runtime.done && outcome.length !== items.length) ||
new Set(outcome.map((item) => item.pipeline_uuid)).size !==
outcome.length
) {
loseObservation();
return;
}
const scopedResults = items.map(
(item) =>
outcome.find(
(result) => result.pipeline_uuid === item.pipeline_uuid,
) ?? {
pipeline_uuid: item.pipeline_uuid,
state: 'pending' as const,
code: null,
},
);
setResults(scopedResults);
if (task.runtime.done) {
if (
!task.runtime.exception &&
scopedResults.some((item) => item.state === 'pending')
) {
loseObservation();
return;
}
submitting.current = false;
setStatus(task.runtime.exception ? 'failed' : 'finished');
void refresh(false);
onCompleteRef.current();
} else {
timer.current = setTimeout(() => {
void poll();
}, 1000);
}
} catch {
loseObservation();
}
};
void poll();
} catch (error) {
if (!isCurrent()) return;
const code =
typeof error === 'object' && error && 'code' in error
? error.code
: undefined;
if (code === -1 || code === -2 || code === undefined) {
loseObservation();
return;
}
submitting.current = false;
setStatus('requestError');
setSelected([]);
}
}
function changeOpen(next: boolean) {
setOpen(next);
if (!submitting.current) {
setSelected([]);
setConfirmed(false);
if (next) void refresh(status === 'idle');
}
}
return (
<>
{(count > 0 || previewError || results.length > 0) && (
<Alert className="mb-4 shrink-0">
<AlertTitle>{t('pipelineMigration.title')}</AlertTitle>
<AlertDescription className="flex items-center justify-between gap-3">
<span>
{previewError
? t('pipelineMigration.previewError')
: t('pipelineMigration.detected', { count })}
</span>
<Button
variant="outline"
size="sm"
onClick={() => changeOpen(true)}
>
{t('pipelineMigration.review')}
</Button>
</AlertDescription>
</Alert>
)}
<Dialog open={open} onOpenChange={changeOpen}>
<DialogContent className="sm:max-w-2xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>{t('pipelineMigration.title')}</DialogTitle>
<DialogDescription>
{t('pipelineMigration.description')}
</DialogDescription>
</DialogHeader>
{!canManage && (
<Alert>
<AlertDescription>
{t('pipelineMigration.readOnly')}
</AlertDescription>
</Alert>
)}
{previewError && (
<Alert variant="destructive">
<AlertDescription>
{t('pipelineMigration.previewError')}
</AlertDescription>
</Alert>
)}
{status !== 'idle' && (
<Alert
variant={
status === 'failed' || status === 'requestError'
? 'destructive'
: 'default'
}
>
<AlertDescription role="status">
{t(`pipelineMigration.${status}`)}
</AlertDescription>
</Alert>
)}
<ScrollArea className="min-h-0 flex-1 overflow-y-auto">
<div className="space-y-3 pr-3">
{rows.map((item) => (
<div
key={item.pipeline_uuid}
className="rounded-md border p-3 space-y-2"
>
<div className="flex items-start gap-3">
<Checkbox
aria-label={item.name}
checked={selected.includes(item.pipeline_uuid)}
disabled={
!canManage ||
busy ||
loading ||
!valid ||
!eligible(item) ||
(selected.length >= 50 &&
!selected.includes(item.pipeline_uuid))
}
onCheckedChange={(checked) => {
setConfirmed(false);
setSelected((current) =>
checked
? [...current, item.pipeline_uuid]
: current.filter((id) => id !== item.pipeline_uuid),
);
}}
/>
<div className="min-w-0 flex-1 space-y-1">
<p className="font-medium text-sm break-words">
{item.name}
</p>
<p className="text-xs text-muted-foreground break-words">
{item.legacy_runner ?? '—'} →{' '}
{item.target_plugin?.name ?? '—'}
</p>
</div>
<Badge variant="secondary">
{t(`pipelineMigration.states.${item.state}`)}
</Badge>
</div>
{item.blockers.map((issue, index) => (
<p className="text-sm text-destructive" key={`b-${index}`}>
{renderIssue(issue)}
</p>
))}
{item.warnings.map((issue, index) => (
<p
className="text-sm text-muted-foreground"
key={`w-${index}`}
>
{renderIssue(issue, true)}
</p>
))}
{item.changed_paths.filter((path) => safeField(path)).length >
0 && (
<p className="text-xs text-muted-foreground">
{t('pipelineMigration.changedFields')}:{' '}
{item.changed_paths
.filter((path) => safeField(path))
.join(', ')}
</p>
)}
{item.state === 'activation_pending' && (
<p className="text-sm text-muted-foreground">
{t(
item.preview_token
? 'pipelineMigration.activationRetryHint'
: 'pipelineMigration.activationHint',
)}
</p>
)}
</div>
))}
{rows.some((item) => item.state === 'needs_plugin') && (
<Alert>
<AlertDescription className="space-y-2">
<p>{t('pipelineMigration.pluginHint')}</p>
<Button variant="link" asChild className="h-auto p-0">
<a
href="/home/extensions"
target="_blank"
rel="noopener noreferrer"
>
{t('pipelineMigration.extensions')}
</a>
</Button>
</AlertDescription>
</Alert>
)}
{results.length > 0 && (
<div
className="space-y-2"
aria-label={t('pipelineMigration.results')}
>
<h3 className="text-sm font-medium">
{t('pipelineMigration.results')}
</h3>
{results.map((result) => (
<div
className="rounded-md border p-3 text-sm"
key={result.pipeline_uuid}
data-testid={`migration-result-${result.pipeline_uuid}`}
>
<p>
{rows.find(
(item) => item.pipeline_uuid === result.pipeline_uuid,
)?.name ?? result.pipeline_uuid}{' '}
— {t(`pipelineMigration.states.${result.state}`)}
</p>
{result.code && (
<p className="text-muted-foreground">
{renderIssue({ code: result.code })}
</p>
)}
{result.state === 'activation_pending' && (
<p>{t('pipelineMigration.activationHint')}</p>
)}
</div>
))}
</div>
)}
</div>
</ScrollArea>
<p className="text-xs text-muted-foreground">
{t('pipelineMigration.selection', { count: selected.length })}
</p>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={confirmed}
onCheckedChange={(checked) => setConfirmed(checked === true)}
disabled={
!canManage || busy || loading || !valid || !selected.length
}
/>
{t('pipelineMigration.confirm')}
</label>
<DialogFooter>
<Button variant="ghost" onClick={() => changeOpen(false)}>
{t('common.cancel')}
</Button>
<Button
variant="outline"
disabled={busy || loading}
onClick={() => {
setStatus('idle');
void refresh();
}}
>
{t('pipelineMigration.refresh')}
</Button>
<Button
disabled={
!canManage ||
busy ||
loading ||
!valid ||
!selected.length ||
!confirmed
}
onClick={() => {
void execute();
}}
>
{t('pipelineMigration.execute')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -1,4 +1,8 @@
import EntityLoadState from '@/components/EntityLoadState';
import { preserveRunnerConfig } from './RunnerConfigPreservation';
import type { ComponentProps } from 'react';
import { isCurrentPipelineConfig } from '../../pipeline-config-safety';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
forwardRef,
useCallback,
@@ -67,6 +71,35 @@ import {
type InstalledRunner,
} from '@/app/home/agents/runner-marketplace';
/** A mount-scoped editing session: normalized defaults are UI state, not edits. */
function PersistedRunnerForm(
props: ComponentProps<typeof DynamicFormComponent>,
) {
const initialValues = useRef(props.initialValues);
const previous = useRef<Record<string, unknown> | undefined>(undefined);
const current = useRef(props.initialValues || {});
current.current = props.initialValues || {};
return (
<DynamicFormComponent
{...props}
initialValues={initialValues.current}
onSubmit={(values) => {
const emitted = values as Record<string, unknown>;
const next = preserveRunnerConfig(
current.current,
previous.current,
emitted,
);
previous.current = structuredClone(emitted);
if (JSON.stringify(next) !== JSON.stringify(current.current)) {
current.current = next;
props.onSubmit?.(next);
}
}}
/>
);
}
interface PipelineFormComponentProps {
pipelineId?: string;
isEditMode: boolean;
@@ -78,6 +111,7 @@ interface PipelineFormComponentProps {
onCancel?: () => void;
onDirtyChange?: (dirty: boolean) => void;
onSavingChange?: (saving: boolean) => void;
onLegacyPipeline?: (pipeline: Pipeline) => void;
}
export interface PipelineFormHandle {
@@ -103,6 +137,7 @@ const PipelineFormComponent = forwardRef<
onCancel,
onDirtyChange,
onSavingChange,
onLegacyPipeline,
},
ref,
) {
@@ -111,7 +146,10 @@ const PipelineFormComponent = forwardRef<
const [showCopyConfirm, setShowCopyConfirm] = useState(false);
const [isDefaultPipeline, setIsDefaultPipeline] = useState<boolean>(false);
const [isSaving, setIsSaving] = useState(false);
const pipelineFormElement = useRef<HTMLFormElement>(null);
const isSavingRef = useRef(false);
const legacyConfigRef = useRef(false);
const [legacyConfig, setLegacyConfig] = useState(false);
const formSchema = isEditMode
? z.object({
@@ -291,6 +329,12 @@ const PipelineFormComponent = forwardRef<
.getPipeline(pipelineId || '')
.then((resp: GetPipelineResponseData) => {
if (cancelled) return;
if (!isCurrentPipelineConfig(resp.pipeline.config)) {
legacyConfigRef.current = true;
setLegacyConfig(true);
onLegacyPipeline?.(resp.pipeline);
return;
}
setIsDefaultPipeline(resp.pipeline.is_default ?? false);
const loadedValues = {
@@ -316,7 +360,7 @@ const PipelineFormComponent = forwardRef<
return () => {
cancelled = true;
};
}, [form, isEditMode, pipelineId, loadAttempt]);
}, [form, isEditMode, pipelineId, loadAttempt, onLegacyPipeline]);
useEffect(() => {
if (
@@ -404,7 +448,15 @@ const PipelineFormComponent = forwardRef<
}
async function handleModify(values: FormValues): Promise<boolean> {
if (isSavingRef.current) return false;
// Imperative saves bypass native form submission validation. In particular,
// never save an old structured value while its visible JSON draft is invalid.
if (
pipelineFormElement.current &&
!pipelineFormElement.current.checkValidity()
)
return false;
if (isSavingRef.current || legacyConfigRef.current || !pipelineLoaded)
return false;
const submittedSnapshot = JSON.stringify(values);
const realConfig = {
ai: values.ai,
@@ -468,6 +520,7 @@ const PipelineFormComponent = forwardRef<
}
},
async save() {
if (legacyConfigRef.current || !pipelineLoaded) return false;
if (!hasUnsavedChangesRef.current) return true;
if (isSavingRef.current || !isEditMode) return false;
const valid = await form.trigger();
@@ -488,7 +541,9 @@ const PipelineFormComponent = forwardRef<
values: object,
) {
const stageKey = `${String(formName)}.${stageName}`;
const isFirstEmission = !initializedStagesRef.current.has(stageKey);
const isFirstEmission =
!initializedStagesRef.current.has(stageKey) &&
!(formName === 'output' && stageName === 'misc');
const currentValues =
(form.getValues(formName) as Record<string, unknown>) || {};
@@ -524,6 +579,29 @@ const PipelineFormComponent = forwardRef<
}
form.setValue(formName, nextValues);
if (formName === 'output' && stageName === 'misc') {
const removeThink = (values as Record<string, unknown>)['remove-think'];
const previousThink = (
currentValues.misc as Record<string, unknown> | undefined
)?.['remove-think'];
const runnerId = form.getValues('ai.runner.id') as string;
const supported = aiConfigTabSchema?.stages.some(
(stage) =>
stage.name === runnerId &&
stage.config.some((item) => item.name === 'remove-think'),
);
if (
supported &&
typeof removeThink === 'boolean' &&
removeThink !== previousThink
) {
const configs = form.getValues('ai.runner_config') || {};
form.setValue('ai.runner_config', {
...configs,
[runnerId]: { ...configs[runnerId], 'remove-think': removeThink },
});
}
}
if (isFirstEmission) {
initializedStagesRef.current.add(stageKey);
@@ -539,22 +617,23 @@ const PipelineFormComponent = forwardRef<
}
function handleRunnerConfigEmit(stageName: string, values: object) {
const stageKey = `ai.runner_config.${stageName}`;
const isFirstEmission = !initializedStagesRef.current.has(stageKey);
const currentRunnerConfigs =
(form.getValues('ai.runner_config') as Record<string, unknown>) || {};
(form.getValues('ai.runner_config') as Record<
string,
Record<string, unknown>
>) || {};
const removeThink = (values as Record<string, unknown>)['remove-think'];
const previousThink = currentRunnerConfigs[stageName]?.['remove-think'];
form.setValue('ai.runner_config', {
...currentRunnerConfigs,
[stageName]: values,
});
if (isFirstEmission) {
initializedStagesRef.current.add(stageKey);
const currentSnapshot = JSON.stringify(form.getValues());
if (savedSnapshotRef.current === '' || !hasUnsavedChangesRef.current) {
savedSnapshotRef.current = currentSnapshot;
}
if (typeof removeThink === 'boolean' && removeThink !== previousThink) {
const output = form.getValues('output') || {};
form.setValue('output', {
...output,
misc: { ...output.misc, 'remove-think': removeThink },
});
}
}
@@ -631,7 +710,7 @@ const PipelineFormComponent = forwardRef<
)}
</CardHeader>
<CardContent className="space-y-6">
<DynamicFormComponent
<PersistedRunnerForm
itemConfigList={stage.config}
initialValues={stageInitialValues}
systemContext={dynamicFormSystemContext}
@@ -645,6 +724,10 @@ const PipelineFormComponent = forwardRef<
}
}
const StageForm =
formName === 'output' && stage.name === 'misc'
? PersistedRunnerForm
: DynamicFormComponent;
const stageInitialValues: Record<string, any> =
(form.watch(formName) as Record<string, any>)?.[stage.name] || {};
@@ -659,7 +742,7 @@ const PipelineFormComponent = forwardRef<
)}
</CardHeader>
<CardContent className="space-y-6">
<DynamicFormComponent
<StageForm
itemConfigList={stage.config}
initialValues={stageInitialValues}
systemContext={dynamicFormSystemContext}
@@ -711,6 +794,12 @@ const PipelineFormComponent = forwardRef<
}
};
if (legacyConfig)
return (
<Alert>
<AlertDescription>{t('pipelineMigration.legacyGate')}</AlertDescription>
</Alert>
);
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
@@ -723,6 +812,7 @@ const PipelineFormComponent = forwardRef<
<Form {...form}>
<form
id="pipeline-form"
ref={pipelineFormElement}
onSubmit={form.handleSubmit(handleFormSubmit)}
className="h-full flex flex-col flex-1 min-h-0 mb-2"
>
@@ -920,8 +1010,11 @@ const PipelineFormComponent = forwardRef<
{/* Dynamic config sections (edit mode only) */}
{isEditMode && (
<>
{activeSection === 'ai' && aiConfigTabSchema && (
<div className="space-y-6">
{aiConfigTabSchema && (
<div
className="space-y-6"
hidden={activeSection !== 'ai'}
>
{aiConfigTabSchema.stages.map((stage) =>
renderDynamicForms(stage, 'ai'),
)}
@@ -0,0 +1,35 @@
type Values = Record<string, unknown>;
const isRecord = (value: unknown): value is Values =>
value !== null && typeof value === 'object' && !Array.isArray(value);
/** Apply only edits relative to the mounted form's normalized emission.
* The raw persisted object remains authoritative for unedited values, including
* fields not represented by the schema, nulls, omitted defaults and whitespace.
*/
export function preserveRunnerConfig(
current: Values,
previous: Values | undefined,
emitted: Values,
): Values {
if (!previous) return current;
const result = { ...current };
for (const key of new Set([
...Object.keys(previous),
...Object.keys(emitted),
])) {
if (JSON.stringify(previous[key]) === JSON.stringify(emitted[key]))
continue;
if (!Object.prototype.hasOwnProperty.call(emitted, key)) {
delete result[key];
} else if (isRecord(previous[key]) && isRecord(emitted[key])) {
result[key] = preserveRunnerConfig(
isRecord(current[key]) ? current[key] : {},
previous[key],
emitted[key],
);
} else {
result[key] = emitted[key];
}
}
return result;
}
+31 -6
View File
@@ -1,19 +1,44 @@
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useCurrentWorkspace } from '@/app/infra/http';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import PipelineDetailContent from './PipelineDetailContent';
import PipelineMigration, { migrationWorkspaceKey } from './PipelineMigration';
export default function PipelineConfigPage() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const detailId = searchParams.get('id');
if (detailId) {
return <PipelineDetailContent key={detailId} id={detailId} />;
}
const workspace = useCurrentWorkspace();
const { refreshPipelines } = useSidebarData();
const [revision, setRevision] = useState(0);
const scopeKey = migrationWorkspaceKey(workspace);
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
<p>{t('pipelines.selectFromSidebar')}</p>
<div className="flex h-full min-h-0 flex-col">
{workspace && workspace.permissions.includes('resource.view') && (
<PipelineMigration
key={scopeKey}
workspace={workspace}
onComplete={() => {
void refreshPipelines();
setRevision((current) => current + 1);
}}
/>
)}
<div className="flex-1 min-h-0">
{detailId ? (
<PipelineDetailContent
key={`${scopeKey}:${detailId}:${revision}`}
id={detailId}
/>
) : (
<div className="flex h-full items-center justify-center text-muted-foreground">
<p>{t('pipelines.selectFromSidebar')}</p>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,26 @@
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
/** Only unambiguous current containers may hydrate editor defaults. */
export function isCurrentPipelineConfig(config: unknown): boolean {
if (!record(config) || !record(config.ai) || !record(config.ai.runner))
return false;
const ai = config.ai;
const runner = ai.runner as Record<string, unknown>;
if (
Object.keys(ai).some((key) => !['runner', 'runner_config'].includes(key)) ||
Object.keys(runner).some((key) => !['id', 'expire-time'].includes(key)) ||
typeof runner.id !== 'string'
)
return false;
// The canonical current default has no selected runner yet. Do not confuse
// it with a legacy selector or an orphaned configuration needing review.
if (runner.id === '')
return (
record(ai.runner_config) && Object.keys(ai.runner_config).length === 0
);
return runner.id.startsWith('plugin:');
}
@@ -0,0 +1,35 @@
const issueKeys: Record<string, string> = {
'local.context_defaults': 'contextDefaults',
'local.model_reasoning': 'modelReasoning',
'local.serial_tools_preserved': 'serialTools',
'local.retrieval_defaults': 'retrievalDefaults',
'local.box_state_reset': 'boxReset',
'coze.persistent_history': 'persistentHistory',
'langflow.persistent_history': 'persistentHistory',
'langflow.tweaks_default': 'tweaksDefault',
'dify.timeout_default': 'timeoutDefault',
'n8n.session_ids_reset': 'historyReset',
'weknora.session_title_changed': 'sessionTitle',
'migration.alias_repaired': 'aliasRepaired',
'migration.null_default': 'nullDefault',
'migration.output_policy_copied': 'outputPolicy',
'migration.filtered_variables': 'filteredVariables',
'migration.history_reset': 'historyReset',
'migration.identity_preserved': 'identityPreserved',
'migration.new_defaults': 'newDefaults',
'migration.legacy_sections_archived': 'legacyArchive',
'external.state_validation_required': 'externalState',
'runtime.plugin_version_mismatch': 'pluginVersion',
'runtime.schema_invalid': 'schemaChanged',
'runtime.schema_missing': 'schemaChanged',
'extensions.runner_excluded': 'runnerExcluded',
'local.box_scope': 'boxScope',
'runtime.pending_interaction': 'pendingInteraction',
};
/** Only locally reviewed codes may select localized copy. */
export function migrationIssueKey(code: string): string | undefined {
return Object.prototype.hasOwnProperty.call(issueKeys, code)
? issueKeys[code]
: undefined;
}
@@ -0,0 +1,55 @@
export type PipelineMigrationState =
| 'ready'
| 'needs_plugin'
| 'blocked'
| 'already_current'
| 'not_legacy'
| 'activation_pending';
export interface PipelineMigrationIssue {
code: string;
field?: string | null;
}
export interface PipelineMigrationItem {
pipeline_uuid: string;
name: string;
state: PipelineMigrationState;
legacy_runner: string | null;
target_runner_id: string | null;
target_plugin: { author: string; name: string; version: string } | null;
changed_paths: string[];
blockers: PipelineMigrationIssue[];
warnings: PipelineMigrationIssue[];
preview_token: string | null;
}
export interface PipelineMigrationPreview {
planner_version: string;
workspace_uuid: string;
items: PipelineMigrationItem[];
total: number;
}
export interface PipelineMigrationRequest {
confirmed: true;
items: { pipeline_uuid: string; preview_token: string }[];
}
export interface PipelineMigrationResult {
pipeline_uuid: string;
state:
| 'pending'
| 'migrated'
| 'already_current'
| 'blocked'
| 'stale'
| 'failed'
| 'activation_pending';
code: string | null;
}
export interface PipelineMigrationTaskMetadata {
kind: 'pipeline_migration';
results: PipelineMigrationResult[];
}
+19 -2
View File
@@ -1,4 +1,8 @@
import { BaseHttpClient, type RequestConfig } from './BaseHttpClient';
import type {
PipelineMigrationPreview,
PipelineMigrationRequest,
} from '@/app/infra/entities/api/pipeline-migration';
import type { DebugExecutionEvent } from '@/app/infra/entities/api/agent-debug';
import type {
CodexAuthStatus,
@@ -441,6 +445,19 @@ export class BackendClient extends BaseHttpClient {
return this.get(`/api/v1/pipelines/${uuid}`);
}
public getPipelineMigrationPreview(
config?: RequestConfig,
): Promise<PipelineMigrationPreview> {
return this.get('/api/v1/pipelines/_/migration/preview', undefined, config);
}
public executePipelineMigration(
body: PipelineMigrationRequest,
config?: RequestConfig,
): Promise<AsyncTaskCreatedResp> {
return this.post('/api/v1/pipelines/_/migration/execute', body, config);
}
public createPipeline(pipeline: Pipeline): Promise<{
uuid: string;
}> {
@@ -1344,8 +1361,8 @@ export class BackendClient extends BaseHttpClient {
return this.get(`/api/v1/system/tasks${qs ? `?${qs}` : ''}`);
}
public getAsyncTask(id: number): Promise<AsyncTask> {
return this.get(`/api/v1/system/tasks/${id}`);
public getAsyncTask(id: number, config?: RequestConfig): Promise<AsyncTask> {
return this.get(`/api/v1/system/tasks/${id}`, undefined, config);
}
public getPluginSystemStatus(): Promise<ApiRespPluginSystemStatus> {
+4
View File
@@ -1,4 +1,7 @@
import pipelineMigration from './pipeline-migration/en-US';
const enUS = {
pipelineMigration,
sidebar: {
home: 'Home',
extensions: 'Extensions',
@@ -260,6 +263,7 @@ const enUS = {
reasoningAbility: 'Reasoning',
reasoningLevel: 'Reasoning level',
reasoningLevels: {
useModelSetting: 'Use model setting',
providerDefault: 'Provider default',
disabled: 'Off',
enabled: 'On',
+4
View File
@@ -1,4 +1,7 @@
import pipelineMigration from './pipeline-migration/es-ES';
const esES = {
pipelineMigration,
sidebar: {
home: 'Inicio',
extensions: 'Extensiones',
@@ -261,6 +264,7 @@ const esES = {
reasoningAbility: 'Razonamiento',
reasoningLevel: 'Nivel de razonamiento',
reasoningLevels: {
useModelSetting: 'Usar ajuste del modelo',
providerDefault: 'Predeterminado del proveedor',
disabled: 'Desactivado',
enabled: 'Activado',
+4
View File
@@ -1,4 +1,7 @@
import pipelineMigration from './pipeline-migration/ja-JP';
const jaJP = {
pipelineMigration,
sidebar: {
home: 'ホーム',
extensions: '拡張機能',
@@ -265,6 +268,7 @@ const jaJP = {
reasoningAbility: '推論',
reasoningLevel: '推論レベル',
reasoningLevels: {
useModelSetting: 'モデル設定を使用',
providerDefault: 'Provider デフォルト',
disabled: 'オフ',
enabled: 'オン',
@@ -0,0 +1,88 @@
export default {
activationRetryHint:
'After checking the runtime, refresh, select this pipeline and confirm to retry activation only. Its saved configuration will not be migrated again.',
notices: {
legacyArchive:
'The active configuration will contain only the selected runner. All original runner settings, including inactive ones, are kept in the migration backup.',
contextDefaults:
'History will use the new context budget and summarization defaults instead of a fixed round limit.',
modelReasoning:
'Per-model reasoning settings are preserved and applied by the host.',
serialTools: 'Tool calls remain sequential after migration.',
retrievalDefaults:
'Retrieval uses the new top-k and result limits. Review them after migration.',
boxReset:
'Existing Box session state is not transferred; a new isolated session will be created.',
persistentHistory:
'New conversations use persistent, isolated history. Existing remote history is not imported.',
tweaksDefault: 'Langflow tweaks default to an empty object.',
timeoutDefault: 'Dify uses a 30-second request timeout by default.',
historyReset:
'Existing conversation history and session identifiers are not transferred. New conversations start after migration.',
sessionTitle: 'New WeKnora sessions use titles generated by the plugin.',
aliasRepaired: 'The old field name is mapped to its supported replacement.',
nullDefault: 'This empty value uses the new plugin default.',
outputPolicy:
'The existing reasoning-output visibility setting is preserved.',
filteredVariables:
'Only supported variables are transferred; reserved variables are supplied by the conversation context.',
identityPreserved: 'The provider user identity keeps its legacy source.',
newDefaults:
'New plugin options use their documented defaults; existing supported values are retained.',
externalState:
'Complete or cancel pending remote interactions before migration; their live state is not transferred.',
pluginVersion:
'Install the required plugin version, then refresh. Older versions are not compatible with this migration.',
schemaChanged:
'The installed runner configuration does not match the migration target. Check the plugin version and refresh.',
runnerExcluded:
'This pipeline excludes the required runner plugin. Update its extension settings first.',
boxScope:
'A custom Box session template cannot be migrated safely. Remove it or review the isolation requirements first.',
pendingInteraction:
'A conversation is waiting for input. Complete or cancel it before migration.',
},
title: 'Pipeline migration',
description:
'Nothing changes until you select pipelines and explicitly confirm. Migration preserves pipeline identity and unrelated settings.',
detected: '{{count}} pipelines need migration review.',
review: 'Review migration',
readOnly: 'Only workspace managers can migrate pipelines.',
previewError: 'Preview unavailable. Refresh to check again.',
submitting: 'Submitting selected pipelines…',
running:
'Migration is running. Closing this dialog does not cancel the task.',
finished: 'Task finished. Check each pipeline result below.',
failed: 'Task failed. Check each pipeline result below.',
lost: 'Task observation lost. Its outcome is unknown; refresh the preview before any further action.',
requestError:
'Request not completed. Refresh the preview before selecting again.',
warningFallback: 'Review this setting before migration.',
blockerFallback:
'This setting or runtime condition cannot be migrated safely. Resolve it and refresh the preview.',
changedFields: 'Changed fields',
activationHint:
'Configuration was saved, but activation is pending. Ask a workspace administrator to check runtime availability, then refresh. Do not rerun migration blindly.',
pluginHint:
'Install or enable the required runner plugin through Extensions / Extension Market. Review your workspace extension quota there; this assistant never installs plugins. Return here and refresh the preview.',
extensions: 'Open Extensions',
results: 'Per-pipeline results',
selection: '{{count}} selected (maximum 50)',
confirm: 'I confirm migration of the selected pipelines.',
refresh: 'Refresh preview',
execute: 'Migrate selected',
legacyGate:
'Legacy configuration is read-only until migration. Save and debug are unavailable to prevent implicit conversion.',
states: {
ready: 'Ready',
needs_plugin: 'Plugin required',
blocked: 'Blocked',
already_current: 'Already current',
not_legacy: 'Not legacy',
activation_pending: 'Activation pending',
pending: 'Pending',
migrated: 'Migrated',
stale: 'Stale preview',
failed: 'Failed',
},
};
@@ -0,0 +1,93 @@
export default {
activationRetryHint:
'Tras comprobar el entorno, actualiza, selecciona esta canalización y confirma para reintentar solo la activación. No se volverá a migrar la configuración guardada.',
notices: {
legacyArchive:
'La configuración activa contendrá solo el Runner seleccionado. Todos los ajustes anteriores, incluidos los inactivos, se conservan en la copia de seguridad de migración.',
contextDefaults:
'El historial usará el nuevo presupuesto de contexto y los valores de resumen, en lugar de un límite fijo de turnos.',
modelReasoning:
'Se conservan los ajustes de razonamiento por modelo y el host los aplica.',
serialTools: 'Las herramientas seguirán ejecutándose de forma secuencial.',
retrievalDefaults:
'La recuperación usará los nuevos límites de top-k y longitud. Revísalos después de migrar.',
boxReset:
'No se transfiere el estado de Box; se creará una nueva sesión aislada.',
persistentHistory:
'Las nuevas conversaciones tendrán un historial persistente y aislado. No se importa el historial remoto anterior.',
tweaksDefault:
'Langflow tweaks usa un objeto vacío de forma predeterminada.',
timeoutDefault:
'Dify usa un tiempo de espera de 30 segundos de forma predeterminada.',
historyReset:
'No se transfieren el historial ni los identificadores de sesión. Después de migrar se iniciarán conversaciones nuevas.',
sessionTitle:
'Las nuevas sesiones de WeKnora usan títulos generados por el plugin.',
aliasRepaired:
'El nombre antiguo del campo se sustituye por el nombre admitido.',
nullDefault:
'Este valor vacío usará el valor predeterminado del nuevo plugin.',
outputPolicy:
'Se conserva la visibilidad actual del contenido de razonamiento.',
filteredVariables:
'Solo se transfieren variables admitidas; el contexto de la conversación proporciona las variables reservadas.',
identityPreserved:
'La identidad de usuario del proveedor conserva su origen anterior.',
newDefaults:
'Las opciones nuevas usan los valores predeterminados documentados; se conservan los valores anteriores admitidos.',
externalState:
'Completa o cancela las interacciones remotas pendientes antes de migrar; su estado activo no se transfiere.',
pluginVersion:
'Instala la versión requerida del plugin y actualiza. Las versiones anteriores no admiten esta migración.',
schemaChanged:
'La configuración del Runner instalado no coincide con el destino. Revisa la versión del plugin y actualiza.',
runnerExcluded:
'Esta canalización excluye el plugin Runner necesario. Ajusta primero la configuración de extensiones.',
boxScope:
'La plantilla personalizada de sesión de Box no se puede migrar de forma segura. Elimínala o revisa los requisitos de aislamiento.',
pendingInteraction:
'Hay una conversación esperando una respuesta. Complétala o cancélala antes de migrar.',
},
title: 'Migración de pipelines',
description:
'No se cambia nada hasta seleccionar pipelines y confirmar explícitamente. Se conservan su identidad y los ajustes no relacionados.',
detected: '{{count}} pipelines requieren revisión.',
review: 'Revisar migración',
readOnly: 'Solo los gestores del espacio pueden migrar pipelines.',
previewError: 'Vista previa no disponible. Actualícela.',
submitting: 'Enviando pipelines seleccionados…',
running: 'Migración en curso. Cerrar el diálogo no cancela la tarea.',
finished: 'Tarea finalizada. Revise el resultado de cada pipeline.',
failed: 'La tarea falló. Revise el resultado de cada pipeline.',
lost: 'Se perdió el seguimiento de la tarea; se desconoce el resultado. Actualice la vista previa antes de actuar.',
requestError:
'Solicitud no completada. Actualice la vista previa y vuelva a seleccionar.',
warningFallback: 'Revise este ajuste antes de migrar.',
blockerFallback:
'Este ajuste o estado de ejecución no se puede migrar de forma segura. Resuélvalo y actualice la vista previa.',
changedFields: 'Campos modificados',
activationHint:
'Configuración guardada, pero activación pendiente. Pida al administrador que compruebe el entorno y actualice. No repita la migración a ciegas.',
pluginHint:
'Instale o active el plugin requerido desde Extensiones / Mercado de extensiones y compruebe la cuota del espacio. Este asistente nunca instala plugins. Al volver, actualice la vista previa.',
extensions: 'Abrir Extensiones',
results: 'Resultados por pipeline',
selection: '{{count}} seleccionados (máximo 50)',
confirm: 'Confirmo la migración de los pipelines seleccionados.',
refresh: 'Actualizar vista previa',
execute: 'Migrar seleccionados',
legacyGate:
'La configuración antigua es de solo lectura hasta la migración. Guardar y depurar no están disponibles para evitar una conversión implícita.',
states: {
ready: 'Listo',
needs_plugin: 'Plugin requerido',
blocked: 'Bloqueado',
already_current: 'Ya actualizado',
not_legacy: 'No es antiguo',
activation_pending: 'Activación pendiente',
pending: 'Pendiente',
migrated: 'Migrado',
stale: 'Vista previa caducada',
failed: 'Fallido',
},
};
@@ -0,0 +1,87 @@
export default {
activationRetryHint:
'実行環境を確認して再読み込みし、このパイプラインを選択して確定すると、有効化のみを再試行します。保存済み設定は再移行しません。',
notices: {
legacyArchive:
'有効な設定には選択した Runner のみを残します。未使用のものを含むすべての旧 Runner 設定は移行バックアップに保存します。',
contextDefaults:
'履歴には固定の往復数制限ではなく、新しいコンテキスト予算と要約の既定値を適用します。',
modelReasoning: 'モデル別の推論設定を保持し、ホスト側で適用します。',
serialTools: '移行後もツール呼び出しは順番に実行します。',
retrievalDefaults:
'検索には新しい top-k と結果長の既定値を適用します。移行後に確認してください。',
boxReset:
'既存の Box セッション状態は引き継がず、新しい分離セッションを作成します。',
persistentHistory:
'新しい会話の履歴は分離して永続化します。既存のリモート履歴は取り込みません。',
tweaksDefault: 'Langflow tweaks の既定値は空のオブジェクトです。',
timeoutDefault: 'Dify のリクエストタイムアウトは既定で 30 秒です。',
historyReset:
'既存の会話履歴とセッション ID は引き継ぎません。移行後は新しい会話を開始します。',
sessionTitle:
'新しい WeKnora セッションにはプラグインが生成したタイトルを使用します。',
aliasRepaired: '旧フィールド名を現在サポートされている名前に置き換えます。',
nullDefault: 'この空の値には新しいプラグインの既定値を使用します。',
outputPolicy: '推論内容の表示設定は保持します。',
filteredVariables:
'対応する変数のみ移行します。予約変数は会話コンテキストから提供します。',
identityPreserved:
'サービス側のユーザー識別子には従来と同じ情報源を使用します。',
newDefaults:
'追加された設定には文書化された既定値を使い、対応する既存の値は保持します。',
externalState:
'移行前に待機中のリモート操作を完了またはキャンセルしてください。実行状態は引き継ぎません。',
pluginVersion:
'必要なバージョンのプラグインをインストールして再読み込みしてください。旧バージョンは移行に対応していません。',
schemaChanged:
'インストール済み Runner の設定形式が移行先と一致しません。プラグインのバージョンを確認してください。',
runnerExcluded:
'このパイプラインでは必要な Runner プラグインが除外されています。拡張機能の設定を変更してください。',
boxScope:
'独自の Box セッションテンプレートは安全に移行できません。削除するか、分離要件を確認してください。',
pendingInteraction:
'入力待ちの会話があります。完了またはキャンセルしてから移行してください。',
},
title: 'パイプライン移行',
description:
'パイプラインを選択し、明示的に確認するまで変更されません。識別情報と無関係な設定は保持されます。',
detected: '{{count}} 件のパイプラインに移行確認が必要です。',
review: '移行を確認',
readOnly: '移行できるのはワークスペースの管理権限を持つユーザーのみです。',
previewError: 'プレビューを取得できません。更新してください。',
submitting: '選択したパイプラインを送信中…',
running: '移行中です。ダイアログを閉じてもタスクは停止しません。',
finished: 'タスクが終了しました。各パイプラインの結果を確認してください。',
failed: 'タスクが失敗しました。各パイプラインの結果を確認してください。',
lost: 'タスクの追跡が失われ、結果は不明です。次の操作前にプレビューを更新してください。',
requestError:
'リクエストを完了できませんでした。プレビューを更新して再選択してください。',
warningFallback: '移行前にこの設定を確認してください。',
blockerFallback:
'この設定または実行状態は安全に移行できません。解決してプレビューを更新してください。',
changedFields: '変更フィールド',
activationHint:
'設定は保存されましたが、有効化待ちです。管理者に実行環境の確認を依頼してから更新してください。移行を無条件に再実行しないでください。',
pluginHint:
'「拡張機能 / 拡張機能マーケット」で必要なランナープラグインをインストールまたは有効化し、ワークスペースの上限を確認してください。このアシスタントはインストールしません。戻った後にプレビューを更新してください。',
extensions: '拡張機能を開く',
results: 'パイプラインごとの結果',
selection: '{{count}} 件選択(最大 50 件)',
confirm: '選択したパイプラインの移行を確認します。',
refresh: 'プレビューを更新',
execute: '選択項目を移行',
legacyGate:
'旧設定は移行まで読み取り専用です。暗黙の変換を防ぐため、保存とデバッグは使用できません。',
states: {
ready: '移行可能',
needs_plugin: 'プラグインが必要',
blocked: '移行不可',
already_current: '移行済みの形式',
not_legacy: '旧形式ではない',
activation_pending: '有効化待ち',
pending: '待機中',
migrated: '移行済み',
stale: 'プレビューが古い',
failed: '失敗',
},
};
@@ -0,0 +1,90 @@
export default {
activationRetryHint:
'Проверьте среду выполнения, обновите список, выберите конвейер и подтвердите повторную активацию. Сохранённая конфигурация не будет мигрировать повторно.',
notices: {
legacyArchive:
'В активной конфигурации останется только выбранный Runner. Все прежние настройки, включая неиспользуемые, сохраняются в резервной копии миграции.',
contextDefaults:
'Вместо фиксированного числа раундов история будет использовать новый бюджет контекста и настройки суммаризации.',
modelReasoning:
'Настройки рассуждений для каждой модели сохраняются и применяются хостом.',
serialTools: 'Вызовы инструментов останутся последовательными.',
retrievalDefaults:
'Для поиска применяются новые значения top-k и ограничения длины результатов. Проверьте их после миграции.',
boxReset:
'Состояние сеанса Box не переносится; будет создан новый изолированный сеанс.',
persistentHistory:
'Новые беседы используют постоянную изолированную историю. Прежняя удалённая история не импортируется.',
tweaksDefault:
'Для Langflow tweaks по умолчанию используется пустой объект.',
timeoutDefault: 'Тайм-аут запроса Dify по умолчанию составляет 30 секунд.',
historyReset:
'История и идентификаторы сеансов не переносятся. После миграции начнутся новые беседы.',
sessionTitle:
'Новые сеансы WeKnora используют заголовки, созданные плагином.',
aliasRepaired: 'Старое имя поля заменяется поддерживаемым именем.',
nullDefault:
'Для этого пустого значения применяется значение нового плагина по умолчанию.',
outputPolicy: 'Текущая настройка видимости рассуждений сохраняется.',
filteredVariables:
'Переносятся только поддерживаемые переменные; зарезервированные переменные задаются контекстом беседы.',
identityPreserved:
'Источник пользовательского идентификатора у провайдера остаётся прежним.',
newDefaults:
'Новые параметры используют документированные значения по умолчанию; поддерживаемые прежние значения сохраняются.',
externalState:
'До миграции завершите или отмените ожидающие удалённые взаимодействия: их текущее состояние не переносится.',
pluginVersion:
'Установите требуемую версию плагина и обновите список. Старые версии не поддерживают эту миграцию.',
schemaChanged:
'Схема установленного Runner не соответствует целевой. Проверьте версию плагина и обновите список.',
runnerExcluded:
'В этом конвейере исключён нужный плагин Runner. Сначала измените настройки расширений.',
boxScope:
'Пользовательский шаблон сеанса Box нельзя безопасно перенести. Удалите его или проверьте требования изоляции.',
pendingInteraction:
'Беседа ожидает ввода. Завершите или отмените её перед миграцией.',
},
title: 'Миграция конвейеров',
description:
'Изменения выполняются только после выбора конвейеров и явного подтверждения. Идентификаторы и прочие настройки сохраняются.',
detected: '{{count}} конвейеров требуют проверки миграции.',
review: 'Проверить миграцию',
readOnly:
'Миграция доступна только пользователям с правами управления пространством.',
previewError: 'Предпросмотр недоступен. Обновите его.',
submitting: 'Отправка выбранных конвейеров…',
running: 'Выполняется миграция. Закрытие окна не отменяет задачу.',
finished: 'Задача завершена. Проверьте результат каждого конвейера.',
failed: 'Задача завершилась ошибкой. Проверьте результат каждого конвейера.',
lost: 'Наблюдение за задачей потеряно; результат неизвестен. Обновите предпросмотр перед дальнейшими действиями.',
requestError: 'Запрос не завершён. Обновите предпросмотр и выберите заново.',
warningFallback: 'Проверьте эту настройку перед миграцией.',
blockerFallback:
'Эту настройку или состояние среды нельзя безопасно перенести. Устраните проблему и обновите предпросмотр.',
changedFields: 'Изменяемые поля',
activationHint:
'Настройки сохранены, но активация ожидается. Попросите администратора проверить среду и обновите предпросмотр. Не повторяйте миграцию вслепую.',
pluginHint:
'Установите или включите нужный плагин через «Расширения / Маркет» и проверьте квоту пространства. Этот помощник не устанавливает плагины. Вернувшись, обновите предпросмотр.',
extensions: 'Открыть расширения',
results: 'Результаты по конвейерам',
selection: 'Выбрано {{count}} (максимум 50)',
confirm: 'Подтверждаю миграцию выбранных конвейеров.',
refresh: 'Обновить предпросмотр',
execute: 'Перенести выбранные',
legacyGate:
'Старая конфигурация доступна только для чтения до миграции. Сохранение и отладка отключены во избежание неявного преобразования.',
states: {
ready: 'Готов',
needs_plugin: 'Нужен плагин',
blocked: 'Заблокирован',
already_current: 'Уже актуален',
not_legacy: 'Не устаревший',
activation_pending: 'Ожидает активации',
pending: 'Ожидает',
migrated: 'Перенесён',
stale: 'Предпросмотр устарел',
failed: 'Ошибка',
},
};
@@ -0,0 +1,83 @@
export default {
activationRetryHint:
'หลังตรวจสอบสภาพแวดล้อมการทำงาน ให้รีเฟรช เลือกไปป์ไลน์นี้และยืนยันเพื่อลองเปิดใช้งานอีกครั้งเท่านั้น การตั้งค่าที่บันทึกไว้จะไม่ถูกย้ายซ้ำ',
notices: {
legacyArchive:
'การตั้งค่าที่ใช้งานจะเหลือเฉพาะ Runner ที่เลือก การตั้งค่า Runner เดิมทั้งหมด รวมถึงส่วนที่ไม่ได้ใช้งาน จะถูกเก็บไว้ในข้อมูลสำรองการย้าย',
contextDefaults:
'ประวัติจะใช้งบประมาณบริบทและค่าการสรุปใหม่ แทนการจำกัดจำนวนรอบแบบตายตัว',
modelReasoning:
'การตั้งค่าการให้เหตุผลของแต่ละโมเดลจะถูกเก็บไว้และใช้โดยโฮสต์',
serialTools: 'หลังย้ายข้อมูล เครื่องมือยังคงทำงานตามลำดับ',
retrievalDefaults:
'การค้นคืนจะใช้ค่า top-k และขีดจำกัดความยาวผลลัพธ์ใหม่ โปรดตรวจสอบหลังย้ายข้อมูล',
boxReset: 'สถานะเซสชัน Box เดิมจะไม่ถูกย้าย ระบบจะสร้างเซสชันแยกใหม่',
persistentHistory:
'บทสนทนาใหม่จะมีประวัติถาวรที่แยกจากกัน โดยไม่นำเข้าประวัติระยะไกลเดิม',
tweaksDefault: 'Langflow tweaks จะใช้วัตถุว่างเป็นค่าเริ่มต้น',
timeoutDefault: 'Dify ใช้เวลารอคำขอเริ่มต้น 30 วินาที',
historyReset:
'ประวัติและรหัสเซสชันเดิมจะไม่ถูกย้าย ระบบจะเริ่มบทสนทนาใหม่หลังย้ายข้อมูล',
sessionTitle: 'เซสชัน WeKnora ใหม่จะใช้ชื่อที่ปลั๊กอินสร้าง',
aliasRepaired: 'ชื่อฟิลด์เดิมจะเปลี่ยนเป็นชื่อที่รองรับในปัจจุบัน',
nullDefault: 'ค่าว่างนี้จะใช้ค่าเริ่มต้นของปลั๊กอินใหม่',
outputPolicy: 'การตั้งค่าการแสดงเนื้อหาการให้เหตุผลเดิมจะถูกเก็บไว้',
filteredVariables:
'ย้ายเฉพาะตัวแปรที่รองรับ ส่วนตัวแปรสงวนจะมาจากบริบทการสนทนา',
identityPreserved: 'ข้อมูลระบุตัวผู้ใช้ของผู้ให้บริการยังใช้แหล่งเดิม',
newDefaults: 'ตัวเลือกใหม่ใช้ค่าเริ่มต้นตามเอกสาร และคงค่าเดิมที่รองรับไว้',
externalState:
'โปรดดำเนินการหรือยกเลิกการโต้ตอบระยะไกลที่ค้างอยู่ก่อนย้ายข้อมูล สถานะที่กำลังทำงานจะไม่ถูกย้าย',
pluginVersion:
'ติดตั้งปลั๊กอินเวอร์ชันที่กำหนดแล้วรีเฟรช เวอร์ชันเก่าไม่รองรับการย้ายข้อมูลนี้',
schemaChanged:
'รูปแบบการตั้งค่า Runner ที่ติดตั้งไม่ตรงกับเป้าหมาย โปรดตรวจสอบเวอร์ชันปลั๊กอินแล้วรีเฟรช',
runnerExcluded:
'ไปป์ไลน์นี้ยกเว้นปลั๊กอิน Runner ที่จำเป็น โปรดแก้ไขการตั้งค่าส่วนขยายก่อน',
boxScope:
'ไม่สามารถย้ายเทมเพลตเซสชัน Box แบบกำหนดเองได้อย่างปลอดภัย โปรดลบหรือทบทวนข้อกำหนดการแยกเซสชัน',
pendingInteraction:
'มีบทสนทนาที่รอข้อมูลอยู่ โปรดดำเนินการให้เสร็จหรือยกเลิกก่อนย้ายข้อมูล',
},
title: 'ย้ายไปป์ไลน์',
description:
'จะไม่มีการเปลี่ยนแปลงจนกว่าคุณจะเลือกไปป์ไลน์และยืนยันอย่างชัดเจน โดยคงตัวตนและการตั้งค่าอื่นไว้',
detected: 'มี {{count}} ไปป์ไลน์ที่ต้องตรวจสอบการย้าย',
review: 'ตรวจสอบการย้าย',
readOnly: 'เฉพาะผู้มีสิทธิ์จัดการพื้นที่ทำงานเท่านั้นที่ย้ายไปป์ไลน์ได้',
previewError: 'ไม่สามารถดูตัวอย่างได้ โปรดรีเฟรช',
submitting: 'กำลังส่งไปป์ไลน์ที่เลือก…',
running: 'กำลังย้าย การปิดหน้าต่างไม่ยกเลิกงาน',
finished: 'งานสิ้นสุดแล้ว โปรดตรวจสอบผลลัพธ์ของแต่ละไปป์ไลน์',
failed: 'งานล้มเหลว โปรดตรวจสอบผลลัพธ์ของแต่ละไปป์ไลน์',
lost: 'ไม่สามารถติดตามงานได้ ไม่ทราบผลลัพธ์ โปรดรีเฟรชตัวอย่างก่อนดำเนินการต่อ',
requestError: 'คำขอไม่เสร็จสมบูรณ์ โปรดรีเฟรชตัวอย่างและเลือกใหม่',
warningFallback: 'ตรวจสอบการตั้งค่านี้ก่อนย้าย',
blockerFallback:
'ไม่สามารถย้ายการตั้งค่าหรือสถานะรันนี้ได้อย่างปลอดภัย โปรดแก้ไขแล้วรีเฟรชตัวอย่าง',
changedFields: 'ฟิลด์ที่เปลี่ยน',
activationHint:
'บันทึกการตั้งค่าแล้วแต่รอเปิดใช้งาน โปรดให้ผู้ดูแลตรวจสอบสภาพแวดล้อมแล้วรีเฟรช อย่าย้ายซ้ำโดยไม่ตรวจสอบ',
pluginHint:
'ติดตั้งหรือเปิดใช้ปลั๊กอินรันเนอร์ผ่านส่วนขยาย / ตลาดส่วนขยาย และตรวจสอบโควตาพื้นที่ทำงานด้วยตนเอง ผู้ช่วยนี้ไม่ติดตั้งปลั๊กอิน เมื่อกลับมาโปรดรีเฟรชตัวอย่าง',
extensions: 'เปิดส่วนขยาย',
results: 'ผลลัพธ์แต่ละไปป์ไลน์',
selection: 'เลือก {{count}} รายการ (สูงสุด 50)',
confirm: 'ฉันยืนยันการย้ายไปป์ไลน์ที่เลือก',
refresh: 'รีเฟรชตัวอย่าง',
execute: 'ย้ายรายการที่เลือก',
legacyGate:
'การตั้งค่าเดิมอ่านได้อย่างเดียวจนกว่าจะย้าย ปิดการบันทึกและดีบักเพื่อป้องกันการแปลงโดยไม่ตั้งใจ',
states: {
ready: 'พร้อม',
needs_plugin: 'ต้องการปลั๊กอิน',
blocked: 'ถูกบล็อก',
already_current: 'เป็นรุ่นใหม่แล้ว',
not_legacy: 'ไม่ใช่รุ่นเก่า',
activation_pending: 'รอเปิดใช้งาน',
pending: 'รอดำเนินการ',
migrated: 'ย้ายแล้ว',
stale: 'ตัวอย่างล้าสมัย',
failed: 'ล้มเหลว',
},
};
@@ -0,0 +1,87 @@
export default {
activationRetryHint:
'Sau khi kiểm tra môi trường chạy, làm mới, chọn pipeline này và xác nhận để chỉ thử kích hoạt lại. Cấu hình đã lưu sẽ không được di chuyển lần nữa.',
notices: {
legacyArchive:
'Cấu hình hoạt động chỉ giữ Runner đã chọn. Tất cả thiết lập Runner cũ, kể cả các thiết lập không dùng, được giữ trong bản sao lưu di chuyển.',
contextDefaults:
'Lịch sử sẽ dùng ngân sách ngữ cảnh và thiết lập tóm tắt mới thay vì giới hạn số lượt cố định.',
modelReasoning:
'Thiết lập suy luận của từng mô hình được giữ lại và do máy chủ áp dụng.',
serialTools: 'Các công cụ vẫn được gọi lần lượt sau khi di chuyển.',
retrievalDefaults:
'Truy xuất dùng giới hạn top-k và độ dài kết quả mới. Hãy kiểm tra sau khi di chuyển.',
boxReset:
'Trạng thái phiên Box hiện tại không được chuyển; một phiên cách ly mới sẽ được tạo.',
persistentHistory:
'Hội thoại mới dùng lịch sử bền vững và riêng biệt. Lịch sử từ xa cũ không được nhập.',
tweaksDefault: 'Langflow tweaks mặc định là một đối tượng rỗng.',
timeoutDefault: 'Dify mặc định có thời gian chờ yêu cầu là 30 giây.',
historyReset:
'Lịch sử và mã phiên cũ không được chuyển. Hội thoại mới sẽ bắt đầu sau khi di chuyển.',
sessionTitle: 'Phiên WeKnora mới dùng tiêu đề do plugin tạo.',
aliasRepaired: 'Tên trường cũ được ánh xạ sang tên đang được hỗ trợ.',
nullDefault: 'Giá trị trống này sẽ dùng giá trị mặc định của plugin mới.',
outputPolicy: 'Thiết lập hiển thị nội dung suy luận hiện tại được giữ lại.',
filteredVariables:
'Chỉ chuyển các biến được hỗ trợ; biến dành riêng do ngữ cảnh hội thoại cung cấp.',
identityPreserved:
'Danh tính người dùng phía nhà cung cấp giữ nguyên nguồn cũ.',
newDefaults:
'Tùy chọn mới dùng giá trị mặc định đã mô tả; các giá trị cũ được hỗ trợ vẫn được giữ.',
externalState:
'Hoàn tất hoặc hủy các tương tác từ xa đang chờ trước khi di chuyển; trạng thái đang chạy không được chuyển.',
pluginVersion:
'Cài phiên bản plugin được yêu cầu rồi làm mới. Phiên bản cũ không hỗ trợ lần di chuyển này.',
schemaChanged:
'Cấu hình Runner đã cài không khớp với đích di chuyển. Kiểm tra phiên bản plugin rồi làm mới.',
runnerExcluded:
'Pipeline này loại trừ plugin Runner cần thiết. Hãy chỉnh thiết lập tiện ích trước.',
boxScope:
'Mẫu phiên Box tùy chỉnh không thể di chuyển an toàn. Hãy xóa mẫu hoặc kiểm tra yêu cầu cách ly.',
pendingInteraction:
'Có hội thoại đang chờ nhập liệu. Hãy hoàn tất hoặc hủy trước khi di chuyển.',
},
title: 'Di chuyển pipeline',
description:
'Chỉ thay đổi sau khi bạn chọn pipeline và xác nhận rõ ràng. Danh tính và các cài đặt không liên quan được giữ nguyên.',
detected: '{{count}} pipeline cần xem xét di chuyển.',
review: 'Xem xét di chuyển',
readOnly:
'Chỉ người có quyền quản lý không gian mới được di chuyển pipeline.',
previewError: 'Không lấy được bản xem trước. Hãy làm mới.',
submitting: 'Đang gửi các pipeline đã chọn…',
running: 'Đang di chuyển. Đóng hộp thoại không hủy tác vụ.',
finished: 'Tác vụ đã kết thúc. Hãy kiểm tra kết quả từng pipeline.',
failed: 'Tác vụ thất bại. Hãy kiểm tra kết quả từng pipeline.',
lost: 'Mất theo dõi tác vụ; chưa biết kết quả. Hãy làm mới bản xem trước trước khi thao tác tiếp.',
requestError: 'Yêu cầu chưa hoàn tất. Làm mới bản xem trước rồi chọn lại.',
warningFallback: 'Kiểm tra cài đặt này trước khi di chuyển.',
blockerFallback:
'Không thể di chuyển an toàn cài đặt hoặc trạng thái chạy này. Hãy xử lý rồi làm mới bản xem trước.',
changedFields: 'Trường thay đổi',
activationHint:
'Cấu hình đã lưu nhưng đang chờ kích hoạt. Nhờ quản trị viên kiểm tra môi trường rồi làm mới. Không chạy lại di chuyển một cách mù quáng.',
pluginHint:
'Cài hoặc bật plugin runner cần thiết qua Tiện ích / Chợ tiện ích và kiểm tra hạn mức không gian. Trợ lý này không cài plugin. Khi quay lại, hãy làm mới bản xem trước.',
extensions: 'Mở Tiện ích',
results: 'Kết quả từng pipeline',
selection: 'Đã chọn {{count}} (tối đa 50)',
confirm: 'Tôi xác nhận di chuyển các pipeline đã chọn.',
refresh: 'Làm mới bản xem trước',
execute: 'Di chuyển đã chọn',
legacyGate:
'Cấu hình cũ chỉ đọc cho đến khi di chuyển. Lưu và gỡ lỗi bị tắt để tránh chuyển đổi ngầm.',
states: {
ready: 'Sẵn sàng',
needs_plugin: 'Cần plugin',
blocked: 'Bị chặn',
already_current: 'Đã cập nhật',
not_legacy: 'Không phải bản cũ',
activation_pending: 'Chờ kích hoạt',
pending: 'Đang chờ',
migrated: 'Đã di chuyển',
stale: 'Bản xem trước đã cũ',
failed: 'Thất bại',
},
};
@@ -0,0 +1,72 @@
export default {
activationRetryHint:
'检查运行环境后刷新,选中此流水线并确认,即可仅重试激活,不会再次迁移已保存的配置。',
notices: {
legacyArchive:
'活动配置仅保留所选 Runner;全部旧 Runner 配置(包括未启用的配置)保存在迁移备份中。',
contextDefaults:
'历史上下文将采用新的容量预算与摘要默认值,不再按固定轮数截断。',
modelReasoning: '各模型的推理设置会保留,并由主程序应用。',
serialTools: '迁移后工具调用仍按顺序执行。',
retrievalDefaults: '检索采用新的 top-k 与结果长度默认值,请在迁移后检查。',
boxReset: '现有 Box 会话状态不迁移,后续将创建新的隔离会话。',
persistentHistory:
'新会话采用持久化、相互隔离的历史记录,不导入原有远端历史。',
tweaksDefault: 'Langflow tweaks 默认设为空对象。',
timeoutDefault: 'Dify 请求超时默认设为 30 秒。',
historyReset: '原有会话历史和会话标识不迁移,迁移后开始新会话。',
sessionTitle: '新的 WeKnora 会话使用插件生成的标题。',
aliasRepaired: '旧字段名称将映射为当前支持的名称。',
nullDefault: '此空值将使用新插件的默认值。',
outputPolicy: '现有推理内容显示设置会保留。',
filteredVariables: '仅迁移支持的变量;保留变量由会话上下文提供。',
identityPreserved: '服务端用户身份沿用旧配置的来源。',
newDefaults: '新增插件选项采用已说明的默认值,原有受支持配置会保留。',
externalState: '迁移前请完成或取消等待中的远端交互,其运行状态不会转移。',
pluginVersion: '请安装所需插件版本后刷新。旧版本不支持此次迁移。',
schemaChanged:
'已安装 Runner 的配置格式与迁移目标不一致,请检查插件版本后刷新。',
runnerExcluded: '此流水线排除了所需 Runner 插件,请先调整扩展设置。',
boxScope: '自定义 Box 会话模板无法安全迁移,请先移除或检查隔离要求。',
pendingInteraction: '有会话正在等待输入,请完成或取消后再迁移。',
},
title: '流水线迁移',
description:
'仅在选择流水线并明确确认后才会更改配置。迁移保留流水线身份及无关设置。',
detected: '{{count}} 条流水线需要检查迁移。',
review: '检查迁移',
readOnly: '只有工作空间管理者可以迁移流水线。',
previewError: '无法获取预览,请刷新重试。',
submitting: '正在提交所选流水线…',
running: '正在迁移。关闭对话框不会取消任务。',
finished: '任务已结束,请逐条检查下方结果。',
failed: '任务失败,请逐条检查下方结果。',
lost: '已失去任务状态连接,结果未知。执行其他操作前请刷新预览。',
requestError: '请求未完成。请刷新预览后重新选择。',
warningFallback: '迁移前请检查此设置。',
blockerFallback: '此设置或运行状态无法安全迁移。请处理后刷新预览。',
changedFields: '变更字段',
activationHint:
'配置已保存,但尚未激活。请工作空间管理员检查运行环境后刷新,不要盲目重复迁移。',
pluginHint:
'请通过「扩展 / 扩展市场」安装或启用所需的运行器插件,并自行检查工作空间扩展配额。本助手不会安装插件。返回此处后请刷新预览。',
extensions: '打开扩展',
results: '逐条迁移结果',
selection: '已选择 {{count}} 条(最多 50 条)',
confirm: '我确认迁移所选流水线。',
refresh: '刷新预览',
execute: '迁移所选流水线',
legacyGate: '旧版配置在迁移前保持只读。为避免隐式转换,已禁用保存和调试。',
states: {
ready: '可迁移',
needs_plugin: '需要插件',
blocked: '已阻止',
already_current: '已是新版',
not_legacy: '非旧版',
activation_pending: '等待激活',
pending: '等待中',
migrated: '已迁移',
stale: '预览已过期',
failed: '失败',
},
};
@@ -0,0 +1,72 @@
export default {
activationRetryHint:
'檢查執行環境後重新整理,選取此流水線並確認,即可只重試啟用,不會再次遷移已儲存的設定。',
notices: {
legacyArchive:
'作用中的設定僅保留所選 Runner;全部舊 Runner 設定(包括未啟用的設定)保存在遷移備份中。',
contextDefaults:
'歷史上下文將採用新的容量預算與摘要預設值,不再按固定輪數截斷。',
modelReasoning: '各模型的推理設定會保留,並由主程式套用。',
serialTools: '遷移後工具呼叫仍依序執行。',
retrievalDefaults: '檢索採用新的 top-k 與結果長度預設值,請在遷移後檢查。',
boxReset: '現有 Box 工作階段狀態不遷移,後續將建立新的隔離工作階段。',
persistentHistory:
'新對話採用持久化、相互隔離的歷史記錄,不匯入原有遠端歷史。',
tweaksDefault: 'Langflow tweaks 預設為空物件。',
timeoutDefault: 'Dify 請求逾時預設為 30 秒。',
historyReset: '原有對話歷史與工作階段識別碼不遷移,遷移後開始新對話。',
sessionTitle: '新的 WeKnora 對話使用外掛產生的標題。',
aliasRepaired: '舊欄位名稱將對應至目前支援的名稱。',
nullDefault: '此空值將使用新外掛的預設值。',
outputPolicy: '現有推理內容顯示設定會保留。',
filteredVariables: '僅遷移支援的變數;保留變數由對話上下文提供。',
identityPreserved: '服務端使用者身分沿用舊設定的來源。',
newDefaults: '新增外掛選項採用已說明的預設值,原有受支援設定會保留。',
externalState: '遷移前請完成或取消等待中的遠端互動,其執行狀態不會轉移。',
pluginVersion: '請安裝所需外掛版本後重新整理。舊版本不支援此次遷移。',
schemaChanged:
'已安裝 Runner 的設定格式與遷移目標不一致,請檢查外掛版本後重新整理。',
runnerExcluded: '此流水線排除了所需 Runner 外掛,請先調整擴充設定。',
boxScope: '自訂 Box 工作階段範本無法安全遷移,請先移除或檢查隔離要求。',
pendingInteraction: '有對話正在等待輸入,請完成或取消後再遷移。',
},
title: '流水線遷移',
description:
'僅在選擇流水線並明確確認後才會變更設定。遷移保留流水線身分及無關設定。',
detected: '{{count}} 條流水線需要檢查遷移。',
review: '檢查遷移',
readOnly: '只有工作空間管理者可以遷移流水線。',
previewError: '無法取得預覽,請重新整理。',
submitting: '正在提交所選流水線…',
running: '正在遷移。關閉對話框不會取消任務。',
finished: '任務已結束,請逐條檢查下方結果。',
failed: '任務失敗,請逐條檢查下方結果。',
lost: '已失去任務狀態連線,結果未知。執行其他操作前請重新整理預覽。',
requestError: '請求未完成。請重新整理預覽後重新選擇。',
warningFallback: '遷移前請檢查此設定。',
blockerFallback: '此設定或執行狀態無法安全遷移。請處理後重新整理預覽。',
changedFields: '變更欄位',
activationHint:
'設定已儲存,但尚未啟用。請工作空間管理員檢查執行環境後重新整理,不要盲目重複遷移。',
pluginHint:
'請透過「擴充功能 / 擴充功能市場」安裝或啟用所需執行器外掛,並自行檢查工作空間配額。本助手不會安裝外掛。返回後請重新整理預覽。',
extensions: '開啟擴充功能',
results: '逐條遷移結果',
selection: '已選擇 {{count}} 條(最多 50 條)',
confirm: '我確認遷移所選流水線。',
refresh: '重新整理預覽',
execute: '遷移所選流水線',
legacyGate: '舊版設定在遷移前保持唯讀。為避免隱式轉換,已停用儲存和除錯。',
states: {
ready: '可遷移',
needs_plugin: '需要外掛',
blocked: '已阻止',
already_current: '已是新版',
not_legacy: '非舊版',
activation_pending: '等待啟用',
pending: '等待中',
migrated: '已遷移',
stale: '預覽已過期',
failed: '失敗',
},
};
+4
View File
@@ -1,4 +1,7 @@
import pipelineMigration from './pipeline-migration/ru-RU';
const ruRU = {
pipelineMigration,
sidebar: {
home: 'Главная',
extensions: 'Расширения',
@@ -257,6 +260,7 @@ const ruRU = {
reasoningAbility: 'Рассуждение',
reasoningLevel: 'Уровень рассуждений',
reasoningLevels: {
useModelSetting: 'Настройка модели',
providerDefault: 'По умолчанию провайдера',
disabled: 'Выключено',
enabled: 'Включено',
+4
View File
@@ -1,4 +1,7 @@
import pipelineMigration from './pipeline-migration/th-TH';
const thTH = {
pipelineMigration,
sidebar: {
home: 'หน้าแรก',
extensions: 'ส่วนขยาย',
@@ -252,6 +255,7 @@ const thTH = {
reasoningAbility: 'ความสามารถในการให้เหตุผล',
reasoningLevel: 'ระดับการให้เหตุผล',
reasoningLevels: {
useModelSetting: 'ใช้การตั้งค่าโมเดล',
providerDefault: 'ค่าเริ่มต้นของผู้ให้บริการ',
disabled: 'ปิด',
enabled: 'เปิด',
+4
View File
@@ -1,4 +1,7 @@
import pipelineMigration from './pipeline-migration/vi-VN';
const viVN = {
pipelineMigration,
sidebar: {
home: 'Trang chủ',
extensions: 'Tiện ích mở rộng',
@@ -256,6 +259,7 @@ const viVN = {
reasoningAbility: 'Khả năng suy luận',
reasoningLevel: 'Mức độ suy luận',
reasoningLevels: {
useModelSetting: 'Dùng cài đặt mô hình',
providerDefault: 'Mặc định của nhà cung cấp',
disabled: 'Tắt',
enabled: 'Bật',
+4
View File
@@ -1,4 +1,7 @@
import pipelineMigration from './pipeline-migration/zh-Hans';
const zhHans = {
pipelineMigration,
sidebar: {
home: '首页',
extensions: '扩展',
@@ -250,6 +253,7 @@ const zhHans = {
reasoningAbility: '思考能力',
reasoningLevel: '思考档位',
reasoningLevels: {
useModelSetting: '使用模型设置',
providerDefault: 'Provider 默认',
disabled: '关闭',
enabled: '开启',
+4
View File
@@ -1,4 +1,7 @@
import pipelineMigration from './pipeline-migration/zh-Hant';
const zhHant = {
pipelineMigration,
sidebar: {
home: '首頁',
extensions: '擴展',
@@ -244,6 +247,7 @@ const zhHant = {
reasoningAbility: '思考能力',
reasoningLevel: '思考等級',
reasoningLevels: {
useModelSetting: '使用模型設定',
providerDefault: '供應商預設',
disabled: '關閉',
enabled: '開啟',
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,287 @@
import { test, expect, type Page } from '@playwright/test';
import { readFileSync } from 'node:fs';
const contract = JSON.parse(
readFileSync('tests/e2e/fixtures/runner-migration-contract.json', 'utf8'),
);
import { installLangBotApiMocks } from './fixtures/langbot-api';
const canonical = JSON.parse(
readFileSync('../src/langbot/templates/default-pipeline-config.json', 'utf8'),
);
const outputSchema = contract.output_schema;
async function setup(
page: Page,
plugin = 'weknora-agent',
name = 'WeKnoraAgent',
agentId: null | 'absent' = null,
converted?: typeof canonical,
) {
const schema = contract.plugins[plugin].component;
const id = `plugin:langbot-team/${name}/default`;
const raw: Record<string, unknown> = {
'api-key': ' fixture credential ',
'base-url': ' http://localhost:8080/api/v1 ',
'app-type': 'agent',
'enable-all-tools': false,
tools: ['tool-allowed'],
'knowledge-bases': ['kb-allowed'],
'mcp-resources': [{ server: 'private', uri: 'secret' }],
'mcp-resource-agent-read-enabled': false,
};
if (agentId === null) raw['agent-id'] = null;
if (plugin === 'LocalAgent') {
raw['advanced-settings'] = true;
raw.model = {
primary: 'llm-valid',
fallbacks: ['llm-fallback'],
reasoning: { 'llm-valid': 'high', 'llm-fallback': 'low' },
};
raw['remove-think'] = true;
}
let config = {
...structuredClone(canonical),
ai: { runner: { id }, runner_config: { [id]: raw } },
};
if (converted) config = structuredClone(converted);
else config.output.misc['remove-think'] = false; // Deliberate disagreement must not synchronize on mount.
const writes: any[] = [];
await installLangBotApiMocks(page, { authenticated: true });
await page.routeWebSocket('**/api/v1/pipelines/**/ws/connect**', (ws) => {
ws.onMessage((raw) => {
if (JSON.parse(String(raw)).type === 'authenticate')
ws.send(
JSON.stringify({
type: 'connected',
connection_id: 'fixture',
session_type: 'person',
}),
);
});
});
await page.route('**/api/v1/pipelines/preserve', async (route) => {
if (route.request().method() !== 'GET') {
const body = route.request().postDataJSON();
writes.push(body);
if (body.config) config = body.config;
}
await route.fulfill({
json: {
code: 0,
data: {
pipeline: {
uuid: 'preserve',
name: 'Preservation',
description: '',
emoji: '⚙️',
config,
},
},
},
});
});
await page.route('**/api/v1/pipelines/_/metadata', (route) =>
route.fulfill({
json: {
code: 0,
data: {
configs: [
{
name: 'ai',
label: { en_US: 'AI' },
stages: [
{
name: 'runner',
label: { en_US: 'Runner' },
config: contract.ai_schema.stages[0].config.map(
(field: Record<string, unknown>) =>
field.name === 'id'
? {
...field,
default: id,
options: [
{ name: id, label: schema.metadata.label },
],
}
: field,
),
},
{
name: id,
label: schema.metadata.label,
config: schema.spec.config,
},
],
},
outputSchema,
],
},
},
}),
);
await page.route('**/api/v1/provider/models/llm', (route) =>
route.fulfill({
json: {
code: 0,
data: {
models: ['llm-valid', 'llm-fallback'].map((uuid) => ({
uuid,
name: uuid,
provider_uuid: 'provider-valid',
provider: { uuid: 'provider-valid', name: 'Mock Provider' },
reasoning: { level: 'high' },
reasoning_capabilities: {
levels: ['provider_default', 'low', 'high'],
},
})),
},
},
}),
);
await page.goto('/home/pipelines?id=preserve');
await page.getByRole('tab', { name: 'AI', exact: true }).click();
return { writes, raw, id };
}
test('reasoning edit distinguishes provider default from model inheritance and preserves fallbacks', async ({
page,
}) => {
const { writes, raw, id } = await setup(page, 'LocalAgent', 'LocalAgent');
await page
.getByRole('button', { name: 'Reasoning level: High', exact: true })
.click();
await page.getByRole('slider').press('Home');
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect.poll(() => writes.length).toBe(1);
expect(writes[0].config.ai.runner_config[id]).toEqual({
...raw,
model: {
...(raw.model as object),
reasoning: { 'llm-valid': 'provider_default', 'llm-fallback': 'low' },
},
});
await page.reload();
await page.getByRole('tab', { name: 'AI', exact: true }).click();
await page
.getByRole('button', {
name: 'Reasoning level: Provider default',
exact: true,
})
.click();
await page
.getByRole('button', { name: 'Use model setting', exact: true })
.click();
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect.poll(() => writes.length).toBe(2);
expect(writes[1].config.ai.runner_config[id]).toEqual({
...raw,
model: { ...(raw.model as object), reasoning: { 'llm-fallback': 'low' } },
});
expect(writes[1].config.output).toEqual(writes[0].config.output);
await page.reload();
await page.getByRole('tab', { name: 'AI', exact: true }).click();
await expect(
page.getByRole('button', {
name: 'Reasoning level: Use model setting',
exact: true,
}),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Save', exact: true }),
).toBeDisabled();
// The reverse transition must not require moving through another level.
await page
.getByRole('button', {
name: 'Reasoning level: Use model setting',
exact: true,
})
.click();
await page
.getByRole('button', { name: 'Provider default', exact: true })
.click();
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect.poll(() => writes.length).toBe(3);
expect(writes[2].config.ai.runner_config[id]).toEqual(
writes[0].config.ai.runner_config[id],
);
// Clearing a fallback override must not clear the primary override.
await page
.getByRole('button', { name: 'Reasoning level: Low', exact: true })
.click();
await page
.getByRole('button', { name: 'Use model setting', exact: true })
.click();
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect.poll(() => writes.length).toBe(4);
expect(writes[3].config.ai.runner_config[id]).toEqual({
...raw,
model: {
...(raw.model as object),
reasoning: { 'llm-valid': 'provider_default' },
},
});
});
test('standalone picker keeps its original levels and provider default API', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/pipelines');
await page.evaluate(async () => {
// Vite serves the actual component, not a test replacement.
const resources = performance
.getEntriesByType('resource')
.map((entry) => entry.name);
const reactPath = resources.find((url) =>
new URL(url).pathname.endsWith('/react.js'),
)!;
const domPath = resources.find((url) =>
new URL(url).pathname.endsWith('/react-dom_client.js'),
)!;
const pickerPath =
'/src/app/home/components/reasoning/ReasoningLevelPicker.tsx';
const reactModule = await import(reactPath);
const React = reactModule.default || reactModule;
const domModule = await import(domPath);
const { createRoot } = domModule.default || domModule;
const { default: Picker } = await import(pickerPath);
const host = document.createElement('div');
const app = document.getElementById('root');
if (app) app.style.display = 'none';
document.body.appendChild(host);
function Harness() {
const [value, setValue] = React.useState('provider_default');
return React.createElement(Picker, {
value,
levels: ['provider_default', 'high'],
onChange: (next: string) => {
host.dataset.value = next;
setValue(next);
},
});
}
host.id = 'standalone-reasoning';
createRoot(host).render(React.createElement(Harness));
});
await page
.getByRole('button', {
name: 'Reasoning level: Provider default',
exact: true,
})
.click();
await expect(
page.getByRole('button', { name: 'Use model setting', exact: true }),
).toHaveCount(0);
await page.getByRole('slider').press('End');
await expect(page.locator('#standalone-reasoning')).toHaveAttribute(
'data-value',
'high',
);
await page.getByRole('slider').press('Home');
await expect(page.locator('#standalone-reasoning')).toHaveAttribute(
'data-value',
'provider_default',
);
});
@@ -0,0 +1,212 @@
import { test, expect, type Page } from '@playwright/test';
import { readFileSync } from 'node:fs';
const contract = JSON.parse(
readFileSync('tests/e2e/fixtures/runner-migration-contract.json', 'utf8'),
);
import { installLangBotApiMocks } from './fixtures/langbot-api';
const canonical = JSON.parse(
readFileSync('../src/langbot/templates/default-pipeline-config.json', 'utf8'),
);
const outputSchema = contract.output_schema;
const switchField = (page: Page, label: string) =>
page
.locator('label')
.filter({ hasText: label })
.locator('../..')
.getByRole('switch');
async function setup(
page: Page,
plugin = 'weknora-agent',
name = 'WeKnoraAgent',
agentId: null | 'absent' = null,
converted?: typeof canonical,
) {
const schema = contract.plugins[plugin].component;
const id = `plugin:langbot-team/${name}/default`;
const raw: Record<string, unknown> = {
'api-key': ' fixture credential ',
'base-url': ' http://localhost:8080/api/v1 ',
'app-type': 'agent',
'enable-all-tools': false,
tools: ['tool-allowed'],
'knowledge-bases': ['kb-allowed'],
'mcp-resources': [{ server: 'private', uri: 'secret' }],
'mcp-resource-agent-read-enabled': false,
};
if (agentId === null) raw['agent-id'] = null;
if (plugin === 'LocalAgent') {
raw['advanced-settings'] = true;
raw.model = {
primary: 'llm-valid',
fallbacks: [],
reasoning: { 'llm-valid': 'provider_default' },
};
raw['remove-think'] = true;
}
let config = {
...structuredClone(canonical),
ai: { runner: { id }, runner_config: { [id]: raw } },
};
if (converted) config = structuredClone(converted);
else config.output.misc['remove-think'] = false; // Deliberate disagreement must not synchronize on mount.
const writes: any[] = [];
await installLangBotApiMocks(page, { authenticated: true });
await page.routeWebSocket('**/api/v1/pipelines/**/ws/connect**', (ws) => {
ws.onMessage((raw) => {
if (JSON.parse(String(raw)).type === 'authenticate')
ws.send(
JSON.stringify({
type: 'connected',
connection_id: 'fixture',
session_type: 'person',
}),
);
});
});
await page.route('**/api/v1/pipelines/preserve', async (route) => {
if (route.request().method() !== 'GET') {
const body = route.request().postDataJSON();
writes.push(body);
if (body.config) config = body.config;
}
await route.fulfill({
json: {
code: 0,
data: {
pipeline: {
uuid: 'preserve',
name: 'Preservation',
description: '',
emoji: '⚙️',
config,
},
},
},
});
});
await page.route('**/api/v1/pipelines/_/metadata', (route) =>
route.fulfill({
json: {
code: 0,
data: {
configs: [
{
name: 'ai',
label: { en_US: 'AI' },
stages: [
{
name: 'runner',
label: { en_US: 'Runner' },
config: contract.ai_schema.stages[0].config.map(
(field: Record<string, unknown>) =>
field.name === 'id'
? {
...field,
default: id,
options: [
{ name: id, label: schema.metadata.label },
],
}
: field,
),
},
{
name: id,
label: schema.metadata.label,
config: schema.spec.config,
},
],
},
outputSchema,
],
},
},
}),
);
await page.goto('/home/pipelines?id=preserve');
await page.getByRole('tab', { name: 'AI', exact: true }).click();
return { writes, raw, id };
}
for (const candidate of contract.cases) {
test(`actual converter output stays editable and unchanged for ${candidate.legacy_runner}`, async ({
page,
}) => {
const name = candidate.plan.target_plugin.name;
const folder = Object.keys(contract.plugins).find(
(key) => contract.plugins[key].manifest.metadata.name === name,
)!;
const { writes } = await setup(
page,
folder,
name,
null,
candidate.plan.config,
);
await expect(
page.getByRole('button', { name: 'Save', exact: true }),
).toBeDisabled();
expect(writes).toHaveLength(0);
await page.getByRole('tab', { name: 'Output', exact: true }).click();
await switchField(page, 'Quote Origin Message').click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect.poll(() => writes.length).toBe(1);
expect(writes[0].config.ai).toEqual(candidate.plan.config.ai);
expect(writes[0].config.output.misc['quote-origin']).toBe(
!candidate.plan.config.output.misc['quote-origin'],
);
});
}
for (const agentId of [null, 'absent'] as const) {
test(`mounted real WeKnora schema preserves ${agentId} agent ID and hidden policy`, async ({
page,
}) => {
const { writes, raw, id } = await setup(
page,
'weknora-agent',
'WeKnoraAgent',
agentId,
);
await expect(page.getByText('Agent ID', { exact: true })).toBeVisible();
expect(writes).toEqual([]);
await expect(
page.getByRole('button', { name: 'Save', exact: true }),
).toBeDisabled();
await switchField(page, 'Enable Web Search').click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect.poll(() => writes.length).toBe(1);
expect(writes[0].config.ai.runner_config[id]).toEqual({
...raw,
'web-search-enabled': true,
});
await page.reload();
await page.getByRole('tab', { name: 'AI', exact: true }).click();
await expect(switchField(page, 'Enable Web Search')).toBeChecked();
await expect(
page.getByRole('button', { name: 'Save', exact: true }),
).toBeDisabled();
expect(writes).toHaveLength(1);
});
}
test('remove-think is synchronized only after intentional output or runner toggles', async ({
page,
}) => {
const { writes, id } = await setup(page, 'LocalAgent', 'LocalAgent');
const runnerThink = switchField(page, 'Remove');
await expect(runnerThink).toBeChecked();
expect(writes).toEqual([]);
await page.getByRole('tab', { name: 'Output', exact: true }).click();
const outputThink = switchField(page, 'Remove CoT');
await expect(outputThink).not.toBeChecked();
await outputThink.click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect.poll(() => writes.length).toBe(1);
expect(writes[0].config.output.misc['remove-think']).toBe(true);
expect(writes[0].config.ai.runner_config[id]['remove-think']).toBe(true);
await page.getByRole('tab', { name: 'AI', exact: true }).click();
await runnerThink.click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect.poll(() => writes.length).toBe(2);
expect(writes[1].config.output.misc['remove-think']).toBe(false);
expect(writes[1].config.ai.runner_config[id]['remove-think']).toBe(false);
});
@@ -0,0 +1,233 @@
import { test, expect, type Page } from '@playwright/test';
import { readFileSync } from 'node:fs';
const contract = JSON.parse(
readFileSync('tests/e2e/fixtures/runner-migration-contract.json', 'utf8'),
);
import { installLangBotApiMocks } from './fixtures/langbot-api';
const canonical = contract.cases[0].plan.config;
const outputSchema = contract.output_schema;
const switchField = (page: Page, label: string) =>
page
.locator('label')
.filter({ hasText: label })
.locator('../..')
.getByRole('switch');
async function setup(
page: Page,
plugin = 'weknora-agent',
name = 'WeKnoraAgent',
agentId: null | 'absent' = null,
converted?: typeof canonical,
) {
const schema = contract.plugins[plugin].component;
const id = `plugin:langbot-team/${name}/default`;
const raw: Record<string, unknown> = {
'api-key': ' fixture credential ',
'base-url': ' http://localhost:8080/api/v1 ',
'app-type': 'agent',
'enable-all-tools': false,
tools: ['tool-allowed'],
'knowledge-bases': ['kb-allowed'],
'mcp-resources': [{ server: 'private', uri: 'secret' }],
'mcp-resource-agent-read-enabled': false,
};
if (agentId === null) raw['agent-id'] = null;
if (plugin === 'LocalAgent') {
raw['advanced-settings'] = true;
raw.model = {
primary: 'llm-valid',
fallbacks: [],
reasoning: { 'llm-valid': 'provider_default' },
};
raw['remove-think'] = true;
}
let config = {
...structuredClone(canonical),
ai: { runner: { id }, runner_config: { [id]: raw } },
};
if (converted) config = structuredClone(converted);
else config.output.misc['remove-think'] = false; // Deliberate disagreement must not synchronize on mount.
const writes: any[] = [];
await installLangBotApiMocks(page, { authenticated: true });
await page.routeWebSocket('**/api/v1/pipelines/**/ws/connect**', (ws) => {
ws.onMessage((raw) => {
if (JSON.parse(String(raw)).type === 'authenticate')
ws.send(
JSON.stringify({
type: 'connected',
connection_id: 'fixture',
session_type: 'person',
}),
);
});
});
await page.route('**/api/v1/pipelines/preserve', async (route) => {
if (route.request().method() !== 'GET') {
const body = route.request().postDataJSON();
writes.push(body);
if (body.config) config = body.config;
}
await route.fulfill({
json: {
code: 0,
data: {
pipeline: {
uuid: 'preserve',
name: 'Preservation',
description: '',
emoji: '⚙️',
config,
},
},
},
});
});
await page.route('**/api/v1/pipelines/_/metadata', (route) =>
route.fulfill({
json: {
code: 0,
data: {
configs: [
{
name: 'ai',
label: { en_US: 'AI' },
stages: [
{
name: 'runner',
label: { en_US: 'Runner' },
config: contract.ai_schema.stages[0].config.map(
(field: Record<string, unknown>) =>
field.name === 'id'
? {
...field,
default: id,
options: [
{ name: id, label: schema.metadata.label },
],
}
: field,
),
},
{
name: id,
label: schema.metadata.label,
config: schema.spec.config,
},
],
},
outputSchema,
],
},
},
}),
);
await page.goto('/home/pipelines?id=preserve');
await page.getByRole('tab', { name: 'AI', exact: true }).click();
return { writes, raw, id };
}
for (const kind of ['json', 'prompt', 'prompt-omitted'] as const) {
test(`canonical ${kind} edits preserve types and metadata, reject invalid drafts`, async ({
page,
}) => {
const candidate = contract.cases.find(
(c: any) =>
c.legacy_runner === (kind === 'json' ? 'langflow-api' : 'local-agent'),
);
const config = structuredClone(candidate.plan.config);
const id = candidate.plan.target_runner_id;
const field = kind === 'json' ? 'tweaks' : 'prompt';
const initial =
kind === 'json'
? {
node: {
enabled: false,
count: 0,
missing: null,
list: [false, 0, null],
},
}
: kind === 'prompt-omitted'
? [
{
role: 'assistant',
tool_calls: [],
name: 'original',
provider_specific_fields: { cache: false, count: 0 },
},
]
: [
{
role: 'user',
content: [{ type: 'text', text: 'original' }],
name: 'alice',
metadata: { flag: false, count: 0 },
},
{
role: 'assistant',
content: null,
tool_calls: [
{
id: 'call1',
type: 'function',
function: { name: 'foo', arguments: '{}' },
},
],
},
{ role: 'tool', content: [], tool_call_id: 'call1' },
];
config.ai.runner_config[id][field] = initial;
config.ai.runner_config[id]['advanced-settings'] = true;
const { writes } = await setup(
page,
kind === 'json' ? 'langflow-agent' : 'LocalAgent',
candidate.plan.target_plugin.name,
null,
config,
);
const editor = page.getByTestId(`structured-editor-${field}`);
await expect(editor).toBeVisible();
expect(JSON.parse(await editor.inputValue())).toEqual(initial);
expect(writes).toHaveLength(0);
await expect(
page.getByRole('button', { name: 'Save', exact: true }),
).toBeDisabled();
const edited = structuredClone(initial) as any;
if (kind === 'json') edited.node.count = 7;
else if (kind === 'prompt-omitted') edited[0].name = 'edited';
else edited[0].content[0].text = 'edited';
await editor.fill(JSON.stringify(edited, null, 2));
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect.poll(() => writes.length).toBe(1);
expect(writes[0].config.ai.runner_config[id][field]).toEqual(edited);
if (kind === 'prompt-omitted')
expect(
Object.hasOwn(
writes[0].config.ai.runner_config[id][field][0],
'content',
),
).toBe(false);
await page.reload();
await page.getByRole('tab', { name: 'AI', exact: true }).click();
expect(JSON.parse(await editor.inputValue())).toEqual(edited);
await editor.fill('{ invalid');
await expect(
page.getByRole('alert').filter({ hasText: `${field} (JSON)` }),
).toBeVisible();
if (kind === 'json') await switchField(page, 'Advanced Settings').click();
// Make another valid edit so Save remains available: invalid JSON must
// block the entire save, not silently persist the previous value.
await page.getByRole('tab', { name: 'Output', exact: true }).click();
await switchField(page, 'Quote Origin Message').click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await page.waitForTimeout(400);
expect(writes).toHaveLength(1);
await page.getByRole('tab', { name: 'AI', exact: true }).click();
if (kind === 'json') await switchField(page, 'Advanced Settings').click();
await expect(editor).toHaveValue('{ invalid');
await editor.fill(JSON.stringify(edited));
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect.poll(() => writes.length).toBe(2);
expect(writes[1].config.ai.runner_config[id][field]).toEqual(edited);
});
}
+24 -11
View File
@@ -3,20 +3,33 @@ import fs from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
function loadCatalog(url) {
const compiled = ts.transpileModule(fs.readFileSync(url, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText;
const module = { exports: {} };
const require = (specifier) => {
assert.ok(specifier.startsWith('./'), 'Locale imports must be relative');
return loadCatalog(new URL(`${specifier}.ts`, url));
};
new Function('module', 'exports', 'require', compiled)(
module,
module.exports,
require,
);
return module.exports;
}
test('all locale catalogs cover Codex states and preserve the expiry placeholder', () => {
const directory = new URL('../../src/i18n/locales/', import.meta.url);
const files = fs
.readdirSync(directory)
.filter((file) => file.endsWith('.ts'));
assert.equal(files.length, 8);
let expected;
for (const file of fs.readdirSync(directory)) {
const compiled = ts.transpileModule(
fs.readFileSync(new URL(file, directory), 'utf8'),
{
compilerOptions: { module: ts.ModuleKind.CommonJS },
},
).outputText;
const module = { exports: {} };
new Function('module', 'exports', compiled)(module, module.exports);
const catalog = (module.exports.default || Object.values(module.exports)[0])
.models.codex;
for (const file of files) {
const exports = loadCatalog(new URL(file, directory));
const catalog = (exports.default || Object.values(exports)[0]).models.codex;
const keys = Object.keys(catalog).sort();
expected ??= keys;
assert.deepEqual(keys, expected, file);
@@ -66,6 +66,7 @@ test('normalizes only single-line text fields in a dynamic form save snapshot',
fallbacks: ['fallback-model'],
reasoning: {
'primary-model': 'high',
'fallback-model': 'provider_default',
},
},
});
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
import test from 'node:test';
import ts from 'typescript';
const source = readFileSync(
'src/app/home/pipelines/pipeline-config-safety.ts',
'utf8',
);
const context = { exports: {} };
vm.runInNewContext(
ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText,
context,
);
const { isCurrentPipelineConfig } = context.exports;
const plugin = 'plugin:langbot-team/LocalAgent/default';
test('canonical current empty runner remains editable', () => {
const config = JSON.parse(
readFileSync(
'../src/langbot/templates/default-pipeline-config.json',
'utf8',
),
);
assert.equal(isCurrentPipelineConfig(config), true);
});
test('configured current binding remains editable', () => {
assert.equal(
isCurrentPipelineConfig({
ai: { runner: { id: plugin }, runner_config: {} },
}),
true,
);
});
for (const [name, ai] of Object.entries({
legacy: { runner: { runner: 'local-agent' } },
mixedSelector: { runner: { id: plugin, runner: 'local-agent' } },
mixedLegacySection: {
runner: { id: plugin },
runner_config: {},
'local-agent': {},
},
emptyMixedLegacySection: {
runner: { id: '' },
runner_config: {},
'dify-service-api': {},
},
emptyMissingContainer: { runner: { id: '' } },
emptyMalformedContainer: { runner: { id: '' }, runner_config: [] },
emptyAmbiguousContainer: {
runner: { id: '' },
runner_config: { orphan: {} },
},
emptyAmbiguousSelector: {
runner: { id: '', legacy: 'local-agent' },
runner_config: {},
},
missingId: { runner: {}, runner_config: {} },
nonPlugin: { runner: { id: 'local-agent' }, runner_config: {} },
})) {
test(`${name} does not hydrate current editor defaults`, () => {
assert.equal(isCurrentPipelineConfig({ ai }), false);
});
}
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import test from 'node:test';
import vm from 'node:vm';
import ts from 'typescript';
function load(path) {
const context = { exports: {} };
vm.runInNewContext(
ts.transpileModule(readFileSync(path, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText,
context,
);
return context.exports;
}
const warnings = [
...new Set(
[
...readFileSync(
'../src/langbot/pkg/pipeline/legacy_config_migration.py',
'utf8',
).matchAll(/_warn\(result,\s*'([^']+)'/g),
].map((match) => match[1]),
),
];
test('every converter warning has a specific explanation in all eight locales', () => {
const { migrationIssueKey } = load(
'src/app/home/pipelines/pipeline-migration-issues.ts',
);
const base = 'src/i18n/locales/pipeline-migration';
const locales = readdirSync(base).filter((name) => name.endsWith('.ts'));
assert.equal(locales.length, 8);
assert.ok(
warnings.length > 10,
'warning discovery must not silently go empty',
);
for (const file of locales) {
const catalog = load(`${base}/${file}`).default;
for (const code of warnings) {
const key = migrationIssueKey(code);
assert.ok(key, `${file}: missing mapping for ${code}`);
assert.equal(typeof catalog.notices?.[key], 'string', `${file}: ${code}`);
assert.ok(catalog.notices[key].length > 10);
}
assert.ok(catalog.notices.pluginVersion);
assert.ok(catalog.notices.pendingInteraction);
}
});
test('unknown backend issue codes never become translation keys or displayed messages', () => {
const { migrationIssueKey } = load(
'src/app/home/pipelines/pipeline-migration-issues.ts',
);
for (const code of [
'unknown',
'__proto__',
'constructor',
'secret-token.example',
]) {
assert.equal(migrationIssueKey(code), undefined);
}
});
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
const source = fs.readFileSync(
'src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx',
'utf8',
);
// Exercise the actual field callback without mocking React's form lifecycle.
const callback = source.slice(
source.indexOf('const updateModelReasoning ='),
source.indexOf('const replaceModel ='),
);
function edit(reasoning, uuid, level) {
let result;
const code = ts.transpileModule(
callback + '\nupdateModelReasoning(uuid, level);',
{ compilerOptions: { target: ts.ScriptTarget.ES2020 } },
).outputText;
new Function('modelValue', 'updateValue', 'uuid', 'level', code)(
{ reasoning },
(value) => {
result = value.reasoning;
},
uuid,
level,
);
return result;
}
test('provider default is an explicit override, not inheritance', () => {
assert.deepEqual(edit({ a: 'high', b: 'low' }, 'a', 'provider_default'), {
a: 'provider_default',
b: 'low',
});
});
test('inherit deletes only the selected override without mutating input', () => {
const input = Object.freeze({ a: 'provider_default', b: 'high' });
const output = edit(input, 'a', undefined);
assert.equal(Object.hasOwn(output, 'a'), false);
assert.deepEqual(output, { b: 'high' });
assert.deepEqual(input, { a: 'provider_default', b: 'high' });
});
@@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
function load(relative) {
const source = fs.readFileSync(new URL(relative, import.meta.url), 'utf8');
const compiled = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
}).outputText;
const module = { exports: {} };
new Function('module', 'exports', compiled)(module, module.exports);
return module.exports;
}
const helper =
'../../src/app/home/pipelines/components/pipeline-form/RunnerConfigPreservation.ts';
test('mount emission is not a persisted edit', () => {
const { preserveRunnerConfig } = load(helper);
const raw = {
'api-key': ' secret ',
'agent-id': null,
'enable-all-tools': false,
};
assert.deepEqual(
preserveRunnerConfig(raw, undefined, {
'api-key': 'secret',
'agent-id': '',
mode: 'chat',
}),
raw,
);
});
test('unrelated edits preserve hidden Host policy, absent/null and credentials', () => {
const { preserveRunnerConfig } = load(helper);
const raw = {
'api-key': ' secret ',
'agent-id': null,
'enable-all-tools': false,
tools: ['one'],
'knowledge-bases': ['kb'],
'mcp-resources': [{ uri: 'private' }],
'mcp-resource-agent-read-enabled': false,
};
const first = {
'api-key': 'secret',
'agent-id': '',
mode: 'chat',
absent: 'default',
};
assert.deepEqual(
preserveRunnerConfig(raw, first, { ...first, mode: 'agent' }),
{ ...raw, mode: 'agent' },
);
});
test('intentional edits, removals and nested edits win without normalizing untouched siblings', () => {
const { preserveRunnerConfig } = load(helper);
const raw = {
'api-key': ' secret ',
model: {
primary: 'one',
fallbacks: ['two'],
reasoning: { one: 'provider_default', two: 'high' },
},
};
const first = { 'api-key': 'secret', model: raw.model };
assert.deepEqual(
preserveRunnerConfig(raw, first, {
'api-key': 'new',
model: {
primary: 'one',
fallbacks: [],
reasoning: { one: 'provider_default' },
},
}),
{
'api-key': 'new',
model: {
primary: 'one',
fallbacks: [],
reasoning: { one: 'provider_default' },
},
},
);
});
test('save normalization preserves explicit selected per-model provider_default', () => {
const { normalizeDynamicFormValuesForSave } = load(
'../../src/app/home/components/dynamic-form/DynamicFormSaveValues.ts',
);
const value = {
primary: 'one',
fallbacks: ['two'],
reasoning: { one: 'provider_default', two: 'high', removed: 'high' },
};
assert.deepEqual(
normalizeDynamicFormValuesForSave(
[{ name: 'model', type: 'model-fallback-selector' }],
{ model: value },
).model.reasoning,
{ one: 'provider_default', two: 'high' },
);
});
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
const file = 'src/app/home/components/dynamic-form/StructuredFieldValue.ts';
function load() {
const compiled = ts.transpileModule(fs.readFileSync(file, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText;
const m = { exports: {} };
new Function('module', 'exports', compiled)(m, m.exports);
return m.exports;
}
test('JSON parsing preserves nested false, zero, null and arrays as typed values', () => {
const { parseStructuredDraft } = load();
const value = { n: { a: false, b: 0, c: null, d: [false, 0, null] } };
assert.deepEqual(parseStructuredDraft(JSON.stringify(value), false), value);
});
test('prompt parsing preserves structured content, null, empty arrays, metadata and first role', () => {
const { parseStructuredDraft } = load();
const value = [
{ role: 'user', content: [{ type: 'text', text: 'x' }], name: 'alice' },
{ role: 'assistant', content: null, tool_calls: [] },
{ role: 'tool', content: [], tool_call_id: 'x' },
];
assert.deepEqual(parseStructuredDraft(JSON.stringify(value), true), value);
});
test('prompt metadata edits preserve omitted content without inserting null', () => {
const { parseStructuredDraft, isPromptValue } = load();
const value = [
{
role: 'assistant',
tool_calls: [],
name: 'edited',
provider_specific_fields: { cache: false },
},
];
assert.equal(isPromptValue(value), true);
const result = parseStructuredDraft(JSON.stringify(value), true);
assert.deepEqual(result, value);
assert.equal(Object.hasOwn(result[0], 'content'), false);
for (const content of [false, 0, {}, undefined])
assert.equal(isPromptValue([{ ...value[0], content }]), false);
});
test('invalid JSON and non-message prompt shapes reject rather than silently retain old values', () => {
const { parseStructuredDraft } = load();
for (const raw of ['{broken', '', 'undefined'])
assert.throws(() => parseStructuredDraft(raw, false));
for (const raw of [
'{}',
'null',
'[{"role":0,"content":"x"}]',
'[{"role":"user","content":false}]',
])
assert.throws(() => parseStructuredDraft(raw, true));
});
test('friendly prompt editor is restricted to losslessly represented roles and string content', () => {
const { isSimplePrompt } = load();
assert.equal(
isSimplePrompt([
{ role: 'system', content: '' },
{ role: 'user', content: 'x' },
]),
true,
);
for (const value of [
[{ role: 'user', content: 'x' }],
[{ role: 'system', content: null }],
[{ role: 'system', content: [] }],
[{ role: 'system', content: 'x', name: 'a' }],
[],
])
assert.equal(isSimplePrompt(value), false);
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'node:path';
export default defineConfig({
plugins: [react()],
cacheDir: '/tmp/411-reasoning-vite-cache',
resolve: { alias: { '@': path.resolve(__dirname, 'src') } },
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'node:path';
export default defineConfig({
plugins: [react()],
cacheDir: '/tmp/411-structured-vite-cache',
resolve: { alias: { '@': path.resolve(__dirname, 'src') } },
});