diff --git a/docs/pipeline-migration-config-map.zh-CN.md b/docs/pipeline-migration-config-map.zh-CN.md
new file mode 100644
index 000000000..6f3b4bbe5
--- /dev/null
+++ b/docs/pipeline-migration-config-map.zh-CN.md
@@ -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-*` 的实际日志和源码哈希为准。
diff --git a/src/langbot/pkg/agent/runner/context_builder.py b/src/langbot/pkg/agent/runner/context_builder.py
index 628d64f07..db8036a58 100644
--- a/src/langbot/pkg/agent/runner/context_builder.py
+++ b/src/langbot/pkg/agent/runner/context_builder.py
@@ -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,
diff --git a/src/langbot/pkg/agent/runner/host_models.py b/src/langbot/pkg/agent/runner/host_models.py
index dc50a4d48..2fb855e29 100644
--- a/src/langbot/pkg/agent/runner/host_models.py
+++ b/src/langbot/pkg/agent/runner/host_models.py
@@ -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):
diff --git a/src/langbot/pkg/agent/runner/interaction_manager.py b/src/langbot/pkg/agent/runner/interaction_manager.py
index 85f19a3e3..99efb0fad 100644
--- a/src/langbot/pkg/agent/runner/interaction_manager.py
+++ b/src/langbot/pkg/agent/runner/interaction_manager.py
@@ -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."""
diff --git a/src/langbot/pkg/agent/runner/interaction_store.py b/src/langbot/pkg/agent/runner/interaction_store.py
index 15613376d..ccd8178b9 100644
--- a/src/langbot/pkg/agent/runner/interaction_store.py
+++ b/src/langbot/pkg/agent/runner/interaction_store.py
@@ -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()
diff --git a/src/langbot/pkg/agent/runner/model_reasoning.py b/src/langbot/pkg/agent/runner/model_reasoning.py
new file mode 100644
index 000000000..1e4e83ee1
--- /dev/null
+++ b/src/langbot/pkg/agent/runner/model_reasoning.py
@@ -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
diff --git a/src/langbot/pkg/agent/runner/orchestrator.py b/src/langbot/pkg/agent/runner/orchestrator.py
index 76348c25d..7e37b4afa 100644
--- a/src/langbot/pkg/agent/runner/orchestrator.py
+++ b/src/langbot/pkg/agent/runner/orchestrator.py
@@ -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.
diff --git a/src/langbot/pkg/agent/runner/query_entry_adapter.py b/src/langbot/pkg/agent/runner/query_entry_adapter.py
index 21d2e1f87..7999f9efd 100644
--- a/src/langbot/pkg/agent/runner/query_entry_adapter.py
+++ b/src/langbot/pkg/agent/runner/query_entry_adapter.py
@@ -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
diff --git a/src/langbot/pkg/agent/runner/session_registry.py b/src/langbot/pkg/agent/runner/session_registry.py
index d7e2910d8..fc12b9b40 100644
--- a/src/langbot/pkg/agent/runner/session_registry.py
+++ b/src/langbot/pkg/agent/runner/session_registry.py
@@ -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 = {
diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/migration.py b/src/langbot/pkg/api/http/controller/groups/pipelines/migration.py
new file mode 100644
index 000000000..238a1fa89
--- /dev/null
+++ b/src/langbot/pkg/api/http/controller/groups/pipelines/migration.py
@@ -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)
diff --git a/src/langbot/pkg/api/http/service/pipeline.py b/src/langbot/pkg/api/http/service/pipeline.py
index f0042cfe3..1935e3a1f 100644
--- a/src/langbot/pkg/api/http/service/pipeline.py
+++ b/src/langbot/pkg/api/http/service/pipeline.py
@@ -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 {},
diff --git a/src/langbot/pkg/api/http/service/pipeline_migration.py b/src/langbot/pkg/api/http/service/pipeline_migration.py
new file mode 100644
index 000000000..f713ab52a
--- /dev/null
+++ b/src/langbot/pkg/api/http/service/pipeline_migration.py
@@ -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
diff --git a/src/langbot/pkg/entity/persistence/pipeline_migration.py b/src/langbot/pkg/entity/persistence/pipeline_migration.py
new file mode 100644
index 000000000..44d42ff4d
--- /dev/null
+++ b/src/langbot/pkg/entity/persistence/pipeline_migration.py
@@ -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'),
+ )
diff --git a/src/langbot/pkg/persistence/alembic/versions/0027_pipeline_migration_snapshots.py b/src/langbot/pkg/persistence/alembic/versions/0027_pipeline_migration_snapshots.py
new file mode 100644
index 000000000..e589b2828
--- /dev/null
+++ b/src/langbot/pkg/persistence/alembic/versions/0027_pipeline_migration_snapshots.py
@@ -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')
diff --git a/src/langbot/pkg/persistence/mgr.py b/src/langbot/pkg/persistence/mgr.py
index 87b9f1635..7f491bea5 100644
--- a/src/langbot/pkg/persistence/mgr.py
+++ b/src/langbot/pkg/persistence/mgr.py
@@ -71,6 +71,7 @@ _ALEMBIC_TENANT_TABLES = {
'rerank_models',
'legacy_pipelines',
'pipeline_run_records',
+ 'pipeline_migration_snapshots',
'plugin_settings',
'knowledge_bases',
'knowledge_base_files',
diff --git a/src/langbot/pkg/persistence/pipeline_admission.py b/src/langbot/pkg/persistence/pipeline_admission.py
new file mode 100644
index 000000000..3f7a1093c
--- /dev/null
+++ b/src/langbot/pkg/persistence/pipeline_admission.py
@@ -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()
diff --git a/src/langbot/pkg/persistence/tenant_uow.py b/src/langbot/pkg/persistence/tenant_uow.py
index 47a705c45..6d954f82f 100644
--- a/src/langbot/pkg/persistence/tenant_uow.py
+++ b/src/langbot/pkg/persistence/tenant_uow.py
@@ -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()
diff --git a/src/langbot/pkg/pipeline/legacy_config_migration.py b/src/langbot/pkg/pipeline/legacy_config_migration.py
new file mode 100644
index 000000000..da9f32a8f
--- /dev/null
+++ b/src/langbot/pkg/pipeline/legacy_config_migration.py
@@ -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
diff --git a/src/langbot/pkg/pipeline/pipelinemgr.py b/src/langbot/pkg/pipeline/pipelinemgr.py
index b069eea7e..45287a34c 100644
--- a/src/langbot/pkg/pipeline/pipelinemgr.py
+++ b/src/langbot/pkg/pipeline/pipelinemgr.py
@@ -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)
diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py
index eee532746..03ec789fd 100644
--- a/src/langbot/pkg/plugin/handler.py
+++ b/src/langbot/pkg/plugin/handler.py
@@ -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
diff --git a/src/langbot/pkg/provider/modelmgr/requesters/codex.py b/src/langbot/pkg/provider/modelmgr/requesters/codex.py
index 0d19928b8..5b793ef75 100644
--- a/src/langbot/pkg/provider/modelmgr/requesters/codex.py
+++ b/src/langbot/pkg/provider/modelmgr/requesters/codex.py
@@ -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
diff --git a/tests/fixtures/pipeline_migration/synthetic_legacy.json b/tests/fixtures/pipeline_migration/synthetic_legacy.json
new file mode 100644
index 000000000..ea3a1b9e8
--- /dev/null
+++ b/tests/fixtures/pipeline_migration/synthetic_legacy.json
@@ -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}
+}
diff --git a/tests/integration/persistence/test_pipeline_admission_postgres.py b/tests/integration/persistence/test_pipeline_admission_postgres.py
new file mode 100644
index 000000000..6c95eae63
--- /dev/null
+++ b/tests/integration/persistence/test_pipeline_admission_postgres.py
@@ -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
diff --git a/tests/unit_tests/agent/test_interaction_manager.py b/tests/unit_tests/agent/test_interaction_manager.py
index bc09a26fc..821c24fc7 100644
--- a/tests/unit_tests/agent/test_interaction_manager.py
+++ b/tests/unit_tests/agent/test_interaction_manager.py
@@ -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']
diff --git a/tests/unit_tests/agent/test_interaction_store.py b/tests/unit_tests/agent/test_interaction_store.py
index 4872be4b1..c3dcb6f93 100644
--- a/tests/unit_tests/agent/test_interaction_store.py
+++ b/tests/unit_tests/agent/test_interaction_store.py
@@ -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',
diff --git a/tests/unit_tests/agent/test_legacy_identity_boundary.py b/tests/unit_tests/agent/test_legacy_identity_boundary.py
new file mode 100644
index 000000000..241b818b4
--- /dev/null
+++ b/tests/unit_tests/agent/test_legacy_identity_boundary.py
@@ -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
diff --git a/tests/unit_tests/agent/test_orchestrator_integration.py b/tests/unit_tests/agent/test_orchestrator_integration.py
index 2bac63dbb..d634d9151 100644
--- a/tests/unit_tests/agent/test_orchestrator_integration.py
+++ b/tests/unit_tests/agent/test_orchestrator_integration.py
@@ -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']
diff --git a/tests/unit_tests/agent/test_runner_model_reasoning.py b/tests/unit_tests/agent/test_runner_model_reasoning.py
new file mode 100644
index 000000000..ff365834f
--- /dev/null
+++ b/tests/unit_tests/agent/test_runner_model_reasoning.py
@@ -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'}
+ )
diff --git a/tests/unit_tests/agent/test_state_store.py b/tests/unit_tests/agent/test_state_store.py
index 4154cd840..a92c6a4af 100644
--- a/tests/unit_tests/agent/test_state_store.py
+++ b/tests/unit_tests/agent/test_state_store.py
@@ -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):
diff --git a/tests/unit_tests/agent/test_state_store_fixture.py b/tests/unit_tests/agent/test_state_store_fixture.py
new file mode 100644
index 000000000..3fc563869
--- /dev/null
+++ b/tests/unit_tests/agent/test_state_store_fixture.py
@@ -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()
diff --git a/tests/unit_tests/api/service/fixtures/langflow-agent-artifact-schema.json b/tests/unit_tests/api/service/fixtures/langflow-agent-artifact-schema.json
new file mode 100644
index 000000000..69f7be97c
--- /dev/null
+++ b/tests/unit_tests/api/service/fixtures/langflow-agent-artifact-schema.json
@@ -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
+ }
+ }
+]
diff --git a/tests/unit_tests/api/service/fixtures/pipeline_migration_deerflow_schema.json b/tests/unit_tests/api/service/fixtures/pipeline_migration_deerflow_schema.json
new file mode 100644
index 000000000..5c11f6006
--- /dev/null
+++ b/tests/unit_tests/api/service/fixtures/pipeline_migration_deerflow_schema.json
@@ -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
+ }
+ }
+]
diff --git a/tests/unit_tests/api/service/fixtures/pipeline_migration_local_schema.json b/tests/unit_tests/api/service/fixtures/pipeline_migration_local_schema.json
new file mode 100644
index 000000000..c3b98c5ae
--- /dev/null
+++ b/tests/unit_tests/api/service/fixtures/pipeline_migration_local_schema.json
@@ -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"
+ ]
+ }
+}
diff --git a/tests/unit_tests/api/service/fixtures/weknora-agent-artifact-schema.json b/tests/unit_tests/api/service/fixtures/weknora-agent-artifact-schema.json
new file mode 100644
index 000000000..3725e7cd1
--- /dev/null
+++ b/tests/unit_tests/api/service/fixtures/weknora-agent-artifact-schema.json
@@ -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
+ }
+ }
+]
diff --git a/tests/unit_tests/api/service/test_pipeline_migration.py b/tests/unit_tests/api/service/test_pipeline_migration.py
new file mode 100644
index 000000000..b9f3080b6
--- /dev/null
+++ b/tests/unit_tests/api/service/test_pipeline_migration.py
@@ -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()
diff --git a/tests/unit_tests/api/service/test_pipeline_migration_all_runner_contracts.py b/tests/unit_tests/api/service/test_pipeline_migration_all_runner_contracts.py
new file mode 100644
index 000000000..c68d7193d
--- /dev/null
+++ b/tests/unit_tests/api/service/test_pipeline_migration_all_runner_contracts.py
@@ -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()
diff --git a/tests/unit_tests/api/service/test_pipeline_migration_cloud.py b/tests/unit_tests/api/service/test_pipeline_migration_cloud.py
new file mode 100644
index 000000000..09fc71ae7
--- /dev/null
+++ b/tests/unit_tests/api/service/test_pipeline_migration_cloud.py
@@ -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
diff --git a/tests/unit_tests/api/service/test_pipeline_migration_pending_interactions.py b/tests/unit_tests/api/service/test_pipeline_migration_pending_interactions.py
new file mode 100644
index 000000000..b9a9801d9
--- /dev/null
+++ b/tests/unit_tests/api/service/test_pipeline_migration_pending_interactions.py
@@ -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
diff --git a/tests/unit_tests/api/service/test_pipeline_migration_resources.py b/tests/unit_tests/api/service/test_pipeline_migration_resources.py
new file mode 100644
index 000000000..d70adbfdb
--- /dev/null
+++ b/tests/unit_tests/api/service/test_pipeline_migration_resources.py
@@ -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()
diff --git a/tests/unit_tests/api/test_pipeline_migration_routes.py b/tests/unit_tests/api/test_pipeline_migration_routes.py
new file mode 100644
index 000000000..6e0ff4b0c
--- /dev/null
+++ b/tests/unit_tests/api/test_pipeline_migration_routes.py
@@ -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
diff --git a/tests/unit_tests/persistence/test_pipeline_migration_snapshot.py b/tests/unit_tests/persistence/test_pipeline_migration_snapshot.py
new file mode 100644
index 000000000..3b8aabe80
--- /dev/null
+++ b/tests/unit_tests/persistence/test_pipeline_migration_snapshot.py
@@ -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()
diff --git a/tests/unit_tests/pipeline/test_legacy_config_migration.py b/tests/unit_tests/pipeline/test_legacy_config_migration.py
new file mode 100644
index 000000000..5f4319dfc
--- /dev/null
+++ b/tests/unit_tests/pipeline/test_legacy_config_migration.py
@@ -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
diff --git a/tests/unit_tests/pipeline/test_migration_current_shape.py b/tests/unit_tests/pipeline/test_migration_current_shape.py
new file mode 100644
index 000000000..8656c156c
--- /dev/null
+++ b/tests/unit_tests/pipeline/test_migration_current_shape.py
@@ -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']
diff --git a/tests/unit_tests/pipeline/test_pipeline_migration_runtime.py b/tests/unit_tests/pipeline/test_pipeline_migration_runtime.py
new file mode 100644
index 000000000..462616a65
--- /dev/null
+++ b/tests/unit_tests/pipeline/test_pipeline_migration_runtime.py
@@ -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]
diff --git a/tests/unit_tests/plugin/test_runner_reasoning_override.py b/tests/unit_tests/plugin/test_runner_reasoning_override.py
new file mode 100644
index 000000000..75e27c012
--- /dev/null
+++ b/tests/unit_tests/plugin/test_runner_reasoning_override.py
@@ -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'}
diff --git a/tests/unit_tests/provider/test_codex_reasoning_override.py b/tests/unit_tests/provider/test_codex_reasoning_override.py
new file mode 100644
index 000000000..7ae5a848e
--- /dev/null
+++ b/tests/unit_tests/provider/test_codex_reasoning_override.py
@@ -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')
diff --git a/web/playwright.completion.config.ts b/web/playwright.completion.config.ts
new file mode 100644
index 000000000..8f5398deb
--- /dev/null
+++ b/web/playwright.completion.config.ts
@@ -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,
+ },
+});
diff --git a/web/playwright.reasoning.config.ts b/web/playwright.reasoning.config.ts
new file mode 100644
index 000000000..ee21e8df7
--- /dev/null
+++ b/web/playwright.reasoning.config.ts
@@ -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,
+ },
+});
diff --git a/web/playwright.structured.config.ts b/web/playwright.structured.config.ts
new file mode 100644
index 000000000..978b17602
--- /dev/null
+++ b/web/playwright.structured.config.ts
@@ -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,
+ },
+});
diff --git a/web/src/app/home/agents/AgentDetailContent.tsx b/web/src/app/home/agents/AgentDetailContent.tsx
index abe7cbe2d..0693f1e24 100644
--- a/web/src/app/home/agents/AgentDetailContent.tsx
+++ b/web/src/app/home/agents/AgentDetailContent.tsx
@@ -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
{t('agents.selectFromSidebar')}
+{t('agents.selectFromSidebar')}
++ {pipelineDetails.description} +
+