From 9629e34a4a98df37b40d7bef86051b434093e412 Mon Sep 17 00:00:00 2001 From: limityan Date: Tue, 11 Aug 2026 18:03:21 +0800 Subject: [PATCH] perf(build): prune dead dependencies and consolidate contract tests --- Cargo.lock | 88 - Cargo.toml | 4 +- .../extensions/plugin-runtime-design.md | 4 +- docs/performance/01-compile-performance.md | 50 +- scripts/check-core-boundaries.test.mjs | 131 ++ scripts/core-boundaries/checker.mjs | 4 +- .../explicit-test-topology.mjs | 180 ++ .../rules/source/forbidden-rules.mjs | 2 +- .../rules/source/required-rules.mjs | 12 +- scripts/core-boundaries/self-test.mjs | 6 +- src/apps/cli/Cargo.toml | 2 - src/apps/cli/src/ui/syntax_highlight.rs | 4 +- src/apps/desktop/Cargo.toml | 1 - src/apps/desktop/capabilities/default.json | 7 +- src/crates/adapters/ai-adapters/Cargo.toml | 9 + .../tests/ai_protocol_contracts.rs | 4 + .../model_selector.rs | 0 .../openai_empty_content_parts.rs | 0 .../ai-adapters/tests/ai_stream_contracts.rs | 12 + .../tests/ai_stream_contracts/common.rs | 6 + .../stream_processor_anthropic.rs | 6 +- .../stream_processor_openai.rs | 8 +- .../stream_processor_tool_arguments.rs | 6 +- .../stream_replay_regressions.rs | 12 +- .../stream_test_harness.rs | 6 +- src/crates/assembly/core/Cargo.toml | 2 - .../assembly/product-capabilities/Cargo.toml | 5 + .../tests/product_capability_contracts.rs | 6 + .../plugin_product_shape.rs | 0 .../product_capabilities.rs | 0 .../product_sdk_assembly.rs | 0 src/crates/contracts/core-types/Cargo.toml | 5 + .../core-types/tests/core_type_contracts.rs | 8 + .../lsp_contracts.rs | 0 .../session_contracts.rs | 0 .../session_usage_contracts.rs | 0 .../surface_contracts.rs | 0 .../contracts/product-domains/Cargo.toml | 22 +- .../tests/external_source_contracts.rs | 2084 +---------------- .../external_hook_catalog_contracts.rs | 0 .../external_hook_contribution_contracts.rs | 0 .../external_source_contracts.rs | 2074 ++++++++++++++++ .../workspace_reference_contracts.rs | 0 .../tests/plugin_source_contracts.rs | 8 +- .../tests/product_domain_contracts.rs | 4 + .../canvas_contracts.rs | 0 .../tool_permission_contracts.rs | 0 src/crates/contracts/runtime-ports/Cargo.toml | 5 + .../tests/runtime_port_contracts.rs | 10 + .../git_port_contracts.rs | 0 .../plugin_runtime_contracts.rs | 0 .../plugin_runtime_diagnostics_contracts.rs | 0 .../script_tool_port_contracts.rs | 0 .../session_store_contracts.rs | 0 .../miniapp-market-service/Cargo.toml | 1 - .../services/page-function-runtime/Cargo.toml | 3 - 56 files changed, 2558 insertions(+), 2243 deletions(-) create mode 100644 src/crates/adapters/ai-adapters/tests/ai_protocol_contracts.rs rename src/crates/adapters/ai-adapters/tests/{ => ai_protocol_contracts}/model_selector.rs (100%) rename src/crates/adapters/ai-adapters/tests/{ => ai_protocol_contracts}/openai_empty_content_parts.rs (100%) create mode 100644 src/crates/adapters/ai-adapters/tests/ai_stream_contracts.rs create mode 100644 src/crates/adapters/ai-adapters/tests/ai_stream_contracts/common.rs rename src/crates/adapters/ai-adapters/tests/{ => ai_stream_contracts}/stream_processor_anthropic.rs (99%) rename src/crates/adapters/ai-adapters/tests/{ => ai_stream_contracts}/stream_processor_openai.rs (99%) rename src/crates/adapters/ai-adapters/tests/{ => ai_stream_contracts}/stream_processor_tool_arguments.rs (94%) rename src/crates/adapters/ai-adapters/tests/{ => ai_stream_contracts}/stream_replay_regressions.rs (98%) rename src/crates/adapters/ai-adapters/tests/{ => ai_stream_contracts}/stream_test_harness.rs (94%) create mode 100644 src/crates/assembly/product-capabilities/tests/product_capability_contracts.rs rename src/crates/assembly/product-capabilities/tests/{ => product_capability_contracts}/plugin_product_shape.rs (100%) rename src/crates/assembly/product-capabilities/tests/{ => product_capability_contracts}/product_capabilities.rs (100%) rename src/crates/assembly/product-capabilities/tests/{ => product_capability_contracts}/product_sdk_assembly.rs (100%) create mode 100644 src/crates/contracts/core-types/tests/core_type_contracts.rs rename src/crates/contracts/core-types/tests/{ => core_type_contracts}/lsp_contracts.rs (100%) rename src/crates/contracts/core-types/tests/{ => core_type_contracts}/session_contracts.rs (100%) rename src/crates/contracts/core-types/tests/{ => core_type_contracts}/session_usage_contracts.rs (100%) rename src/crates/contracts/core-types/tests/{ => core_type_contracts}/surface_contracts.rs (100%) rename src/crates/contracts/product-domains/tests/{ => external_source_contracts}/external_hook_catalog_contracts.rs (100%) rename src/crates/contracts/product-domains/tests/{ => external_source_contracts}/external_hook_contribution_contracts.rs (100%) create mode 100644 src/crates/contracts/product-domains/tests/external_source_contracts/external_source_contracts.rs rename src/crates/contracts/product-domains/tests/{ => external_source_contracts}/workspace_reference_contracts.rs (100%) create mode 100644 src/crates/contracts/product-domains/tests/product_domain_contracts.rs rename src/crates/contracts/product-domains/tests/{ => product_domain_contracts}/canvas_contracts.rs (100%) rename src/crates/contracts/product-domains/tests/{ => product_domain_contracts}/tool_permission_contracts.rs (100%) create mode 100644 src/crates/contracts/runtime-ports/tests/runtime_port_contracts.rs rename src/crates/contracts/runtime-ports/tests/{ => runtime_port_contracts}/git_port_contracts.rs (100%) rename src/crates/contracts/runtime-ports/tests/{ => runtime_port_contracts}/plugin_runtime_contracts.rs (100%) rename src/crates/contracts/runtime-ports/tests/{ => runtime_port_contracts}/plugin_runtime_diagnostics_contracts.rs (100%) rename src/crates/contracts/runtime-ports/tests/{ => runtime_port_contracts}/script_tool_port_contracts.rs (100%) rename src/crates/contracts/runtime-ports/tests/{ => runtime_port_contracts}/session_store_contracts.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index 51e9c22e5..bbc6b1406 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -991,7 +991,6 @@ dependencies = [ "chrono", "clap", "crossterm", - "dashmap", "dirs 6.0.0", "dunce", "flate2", @@ -1013,7 +1012,6 @@ dependencies = [ "shlex 1.3.0", "similar", "syntect", - "syntect-tui", "tar", "tempfile", "thiserror 2.0.19", @@ -1105,14 +1103,12 @@ dependencies = [ "terminal-core", "thiserror 2.0.19", "tokio", - "tokio-stream", "tokio-tungstenite", "tokio-util", "tool-runtime", "tower-http", "ts-rs", "unic-langid", - "urlencoding", "uuid", ] @@ -1187,7 +1183,6 @@ dependencies = [ "tauri-plugin-autostart", "tauri-plugin-dialog", "tauri-plugin-fs", - "tauri-plugin-global-shortcut", "tauri-plugin-log", "tauri-plugin-notification", "tauri-plugin-opener", @@ -1281,7 +1276,6 @@ dependencies = [ "tower-http", "tracing", "url", - "urlencoding", "uuid", "zip 4.6.1", ] @@ -1322,7 +1316,6 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.19", - "tokio", ] [[package]] @@ -2692,12 +2685,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "custom_error" -version = "1.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f8a51dd197fa6ba5b4dc98a990a43cc13693c23eb0089ebb0fcc1f04152bca6" - [[package]] name = "dark-light" version = "1.1.1" @@ -3489,17 +3476,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" -[[package]] -name = "fancy-regex" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" -dependencies = [ - "bit-set", - "regex-automata", - "regex-syntax", -] - [[package]] name = "fast-float2" version = "0.2.3" @@ -4204,24 +4180,6 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" -[[package]] -name = "global-hotkey" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e" -dependencies = [ - "crossbeam-channel", - "keyboard-types", - "objc2 0.6.4", - "objc2-app-kit", - "once_cell", - "serde", - "thiserror 2.0.19", - "windows-sys 0.59.0", - "x11rb", - "xkeysym", -] - [[package]] name = "globset" version = "0.4.19" @@ -5620,12 +5578,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "linked-hash-map" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" - [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -8178,7 +8130,6 @@ dependencies = [ "lru", "paste", "strum 0.26.3", - "time", "unicode-segmentation", "unicode-truncate", "unicode-width 0.2.0", @@ -10040,30 +9991,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" dependencies = [ "bincode", - "fancy-regex", "flate2", "fnv", "once_cell", "onig", - "plist", "regex-syntax", "serde", "serde_derive", - "serde_json", "thiserror 2.0.19", "walkdir", - "yaml-rust", -] - -[[package]] -name = "syntect-tui" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24486acfb54bfcae77f45784cb59254e14454949a44f9d0b62613a699619c210" -dependencies = [ - "custom_error", - "ratatui", - "syntect", ] [[package]] @@ -10354,21 +10290,6 @@ dependencies = [ "url", ] -[[package]] -name = "tauri-plugin-global-shortcut" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4dd9f4c5136c09cd962da0c86dc4accd4666db2ea591cf16e6597435843bd2b" -dependencies = [ - "global-hotkey", - "log", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "thiserror 2.0.19", -] - [[package]] name = "tauri-plugin-log" version = "2.9.0" @@ -12891,15 +12812,6 @@ dependencies = [ "lzma-sys", ] -[[package]] -name = "yaml-rust" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" -dependencies = [ - "linked-hash-map", -] - [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 2e53e5769..bc9983d5b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -172,8 +172,7 @@ crossterm = "0.28" ratatui = "0.29" unicode-width = "0.2" pulldown-cmark = "0.11" -syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "regex-fancy"] } -syntect-tui = "3.0" +syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "regex-onig"] } once_cell = "1" libc = "0.2" arboard = "3" @@ -206,7 +205,6 @@ tauri-plugin-log = "2.8" tauri-plugin-autostart = "2.5" tauri-plugin-notification = "2.3" tauri-plugin-updater = "2.10" -tauri-plugin-global-shortcut = "2.3" tauri-plugin-single-instance = "2.4" tauri-plugin-window-state = "2.4" tauri-build = { version = "2.6", features = [] } diff --git a/docs/architecture/extensions/plugin-runtime-design.md b/docs/architecture/extensions/plugin-runtime-design.md index bcaada2a7..18f07e242 100644 --- a/docs/architecture/extensions/plugin-runtime-design.md +++ b/docs/architecture/extensions/plugin-runtime-design.md @@ -281,8 +281,8 @@ plugin、Hook、完整 Client 或 TUI 插件入口。与其独立的 standalone 当前 Rust 边界调整至少运行: -- `cargo test -p bitfun-runtime-ports --test plugin_runtime_contracts` -- `cargo test -p bitfun-runtime-ports --test plugin_runtime_diagnostics_contracts` +- `cargo test -p bitfun-runtime-ports --test runtime_port_contracts plugin_runtime_contracts` +- `cargo test -p bitfun-runtime-ports --test runtime_port_contracts plugin_runtime_diagnostics_contracts` - `cargo test -p bitfun-plugin-runtime-client` - `cargo test -p bitfun-opencode-adapter --test opencode_source_adapter` - `cargo test -p bitfun-core plugin_runtime::tests --lib` diff --git a/docs/performance/01-compile-performance.md b/docs/performance/01-compile-performance.md index d54213f80..13a506fc0 100644 --- a/docs/performance/01-compile-performance.md +++ b/docs/performance/01-compile-performance.md @@ -2,7 +2,7 @@ > 最近核实:2026-08-11 > -> 实现复核基线:`gcwing/main@9f8b56082` +> 实现复核基线:`gcwing/main@3d8ee4bc0` > > 性能 A/B 基线:`gcwing/main@1f538b96d` > @@ -16,11 +16,11 @@ | 结论 | 说明 | |---|---| -| 集成测试链接拓扑已收敛 | Services 两个 crate 的集成 target 总数从 33 降到 25;External Sources 的 adapter/assembly target 从 22 降到 7,进程和外部系统失败域保持独立 | +| 集成测试链接拓扑已收敛 | Services 两个 crate 的集成 target 总数从 33 降到 25;External Sources 的 adapter/assembly target 从 22 降到 7;五个 Contracts/AI/Assembly crate 又从 28 降到 10,feature、平台和外部系统失败域保持独立 | | Agent Runtime 基线不再隐藏重型 capability | `bitfun-core/agent-runtime` 只保留生命周期和基础工具 owner;文档转换与订阅认证也改为产品显式 modifier。在最新主线 A/B 中,三平台 normal/build 闭包进一步减少 69/64/110 个版本化 package instance | | App Server 不继承未消费能力 | App Server 保持现有 Agent/Git/外部来源 handler 边界,不再因 Core 基线携带文档转换和本地订阅凭据,三平台闭包减少 61/56/78 | | SDK Host 使用显式能力闭包 | SDK Host 保留当前本机协议和工具能力,但不再通过 `product-full` 携带协议未暴露的 Remote Connect、SSH、Function Agent 等能力;Windows/macOS/Linux normal/build 闭包减少 66/68/76 | -| 完整产品行为和闭包保持 | `product-full` 显式组合全部 owner,Windows normal/build 闭包保持 570;CLI 保持 649。ACP 只退出未选择或未使用的隐含能力,累计在 Windows/macOS/Linux 分别减少 12/15/24 | +| 完整产品行为保持 | `product-full` 显式组合全部 owner且三平台闭包不变;CLI 删除未调用适配层时显式保留原先实际生效的 Oniguruma 高亮后端,三平台闭包进一步减少 6/7/7。ACP 只退出未选择或未使用的隐含能力 | | Installer 删除未使用的直接能力 | 独立 manifest 的直接 dependency 从 18 降到 10,Windows normal/build 闭包减少 6;不把 Installer 并入根 workspace,本 PR 按要求不提交其生成 lockfile | | focused test 仍保持精确 | 同 owner、feature、平台和进程语义的源文件进入分组 target;使用 `--test ::` 运行单模块 | @@ -102,6 +102,26 @@ target 多 1。PDB 大小会随工具链变化,只比较同次 A/B: | local-storage | 13 → 6 | 25.2 → 19.2 MiB | 135.7 → 91.9 MiB | | 基础 Remote SSH | 3 → 2 | 3.9 → 2.8 MiB | 53.5 → 43.8 MiB | +#### Contracts、AI adapters 与 Product Assembly + +五个纯合同/组装 owner 使用显式 wrapper target;AI 的纯协议测试与真实 loopback SSE 测试继续分成两个 +失败域,Product Domains 的默认、Plugin Source、External Sources、Function Agent 与 MiniApp 也继续按 +owner feature 分开。270 个 integration tests 不变,模块过滤仍可聚焦单个 leaf: + +| 范围 | 变更前 target | 变更后 target | 集成测试数 | +|---|---:|---:|---:| +| `core-types` | 4 | 1 | 10 | +| `runtime-ports` | 5 | 1 | 21 | +| `product-domains` | 9 | 5 | 179 | +| `ai-adapters` | 7 | 2 | 29 | +| `product-capabilities` | 3 | 1 | 31 | +| 合计 | 28 | 10 | 270 | + +对应五个 lib test harness 的 test executable 总数从 33 降到 15;workspace integration target 从 +91 降到 73。该变化减少 18 次重复链接,但单叶变更会重链所属分组,因此这里只报告确定的拓扑收益, +不在缺少同机多轮 A/B 时宣称 wall-clock 提速。边界检查锁定 exact leaf、owner feature 和空 +`required-features` 的默认 target,避免以后用 `product-full` 扩大测试闭包。 + ### 3.2 依赖与 feature 闭包使用 `cargo tree -e normal,build` 按目标平台统计版本化 package instance;它衡量进入编译图的 @@ -136,19 +156,35 @@ normal dependency,根 lock package 集合不变。前两类收益来自 `anydo SSH、密钥和连接子图退出。完整产品 package 集合不变, 因此这里只报告依赖图收敛,不宣称 `product-full` wall-clock 提速。 +以下是以 `gcwing/main@3d8ee4bc0` 为变更前基线、使用同样三个 target triple 和去重口径复算的最新 A/B: + +| 最新闭包 | Windows | macOS | Linux | 行为边界 | +|---|---:|---:|---:|---| +| Core `--no-default-features` | 104 → 102 | 93 → 91 | 92 → 90 | 删除 Core 不再消费的 `tokio-stream`、`urlencoding` 直接边 | +| Core `product-full` | 570 → 570 | 557 → 557 | 601 → 601 | 完整产品仍从真实 adapter/service owner 获得两项依赖 | +| CLI | 649 → 643 | 649 → 642 | 672 → 665 | 删除未调用的 `syntect-tui`/`dashmap`;显式保留既有 Oniguruma 高亮后端 | +| Desktop | 792 → 790 | 807 → 805 | 892 → 887 | 删除从未注册、没有调用方的 global-shortcut 插件和 ACL | +| MiniApp Market | 205 → 204 | 208 → 207 | 206 → 205 | 删除服务从未消费的 `urlencoding` 直接边 | +| Page Function tests | 38 → 35 | 38 → 35 | 38 → 35 | 删除同步 Rust 测试未使用的 dev-only Tokio 闭包 | + +Syntect 不能机械地只删适配层:旧 feature union 同时启用 `regex-fancy` 与 `regex-onig` 时,实际由 +Oniguruma 后端处理。当前 manifest 直接选择 `regex-onig`,因此运行后端、默认 syntax/theme 和 +Syntect→Ratatui 样式转换保持不变,同时让未生效的 fancy 后端与未消费的 YAML loader 退出。 + Package instance 会低估“同一个大 crate 少编译了多少 feature 代码”。在 Windows `agent-runtime` 闭包中,`bitfun-services-integrations` 的 Cargo active feature 从 61 个降到 6 个, 只保留 `workspace-search` 及其 5 个直接依赖 feature;`bitfun-product-domains` 从 13 个降到 5 个, 只保留 Agent Runtime 实际使用的 external-subagent contract slice。Function Agent、MiniApp、 Plugin Source 由各自 owner 选择,完整产品仍经 `product-full` 显式恢复。 -根 `Cargo.lock` 与实现复核基线保持一致,package 记录不增加;Installer 自己生成的 -`BitFun-Installer/src-tauri/Cargo.lock` 本 PR 不提交。 +根 `Cargo.lock` 从 1176 降到 1169,精确删除 `syntect-tui`、`custom_error`、`fancy-regex`、 +`yaml-rust`、`linked-hash-map`、`tauri-plugin-global-shortcut` 和 `global-hotkey`;没有新增、升级或 +降级 package。Installer 自己生成的 `BitFun-Installer/src-tauri/Cargo.lock` 本 PR 不提交。 | 状态 | 范围 | 处理结论 | |---|---|---| | 已稳定 | 根 `Cargo.lock`、Reqwest Rustls 单栈、workspace Tokio 最小基线 | 不重复治理 | -| 本轮完成 | Core Agent Runtime capability、文档转换与订阅认证 modifier、SDK Host 显式 owner closure、Installer 未使用直接依赖 | 以真实入口 closure 收敛,不建立新的产品 umbrella,也不扩大根依赖宇宙 | +| 本轮完成 | Core Agent Runtime capability、文档转换与订阅认证 modifier、SDK Host 显式 owner closure、Installer/CLI/Desktop/Core/MiniApp Market/Page Function 未使用直接依赖 | 以真实入口 closure 收敛,不建立新的产品 umbrella;根 lock 只减少 package | | 当前不动 | App Server / Server | 只为保持现有 handler 编译显式声明其已消费的 Core owner;不在改造稳定前继续拆其生产路径 | | 明确保留 | Desktop screenshots backend | 替换方案必须同时保持三平台坐标/权限/区域捕获语义且不增加根 lock package;当前候选不满足 | | 明确保留 | `portable-pty 0.8/0.9` | 非 OHOS 与 OHOS 的平台兼容选择,不为去重破坏 | @@ -179,6 +215,8 @@ Plugin Source 由各自 owner 选择,完整产品仍经 `product-full` 显式 | Agent Runtime 测试 | 28 个 integration executable 已收敛为 5 个职责/平台 target | | Services 测试 | 两个服务 crate 使用显式 target;选中闭包少 8 个 integration executable,进程/feature/external-system 边界保持独立 | | External Sources 测试 | 四个 adapter/assembly crate 从 22 个 target 收敛到 7 个;MCP、插件服务和脚本 runtime 继续独立 | +| Contracts/AI/Assembly 测试 | 五个 crate 从 28 个 target 收敛到 10 个;AI loopback 与纯协议、Product Domains 各 owner feature 保持独立 | +| 未使用直接依赖 | 删除 CLI/Desktop/Core/MiniApp Market/Page Function 的失效直接边;保留 Syntect 实际 Oniguruma 后端,根 lock 只减 7 个 package | 内置 Agent 内容已经移到无第三方依赖的 `bitfun-agent-content`,减少了 Core build-script 工作; 但 Core 仍直接依赖该 crate。没有足够产品收益前,不为消除这一编译指纹引入动态 provider、 diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index eef315b9a..af9eb725b 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -260,6 +260,137 @@ test('service integration tests keep their reviewed explicit target topology', ( assert.deepEqual(checkServicesIntegrationsIntegrationTestTopology(repositoryRoot), []); }); +test('contract and AI adapter tests keep reviewed feature and failure-domain topology', async () => { + const repositoryRoot = fileURLToPath(new URL('..', import.meta.url)); + const topology = await import('./core-boundaries/explicit-test-topology.mjs'); + + assert.deepEqual(topology.coreTypesIntegrationTestTargets, [ + { + name: 'core_type_contracts', + path: 'tests/core_type_contracts.rs', + leaves: [ + 'tests/core_type_contracts/lsp_contracts.rs', + 'tests/core_type_contracts/session_contracts.rs', + 'tests/core_type_contracts/session_usage_contracts.rs', + 'tests/core_type_contracts/surface_contracts.rs', + ], + forbidRequiredFeatures: true, + }, + ]); + assert.deepEqual(topology.runtimePortsIntegrationTestTargets, [ + { + name: 'runtime_port_contracts', + path: 'tests/runtime_port_contracts.rs', + leaves: [ + 'tests/runtime_port_contracts/git_port_contracts.rs', + 'tests/runtime_port_contracts/plugin_runtime_contracts.rs', + 'tests/runtime_port_contracts/plugin_runtime_diagnostics_contracts.rs', + 'tests/runtime_port_contracts/script_tool_port_contracts.rs', + 'tests/runtime_port_contracts/session_store_contracts.rs', + ], + forbidRequiredFeatures: true, + }, + ]); + assert.deepEqual(topology.productDomainsIntegrationTestTargets, [ + { + name: 'product_domain_contracts', + path: 'tests/product_domain_contracts.rs', + leaves: [ + 'tests/product_domain_contracts/canvas_contracts.rs', + 'tests/product_domain_contracts/tool_permission_contracts.rs', + ], + forbidRequiredFeatures: true, + }, + { + name: 'external_source_contracts', + path: 'tests/external_source_contracts.rs', + leaves: [ + 'tests/external_source_contracts/external_hook_catalog_contracts.rs', + 'tests/external_source_contracts/external_hook_contribution_contracts.rs', + 'tests/external_source_contracts/external_source_contracts.rs', + 'tests/external_source_contracts/workspace_reference_contracts.rs', + ], + requiredFeatures: ['external-sources'], + }, + { + name: 'function_agent_contracts', + path: 'tests/function_agent_contracts.rs', + requiredFeatures: ['function-agents'], + }, + { + name: 'miniapp_contracts', + path: 'tests/miniapp_contracts.rs', + requiredFeatures: ['miniapp'], + }, + { + name: 'plugin_source_contracts', + path: 'tests/plugin_source_contracts.rs', + requiredFeatures: ['plugin-source'], + }, + ]); + assert.deepEqual(topology.aiAdaptersIntegrationTestTargets, [ + { + name: 'ai_protocol_contracts', + path: 'tests/ai_protocol_contracts.rs', + leaves: [ + 'tests/ai_protocol_contracts/model_selector.rs', + 'tests/ai_protocol_contracts/openai_empty_content_parts.rs', + ], + forbidRequiredFeatures: true, + }, + { + name: 'ai_stream_contracts', + path: 'tests/ai_stream_contracts.rs', + leaves: [ + 'tests/ai_stream_contracts/common.rs', + 'tests/ai_stream_contracts/stream_processor_anthropic.rs', + 'tests/ai_stream_contracts/stream_processor_openai.rs', + 'tests/ai_stream_contracts/stream_processor_tool_arguments.rs', + 'tests/ai_stream_contracts/stream_replay_regressions.rs', + 'tests/ai_stream_contracts/stream_test_harness.rs', + ], + forbidRequiredFeatures: true, + }, + ]); + assert.deepEqual(topology.productCapabilitiesIntegrationTestTargets, [ + { + name: 'product_capability_contracts', + path: 'tests/product_capability_contracts.rs', + leaves: [ + 'tests/product_capability_contracts/plugin_product_shape.rs', + 'tests/product_capability_contracts/product_capabilities.rs', + 'tests/product_capability_contracts/product_sdk_assembly.rs', + ], + forbidRequiredFeatures: true, + }, + ]); + assert.deepEqual(topology.checkBuildGraphContractIntegrationTestTopologies(repositoryRoot), []); + + const widenedOwnerErrors = validateExplicitIntegrationTestTopology({ + manifestText: [ + '[package]', + 'autotests = false', + '[[test]]', + 'name = "external_source_contracts"', + 'path = "tests/external_source_contracts.rs"', + 'required-features = ["product-full"]', + ].join('\n'), + expectedTargets: [{ + name: 'external_source_contracts', + path: 'tests/external_source_contracts.rs', + requiredFeatures: ['external-sources'], + }], + topLevelRustFiles: ['tests/external_source_contracts.rs'], + rootSources: new Map([[ + 'tests/external_source_contracts.rs', + '#![cfg(feature = "product-full")]\n', + ]]), + leafRustFiles: [], + leafSources: new Map(), + }); + assert.match(widenedOwnerErrors.join('\n'), /required-features.*external-sources/); +}); + test('external source integration tests keep reviewed owner and process boundaries', () => { const repositoryRoot = fileURLToPath(new URL('..', import.meta.url)); diff --git a/scripts/core-boundaries/checker.mjs b/scripts/core-boundaries/checker.mjs index 2247f1ee5..d42ed7b39 100644 --- a/scripts/core-boundaries/checker.mjs +++ b/scripts/core-boundaries/checker.mjs @@ -43,7 +43,7 @@ import { checkAgentRuntimeIntegrationTestTopology, checkCliIntegrationTestTopology, checkExternalSourceIntegrationTestTopologies, - checkServiceIntegrationTestTopologies, + checkReviewedIntegrationTestTopologies, cliIntegrationTestTargets, validateExplicitIntegrationTestTopology, } from './explicit-test-topology.mjs'; @@ -1126,7 +1126,7 @@ export function runCoreBoundaryCheck() { failures.push(...checkCargoDependencyBoundariesSafely({ root: ROOT, crateLayoutRules })); failures.push(...checkAgentRuntimeIntegrationTestTopology(ROOT)); failures.push(...checkCliIntegrationTestTopology(ROOT)); - failures.push(...checkExternalSourceIntegrationTestTopologies(ROOT), ...checkServiceIntegrationTestTopologies(ROOT)); + failures.push(...checkExternalSourceIntegrationTestTopologies(ROOT), ...checkReviewedIntegrationTestTopologies(ROOT)); failures.push(...checkPeerCommandPolicySync(ROOT)); for (const rule of forbiddenManifestDependencyRules) { diff --git a/scripts/core-boundaries/explicit-test-topology.mjs b/scripts/core-boundaries/explicit-test-topology.mjs index d1ff06f9e..e5b14cc36 100644 --- a/scripts/core-boundaries/explicit-test-topology.mjs +++ b/scripts/core-boundaries/explicit-test-topology.mjs @@ -108,6 +108,111 @@ export const externalSourcesIntegrationTestTargets = [ }, ]; +export const coreTypesIntegrationTestTargets = [ + { + name: 'core_type_contracts', + path: 'tests/core_type_contracts.rs', + leaves: [ + 'tests/core_type_contracts/lsp_contracts.rs', + 'tests/core_type_contracts/session_contracts.rs', + 'tests/core_type_contracts/session_usage_contracts.rs', + 'tests/core_type_contracts/surface_contracts.rs', + ], + forbidRequiredFeatures: true, + }, +]; + +export const runtimePortsIntegrationTestTargets = [ + { + name: 'runtime_port_contracts', + path: 'tests/runtime_port_contracts.rs', + leaves: [ + 'tests/runtime_port_contracts/git_port_contracts.rs', + 'tests/runtime_port_contracts/plugin_runtime_contracts.rs', + 'tests/runtime_port_contracts/plugin_runtime_diagnostics_contracts.rs', + 'tests/runtime_port_contracts/script_tool_port_contracts.rs', + 'tests/runtime_port_contracts/session_store_contracts.rs', + ], + forbidRequiredFeatures: true, + }, +]; + +export const productDomainsIntegrationTestTargets = [ + { + name: 'product_domain_contracts', + path: 'tests/product_domain_contracts.rs', + leaves: [ + 'tests/product_domain_contracts/canvas_contracts.rs', + 'tests/product_domain_contracts/tool_permission_contracts.rs', + ], + forbidRequiredFeatures: true, + }, + { + name: 'external_source_contracts', + path: 'tests/external_source_contracts.rs', + leaves: [ + 'tests/external_source_contracts/external_hook_catalog_contracts.rs', + 'tests/external_source_contracts/external_hook_contribution_contracts.rs', + 'tests/external_source_contracts/external_source_contracts.rs', + 'tests/external_source_contracts/workspace_reference_contracts.rs', + ], + requiredFeatures: ['external-sources'], + }, + { + name: 'function_agent_contracts', + path: 'tests/function_agent_contracts.rs', + requiredFeatures: ['function-agents'], + }, + { + name: 'miniapp_contracts', + path: 'tests/miniapp_contracts.rs', + requiredFeatures: ['miniapp'], + }, + { + name: 'plugin_source_contracts', + path: 'tests/plugin_source_contracts.rs', + requiredFeatures: ['plugin-source'], + }, +]; + +export const aiAdaptersIntegrationTestTargets = [ + { + name: 'ai_protocol_contracts', + path: 'tests/ai_protocol_contracts.rs', + leaves: [ + 'tests/ai_protocol_contracts/model_selector.rs', + 'tests/ai_protocol_contracts/openai_empty_content_parts.rs', + ], + forbidRequiredFeatures: true, + }, + { + name: 'ai_stream_contracts', + path: 'tests/ai_stream_contracts.rs', + leaves: [ + 'tests/ai_stream_contracts/common.rs', + 'tests/ai_stream_contracts/stream_processor_anthropic.rs', + 'tests/ai_stream_contracts/stream_processor_openai.rs', + 'tests/ai_stream_contracts/stream_processor_tool_arguments.rs', + 'tests/ai_stream_contracts/stream_replay_regressions.rs', + 'tests/ai_stream_contracts/stream_test_harness.rs', + ], + forbidRequiredFeatures: true, + }, +]; + +export const productCapabilitiesIntegrationTestTargets = [ + { + name: 'product_capability_contracts', + path: 'tests/product_capability_contracts.rs', + leaves: [ + 'tests/product_capability_contracts/plugin_product_shape.rs', + 'tests/product_capability_contracts/product_capabilities.rs', + 'tests/product_capability_contracts/product_sdk_assembly.rs', + ], + forbidRequiredFeatures: true, + }, +]; + function decodeBasicTomlKey(token) { let decoded = ''; const simpleEscapes = new Map([ @@ -155,6 +260,34 @@ function tomlFieldName(line) { return token.startsWith('"') ? decodeBasicTomlKey(token) : token; } +function parseTomlStringArrayValue(line) { + const equalsIndex = line.indexOf('='); + const value = equalsIndex === -1 ? '' : line.slice(equalsIndex + 1).trim(); + const array = value.match(/^\[(.*)\]\s*(?:#.*)?$/); + if (!array) { + return null; + } + const inner = array[1]; + const values = []; + const stringPattern = /'[^']*'|"(?:[^"\\]|\\.)*"/g; + let cursor = 0; + for (const match of inner.matchAll(stringPattern)) { + if (!/^[\s,]*$/.test(inner.slice(cursor, match.index))) { + return null; + } + const token = match[0]; + const decoded = token.startsWith("'") + ? token.slice(1, -1) + : decodeBasicTomlKey(token); + if (decoded === null) { + return null; + } + values.push(decoded); + cursor = match.index + token.length; + } + return /^[\s,]*$/.test(inner.slice(cursor)) ? values : null; +} + function parseExplicitTestTargets(manifestText) { const targets = []; let current = null; @@ -178,6 +311,7 @@ function parseExplicitTestTargets(manifestText) { } if (current && tomlFieldName(trimmed) === 'required-features') { current.hasRequiredFeatures = true; + current.requiredFeatures = parseTomlStringArrayValue(trimmed); } const field = current && trimmed.match(/^(name|path)\s*=\s*"([^"]+)"\s*$/); if (field) { @@ -515,6 +649,24 @@ export function validateExplicitIntegrationTestTopology({ errors.push(`explicit test target ${name} must not declare required-features`); } } + for (const { name, path, requiredFeatures } of expectedTargets) { + if (requiredFeatures === undefined) { + continue; + } + const actual = actualTargets.find( + (target) => target.name === name && target.path === path, + ); + const actualRequiredFeatures = actual?.requiredFeatures; + if ( + actualRequiredFeatures === null + || actualRequiredFeatures === undefined + || [...actualRequiredFeatures].sort().join('\n') !== [...requiredFeatures].sort().join('\n') + ) { + errors.push( + `explicit test target ${name} required-features must be exactly: ${requiredFeatures.join(', ')}`, + ); + } + } const expectedRoots = expectedTargets.map(({ path }) => path).sort(); if ([...topLevelRustFiles].sort().join('\n') !== expectedRoots.join('\n')) { @@ -717,3 +869,31 @@ export function checkServiceIntegrationTestTopologies(root) { ...checkServicesIntegrationsIntegrationTestTopology(root), ]; } + +export function checkBuildGraphContractIntegrationTestTopologies(root) { + const topologies = [ + ['src/crates/contracts/core-types', coreTypesIntegrationTestTargets], + ['src/crates/contracts/runtime-ports', runtimePortsIntegrationTestTargets], + ['src/crates/contracts/product-domains', productDomainsIntegrationTestTargets], + [ + 'src/crates/adapters/ai-adapters', + aiAdaptersIntegrationTestTargets, + ['tests/common', 'tests/fixtures'], + ], + ['src/crates/assembly/product-capabilities', productCapabilitiesIntegrationTestTargets], + ]; + return topologies.flatMap(([cratePath, expectedTargets, ignoredDirectories]) => ( + checkExplicitIntegrationTestTopology(root, { + cratePath, + expectedTargets, + ignoredDirectories, + }) + )); +} + +export function checkReviewedIntegrationTestTopologies(root) { + return [ + ...checkServiceIntegrationTestTopologies(root), + ...checkBuildGraphContractIntegrationTestTopologies(root), + ]; +} diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index 43576f863..1ed5f3cc5 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -216,7 +216,7 @@ export const forbiddenContentRules = [ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/product_sdk_assembly.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_sdk_assembly.rs', patterns: [ { regex: /\bbitfun_core\b/, diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 8b4dc744d..b0aa4ce73 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -432,7 +432,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/contracts/core-types/tests/lsp_contracts.rs', + path: 'src/crates/contracts/core-types/tests/core_type_contracts/lsp_contracts.rs', reason: 'core-types must keep LSP manifest serialization, default-value, and placeholder regressions', patterns: [ @@ -1513,7 +1513,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/product_capabilities.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs', reason: 'product-capabilities tests must protect product shape facts, runtime service gap reporting, and legacy harness routing', patterns: [ @@ -1544,7 +1544,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/plugin_product_shape.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/plugin_product_shape.rs', reason: 'product-capabilities plugin shape tests must protect P0 plugin-capable profiles, non-P0 rejection, default availability reasons, and runtime handoff', patterns: [ @@ -1571,7 +1571,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/product_sdk_assembly.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_sdk_assembly.rs', reason: 'product-capabilities must prove product runtime parts can feed the SDK runtime without bitfun-core', patterns: [ @@ -4843,7 +4843,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/contracts/runtime-ports/tests/plugin_runtime_contracts.rs', + path: 'src/crates/contracts/runtime-ports/tests/runtime_port_contracts/plugin_runtime_contracts.rs', reason: 'runtime-ports plugin contract tests must cover typed envelopes, candidate effects, and disabled/projection-only behavior', patterns: [ @@ -4900,7 +4900,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/contracts/runtime-ports/tests/plugin_runtime_diagnostics_contracts.rs', + path: 'src/crates/contracts/runtime-ports/tests/runtime_port_contracts/plugin_runtime_diagnostics_contracts.rs', reason: 'runtime-ports plugin diagnostics contract tests must cover permission prompts, diagnostics, and quarantine facts', patterns: [ diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 666ce0d0e..1f93445ef 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -3238,7 +3238,7 @@ export function runManifestParserSelfTest({ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/product_capabilities.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs', contracts: [ 'product_assembly_plan_exposes_build_feature_groups_explicitly', 'product_runtime_assembly_reports_runtime_service_capability_gaps', @@ -3246,7 +3246,7 @@ export function runManifestParserSelfTest({ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/plugin_product_shape.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/plugin_product_shape.rs', contracts: [ 'executable_plugin_runtime_is_limited_to_product_full_desktop_and_cli', 'executable_plugin_runtime_client_builds_agent_runtime_parts', @@ -3285,7 +3285,7 @@ export function runManifestParserSelfTest({ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/product_sdk_assembly.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_sdk_assembly.rs', contracts: [ 'product_runtime_parts_can_build_agent_runtime_sdk_without_core', 'sdk_delivery_profile_builds_shared_runtime_owner_ceiling_without_bitfun_core', diff --git a/src/apps/cli/Cargo.toml b/src/apps/cli/Cargo.toml index 2d57b3f0d..8726604c0 100644 --- a/src/apps/cli/Cargo.toml +++ b/src/apps/cli/Cargo.toml @@ -78,7 +78,6 @@ toml = { workspace = true } # Session management uuid = { workspace = true } chrono = { workspace = true } -dashmap = { workspace = true } # Async trait async-trait = { workspace = true } @@ -97,7 +96,6 @@ similar = { workspace = true } # Syntax highlighting for code blocks and tool cards syntect = { workspace = true } -syntect-tui = { workspace = true } # Lazy initialization for syntax highlighter singleton once_cell = { workspace = true } diff --git a/src/apps/cli/src/ui/syntax_highlight.rs b/src/apps/cli/src/ui/syntax_highlight.rs index 1652fea12..1d2e7de5f 100644 --- a/src/apps/cli/src/ui/syntax_highlight.rs +++ b/src/apps/cli/src/ui/syntax_highlight.rs @@ -1,7 +1,7 @@ /// Syntax highlighting module for TUI /// -/// Uses `syntect` for syntax analysis and `syntect-tui` to convert -/// highlighted output into ratatui `Span`s. +/// Uses `syntect` for syntax analysis and converts highlighted output directly +/// into ratatui `Span`s. use once_cell::sync::Lazy; use ratatui::{ style::Style, diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index a99882a50..c7077c423 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -41,7 +41,6 @@ tauri-plugin-log = { workspace = true } tauri-plugin-autostart = { workspace = true } tauri-plugin-notification = { workspace = true } tauri-plugin-updater = { workspace = true } -tauri-plugin-global-shortcut = { workspace = true } tauri-plugin-single-instance = { workspace = true } tauri-plugin-window-state = { workspace = true } keepawake = { workspace = true } diff --git a/src/apps/desktop/capabilities/default.json b/src/apps/desktop/capabilities/default.json index ff6229e4b..2ecc97db9 100644 --- a/src/apps/desktop/capabilities/default.json +++ b/src/apps/desktop/capabilities/default.json @@ -105,11 +105,6 @@ "notification:allow-request-permission", "notification:allow-check-permissions", "notification:allow-permission-state", - "notification:allow-is-permission-granted", - "global-shortcut:default", - "global-shortcut:allow-register", - "global-shortcut:allow-unregister", - "global-shortcut:allow-unregister-all", - "global-shortcut:allow-is-registered" + "notification:allow-is-permission-granted" ] } diff --git a/src/crates/adapters/ai-adapters/Cargo.toml b/src/crates/adapters/ai-adapters/Cargo.toml index 2f8f05037..12cd71a50 100644 --- a/src/crates/adapters/ai-adapters/Cargo.toml +++ b/src/crates/adapters/ai-adapters/Cargo.toml @@ -4,11 +4,20 @@ version.workspace = true authors.workspace = true edition.workspace = true description = "Shared AI protocol adapters for BitFun core and installer" +autotests = false [lib] name = "bitfun_ai_adapters" crate-type = ["rlib"] +[[test]] +name = "ai_protocol_contracts" +path = "tests/ai_protocol_contracts.rs" + +[[test]] +name = "ai_stream_contracts" +path = "tests/ai_stream_contracts.rs" + [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } diff --git a/src/crates/adapters/ai-adapters/tests/ai_protocol_contracts.rs b/src/crates/adapters/ai-adapters/tests/ai_protocol_contracts.rs new file mode 100644 index 000000000..ba553f50b --- /dev/null +++ b/src/crates/adapters/ai-adapters/tests/ai_protocol_contracts.rs @@ -0,0 +1,4 @@ +#[path = "ai_protocol_contracts/model_selector.rs"] +mod model_selector; +#[path = "ai_protocol_contracts/openai_empty_content_parts.rs"] +mod openai_empty_content_parts; diff --git a/src/crates/adapters/ai-adapters/tests/model_selector.rs b/src/crates/adapters/ai-adapters/tests/ai_protocol_contracts/model_selector.rs similarity index 100% rename from src/crates/adapters/ai-adapters/tests/model_selector.rs rename to src/crates/adapters/ai-adapters/tests/ai_protocol_contracts/model_selector.rs diff --git a/src/crates/adapters/ai-adapters/tests/openai_empty_content_parts.rs b/src/crates/adapters/ai-adapters/tests/ai_protocol_contracts/openai_empty_content_parts.rs similarity index 100% rename from src/crates/adapters/ai-adapters/tests/openai_empty_content_parts.rs rename to src/crates/adapters/ai-adapters/tests/ai_protocol_contracts/openai_empty_content_parts.rs diff --git a/src/crates/adapters/ai-adapters/tests/ai_stream_contracts.rs b/src/crates/adapters/ai-adapters/tests/ai_stream_contracts.rs new file mode 100644 index 000000000..0a1dd8ec5 --- /dev/null +++ b/src/crates/adapters/ai-adapters/tests/ai_stream_contracts.rs @@ -0,0 +1,12 @@ +#[path = "ai_stream_contracts/common.rs"] +mod common; +#[path = "ai_stream_contracts/stream_processor_anthropic.rs"] +mod stream_processor_anthropic; +#[path = "ai_stream_contracts/stream_processor_openai.rs"] +mod stream_processor_openai; +#[path = "ai_stream_contracts/stream_processor_tool_arguments.rs"] +mod stream_processor_tool_arguments; +#[path = "ai_stream_contracts/stream_replay_regressions.rs"] +mod stream_replay_regressions; +#[path = "ai_stream_contracts/stream_test_harness.rs"] +mod stream_test_harness; diff --git a/src/crates/adapters/ai-adapters/tests/ai_stream_contracts/common.rs b/src/crates/adapters/ai-adapters/tests/ai_stream_contracts/common.rs new file mode 100644 index 000000000..6f78b75e3 --- /dev/null +++ b/src/crates/adapters/ai-adapters/tests/ai_stream_contracts/common.rs @@ -0,0 +1,6 @@ +#[path = "../common/fixture_loader.rs"] +pub(crate) mod fixture_loader; +#[path = "../common/sse_fixture_server.rs"] +pub(crate) mod sse_fixture_server; +#[path = "../common/stream_test_harness.rs"] +pub(crate) mod stream_test_harness; diff --git a/src/crates/adapters/ai-adapters/tests/stream_processor_anthropic.rs b/src/crates/adapters/ai-adapters/tests/ai_stream_contracts/stream_processor_anthropic.rs similarity index 99% rename from src/crates/adapters/ai-adapters/tests/stream_processor_anthropic.rs rename to src/crates/adapters/ai-adapters/tests/ai_stream_contracts/stream_processor_anthropic.rs index d8361e0c2..0025ad2a3 100644 --- a/src/crates/adapters/ai-adapters/tests/stream_processor_anthropic.rs +++ b/src/crates/adapters/ai-adapters/tests/ai_stream_contracts/stream_processor_anthropic.rs @@ -1,9 +1,7 @@ -mod common; - -use bitfun_events::AgenticEvent; -use common::stream_test_harness::{ +use crate::common::stream_test_harness::{ run_stream_fixture_with_options, StreamFixtureProvider, StreamFixtureRunOptions, }; +use bitfun_events::AgenticEvent; use serde_json::json; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/src/crates/adapters/ai-adapters/tests/stream_processor_openai.rs b/src/crates/adapters/ai-adapters/tests/ai_stream_contracts/stream_processor_openai.rs similarity index 99% rename from src/crates/adapters/ai-adapters/tests/stream_processor_openai.rs rename to src/crates/adapters/ai-adapters/tests/ai_stream_contracts/stream_processor_openai.rs index 133cb3469..95cc8d613 100644 --- a/src/crates/adapters/ai-adapters/tests/stream_processor_openai.rs +++ b/src/crates/adapters/ai-adapters/tests/ai_stream_contracts/stream_processor_openai.rs @@ -1,11 +1,9 @@ -mod common; - -use bitfun_events::{AgenticEvent, ToolEventData}; -use common::sse_fixture_server::FixtureSseServerOptions; -use common::stream_test_harness::{ +use crate::common::sse_fixture_server::FixtureSseServerOptions; +use crate::common::stream_test_harness::{ run_stream_fixture, run_stream_fixture_with_options, StreamFixtureProvider, StreamFixtureRunOptions, }; +use bitfun_events::{AgenticEvent, ToolEventData}; use serde_json::json; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/src/crates/adapters/ai-adapters/tests/stream_processor_tool_arguments.rs b/src/crates/adapters/ai-adapters/tests/ai_stream_contracts/stream_processor_tool_arguments.rs similarity index 94% rename from src/crates/adapters/ai-adapters/tests/stream_processor_tool_arguments.rs rename to src/crates/adapters/ai-adapters/tests/ai_stream_contracts/stream_processor_tool_arguments.rs index 64f2c9182..62e88cfd5 100644 --- a/src/crates/adapters/ai-adapters/tests/stream_processor_tool_arguments.rs +++ b/src/crates/adapters/ai-adapters/tests/ai_stream_contracts/stream_processor_tool_arguments.rs @@ -1,8 +1,6 @@ -mod common; - +use crate::common::sse_fixture_server::FixtureSseServerOptions; +use crate::common::stream_test_harness::{run_stream_fixture, StreamFixtureProvider}; use bitfun_events::AgenticEvent; -use common::sse_fixture_server::FixtureSseServerOptions; -use common::stream_test_harness::{run_stream_fixture, StreamFixtureProvider}; use serde_json::json; fn assert_no_stream_failure_event(events: &[AgenticEvent]) { diff --git a/src/crates/adapters/ai-adapters/tests/stream_replay_regressions.rs b/src/crates/adapters/ai-adapters/tests/ai_stream_contracts/stream_replay_regressions.rs similarity index 98% rename from src/crates/adapters/ai-adapters/tests/stream_replay_regressions.rs rename to src/crates/adapters/ai-adapters/tests/ai_stream_contracts/stream_replay_regressions.rs index 80b96afc8..c499c7ec6 100644 --- a/src/crates/adapters/ai-adapters/tests/stream_replay_regressions.rs +++ b/src/crates/adapters/ai-adapters/tests/ai_stream_contracts/stream_replay_regressions.rs @@ -1,14 +1,12 @@ -mod common; - +use crate::common::sse_fixture_server::FixtureSseServerOptions; +use crate::common::stream_test_harness::{ + run_stream_fixture, run_stream_fixture_with_options, StreamFixtureProvider, + StreamFixtureRunOptions, +}; use bitfun_agent_stream::StreamResult; use bitfun_ai_adapters::providers::{openai::OpenAIMessageConverter, AnthropicMessageConverter}; use bitfun_ai_adapters::{Message as AIMessage, ToolCall as AIToolCall}; use bitfun_events::{AgenticEvent, ToolEventData}; -use common::sse_fixture_server::FixtureSseServerOptions; -use common::stream_test_harness::{ - run_stream_fixture, run_stream_fixture_with_options, StreamFixtureProvider, - StreamFixtureRunOptions, -}; use serde_json::json; fn build_replay_assistant_message(result: &StreamResult) -> AIMessage { diff --git a/src/crates/adapters/ai-adapters/tests/stream_test_harness.rs b/src/crates/adapters/ai-adapters/tests/ai_stream_contracts/stream_test_harness.rs similarity index 94% rename from src/crates/adapters/ai-adapters/tests/stream_test_harness.rs rename to src/crates/adapters/ai-adapters/tests/ai_stream_contracts/stream_test_harness.rs index 98168c699..55bcce064 100644 --- a/src/crates/adapters/ai-adapters/tests/stream_test_harness.rs +++ b/src/crates/adapters/ai-adapters/tests/ai_stream_contracts/stream_test_harness.rs @@ -1,7 +1,5 @@ -mod common; - -use common::sse_fixture_server::FixtureSseServerOptions; -use common::stream_test_harness::{ +use crate::common::sse_fixture_server::FixtureSseServerOptions; +use crate::common::stream_test_harness::{ run_stream_fixture_with_options, StreamFixtureProvider, StreamFixtureRunOptions, }; use std::time::Duration; diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index be424303d..6e2f70a06 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -12,7 +12,6 @@ crate-type = ["rlib"] [dependencies] # Inherit shared dependencies from workspace tokio = { workspace = true, features = ["fs", "io-util", "macros", "net", "rt", "sync", "time"] } -tokio-stream = { workspace = true } tokio-util = { workspace = true } async-trait = { workspace = true } futures = { workspace = true } @@ -57,7 +56,6 @@ include_dir = { workspace = true, optional = true } # Command detection (cross-platform) similar = { workspace = true, optional = true } -urlencoding = { workspace = true } # Shared AI protocol adapters bitfun-ai-adapters = { path = "../../adapters/ai-adapters", optional = true } diff --git a/src/crates/assembly/product-capabilities/Cargo.toml b/src/crates/assembly/product-capabilities/Cargo.toml index 4d8e17738..a8428c8d8 100644 --- a/src/crates/assembly/product-capabilities/Cargo.toml +++ b/src/crates/assembly/product-capabilities/Cargo.toml @@ -4,11 +4,16 @@ version.workspace = true authors.workspace = true edition.workspace = true description = "BitFun product capability pack contracts" +autotests = false [lib] name = "bitfun_product_capabilities" crate-type = ["rlib"] +[[test]] +name = "product_capability_contracts" +path = "tests/product_capability_contracts.rs" + [dependencies] bitfun-harness = { path = "../../execution/harness" } bitfun-runtime-ports = { path = "../../contracts/runtime-ports" } diff --git a/src/crates/assembly/product-capabilities/tests/product_capability_contracts.rs b/src/crates/assembly/product-capabilities/tests/product_capability_contracts.rs new file mode 100644 index 000000000..91f73bb64 --- /dev/null +++ b/src/crates/assembly/product-capabilities/tests/product_capability_contracts.rs @@ -0,0 +1,6 @@ +#[path = "product_capability_contracts/plugin_product_shape.rs"] +mod plugin_product_shape; +#[path = "product_capability_contracts/product_capabilities.rs"] +mod product_capabilities; +#[path = "product_capability_contracts/product_sdk_assembly.rs"] +mod product_sdk_assembly; diff --git a/src/crates/assembly/product-capabilities/tests/plugin_product_shape.rs b/src/crates/assembly/product-capabilities/tests/product_capability_contracts/plugin_product_shape.rs similarity index 100% rename from src/crates/assembly/product-capabilities/tests/plugin_product_shape.rs rename to src/crates/assembly/product-capabilities/tests/product_capability_contracts/plugin_product_shape.rs diff --git a/src/crates/assembly/product-capabilities/tests/product_capabilities.rs b/src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs similarity index 100% rename from src/crates/assembly/product-capabilities/tests/product_capabilities.rs rename to src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs diff --git a/src/crates/assembly/product-capabilities/tests/product_sdk_assembly.rs b/src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_sdk_assembly.rs similarity index 100% rename from src/crates/assembly/product-capabilities/tests/product_sdk_assembly.rs rename to src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_sdk_assembly.rs diff --git a/src/crates/contracts/core-types/Cargo.toml b/src/crates/contracts/core-types/Cargo.toml index 22486a953..d714504e6 100644 --- a/src/crates/contracts/core-types/Cargo.toml +++ b/src/crates/contracts/core-types/Cargo.toml @@ -3,11 +3,16 @@ name = "bitfun-core-types" version.workspace = true edition.workspace = true description = "BitFun shared low-level product DTOs" +autotests = false [lib] name = "bitfun_core_types" crate-type = ["rlib"] +[[test]] +name = "core_type_contracts" +path = "tests/core_type_contracts.rs" + [dependencies] serde = { workspace = true } serde_json = { workspace = true } diff --git a/src/crates/contracts/core-types/tests/core_type_contracts.rs b/src/crates/contracts/core-types/tests/core_type_contracts.rs new file mode 100644 index 000000000..9cc3a9546 --- /dev/null +++ b/src/crates/contracts/core-types/tests/core_type_contracts.rs @@ -0,0 +1,8 @@ +#[path = "core_type_contracts/lsp_contracts.rs"] +mod lsp_contracts; +#[path = "core_type_contracts/session_contracts.rs"] +mod session_contracts; +#[path = "core_type_contracts/session_usage_contracts.rs"] +mod session_usage_contracts; +#[path = "core_type_contracts/surface_contracts.rs"] +mod surface_contracts; diff --git a/src/crates/contracts/core-types/tests/lsp_contracts.rs b/src/crates/contracts/core-types/tests/core_type_contracts/lsp_contracts.rs similarity index 100% rename from src/crates/contracts/core-types/tests/lsp_contracts.rs rename to src/crates/contracts/core-types/tests/core_type_contracts/lsp_contracts.rs diff --git a/src/crates/contracts/core-types/tests/session_contracts.rs b/src/crates/contracts/core-types/tests/core_type_contracts/session_contracts.rs similarity index 100% rename from src/crates/contracts/core-types/tests/session_contracts.rs rename to src/crates/contracts/core-types/tests/core_type_contracts/session_contracts.rs diff --git a/src/crates/contracts/core-types/tests/session_usage_contracts.rs b/src/crates/contracts/core-types/tests/core_type_contracts/session_usage_contracts.rs similarity index 100% rename from src/crates/contracts/core-types/tests/session_usage_contracts.rs rename to src/crates/contracts/core-types/tests/core_type_contracts/session_usage_contracts.rs diff --git a/src/crates/contracts/core-types/tests/surface_contracts.rs b/src/crates/contracts/core-types/tests/core_type_contracts/surface_contracts.rs similarity index 100% rename from src/crates/contracts/core-types/tests/surface_contracts.rs rename to src/crates/contracts/core-types/tests/core_type_contracts/surface_contracts.rs diff --git a/src/crates/contracts/product-domains/Cargo.toml b/src/crates/contracts/product-domains/Cargo.toml index 856fc2667..132a99f39 100644 --- a/src/crates/contracts/product-domains/Cargo.toml +++ b/src/crates/contracts/product-domains/Cargo.toml @@ -4,11 +4,16 @@ version.workspace = true authors.workspace = true edition.workspace = true description = "BitFun product domain owner crate" +autotests = false [lib] name = "bitfun_product_domains" crate-type = ["rlib"] +[[test]] +name = "product_domain_contracts" +path = "tests/product_domain_contracts.rs" + [[test]] name = "plugin_source_contracts" path = "tests/plugin_source_contracts.rs" @@ -19,27 +24,14 @@ name = "external_source_contracts" path = "tests/external_source_contracts.rs" required-features = ["external-sources"] -[[test]] -name = "external_hook_contribution_contracts" -path = "tests/external_hook_contribution_contracts.rs" -required-features = ["external-sources"] - -[[test]] -name = "external_hook_catalog_contracts" -path = "tests/external_hook_catalog_contracts.rs" -required-features = ["external-sources"] - -[[test]] -name = "workspace_reference_contracts" -path = "tests/workspace_reference_contracts.rs" -required-features = ["external-sources"] - [[test]] name = "function_agent_contracts" +path = "tests/function_agent_contracts.rs" required-features = ["function-agents"] [[test]] name = "miniapp_contracts" +path = "tests/miniapp_contracts.rs" required-features = ["miniapp"] [dependencies] diff --git a/src/crates/contracts/product-domains/tests/external_source_contracts.rs b/src/crates/contracts/product-domains/tests/external_source_contracts.rs index e9f9cc074..0ba4a81aa 100644 --- a/src/crates/contracts/product-domains/tests/external_source_contracts.rs +++ b/src/crates/contracts/product-domains/tests/external_source_contracts.rs @@ -1,2074 +1,10 @@ -use bitfun_product_domains::external_integration_policy::{ - evaluate_external_integration_policy, external_integration_policy_snapshot, - ExternalEcosystemPolicy, ExternalEcosystemPolicyOverride, ExternalIntegrationAccess, - ExternalIntegrationCapabilityDescriptor, ExternalIntegrationEcosystemDescriptor, - ExternalIntegrationMode, ExternalIntegrationPolicyDocument, ExternalIntegrationPolicyOverride, - ExternalIntegrationPolicyStatus, -}; -use bitfun_product_domains::external_source_control::{ - ExternalSourceControlActionV1, ExternalSourceControlRequestV1, ExternalSourceControlSnapshotV1, - ExternalSourceDesiredState, ExternalSourceDiscoveryState, ExternalSourceOperationStage, - ExternalSourceRecoveryActionV1, ExternalSourceReviewState, EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, -}; -use bitfun_product_domains::external_sources::{ - external_mcp_approval_key, external_mcp_conflict_key, external_tool_approval_key, - external_tool_conflict_key, prompt_command_conflict_key, EcosystemId, ExecutionDomainId, - ExpandedPromptCommand, ExternalIntegrationCapabilityId, ExternalMcpActivationState, - ExternalMcpApprovalRequest, ExternalMcpCatalogEntry, ExternalMcpConflict, - ExternalMcpConflictCandidate, ExternalMcpDiscoveryInput, ExternalMcpImportApplyRequestV1, - ExternalMcpImportSelectionV1, ExternalMcpProviderIdentity, ExternalMcpProviderSnapshot, - ExternalMcpRevisionKey, ExternalMcpServerDefinition, ExternalMcpStaticStatus, - ExternalMcpTimeouts, ExternalMcpTransportKind, ExternalSourceAssetKind, - ExternalSourceCatalogEntry, ExternalSourceCatalogSnapshot, ExternalSourceContext, - ExternalSourceDiagnostic, ExternalSourceHealth, ExternalSourceHostCapabilities, - ExternalSourceLifecycleState, ExternalSourceOperationError, ExternalSourceOperationErrorCode, - ExternalSourceProviderError, ExternalSourcePublicSnapshot, ExternalSourceRecord, - ExternalSourceScope, ExternalToolCapability, ExternalToolDefinition, ExternalToolRuntimeKind, - ExternalToolStaticStatus, ExternalWatchRoot, NativePromptCommandDescriptor, - PreparedExternalMcpImportServer, PreparedExternalMcpImportTransport, PreparedExternalMcpServer, - PreparedExternalMcpTransport, PromptCommandAvailability, PromptCommandCatalogEntry, - PromptCommandDefinition, PromptCommandExpansion, PromptCommandProviderIdentity, - PromptCommandProviderSnapshot, PromptCommandSourceProvider, SecretValue, SourceKey, - SourceQualifiedCommandId, SourceQualifiedMcpServerId, SourceQualifiedToolId, - SourceQualifiedToolTargetId, -}; -use bitfun_product_domains::external_subagents::{ - external_subagent_approval_key, external_subagent_candidate_id, external_subagent_conflict_key, - external_subagent_model_binding_key, ExternalSubagentBehaviorVersion, - ExternalSubagentCandidateId, ExternalSubagentCompatibilityState, - ExternalSubagentContributionId, ExternalSubagentContributionRole, ExternalSubagentDefinition, - ExternalSubagentDiscoveryInput, ExternalSubagentLocalId, ExternalSubagentMode, - ExternalSubagentModelBindingGroup, ExternalSubagentModelBindingMethod, - ExternalSubagentModelBindingOption, ExternalSubagentModelBindingTarget, - ExternalSubagentModelProfileRequest, ExternalSubagentModelRequest, - ExternalSubagentProvenanceRef, ExternalSubagentProviderIdentity, - ExternalSubagentProviderSnapshot, ExternalSubagentToolRequest, ExternalSubagentToolSelector, - SecretText, -}; -use bitfun_product_domains::tool_permissions::{ - PermissionConstraintLayer, PermissionEffect, PermissionRule, -}; -use sha2::{Digest, Sha256}; -use std::path::PathBuf; - -#[test] -fn native_prompt_command_descriptors_reject_external_candidate_namespaces() { - let descriptor = NativePromptCommandDescriptor { - command_name: "review".to_string(), - candidate_id: "opencode.commands:project:review".to_string(), - behavior_version: "v1".to_string(), - }; - - assert!(descriptor.validate().is_err()); -} - -#[test] -fn external_mcp_import_contract_keeps_private_values_out_of_debug_and_requests() { - let source = SourceKey::new("opencode.mcp", "user-config").unwrap(); - let prepared = PreparedExternalMcpImportServer { - id: SourceQualifiedMcpServerId::new(source, "docs").unwrap(), - behavior_version: "sha256:behavior-v1".to_string(), - transport: PreparedExternalMcpImportTransport::Local { - command: "secret-command".to_string(), - args: vec!["secret-argument".to_string()], - }, - }; - let debug = format!("{prepared:?}"); - assert!(!debug.contains("secret-command")); - assert!(!debug.contains("secret-argument")); - prepared.validate().unwrap(); - - let request = ExternalMcpImportApplyRequestV1 { - schema_version: 1, - plan_fingerprint: "sha256:plan-v1".to_string(), - selections: vec![ExternalMcpImportSelectionV1 { - candidate_id: "opencode:mcp:docs".to_string(), - requested_native_id: None, - }], - }; - request.validate().unwrap(); - let encoded = serde_json::to_string(&request).unwrap(); - assert!(!encoded.contains("command")); - assert!(!encoded.contains("argument")); -} - -#[test] -fn external_mcp_import_contract_rejects_urls_that_cannot_be_copied_losslessly() { - let prepared = |url: &str| PreparedExternalMcpImportServer { - id: SourceQualifiedMcpServerId::new( - SourceKey::new("codex.mcp", "user-config").unwrap(), - "docs", - ) - .unwrap(), - behavior_version: "sha256:behavior-v1".to_string(), - transport: PreparedExternalMcpImportTransport::Remote { - url: url.to_string(), - }, - }; - - prepared("https://docs.example.test/mcp") - .validate() - .unwrap(); - for url in [ - "http://docs.example.test/mcp", - "https://user@docs.example.test/mcp", - "https://user:secret@docs.example.test/mcp", - "https://docs.example.test/mcp?token=secret", - "https://docs.example.test/mcp#private", - ] { - assert!( - prepared(url).validate().is_err(), - "unexpectedly safe: {url}" - ); - } -} - -fn source(provider_id: &str, ecosystem_id: &str, source_id: &str) -> ExternalSourceRecord { - ExternalSourceRecord { - key: SourceKey::new(provider_id, source_id).expect("valid source key"), - ecosystem_id: EcosystemId::new(ecosystem_id).expect("valid ecosystem id"), - display_name: format!("{provider_id} commands"), - source_kind: "prompt_commands".to_string(), - scope: ExternalSourceScope::Project, - location: format!("/workspace/{provider_id}"), - execution_domain_id: ExecutionDomainId::new("local-user").expect("valid domain"), - health: ExternalSourceHealth::Available, - content_version: format!("{provider_id}-v1"), - diagnostics: Vec::new(), - } -} - -fn command(provider_id: &str, source_id: &str, precedence: i32) -> PromptCommandDefinition { - PromptCommandDefinition { - id: SourceQualifiedCommandId::new( - SourceKey::new(provider_id, source_id).unwrap(), - "review", - ) - .unwrap(), - name: "review".to_string(), - description: format!("Review from {provider_id}"), - template: format!("{provider_id}: $ARGUMENTS"), - shell_preference: None, - execution_target: Default::default(), - availability: PromptCommandAvailability::Available, - content_version: format!("command-v{precedence}"), - } -} - -fn context() -> ExternalSourceContext { - ExternalSourceContext { - workspace_root: Some(PathBuf::from("/workspace")), - execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), - } -} - -#[test] -fn opaque_ids_are_validated_without_closing_the_ecosystem_set() { - assert_eq!( - EcosystemId::new("future.product/v2") - .expect("future ecosystem ids remain open") - .as_str(), - "future.product/v2" - ); - assert!(EcosystemId::new(" ").is_err()); - assert!(ExecutionDomainId::new("domain\nwith-control").is_err()); -} - -#[test] -fn source_and_command_identity_remain_provider_qualified() { - let left = SourceQualifiedCommandId::new( - SourceKey::new("adapter-a", "project-commands").unwrap(), - "review", - ) - .unwrap(); - let right = SourceQualifiedCommandId::new( - SourceKey::new("adapter-b", "project-commands").unwrap(), - "review", - ) - .unwrap(); - - assert_ne!(left, right); - assert_ne!(left.stable_key(), right.stable_key()); -} - -#[test] -fn presentation_group_id_is_optional_and_uses_the_camel_case_wire_name() { - let mut entry = ExternalSourceCatalogEntry { - stable_key: "opencode.commands:project".to_string(), - presentation_group_id: None, - record: source("opencode.commands", "opencode", "project"), - lifecycle: ExternalSourceLifecycleState::Available, - }; - - let legacy_value = serde_json::to_value(&entry).unwrap(); - assert!(legacy_value.get("presentationGroupId").is_none()); - let legacy_entry: ExternalSourceCatalogEntry = serde_json::from_value(legacy_value).unwrap(); - assert!(legacy_entry.presentation_group_id.is_none()); - - entry.presentation_group_id = Some("external-source:[\"source\"]".to_string()); - let current_value = serde_json::to_value(&entry).unwrap(); - assert_eq!( - current_value["presentationGroupId"], - "external-source:[\"source\"]" - ); -} - -#[test] -fn conflict_fingerprint_is_order_independent_and_changes_with_content() { - let first = prompt_command_conflict_key("local-user", "review", [("a", "v1"), ("b", "v2")]); - let reordered = prompt_command_conflict_key("local-user", "REVIEW", [("b", "v2"), ("a", "v1")]); - let updated = prompt_command_conflict_key("local-user", "review", [("a", "v1"), ("b", "v3")]); - let remote = prompt_command_conflict_key("remote-user", "review", [("a", "v1"), ("b", "v2")]); - - assert_eq!(first, reordered); - assert_ne!(first, updated); - assert_ne!(first, remote); -} - -#[test] -fn prompt_commands_use_a_typed_contract_instead_of_an_arbitrary_asset_payload() { - let command = PromptCommandDefinition { - id: SourceQualifiedCommandId::new( - SourceKey::new("example-provider", "project-commands").unwrap(), - "review", - ) - .unwrap(), - name: "review".to_string(), - description: "Review the current change".to_string(), - template: "Review $ARGUMENTS".to_string(), - shell_preference: None, - execution_target: Default::default(), - availability: PromptCommandAvailability::Restricted { - reason: "Shell expansion is not supported yet".to_string(), - required_capabilities: vec!["command.shell".to_string()], - }, - content_version: "sha256:command-v1".to_string(), - }; - - let encoded = serde_json::to_value(&command).expect("serialize command contract"); - assert_eq!(encoded["name"], "review"); - assert_eq!(encoded["availability"]["state"], "restricted"); - assert!(encoded.get("payload").is_none()); -} - -struct FakeProvider { - identity: PromptCommandProviderIdentity, - snapshot: PromptCommandProviderSnapshot, -} - -impl FakeProvider { - fn new(provider_id: &str, ecosystem_id: &str, source_id: &str, precedence: i32) -> Self { - let identity = PromptCommandProviderIdentity::new( - provider_id, - ecosystem_id, - format!("{provider_id} display"), - ) - .unwrap(); - Self { - identity: identity.clone(), - snapshot: PromptCommandProviderSnapshot { - provider: identity, - sources: vec![source(provider_id, ecosystem_id, source_id)], - commands: vec![command(provider_id, source_id, precedence)], - unavailable_command_ids: Vec::new(), - diagnostics: Vec::new(), - }, - } - } -} - -impl PromptCommandSourceProvider for FakeProvider { - fn identity(&self) -> PromptCommandProviderIdentity { - self.identity.clone() - } - - fn discover( - &self, - _context: &ExternalSourceContext, - ) -> Result { - Ok(self.snapshot.clone()) - } - - fn expand( - &self, - _context: &ExternalSourceContext, - command: &PromptCommandDefinition, - arguments: &str, - ) -> Result { - Ok(PromptCommandExpansion { - content: command.template.replace("$ARGUMENTS", arguments), - workspace_file_references: vec!["src/lib.rs".to_string()], - shell: None, - }) - } - - fn watch_roots(&self, context: &ExternalSourceContext) -> Vec { - vec![ExternalWatchRoot { - path: context.workspace_root.clone().unwrap(), - recursive: true, - }] - } -} - -#[test] -fn capability_provider_contract_does_not_require_core_or_an_ecosystem_enum() { - let provider: Box = Box::new(FakeProvider::new( - "fake-provider", - "fake.ecosystem", - "project-commands", - 1, - )); - - let snapshot = provider.discover(&context()).expect("discover fake source"); - assert_eq!(snapshot.provider.ecosystem_id.as_str(), "fake.ecosystem"); - assert_eq!(provider.watch_roots(&context()).len(), 1); - let expansion = provider - .expand(&context(), &snapshot.commands[0], "change") - .expect("prepare fake command expansion"); - assert_eq!(expansion.content, "fake-provider: change"); - assert_eq!(expansion.workspace_file_references, ["src/lib.rs"]); - - let final_result = ExpandedPromptCommand { - content: expansion.content, - }; - assert_eq!( - serde_json::to_value(final_result).unwrap(), - serde_json::json!({"content": "fake-provider: change"}) - ); -} - -#[test] -fn persisted_source_preference_keys_round_trip_without_path_guessing() { - let record = source( - "provider.with.dots", - "fake.ecosystem", - "project/source:agents", - ); - assert_eq!( - ExternalSourceRecord::source_key_from_preference_key(&record.preference_key()), - Some(record.key) - ); - assert!(ExternalSourceRecord::source_key_from_preference_key("malformed").is_none()); -} - -#[test] -fn external_subagent_identity_preserves_ordered_provenance_and_separate_revisions() { - let provider = - ExternalSubagentProviderIdentity::new("fake.agents", "fake.ecosystem", "Fake Agents") - .unwrap(); - let first = ExternalSubagentContributionId::new( - SourceKey::new("fake.agents", "global-config").unwrap(), - ExternalSubagentLocalId::new("review").unwrap(), - ); - let second = ExternalSubagentContributionId::new( - SourceKey::new("fake.agents", "project-config").unwrap(), - ExternalSubagentLocalId::new("review").unwrap(), - ); - let provenance = vec![ - ExternalSubagentProvenanceRef { - contribution_id: first, - role: ExternalSubagentContributionRole::Base, - }, - ExternalSubagentProvenanceRef { - contribution_id: second, - role: ExternalSubagentContributionRole::Overlay, - }, - ]; - let candidate_id = external_subagent_candidate_id(&provider.provider_id, "review", &provenance); - let reversed = external_subagent_candidate_id( - &provider.provider_id, - "review", - &provenance.iter().cloned().rev().collect::>(), - ); - assert_ne!( - candidate_id, reversed, - "provenance order changes behavior identity" - ); - - let definition = ExternalSubagentDefinition { - candidate_id, - logical_id: "review".to_string(), - provenance, - display_name: "Review".to_string(), - description: "Reviews a change".to_string(), - prompt: SecretText::new("Review carefully"), - mode: ExternalSubagentMode::Subagent, - disabled: false, - hidden: false, - requested_model: ExternalSubagentModelRequest::Default, - requested_model_profile: None, - requested_tools: ExternalSubagentToolRequest { - selectors: vec![ExternalSubagentToolSelector { - source_name: "read".to_string(), - canonical_host_name: Some("Read".to_string()), - allowed: true, - }], - uses_conservative_default: false, - }, - permission_constraints: PermissionConstraintLayer::new(vec![PermissionRule::new( - "read", - "C:/sensitive/private/*", - PermissionEffect::Deny, - )]), - compatibility: ExternalSubagentCompatibilityState::Ready, - diagnostic_codes: Vec::new(), - behavior_version: ExternalSubagentBehaviorVersion::new("behavior-v1").unwrap(), - }; - assert_eq!(definition.prompt.expose(), "Review carefully"); - assert!(!format!("{definition:?}").contains("Review carefully")); - assert!(!format!("{definition:?}").contains("C:/sensitive/private")); - - let mut invalid_model = definition.clone(); - invalid_model.requested_model = ExternalSubagentModelRequest::Reference { - provider_hint: Some("fake\nprovider".to_string()), - model_name: "model".to_string(), - }; - assert!(invalid_model.validate().is_err()); - - let mut invalid_tool = definition.clone(); - invalid_tool.requested_tools.selectors[0].source_name = "read\nsecret".to_string(); - assert!(invalid_tool.validate().is_err()); - - let mut invalid_permission = definition.clone(); - invalid_permission.permission_constraints = - PermissionConstraintLayer::new(vec![PermissionRule::new( - "read\nsecret", - "*", - PermissionEffect::Deny, - )]); - assert!(invalid_permission.validate().is_err()); - - let mut invalid_diagnostic = definition.clone(); - invalid_diagnostic.diagnostic_codes = vec!["provider.invalid:raw-source-key".to_string()]; - assert!(invalid_diagnostic.validate().is_err()); - - let mut excessive_tools = definition.clone(); - excessive_tools.requested_tools.selectors = (0..257) - .map(|index| ExternalSubagentToolSelector { - source_name: format!("tool-{index}"), - canonical_host_name: None, - allowed: true, - }) - .collect(); - assert!(excessive_tools.validate().is_err()); - - let snapshot = ExternalSubagentProviderSnapshot { - provider, - sources: vec![ - source("fake.agents", "fake.ecosystem", "global-config"), - source("fake.agents", "fake.ecosystem", "project-config"), - ], - definitions: vec![definition], - diagnostics: Vec::new(), - }; - snapshot - .validate() - .expect("valid external subagent provider snapshot"); - - let source_key = snapshot.sources[0].key.clone(); - let mut valid_diagnostic = snapshot.clone(); - valid_diagnostic.diagnostics.push( - ExternalSourceDiagnostic::warning( - "fake.agent.degraded", - "An optional field is not supported", - Some(source_key), - ) - .with_asset_kind(ExternalSourceAssetKind::Subagent), - ); - valid_diagnostic - .validate() - .expect("bounded provider diagnostics with a known source are valid"); - - let mut valid_source_diagnostic = snapshot.clone(); - let valid_source_key = valid_source_diagnostic.sources[0].key.clone(); - valid_source_diagnostic.sources[0].diagnostics.push( - ExternalSourceDiagnostic::warning( - "fake.agent.source_degraded", - "This source has a recoverable warning", - Some(valid_source_key), - ) - .with_asset_kind(ExternalSourceAssetKind::Subagent), - ); - valid_source_diagnostic - .validate() - .expect("source-owned diagnostics use the same provider contract"); - - let mut invalid_provider_diagnostic = snapshot.clone(); - invalid_provider_diagnostic.diagnostics.push( - ExternalSourceDiagnostic::warning( - "fake.agent:raw-source", - "Invalid diagnostic code", - Some(SourceKey::new("other.agents", "project").unwrap()), - ) - .with_asset_kind(ExternalSourceAssetKind::Command), - ); - assert!(invalid_provider_diagnostic.validate().is_err()); - - let mut wrong_provider_diagnostic = snapshot.clone(); - wrong_provider_diagnostic.diagnostics.push( - ExternalSourceDiagnostic::warning( - "fake.agent.invalid_source", - "Unknown provider source", - Some(SourceKey::new("other.agents", "project").unwrap()), - ) - .with_asset_kind(ExternalSourceAssetKind::Subagent), - ); - assert!(wrong_provider_diagnostic.validate().is_err()); - - let mut unknown_source_diagnostic = snapshot.clone(); - unknown_source_diagnostic.diagnostics.push( - ExternalSourceDiagnostic::warning( - "fake.agent.unknown_source", - "Unknown source", - Some(SourceKey::new("fake.agents", "missing").unwrap()), - ) - .with_asset_kind(ExternalSourceAssetKind::Subagent), - ); - assert!(unknown_source_diagnostic.validate().is_err()); - - let mut invalid_diagnostic_message = snapshot.clone(); - invalid_diagnostic_message.diagnostics.push( - ExternalSourceDiagnostic::warning("fake.agent.invalid_message", "invalid\nmessage", None) - .with_asset_kind(ExternalSourceAssetKind::Subagent), - ); - assert!(invalid_diagnostic_message.validate().is_err()); - - let mut wrong_asset_kind = snapshot.clone(); - wrong_asset_kind.diagnostics.push( - ExternalSourceDiagnostic::warning( - "fake.agent.wrong_kind", - "Diagnostic belongs to another asset kind", - None, - ) - .with_asset_kind(ExternalSourceAssetKind::Tool), - ); - assert!(wrong_asset_kind.validate().is_err()); - - let mut excessive_sources = snapshot.clone(); - excessive_sources.sources = vec![snapshot.sources[0].clone(); 1025]; - assert!(excessive_sources.validate().is_err()); - - let mut excessive_definitions = snapshot.clone(); - excessive_definitions.definitions = vec![snapshot.definitions[0].clone(); 1025]; - assert!(excessive_definitions.validate().is_err()); - - let mut excessive_diagnostics = snapshot.clone(); - excessive_diagnostics.diagnostics = vec![ - ExternalSourceDiagnostic::warning( - "fake.agent.degraded", - "An optional field is not supported", - None, - ) - .with_asset_kind(ExternalSourceAssetKind::Subagent); - 1025 - ]; - assert!(excessive_diagnostics.validate().is_err()); - - let mut excessive_provenance = snapshot.clone(); - excessive_provenance.definitions[0].provenance = - vec![snapshot.definitions[0].provenance[0].clone(); 257]; - assert!(excessive_provenance.validate().is_err()); - - let input = ExternalSubagentDiscoveryInput { - context: context(), - suppressed_sources: [SourceKey::new("fake.agents", "suppressed").unwrap()] - .into_iter() - .collect(), - }; - assert_eq!(input.suppressed_sources.len(), 1); -} - -#[test] -fn external_subagent_model_contract_preserves_control_and_opaque_reference_semantics() { - let requests = [ - ExternalSubagentModelRequest::Default, - ExternalSubagentModelRequest::Inherit, - ExternalSubagentModelRequest::Reference { - provider_hint: Some("openrouter".to_string()), - model_name: "anthropic/claude-sonnet-4".to_string(), - }, - ExternalSubagentModelRequest::Reference { - provider_hint: None, - model_name: "gpt-5.6-codex".to_string(), - }, - ExternalSubagentModelRequest::Reference { - provider_hint: None, - model_name: "glm-5".to_string(), - }, - ExternalSubagentModelRequest::Reference { - provider_hint: None, - model_name: "deepseek-v4".to_string(), - }, - ExternalSubagentModelRequest::Reference { - provider_hint: None, - model_name: "future-model-that-does-not-exist-yet".to_string(), - }, - ]; - - for request in requests { - let encoded = serde_json::to_value(&request).unwrap(); - if let ExternalSubagentModelRequest::Reference { - provider_hint, - model_name, - } = &request - { - assert_eq!(encoded["modelName"], model_name.as_str()); - assert!(encoded.get("model_name").is_none()); - if let Some(provider_hint) = provider_hint { - assert_eq!(encoded["providerHint"], provider_hint.as_str()); - assert!(encoded.get("provider_hint").is_none()); - } - } - let decoded: ExternalSubagentModelRequest = serde_json::from_value(encoded).unwrap(); - assert_eq!(decoded, request); - } - - assert_ne!( - ExternalSubagentModelRequest::Inherit, - ExternalSubagentModelRequest::Reference { - provider_hint: None, - model_name: "inherit".to_string(), - } - ); -} - -#[test] -fn external_subagent_model_profile_contract_keeps_variant_and_effort_semantics_distinct() { - let profiles = [ - ExternalSubagentModelProfileRequest::NamedVariant { - name: "high".to_string(), - }, - ExternalSubagentModelProfileRequest::ReasoningEffort { - value: "high".to_string(), - }, - ]; - - let encoded = profiles - .iter() - .map(|profile| serde_json::to_value(profile).unwrap()) - .collect::>(); - assert_eq!( - encoded[0], - serde_json::json!({ "kind": "named_variant", "name": "high" }) - ); - assert_eq!( - encoded[1], - serde_json::json!({ "kind": "reasoning_effort", "value": "high" }) - ); - assert_ne!(profiles[0], profiles[1]); - - for (profile, encoded) in profiles.into_iter().zip(encoded) { - let decoded: ExternalSubagentModelProfileRequest = serde_json::from_value(encoded).unwrap(); - assert_eq!(decoded, profile); - } - - assert!(ExternalSubagentModelProfileRequest::NamedVariant { - name: "x".repeat(4097), - } - .validate() - .is_err()); - assert!(ExternalSubagentModelProfileRequest::ReasoningEffort { - value: "bad\u{0001}".to_string(), - } - .validate() - .is_err()); -} - -#[test] -fn external_subagent_model_binding_contract_groups_only_matching_scope_identity() { - let ecosystem = EcosystemId::new("opencode").unwrap(); - let request = ExternalSubagentModelRequest::Reference { - provider_hint: Some("openrouter".to_string()), - model_name: "vendor/model".to_string(), - }; - let global_a = external_subagent_model_binding_key( - &ecosystem, - &request, - None, - "local-user", - ExternalSourceScope::UserGlobal, - "D:/workspace/a", - ) - .unwrap(); - assert_eq!( - global_a, - "external_subagent_model_binding:408ebedb7c2644acda3b4c0c5a78e8eb83fb2ece8b3a1671a866ed0d6cc08f56", - "profile-free bindings must retain their pre-profile persisted identity" - ); - let global_b = external_subagent_model_binding_key( - &ecosystem, - &request, - None, - "local-user", - ExternalSourceScope::UserGlobal, - "D:/workspace/b", - ) - .unwrap(); - assert_eq!( - global_a, global_b, - "user bindings belong to the execution domain" - ); - - let project_a = external_subagent_model_binding_key( - &ecosystem, - &request, - None, - "local-user", - ExternalSourceScope::Project, - "D:/workspace/a", - ) - .unwrap(); - let project_b = external_subagent_model_binding_key( - &ecosystem, - &request, - None, - "local-user", - ExternalSourceScope::Project, - "D:/workspace/b", - ) - .unwrap(); - assert_ne!( - project_a, project_b, - "project bindings stay workspace-scoped" - ); - assert_ne!( - global_a, project_a, - "global and project bindings never alias" - ); - let remote_global = external_subagent_model_binding_key( - &ecosystem, - &request, - None, - "remote:user@example", - ExternalSourceScope::RemoteUser, - "D:/workspace/a", - ) - .unwrap(); - assert_ne!( - global_a, remote_global, - "remote and local execution domains never share bindings" - ); - - let option = ExternalSubagentModelBindingOption { - target: ExternalSubagentModelBindingTarget::Primary, - effective_model_label: "Provider / Model".to_string(), - configured_reasoning_effort: Some("high".to_string()), - }; - let group = ExternalSubagentModelBindingGroup { - binding_key: project_a, - request, - profile_request: Some(ExternalSubagentModelProfileRequest::ReasoningEffort { - value: "high".to_string(), - }), - scope: ExternalSourceScope::Project, - method: ExternalSubagentModelBindingMethod::Explicit, - selected_target: Some(option.target.clone()), - effective_model_label: Some(option.effective_model_label.clone()), - affected_candidate_ids: vec!["candidate-a".to_string(), "candidate-b".to_string()], - }; - let encoded = serde_json::to_value((&option, &group)).unwrap(); - let decoded: ( - ExternalSubagentModelBindingOption, - ExternalSubagentModelBindingGroup, - ) = serde_json::from_value(encoded).unwrap(); - assert_eq!(decoded, (option, group)); -} - -#[test] -fn external_subagent_profile_binding_identity_extends_existing_model_binding_scope() { - let ecosystem = EcosystemId::new("opencode").unwrap(); - let default_request = ExternalSubagentModelRequest::Default; - assert!(external_subagent_model_binding_key( - &ecosystem, - &default_request, - None, - "local-user", - ExternalSourceScope::Project, - "D:/workspace/a", - ) - .is_none()); - - let variant = ExternalSubagentModelProfileRequest::NamedVariant { - name: "high".to_string(), - }; - let effort = ExternalSubagentModelProfileRequest::ReasoningEffort { - value: "high".to_string(), - }; - let variant_key = external_subagent_model_binding_key( - &ecosystem, - &default_request, - Some(&variant), - "local-user", - ExternalSourceScope::Project, - "D:/workspace/a", - ) - .unwrap(); - let effort_key = external_subagent_model_binding_key( - &ecosystem, - &default_request, - Some(&effort), - "local-user", - ExternalSourceScope::Project, - "D:/workspace/a", - ) - .unwrap(); - assert_ne!(variant_key, effort_key); - let delimited_provider = ExternalSubagentModelRequest::Reference { - provider_hint: Some("a:b".to_string()), - model_name: "c".to_string(), - }; - let delimited_model = ExternalSubagentModelRequest::Reference { - provider_hint: Some("a".to_string()), - model_name: "b:c".to_string(), - }; - let key_for = |request| { - external_subagent_model_binding_key( - &ecosystem, - request, - Some(&effort), - "local-user", - ExternalSourceScope::Project, - "D:/workspace/a", - ) - .unwrap() - }; - assert_ne!(key_for(&delimited_provider), key_for(&delimited_model)); -} - -#[test] -fn external_subagent_decision_keys_bind_behavior_but_not_catalog_copy() { - let candidate = ExternalSubagentCandidateId::new("candidate-v1").unwrap(); - let behavior = ExternalSubagentBehaviorVersion::new("behavior-v1").unwrap(); - let approval = external_subagent_approval_key(&candidate, &behavior, "envelope-v1"); - let same = external_subagent_approval_key(&candidate, &behavior, "envelope-v1"); - let changed = external_subagent_approval_key( - &candidate, - &ExternalSubagentBehaviorVersion::new("behavior-v2").unwrap(), - "envelope-v1", - ); - assert_eq!(approval, same); - assert_ne!(approval, changed); - - let first = external_subagent_conflict_key( - "local-user", - "/workspace", - "review", - [("local", "v1"), (candidate.as_str(), behavior.as_str())], - ); - let reordered = external_subagent_conflict_key( - "local-user", - "/workspace", - "REVIEW", - [(candidate.as_str(), behavior.as_str()), ("local", "v1")], - ); - assert_eq!(first, reordered); -} - -#[test] -fn diagnostics_remain_source_qualified() { - let diagnostic = ExternalSourceDiagnostic::warning( - "fake.warning", - "A non-blocking fake diagnostic", - Some(SourceKey::new("fake", "source").unwrap()), - ); - assert_eq!(diagnostic.source.unwrap().provider_id.as_str(), "fake"); -} - -#[test] -fn provider_snapshot_rejects_duplicate_sources_and_commands() { - let provider = FakeProvider::new("fake", "fake.ecosystem", "project", 1); - let mut duplicate_source = provider.snapshot.clone(); - duplicate_source - .sources - .push(duplicate_source.sources[0].clone()); - assert!(duplicate_source.validate().is_err()); - - let mut duplicate_command = provider.snapshot; - duplicate_command - .commands - .push(duplicate_command.commands[0].clone()); - assert!(duplicate_command.validate().is_err()); -} - -#[test] -fn unavailable_command_must_be_unique_absent_and_source_qualified() { - let provider = FakeProvider::new("fake", "fake.ecosystem", "project", 1); - let mut invalid = provider.snapshot; - invalid - .unavailable_command_ids - .push(invalid.commands[0].id.clone()); - assert!(invalid.validate().is_err()); -} - -#[test] -fn standalone_tool_contract_separates_static_preview_from_executable_source() { - let target = SourceQualifiedToolTargetId::new( - SourceKey::new("opencode.tools", "project-tools").unwrap(), - "weather.js", - ) - .unwrap(); - let tool = ExternalToolDefinition { - id: SourceQualifiedToolId::new(target, "default").unwrap(), - name: "weather".to_string(), - description_preview: "Get the weather for a location".to_string(), - module_path: "/workspace/.opencode/tools/weather.js".to_string(), - working_directory: "/workspace".to_string(), - runtime_kind: ExternalToolRuntimeKind::JavaScript, - capabilities: vec![ - ExternalToolCapability::FileSystem, - ExternalToolCapability::Network, - ExternalToolCapability::Process, - ], - content_version: "sha256:v1".to_string(), - static_status: ExternalToolStaticStatus::Ready, - }; - - let encoded = serde_json::to_value(&tool).expect("serialize tool preview"); - assert_eq!(encoded["name"], "weather"); - assert_eq!(encoded["runtimeKind"], "java_script"); - assert!(encoded.get("moduleSource").is_none()); - assert!(encoded.get("payload").is_none()); - tool.validate().expect("valid standalone tool preview"); -} - -#[test] -fn legacy_public_snapshot_downprojects_new_tool_review_variants() { - let snapshot: ExternalSourcePublicSnapshot = serde_json::from_value(serde_json::json!({ - "generation": 1, - "discoveryPending": false, - "sources": [], - "commands": [{ - "candidateId": "17:opencode.commands6:global6:review", - "definition": { - "id": { - "source": { "providerId": "opencode.commands", "sourceId": "global" }, - "localId": "review" - }, - "name": "review", - "description": "Review changes", - "availability": { "state": "available" }, - "contentVersion": "v1" - } - }], - "tools": [{ - "definition": { - "id": { - "target": { - "source": { "providerId": "opencode.tools", "sourceId": "project" }, - "localId": "weather.js" - }, - "exportId": "default" - }, - "name": "weather", - "descriptionPreview": "Get weather", - "modulePath": "/.opencode/tools/weather.js", - "workingDirectory": "", - "runtimeKind": "java_script", - "capabilities": [], - "contentVersion": "sha256:v1", - "staticStatus": { "state": "ready" } - }, - "approvalKey": "approval-v1", - "decisionKey": "decision-v1", - "activation": { "state": "declined" } - }], - "subagents": [{ - "candidateId": "external-review", - "logicalId": "review", - "displayName": "External Review", - "description": "Review changes", - "providerLabel": "OpenCode", - "scope": "project", - "sourceKeys": [], - "sourceLocationLabels": [], - "sourceCount": 1, - "requestedModel": { - "kind": "reference", - "providerHint": "anthropic", - "modelName": "claude-sonnet-4" - }, - "requestedModelProfile": { - "kind": "reasoning_effort", - "value": "high" - }, - "modelBindingMethod": "binding_required", - "modelBindingKey": "external_subagent_model_binding:review", - "effectiveToolLabels": ["Read"], - "unavailableToolLabels": ["Shell"], - "supportsFollowUp": false, - "compatibilityState": "blocked", - "diagnostics": [{ - "code": "external_subagent.tool_unavailable", - "blocksActivation": true - }], - "activationState": { "state": "blocked" }, - "decisionKey": "agent-decision-v1" - }], - "subagentModelBindingGroups": [{ - "bindingKey": "external_subagent_model_binding:review", - "request": { "kind": "reference", "modelName": "claude-sonnet-4" }, - "profileRequest": { "kind": "reasoning_effort", "value": "high" }, - "scope": "project", - "method": "binding_required", - "affectedCandidateIds": ["external-review"] - }], - "subagentModelBindingOptions": [{ - "target": { "kind": "fast" }, - "effectiveModelLabel": "Fast", - "configuredReasoningEffort": "high" - }] - })) - .expect("new public snapshot"); - - let legacy = - serde_json::to_value(snapshot.into_legacy_v0_compatible()).expect("legacy public snapshot"); - assert!(legacy["commands"][0].get("candidateId").is_none()); - assert_eq!(legacy["tools"][0]["activation"]["state"], "disabled"); - assert!(legacy["subagents"][0] - .get("unavailableToolLabels") - .is_none()); - assert!(legacy["subagents"][0].get("requestedModel").is_none()); - assert!(legacy["subagents"][0] - .get("requestedModelProfile") - .is_none()); - assert!(legacy["subagents"][0].get("modelBindingMethod").is_none()); - assert!(legacy["subagents"][0].get("modelBindingKey").is_none()); - assert!(legacy.get("subagentModelBindingGroups").is_none()); - assert!(legacy.get("subagentModelBindingOptions").is_none()); -} - -#[test] -fn standalone_tool_contract_rejects_names_that_are_not_model_callable() { - let target = SourceQualifiedToolTargetId::new( - SourceKey::new("fake.tools", "project-tools").unwrap(), - "unsafe.js", - ) - .unwrap(); - let mut tool = ExternalToolDefinition { - id: SourceQualifiedToolId::new(target, "default").unwrap(), - name: "unsafe tool".to_string(), - description_preview: String::new(), - module_path: "/workspace/unsafe.js".to_string(), - working_directory: "/workspace".to_string(), - runtime_kind: ExternalToolRuntimeKind::JavaScript, - capabilities: vec![ExternalToolCapability::FileSystem], - content_version: "sha256:v1".to_string(), - static_status: ExternalToolStaticStatus::Ready, - }; - - assert!(tool.validate().is_err()); - tool.name = "safe_tool-1".to_string(); - tool.validate() - .expect("portable tool name should be accepted"); -} - -#[test] -fn tool_approval_is_stable_for_safe_updates_but_changes_with_capabilities_or_domain() { - let target = SourceQualifiedToolTargetId::new( - SourceKey::new("opencode.tools", "project-tools").unwrap(), - "weather.js", - ) - .unwrap(); - let first = external_tool_approval_key( - "local-user", - &target, - ExternalToolRuntimeKind::JavaScript, - [ - ExternalToolCapability::FileSystem, - ExternalToolCapability::Network, - ], - ); - let reordered = external_tool_approval_key( - "local-user", - &target, - ExternalToolRuntimeKind::JavaScript, - [ - ExternalToolCapability::Network, - ExternalToolCapability::FileSystem, - ], - ); - let expanded = external_tool_approval_key( - "local-user", - &target, - ExternalToolRuntimeKind::JavaScript, - [ - ExternalToolCapability::FileSystem, - ExternalToolCapability::Network, - ExternalToolCapability::Process, - ], - ); - let remote = external_tool_approval_key( - "remote-user", - &target, - ExternalToolRuntimeKind::JavaScript, - [ - ExternalToolCapability::FileSystem, - ExternalToolCapability::Network, - ], - ); - - assert_eq!(first, reordered); - assert_ne!(first, expanded); - assert_ne!(first, remote); -} - -#[test] -fn tool_conflict_choice_is_invalidated_when_name_or_candidate_changes() { - let first = external_tool_conflict_key( - "local-user", - "weather", - [ - ("builtin:weather", "builtin-v1"), - ("opencode:weather", "tool-v1"), - ], - ); - let reordered = external_tool_conflict_key( - "local-user", - "WEATHER", - [ - ("opencode:weather", "tool-v1"), - ("builtin:weather", "builtin-v1"), - ], - ); - let updated = external_tool_conflict_key( - "local-user", - "weather", - [ - ("builtin:weather", "builtin-v1"), - ("opencode:weather", "tool-v2"), - ], - ); - - assert_ne!(first, reordered); - assert_ne!(first, updated); -} - -#[test] -fn external_mcp_contract_keeps_runtime_secrets_out_of_static_snapshots() { - let source = source("opencode.mcp", "opencode", "project-config"); - let definition = ExternalMcpServerDefinition { - id: SourceQualifiedMcpServerId::new(source.key.clone(), "github").unwrap(), - provenance: vec![source.key.clone()], - name: "github".to_string(), - transport: ExternalMcpTransportKind::StreamableHttp, - command_preview: None, - argument_count: 0, - working_directory: None, - environment_keys: Vec::new(), - environment_reference_names: Vec::new(), - remote_url_preview: Some("https://mcp.example.com/mcp".to_string()), - header_names: vec!["Authorization".to_string()], - timeouts: ExternalMcpTimeouts::default(), - source_enabled: true, - behavior_version: "sha256:behavior-v1".to_string(), - static_status: ExternalMcpStaticStatus::Ready, - }; - let provider = - ExternalMcpProviderIdentity::new("opencode.mcp", "opencode", "OpenCode MCP servers") - .unwrap(); - let snapshot = ExternalMcpProviderSnapshot { - provider, - sources: vec![source], - servers: vec![definition.clone()], - diagnostics: Vec::new(), - }; - - snapshot.validate().expect("valid MCP provider snapshot"); - let encoded = serde_json::to_string(&snapshot).expect("serialize MCP snapshot"); - assert!(encoded.contains("Authorization")); - assert!(!encoded.contains("Bearer secret")); - assert!(encoded.contains("mcp.example.com")); - - let prepared = PreparedExternalMcpServer { - id: definition.id, - behavior_version: definition.behavior_version, - timeouts: ExternalMcpTimeouts::default(), - transport: PreparedExternalMcpTransport::Remote { - url: "https://mcp.example.com/mcp?token=url-secret".to_string(), - headers: [( - "Authorization".to_string(), - SecretValue::new("Bearer secret"), - )] - .into_iter() - .collect(), - oauth_enabled: true, - }, - }; - assert_eq!( - prepared.transport.remote_headers().unwrap()["Authorization"].expose(), - "Bearer secret" - ); - assert!(!format!("{prepared:?}").contains("Bearer secret")); - assert!(!format!("{prepared:?}").contains("url-secret")); -} - -#[test] -fn external_mcp_timeouts_are_positive_optional_millisecond_facts() { - let timeouts = ExternalMcpTimeouts { - startup_ms: Some(2_000), - catalog_ms: None, - execution_ms: Some(30_000), - }; - - timeouts.validate().expect("positive timeouts are valid"); - assert_eq!( - serde_json::to_value(&timeouts).unwrap(), - serde_json::json!({ - "startupMs": 2_000, - "executionMs": 30_000, - }) - ); - assert!(ExternalMcpTimeouts { - startup_ms: Some(0), - ..Default::default() - } - .validate() - .is_err()); - assert!(ExternalMcpTimeouts { - execution_ms: Some(9_007_199_254_740_991), - ..Default::default() - } - .validate() - .is_ok()); - assert!(ExternalMcpTimeouts { - execution_ms: Some(9_007_199_254_740_992), - ..Default::default() - } - .validate() - .is_err()); - assert!(ExternalMcpTimeouts::default().is_empty()); -} - -#[test] -fn external_mcp_revision_key_never_exposes_material_through_debug_output() { - let key = ExternalMcpRevisionKey::new([0x5a; 32]); - assert_eq!(format!("{key:?}"), "ExternalMcpRevisionKey([REDACTED])"); - assert!(!format!("{key:?}").contains("5a")); -} - -#[test] -fn external_mcp_revision_is_stable_secret_sensitive_and_not_an_unkeyed_oracle() { - let key = ExternalMcpRevisionKey::new([7; 32]); - let first = key.opaque_revision( - "test.mcp.behavior.v1", - [b"server".as_slice(), b"PIN=0007".as_slice()], - ); - let repeated = key.opaque_revision( - "test.mcp.behavior.v1", - [b"server".as_slice(), b"PIN=0007".as_slice()], - ); - let changed = key.opaque_revision( - "test.mcp.behavior.v1", - [b"server".as_slice(), b"PIN=0008".as_slice()], - ); - let raw_candidate = format!( - "sha256:{}", - hex::encode(Sha256::digest(b"server\0PIN=0007")) - ); - - assert_eq!(first, repeated); - assert_ne!(first, changed); - assert_ne!(first, raw_candidate); - assert!(first.starts_with("hmac-sha256:")); -} - -#[test] -fn external_mcp_snapshot_rejects_cross_provider_and_duplicate_servers() { - let provider = - ExternalMcpProviderIdentity::new("opencode.mcp", "opencode", "OpenCode MCP").unwrap(); - let source = source("opencode.mcp", "opencode", "project-config"); - let definition = ExternalMcpServerDefinition { - id: SourceQualifiedMcpServerId::new(source.key.clone(), "github").unwrap(), - provenance: vec![source.key.clone()], - name: "github".to_string(), - transport: ExternalMcpTransportKind::LocalStdio, - command_preview: Some("npx".to_string()), - argument_count: 2, - working_directory: Some("/workspace".to_string()), - environment_keys: vec!["GITHUB_TOKEN".to_string()], - environment_reference_names: Vec::new(), - remote_url_preview: None, - header_names: Vec::new(), - timeouts: ExternalMcpTimeouts::default(), - source_enabled: true, - behavior_version: "sha256:behavior-v1".to_string(), - static_status: ExternalMcpStaticStatus::Ready, - }; - let snapshot = ExternalMcpProviderSnapshot { - provider, - sources: vec![source], - servers: vec![definition.clone(), definition], - diagnostics: Vec::new(), - }; - - assert!(snapshot.validate().is_err()); - - let input = ExternalMcpDiscoveryInput { - context: context(), - suppressed_sources: [SourceKey::new("opencode.mcp", "suppressed").unwrap()] - .into_iter() - .collect(), - revision_key: ExternalMcpRevisionKey::new([7; 32]), - }; - assert_eq!(input.suppressed_sources.len(), 1); -} - -#[test] -fn external_mcp_decisions_change_only_with_behavior_domain_or_conflict_participants() { - let id = SourceQualifiedMcpServerId::new( - SourceKey::new("opencode.mcp", "project-config").unwrap(), - "github", - ) - .unwrap(); - let first = external_mcp_approval_key("local-user", "/workspace-a", &id, "behavior-v1"); - let same = external_mcp_approval_key("local-user", "/workspace-a", &id, "behavior-v1"); - let updated = external_mcp_approval_key("local-user", "/workspace-a", &id, "behavior-v2"); - let other_workspace = - external_mcp_approval_key("local-user", "/workspace-b", &id, "behavior-v1"); - let remote = external_mcp_approval_key("remote-user", "/workspace-a", &id, "behavior-v1"); - assert_eq!(first, same); - assert_ne!(first, updated); - assert_ne!(first, other_workspace); - assert_ne!(first, remote); - - let stable_id = id.stable_key(); - let conflict = external_mcp_conflict_key( - "local-user", - "/workspace-a", - "github", - [ - ("bitfun:github", "native-v1"), - (stable_id.as_str(), "behavior-v1"), - ], - ); - let reordered = external_mcp_conflict_key( - "local-user", - "/workspace-a", - "GITHUB", - [ - (stable_id.as_str(), "behavior-v1"), - ("bitfun:github", "native-v1"), - ], - ); - let participant_updated = external_mcp_conflict_key( - "local-user", - "/workspace-a", - "github", - [ - ("bitfun:github", "native-v1"), - (stable_id.as_str(), "behavior-v2"), - ], - ); - assert_eq!(conflict, reordered); - assert_ne!(conflict, participant_updated); - assert_ne!( - conflict, - external_mcp_conflict_key( - "local-user", - "/workspace-b", - "github", - [ - ("bitfun:github", "native-v1"), - (stable_id.as_str(), "behavior-v1"), - ], - ) - ); -} - -#[test] -fn external_mcp_product_view_is_version_guarded_and_contains_only_disclosed_fields() { - let source = source("opencode.mcp", "opencode", "project-config"); - let definition = ExternalMcpServerDefinition { - id: SourceQualifiedMcpServerId::new(source.key.clone(), "github").unwrap(), - provenance: vec![source.key], - name: "github".to_string(), - transport: ExternalMcpTransportKind::LocalStdio, - command_preview: Some("npx".to_string()), - argument_count: 2, - working_directory: Some("".to_string()), - environment_keys: vec!["GITHUB_TOKEN".to_string()], - environment_reference_names: Vec::new(), - remote_url_preview: None, - header_names: Vec::new(), - timeouts: ExternalMcpTimeouts::default(), - source_enabled: true, - behavior_version: "sha256:behavior-v1".to_string(), - static_status: ExternalMcpStaticStatus::Ready, - }; - let entry = ExternalMcpCatalogEntry { - candidate_id: definition.candidate_id(), - definition: definition.clone(), - approval_key: "external_mcp_approval:local-user:v1".to_string(), - decision_key: "external_mcp_approval:local-user:v1".to_string(), - runtime_id: None, - activation_state: ExternalMcpActivationState::ApprovalRequired, - }; - let request = ExternalMcpApprovalRequest { - candidate_id: entry.candidate_id.clone(), - approval_key: entry.approval_key.clone(), - decision_key: entry.decision_key.clone(), - definition, - }; - let conflict = ExternalMcpConflict { - conflict_key: "external_mcp:local-user:github:v1".to_string(), - server_name: "github".to_string(), - candidates: vec![ - ExternalMcpConflictCandidate { - candidate_id: "native_mcp:github".to_string(), - display_name: "BitFun: github".to_string(), - external: false, - source: None, - behavior_version: "native-v1".to_string(), - available: true, - unavailable_reason: None, - }, - ExternalMcpConflictCandidate { - candidate_id: entry.candidate_id.clone(), - display_name: "OpenCode: github".to_string(), - external: true, - source: Some(entry.definition.id.source.clone()), - behavior_version: entry.definition.behavior_version.clone(), - available: true, - unavailable_reason: None, - }, - ], - selected_candidate_id: None, - }; - - let encoded = serde_json::to_string(&(entry, request, conflict)).unwrap(); - assert!(encoded.contains("GITHUB_TOKEN")); - assert!(!encoded.contains("Bearer secret")); - assert!(encoded.contains("approval_required")); -} - -fn external_capability(value: &str) -> ExternalIntegrationCapabilityId { - ExternalIntegrationCapabilityId::new(value).expect("valid external capability id") -} - -const TEST_ECOSYSTEM_ID: &str = "test-ecosystem"; -const EXTERNAL_CAPABILITY_COMMAND: &str = "command"; -const EXTERNAL_CAPABILITY_TOOL: &str = "tool"; -const EXTERNAL_CAPABILITY_SUBAGENT: &str = "subagent"; -const EXTERNAL_CAPABILITY_MCP: &str = "mcp"; - -fn test_external_integration_ecosystems() -> Vec { - let capability = - |id, recommended_access, safety_ceiling| ExternalIntegrationCapabilityDescriptor { - capability_id: external_capability(id), - recommended_access, - safety_ceiling, - }; - vec![ExternalIntegrationEcosystemDescriptor { - ecosystem_id: EcosystemId::new(TEST_ECOSYSTEM_ID).unwrap(), - display_name: "Test ecosystem".to_string(), - adapter_revision: "1".to_string(), - capabilities: vec![ - capability( - EXTERNAL_CAPABILITY_COMMAND, - ExternalIntegrationAccess::Auto, - ExternalIntegrationAccess::Auto, - ), - capability( - EXTERNAL_CAPABILITY_TOOL, - ExternalIntegrationAccess::AskBeforeUse, - ExternalIntegrationAccess::AskBeforeUse, - ), - capability( - EXTERNAL_CAPABILITY_SUBAGENT, - ExternalIntegrationAccess::AskBeforeUse, - ExternalIntegrationAccess::AskBeforeUse, - ), - capability( - EXTERNAL_CAPABILITY_MCP, - ExternalIntegrationAccess::AskBeforeUse, - ExternalIntegrationAccess::AskBeforeUse, - ), - ], - }] -} - -#[test] -fn external_integration_policy_is_disabled_by_default() { - let effective = evaluate_external_integration_policy( - &ExternalIntegrationPolicyDocument::default(), - Some("workspace-a"), - &test_external_integration_ecosystems(), - ) - .expect("default policy evaluates"); - let opencode = effective - .ecosystems - .get(&EcosystemId::new(TEST_ECOSYSTEM_ID).unwrap()) - .expect("test ecosystem is registered"); - - assert!(!effective.enabled); - assert_eq!(opencode.mode, ExternalIntegrationMode::Disabled); - for capability in [ - EXTERNAL_CAPABILITY_COMMAND, - EXTERNAL_CAPABILITY_TOOL, - EXTERNAL_CAPABILITY_SUBAGENT, - EXTERNAL_CAPABILITY_MCP, - ] { - assert_eq!( - opencode.capabilities[&external_capability(capability)], - ExternalIntegrationAccess::Disabled - ); - } -} - -#[test] -fn explicitly_enabled_recommended_policy_keeps_registered_access_defaults() { - let mut document = ExternalIntegrationPolicyDocument::default(); - document.user_defaults.enabled = true; - - let effective = evaluate_external_integration_policy( - &document, - Some("workspace-a"), - &test_external_integration_ecosystems(), - ) - .expect("enabled recommended policy evaluates"); - let opencode = effective - .ecosystems - .get(&EcosystemId::new(TEST_ECOSYSTEM_ID).unwrap()) - .expect("test ecosystem is registered"); - - assert!(effective.enabled); - assert_eq!(opencode.mode, ExternalIntegrationMode::Recommended); - assert_eq!( - opencode.capabilities[&external_capability(EXTERNAL_CAPABILITY_COMMAND)], - ExternalIntegrationAccess::Auto - ); - for capability in [ - EXTERNAL_CAPABILITY_TOOL, - EXTERNAL_CAPABILITY_SUBAGENT, - EXTERNAL_CAPABILITY_MCP, - ] { - assert_eq!( - opencode.capabilities[&external_capability(capability)], - ExternalIntegrationAccess::AskBeforeUse - ); - } -} - -#[test] -fn workspace_policy_overrides_only_the_fields_the_user_changed() { - let ecosystem = EcosystemId::new(TEST_ECOSYSTEM_ID).unwrap(); - let mut document = ExternalIntegrationPolicyDocument::default(); - document.user_defaults.enabled = true; - document.user_defaults.ecosystems.insert( - ecosystem.clone(), - ExternalEcosystemPolicy { - mode: ExternalIntegrationMode::DiscoverOnly, - ..ExternalEcosystemPolicy::default() - }, - ); - document.workspace_overrides.insert( - "workspace-a".to_string(), - ExternalIntegrationPolicyOverride { - ecosystems: [( - ecosystem.clone(), - ExternalEcosystemPolicyOverride { - mode: Some(ExternalIntegrationMode::Custom), - capability_overrides: [( - external_capability(EXTERNAL_CAPABILITY_COMMAND), - ExternalIntegrationAccess::Auto, - )] - .into_iter() - .collect(), - ..ExternalEcosystemPolicyOverride::default() - }, - )] - .into_iter() - .collect(), - ..ExternalIntegrationPolicyOverride::default() - }, - ); - - let effective = evaluate_external_integration_policy( - &document, - Some("workspace-a"), - &test_external_integration_ecosystems(), - ) - .unwrap(); - let opencode = &effective.ecosystems[&ecosystem]; - assert_eq!(opencode.mode, ExternalIntegrationMode::Custom); - assert_eq!( - opencode.capabilities[&external_capability(EXTERNAL_CAPABILITY_COMMAND)], - ExternalIntegrationAccess::Auto - ); - assert_eq!( - opencode.capabilities[&external_capability(EXTERNAL_CAPABILITY_MCP)], - ExternalIntegrationAccess::DiscoverOnly - ); - - let inherited = evaluate_external_integration_policy( - &document, - Some("workspace-b"), - &test_external_integration_ecosystems(), - ) - .unwrap(); - assert_eq!( - inherited.ecosystems[&ecosystem].mode, - ExternalIntegrationMode::DiscoverOnly - ); -} - -#[test] -fn high_risk_auto_access_is_limited_by_the_capability_owner() { - let ecosystem = EcosystemId::new(TEST_ECOSYSTEM_ID).unwrap(); - let mcp = external_capability(EXTERNAL_CAPABILITY_MCP); - let mut document = ExternalIntegrationPolicyDocument::default(); - document.user_defaults.enabled = true; - document.user_defaults.ecosystems.insert( - ecosystem.clone(), - ExternalEcosystemPolicy { - mode: ExternalIntegrationMode::Custom, - capability_overrides: [(mcp.clone(), ExternalIntegrationAccess::Auto)] - .into_iter() - .collect(), - ..ExternalEcosystemPolicy::default() - }, - ); - - let effective = evaluate_external_integration_policy( - &document, - None, - &test_external_integration_ecosystems(), - ) - .unwrap(); - let opencode = &effective.ecosystems[&ecosystem]; - assert_eq!( - opencode.capabilities[&mcp], - ExternalIntegrationAccess::AskBeforeUse - ); - assert!(opencode.policy_limited_capabilities.contains(&mcp)); -} - -#[test] -fn future_policy_values_and_minor_fields_survive_read_modify_write() { - let raw = serde_json::json!({ - "schemaMajor": 1, - "userDefaults": { - "enabled": true, - "ecosystems": { - "opencode": { - "mode": "future_mode", - "capabilityOverrides": { - "future-capability": "future_access" - }, - "futureEcosystemField": { "enabled": true } - } - }, - "futureSettingsField": "preserve-me" - }, - "workspaceOverrides": {}, - "futureDocumentField": [1, 2, 3] - }); - let mut document: ExternalIntegrationPolicyDocument = - serde_json::from_value(raw.clone()).expect("future minor data remains readable"); - document.user_defaults.enabled = false; - let encoded = serde_json::to_value(&document).expect("policy remains serializable"); - - assert_eq!( - encoded["userDefaults"]["ecosystems"]["opencode"]["mode"], - "future_mode" - ); - assert_eq!( - encoded["userDefaults"]["ecosystems"]["opencode"]["capabilityOverrides"] - ["future-capability"], - "future_access" - ); - assert_eq!( - encoded["userDefaults"]["ecosystems"]["opencode"]["futureEcosystemField"], - raw["userDefaults"]["ecosystems"]["opencode"]["futureEcosystemField"] - ); - assert_eq!( - encoded["userDefaults"]["futureSettingsField"], - "preserve-me" - ); - assert_eq!(encoded["futureDocumentField"], raw["futureDocumentField"]); - - let effective = evaluate_external_integration_policy( - &document, - None, - &test_external_integration_ecosystems(), - ) - .unwrap(); - assert!(!effective.enabled); -} - -#[test] -fn incompatible_policy_schema_major_is_rejected_without_downgrade() { - let document = ExternalIntegrationPolicyDocument { - schema_major: 2, - ..ExternalIntegrationPolicyDocument::default() - }; - let error = evaluate_external_integration_policy( - &document, - None, - &test_external_integration_ecosystems(), - ) - .expect_err("future major schemas must fail closed"); - assert!(error.to_string().contains("schema major: 2")); -} - -#[test] -fn incompatible_policy_schema_has_a_safe_read_only_public_snapshot() { - let raw = serde_json::json!({ - "schemaMajor": 2, - "userDefaults": { - "enabled": true, - "futureSecretHostField": "persistence-only" - }, - "futureDocumentField": { "keep": true } - }); - let document: ExternalIntegrationPolicyDocument = serde_json::from_value(raw).unwrap(); - let snapshot = external_integration_policy_snapshot( - &document, - Some("workspace-a"), - test_external_integration_ecosystems(), - ) - .expect("incompatible schemas remain inspectable through a safe snapshot"); - - assert_eq!( - snapshot.status, - ExternalIntegrationPolicyStatus::IncompatibleSchema - ); - assert!(!snapshot.global_effective.enabled); - assert!(!snapshot.effective.enabled); - assert!(snapshot - .effective - .ecosystems - .values() - .all(|ecosystem| ecosystem - .capabilities - .values() - .all(|access| { matches!(access, ExternalIntegrationAccess::Disabled) }))); - - let public = serde_json::to_string(&snapshot).unwrap(); - assert!(!public.contains("futureSecretHostField")); - assert!(!public.contains("futureDocumentField")); - - let persisted = serde_json::to_string(&document).unwrap(); - assert!(persisted.contains("futureSecretHostField")); - assert!(persisted.contains("futureDocumentField")); -} - -#[test] -fn integration_registry_rejects_ambiguous_or_unsafe_descriptors() { - let mut duplicate_ecosystem = test_external_integration_ecosystems(); - duplicate_ecosystem.push(duplicate_ecosystem[0].clone()); - let duplicate_error = evaluate_external_integration_policy( - &ExternalIntegrationPolicyDocument::default(), - None, - &duplicate_ecosystem, - ) - .expect_err("duplicate ecosystem registrations must fail closed"); - assert!(duplicate_error.to_string().contains("duplicate ecosystem")); - - let mut unsafe_recommendation = test_external_integration_ecosystems(); - unsafe_recommendation[0].capabilities[1].recommended_access = ExternalIntegrationAccess::Auto; - let unsafe_error = evaluate_external_integration_policy( - &ExternalIntegrationPolicyDocument::default(), - None, - &unsafe_recommendation, - ) - .expect_err("registry defaults cannot exceed their safety ceiling"); - assert!(unsafe_error - .to_string() - .contains("exceeds the safety ceiling")); -} - -#[test] -fn public_snapshot_never_exposes_executable_prompt_templates() { - let snapshot = ExternalSourceCatalogSnapshot { - generation: 1, - discovery_pending: false, - sources: Vec::new(), - commands: vec![PromptCommandCatalogEntry { - definition: command("opencode", "project-commands", 1), - }], - command_conflicts: Vec::new(), - tools: Vec::new(), - tool_approval_requests: Vec::new(), - tool_conflicts: Vec::new(), - mcp_generation: 0, - mcp_servers: Vec::new(), - mcp_approval_requests: Vec::new(), - mcp_conflicts: Vec::new(), - subagent_generation: 0, - preference_revision: 0, - subagents: Vec::new(), - subagent_model_binding_groups: vec![ExternalSubagentModelBindingGroup { - binding_key: "external_subagent_model_binding:review".to_string(), - request: ExternalSubagentModelRequest::Reference { - provider_hint: Some("anthropic".to_string()), - model_name: "claude-sonnet-4".to_string(), - }, - profile_request: None, - scope: ExternalSourceScope::Project, - method: ExternalSubagentModelBindingMethod::BindingRequired, - selected_target: None, - effective_model_label: None, - affected_candidate_ids: vec!["opencode-review".to_string()], - }], - subagent_model_binding_options: vec![ExternalSubagentModelBindingOption { - target: ExternalSubagentModelBindingTarget::Fast, - effective_model_label: "GLM-4.5-Air".to_string(), - configured_reasoning_effort: None, - }], - subagent_conflicts: Vec::new(), - pending_subagent_approvals: Vec::new(), - integration_policy: Default::default(), - diagnostics: Vec::new(), - }; - - let public = ExternalSourcePublicSnapshot::from(snapshot); - let encoded = serde_json::to_value(public).expect("serialize public projection"); - - assert_eq!(encoded["commands"][0]["definition"]["name"], "review"); - assert!(encoded["commands"][0]["definition"] - .get("template") - .is_none()); - assert_eq!( - encoded["subagentModelBindingGroups"][0]["bindingKey"], - "external_subagent_model_binding:review" - ); - assert_eq!( - encoded["subagentModelBindingOptions"][0]["effectiveModelLabel"], - "GLM-4.5-Air" - ); -} - -#[test] -fn control_projection_keeps_lifecycle_facts_orthogonal() { - let catalog = ExternalSourceCatalogSnapshot { - generation: 7, - discovery_pending: false, - sources: vec![ExternalSourceCatalogEntry { - stable_key: "opencode.commands:project".to_string(), - presentation_group_id: None, - record: source("opencode.commands", "opencode", "project"), - lifecycle: ExternalSourceLifecycleState::UsingLastValidVersion, - }], - commands: vec![PromptCommandCatalogEntry { - definition: command("opencode.commands", "project", 1), - }], - command_conflicts: Vec::new(), - tools: Vec::new(), - tool_approval_requests: Vec::new(), - tool_conflicts: Vec::new(), - mcp_generation: 2, - mcp_servers: Vec::new(), - mcp_approval_requests: Vec::new(), - mcp_conflicts: Vec::new(), - subagent_generation: 3, - preference_revision: 11, - subagents: Vec::new(), - subagent_model_binding_groups: Vec::new(), - subagent_model_binding_options: Vec::new(), - subagent_conflicts: Vec::new(), - pending_subagent_approvals: Vec::new(), - integration_policy: Default::default(), - diagnostics: Vec::new(), - }; - - let control = ExternalSourceControlSnapshotV1::from_catalog( - &catalog, - ExecutionDomainId::new("local-user").unwrap(), - false, - ExternalSourceHostCapabilities::read_write(), - ); - - assert_eq!(control.schema_version, EXTERNAL_SOURCE_CONTROL_SCHEMA_V1); - assert_eq!(control.refresh_generation, 7); - assert_eq!(control.preference_revision, 11); - assert_eq!(control.sources.len(), 1); - assert_eq!( - control.sources[0].discovery, - ExternalSourceDiscoveryState::LastKnownGood - ); - assert_eq!( - control.sources[0].desired, - ExternalSourceDesiredState::Enabled - ); - assert_eq!( - control.sources[0].review, - ExternalSourceReviewState::NotRequired - ); - assert_eq!(control.capabilities.len(), 4); -} - -#[test] -fn control_projection_does_not_infer_review_facts_from_runtime_activation() { - let record = source("opencode.mcp", "opencode", "project-config"); - let definition = ExternalMcpServerDefinition { - id: SourceQualifiedMcpServerId::new(record.key.clone(), "docs").unwrap(), - provenance: vec![record.key.clone()], - name: "docs".to_string(), - transport: ExternalMcpTransportKind::StreamableHttp, - command_preview: None, - argument_count: 0, - working_directory: None, - environment_keys: Vec::new(), - environment_reference_names: Vec::new(), - remote_url_preview: Some("https://mcp.example.com".to_string()), - header_names: Vec::new(), - timeouts: ExternalMcpTimeouts::default(), - source_enabled: true, - behavior_version: "behavior-v1".to_string(), - static_status: ExternalMcpStaticStatus::Ready, - }; - let catalog = ExternalSourceCatalogSnapshot { - generation: 1, - discovery_pending: false, - sources: vec![ExternalSourceCatalogEntry { - stable_key: "opencode.mcp:project-config".to_string(), - presentation_group_id: None, - record: record.clone(), - lifecycle: ExternalSourceLifecycleState::Available, - }], - commands: Vec::new(), - command_conflicts: Vec::new(), - tools: Vec::new(), - tool_approval_requests: Vec::new(), - tool_conflicts: Vec::new(), - mcp_generation: 1, - mcp_servers: vec![ExternalMcpCatalogEntry { - candidate_id: "external_mcp:docs".to_string(), - definition, - approval_key: "approval-v1".to_string(), - decision_key: "decision-v1".to_string(), - runtime_id: None, - activation_state: ExternalMcpActivationState::Declined, - }], - mcp_approval_requests: Vec::new(), - mcp_conflicts: Vec::new(), - subagent_generation: 1, - preference_revision: 1, - subagents: Vec::new(), - subagent_model_binding_groups: Vec::new(), - subagent_model_binding_options: Vec::new(), - subagent_conflicts: Vec::new(), - pending_subagent_approvals: Vec::new(), - integration_policy: Default::default(), - diagnostics: Vec::new(), - }; - - let control = ExternalSourceControlSnapshotV1::from_catalog( - &catalog, - ExecutionDomainId::new("local-user").unwrap(), - false, - ExternalSourceHostCapabilities::read_write(), - ); - - assert_eq!( - control.sources[0].review, - ExternalSourceReviewState::NotRequired - ); -} - -#[test] -fn desktop_local_host_capability_is_additive_on_the_wire() { - let portable = serde_json::to_value(ExternalSourceHostCapabilities::read_write()).unwrap(); - let read_only = - serde_json::to_value(ExternalSourceHostCapabilities::read_only_projection()).unwrap(); - let desktop = serde_json::to_value(ExternalSourceHostCapabilities::local_desktop()).unwrap(); - - assert!(portable.get("canRevealSourceLocation").is_none()); - assert!(read_only.get("canRevealSourceLocation").is_none()); - assert_eq!(desktop["canRevealSourceLocation"], true); - - let legacy: ExternalSourceHostCapabilities = serde_json::from_value(serde_json::json!({ - "canRefresh": true, - "canMutatePolicy": true, - "canManageSources": true, - "canApproveRuntime": true, - "canExecuteExternalAssets": true, - "canSetSafeMode": true - })) - .unwrap(); - assert!(!legacy.can_reveal_source_location); -} - -#[test] -fn operation_error_round_trip_preserves_typed_recovery_without_message_parsing() { - let error = ExternalSourceOperationError::new( - ExternalSourceOperationErrorCode::StaleRevision, - "refresh required", - true, - ) - .with_stage(ExternalSourceOperationStage::ApplyPreference) - .with_causation_id("refresh-generation-7") - .with_recovery_action(ExternalSourceRecoveryActionV1::Refresh); - - let encoded = error.encode(); - assert_eq!(ExternalSourceOperationError::decode(&encoded), Some(error)); - assert!(!encoded.contains("metadata")); -} - -#[test] -fn control_action_uses_one_camel_case_dto_across_product_surfaces() { - let request = ExternalSourceControlRequestV1 { - schema_version: EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, - operation_id: "surface-operation-1".to_string(), - expected_preference_revision: Some(8), - action: ExternalSourceControlActionV1::SetSourceEnabled { - source_key: "opencode.commands:project".to_string(), - enabled: false, - }, - }; - - let encoded = serde_json::to_value(&request).expect("serialize control request"); - assert_eq!(encoded["schemaVersion"], 1); - assert_eq!(encoded["operationId"], "surface-operation-1"); - assert_eq!(encoded["expectedPreferenceRevision"], 8); - assert_eq!(encoded["action"]["type"], "set_source_enabled"); - assert_eq!(encoded["action"]["sourceKey"], "opencode.commands:project"); - assert!(encoded["action"].get("source_key").is_none()); - assert_eq!( - serde_json::from_value::(encoded) - .expect("deserialize the shared control request"), - request - ); -} - -#[test] -fn legacy_operation_errors_decode_with_empty_extension_fields() { - let decoded = ExternalSourceOperationError::decode( - r#"{"code":"unavailable","detail":"retry","retryable":true}"#, - ) - .expect("legacy operation error remains readable"); - - assert_eq!(decoded.code, ExternalSourceOperationErrorCode::Unavailable); - assert!(decoded.stage.is_none()); - assert!(decoded.causation_id.is_none()); - assert!(decoded.recovery_actions.is_empty()); -} - -#[test] -fn decoded_operation_errors_bound_untrusted_extension_fields() { - let oversized = "x".repeat(5000); - let encoded = serde_json::json!({ - "code": "stale_revision", - "detail": oversized, - "retryable": true, - "correlationId": "forged\nreference", - "recoveryActions": [ - { "type": "refresh" }, - { "type": "refresh" }, - { "type": "retry" } - ] - }) - .to_string(); - - let decoded = ExternalSourceOperationError::decode(&encoded).unwrap(); - assert_eq!(decoded.detail.chars().count(), 4096); - assert!(decoded.correlation_id.is_none()); - assert_eq!( - decoded.recovery_actions, - vec![ - ExternalSourceRecoveryActionV1::Refresh, - ExternalSourceRecoveryActionV1::Retry, - ] - ); -} +#![cfg(feature = "external-sources")] + +#[path = "external_source_contracts/external_hook_catalog_contracts.rs"] +mod external_hook_catalog_contracts; +#[path = "external_source_contracts/external_hook_contribution_contracts.rs"] +mod external_hook_contribution_contracts; +#[path = "external_source_contracts/external_source_contracts.rs"] +mod external_source_contracts; +#[path = "external_source_contracts/workspace_reference_contracts.rs"] +mod workspace_reference_contracts; diff --git a/src/crates/contracts/product-domains/tests/external_hook_catalog_contracts.rs b/src/crates/contracts/product-domains/tests/external_source_contracts/external_hook_catalog_contracts.rs similarity index 100% rename from src/crates/contracts/product-domains/tests/external_hook_catalog_contracts.rs rename to src/crates/contracts/product-domains/tests/external_source_contracts/external_hook_catalog_contracts.rs diff --git a/src/crates/contracts/product-domains/tests/external_hook_contribution_contracts.rs b/src/crates/contracts/product-domains/tests/external_source_contracts/external_hook_contribution_contracts.rs similarity index 100% rename from src/crates/contracts/product-domains/tests/external_hook_contribution_contracts.rs rename to src/crates/contracts/product-domains/tests/external_source_contracts/external_hook_contribution_contracts.rs diff --git a/src/crates/contracts/product-domains/tests/external_source_contracts/external_source_contracts.rs b/src/crates/contracts/product-domains/tests/external_source_contracts/external_source_contracts.rs new file mode 100644 index 000000000..e9f9cc074 --- /dev/null +++ b/src/crates/contracts/product-domains/tests/external_source_contracts/external_source_contracts.rs @@ -0,0 +1,2074 @@ +use bitfun_product_domains::external_integration_policy::{ + evaluate_external_integration_policy, external_integration_policy_snapshot, + ExternalEcosystemPolicy, ExternalEcosystemPolicyOverride, ExternalIntegrationAccess, + ExternalIntegrationCapabilityDescriptor, ExternalIntegrationEcosystemDescriptor, + ExternalIntegrationMode, ExternalIntegrationPolicyDocument, ExternalIntegrationPolicyOverride, + ExternalIntegrationPolicyStatus, +}; +use bitfun_product_domains::external_source_control::{ + ExternalSourceControlActionV1, ExternalSourceControlRequestV1, ExternalSourceControlSnapshotV1, + ExternalSourceDesiredState, ExternalSourceDiscoveryState, ExternalSourceOperationStage, + ExternalSourceRecoveryActionV1, ExternalSourceReviewState, EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, +}; +use bitfun_product_domains::external_sources::{ + external_mcp_approval_key, external_mcp_conflict_key, external_tool_approval_key, + external_tool_conflict_key, prompt_command_conflict_key, EcosystemId, ExecutionDomainId, + ExpandedPromptCommand, ExternalIntegrationCapabilityId, ExternalMcpActivationState, + ExternalMcpApprovalRequest, ExternalMcpCatalogEntry, ExternalMcpConflict, + ExternalMcpConflictCandidate, ExternalMcpDiscoveryInput, ExternalMcpImportApplyRequestV1, + ExternalMcpImportSelectionV1, ExternalMcpProviderIdentity, ExternalMcpProviderSnapshot, + ExternalMcpRevisionKey, ExternalMcpServerDefinition, ExternalMcpStaticStatus, + ExternalMcpTimeouts, ExternalMcpTransportKind, ExternalSourceAssetKind, + ExternalSourceCatalogEntry, ExternalSourceCatalogSnapshot, ExternalSourceContext, + ExternalSourceDiagnostic, ExternalSourceHealth, ExternalSourceHostCapabilities, + ExternalSourceLifecycleState, ExternalSourceOperationError, ExternalSourceOperationErrorCode, + ExternalSourceProviderError, ExternalSourcePublicSnapshot, ExternalSourceRecord, + ExternalSourceScope, ExternalToolCapability, ExternalToolDefinition, ExternalToolRuntimeKind, + ExternalToolStaticStatus, ExternalWatchRoot, NativePromptCommandDescriptor, + PreparedExternalMcpImportServer, PreparedExternalMcpImportTransport, PreparedExternalMcpServer, + PreparedExternalMcpTransport, PromptCommandAvailability, PromptCommandCatalogEntry, + PromptCommandDefinition, PromptCommandExpansion, PromptCommandProviderIdentity, + PromptCommandProviderSnapshot, PromptCommandSourceProvider, SecretValue, SourceKey, + SourceQualifiedCommandId, SourceQualifiedMcpServerId, SourceQualifiedToolId, + SourceQualifiedToolTargetId, +}; +use bitfun_product_domains::external_subagents::{ + external_subagent_approval_key, external_subagent_candidate_id, external_subagent_conflict_key, + external_subagent_model_binding_key, ExternalSubagentBehaviorVersion, + ExternalSubagentCandidateId, ExternalSubagentCompatibilityState, + ExternalSubagentContributionId, ExternalSubagentContributionRole, ExternalSubagentDefinition, + ExternalSubagentDiscoveryInput, ExternalSubagentLocalId, ExternalSubagentMode, + ExternalSubagentModelBindingGroup, ExternalSubagentModelBindingMethod, + ExternalSubagentModelBindingOption, ExternalSubagentModelBindingTarget, + ExternalSubagentModelProfileRequest, ExternalSubagentModelRequest, + ExternalSubagentProvenanceRef, ExternalSubagentProviderIdentity, + ExternalSubagentProviderSnapshot, ExternalSubagentToolRequest, ExternalSubagentToolSelector, + SecretText, +}; +use bitfun_product_domains::tool_permissions::{ + PermissionConstraintLayer, PermissionEffect, PermissionRule, +}; +use sha2::{Digest, Sha256}; +use std::path::PathBuf; + +#[test] +fn native_prompt_command_descriptors_reject_external_candidate_namespaces() { + let descriptor = NativePromptCommandDescriptor { + command_name: "review".to_string(), + candidate_id: "opencode.commands:project:review".to_string(), + behavior_version: "v1".to_string(), + }; + + assert!(descriptor.validate().is_err()); +} + +#[test] +fn external_mcp_import_contract_keeps_private_values_out_of_debug_and_requests() { + let source = SourceKey::new("opencode.mcp", "user-config").unwrap(); + let prepared = PreparedExternalMcpImportServer { + id: SourceQualifiedMcpServerId::new(source, "docs").unwrap(), + behavior_version: "sha256:behavior-v1".to_string(), + transport: PreparedExternalMcpImportTransport::Local { + command: "secret-command".to_string(), + args: vec!["secret-argument".to_string()], + }, + }; + let debug = format!("{prepared:?}"); + assert!(!debug.contains("secret-command")); + assert!(!debug.contains("secret-argument")); + prepared.validate().unwrap(); + + let request = ExternalMcpImportApplyRequestV1 { + schema_version: 1, + plan_fingerprint: "sha256:plan-v1".to_string(), + selections: vec![ExternalMcpImportSelectionV1 { + candidate_id: "opencode:mcp:docs".to_string(), + requested_native_id: None, + }], + }; + request.validate().unwrap(); + let encoded = serde_json::to_string(&request).unwrap(); + assert!(!encoded.contains("command")); + assert!(!encoded.contains("argument")); +} + +#[test] +fn external_mcp_import_contract_rejects_urls_that_cannot_be_copied_losslessly() { + let prepared = |url: &str| PreparedExternalMcpImportServer { + id: SourceQualifiedMcpServerId::new( + SourceKey::new("codex.mcp", "user-config").unwrap(), + "docs", + ) + .unwrap(), + behavior_version: "sha256:behavior-v1".to_string(), + transport: PreparedExternalMcpImportTransport::Remote { + url: url.to_string(), + }, + }; + + prepared("https://docs.example.test/mcp") + .validate() + .unwrap(); + for url in [ + "http://docs.example.test/mcp", + "https://user@docs.example.test/mcp", + "https://user:secret@docs.example.test/mcp", + "https://docs.example.test/mcp?token=secret", + "https://docs.example.test/mcp#private", + ] { + assert!( + prepared(url).validate().is_err(), + "unexpectedly safe: {url}" + ); + } +} + +fn source(provider_id: &str, ecosystem_id: &str, source_id: &str) -> ExternalSourceRecord { + ExternalSourceRecord { + key: SourceKey::new(provider_id, source_id).expect("valid source key"), + ecosystem_id: EcosystemId::new(ecosystem_id).expect("valid ecosystem id"), + display_name: format!("{provider_id} commands"), + source_kind: "prompt_commands".to_string(), + scope: ExternalSourceScope::Project, + location: format!("/workspace/{provider_id}"), + execution_domain_id: ExecutionDomainId::new("local-user").expect("valid domain"), + health: ExternalSourceHealth::Available, + content_version: format!("{provider_id}-v1"), + diagnostics: Vec::new(), + } +} + +fn command(provider_id: &str, source_id: &str, precedence: i32) -> PromptCommandDefinition { + PromptCommandDefinition { + id: SourceQualifiedCommandId::new( + SourceKey::new(provider_id, source_id).unwrap(), + "review", + ) + .unwrap(), + name: "review".to_string(), + description: format!("Review from {provider_id}"), + template: format!("{provider_id}: $ARGUMENTS"), + shell_preference: None, + execution_target: Default::default(), + availability: PromptCommandAvailability::Available, + content_version: format!("command-v{precedence}"), + } +} + +fn context() -> ExternalSourceContext { + ExternalSourceContext { + workspace_root: Some(PathBuf::from("/workspace")), + execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), + } +} + +#[test] +fn opaque_ids_are_validated_without_closing_the_ecosystem_set() { + assert_eq!( + EcosystemId::new("future.product/v2") + .expect("future ecosystem ids remain open") + .as_str(), + "future.product/v2" + ); + assert!(EcosystemId::new(" ").is_err()); + assert!(ExecutionDomainId::new("domain\nwith-control").is_err()); +} + +#[test] +fn source_and_command_identity_remain_provider_qualified() { + let left = SourceQualifiedCommandId::new( + SourceKey::new("adapter-a", "project-commands").unwrap(), + "review", + ) + .unwrap(); + let right = SourceQualifiedCommandId::new( + SourceKey::new("adapter-b", "project-commands").unwrap(), + "review", + ) + .unwrap(); + + assert_ne!(left, right); + assert_ne!(left.stable_key(), right.stable_key()); +} + +#[test] +fn presentation_group_id_is_optional_and_uses_the_camel_case_wire_name() { + let mut entry = ExternalSourceCatalogEntry { + stable_key: "opencode.commands:project".to_string(), + presentation_group_id: None, + record: source("opencode.commands", "opencode", "project"), + lifecycle: ExternalSourceLifecycleState::Available, + }; + + let legacy_value = serde_json::to_value(&entry).unwrap(); + assert!(legacy_value.get("presentationGroupId").is_none()); + let legacy_entry: ExternalSourceCatalogEntry = serde_json::from_value(legacy_value).unwrap(); + assert!(legacy_entry.presentation_group_id.is_none()); + + entry.presentation_group_id = Some("external-source:[\"source\"]".to_string()); + let current_value = serde_json::to_value(&entry).unwrap(); + assert_eq!( + current_value["presentationGroupId"], + "external-source:[\"source\"]" + ); +} + +#[test] +fn conflict_fingerprint_is_order_independent_and_changes_with_content() { + let first = prompt_command_conflict_key("local-user", "review", [("a", "v1"), ("b", "v2")]); + let reordered = prompt_command_conflict_key("local-user", "REVIEW", [("b", "v2"), ("a", "v1")]); + let updated = prompt_command_conflict_key("local-user", "review", [("a", "v1"), ("b", "v3")]); + let remote = prompt_command_conflict_key("remote-user", "review", [("a", "v1"), ("b", "v2")]); + + assert_eq!(first, reordered); + assert_ne!(first, updated); + assert_ne!(first, remote); +} + +#[test] +fn prompt_commands_use_a_typed_contract_instead_of_an_arbitrary_asset_payload() { + let command = PromptCommandDefinition { + id: SourceQualifiedCommandId::new( + SourceKey::new("example-provider", "project-commands").unwrap(), + "review", + ) + .unwrap(), + name: "review".to_string(), + description: "Review the current change".to_string(), + template: "Review $ARGUMENTS".to_string(), + shell_preference: None, + execution_target: Default::default(), + availability: PromptCommandAvailability::Restricted { + reason: "Shell expansion is not supported yet".to_string(), + required_capabilities: vec!["command.shell".to_string()], + }, + content_version: "sha256:command-v1".to_string(), + }; + + let encoded = serde_json::to_value(&command).expect("serialize command contract"); + assert_eq!(encoded["name"], "review"); + assert_eq!(encoded["availability"]["state"], "restricted"); + assert!(encoded.get("payload").is_none()); +} + +struct FakeProvider { + identity: PromptCommandProviderIdentity, + snapshot: PromptCommandProviderSnapshot, +} + +impl FakeProvider { + fn new(provider_id: &str, ecosystem_id: &str, source_id: &str, precedence: i32) -> Self { + let identity = PromptCommandProviderIdentity::new( + provider_id, + ecosystem_id, + format!("{provider_id} display"), + ) + .unwrap(); + Self { + identity: identity.clone(), + snapshot: PromptCommandProviderSnapshot { + provider: identity, + sources: vec![source(provider_id, ecosystem_id, source_id)], + commands: vec![command(provider_id, source_id, precedence)], + unavailable_command_ids: Vec::new(), + diagnostics: Vec::new(), + }, + } + } +} + +impl PromptCommandSourceProvider for FakeProvider { + fn identity(&self) -> PromptCommandProviderIdentity { + self.identity.clone() + } + + fn discover( + &self, + _context: &ExternalSourceContext, + ) -> Result { + Ok(self.snapshot.clone()) + } + + fn expand( + &self, + _context: &ExternalSourceContext, + command: &PromptCommandDefinition, + arguments: &str, + ) -> Result { + Ok(PromptCommandExpansion { + content: command.template.replace("$ARGUMENTS", arguments), + workspace_file_references: vec!["src/lib.rs".to_string()], + shell: None, + }) + } + + fn watch_roots(&self, context: &ExternalSourceContext) -> Vec { + vec![ExternalWatchRoot { + path: context.workspace_root.clone().unwrap(), + recursive: true, + }] + } +} + +#[test] +fn capability_provider_contract_does_not_require_core_or_an_ecosystem_enum() { + let provider: Box = Box::new(FakeProvider::new( + "fake-provider", + "fake.ecosystem", + "project-commands", + 1, + )); + + let snapshot = provider.discover(&context()).expect("discover fake source"); + assert_eq!(snapshot.provider.ecosystem_id.as_str(), "fake.ecosystem"); + assert_eq!(provider.watch_roots(&context()).len(), 1); + let expansion = provider + .expand(&context(), &snapshot.commands[0], "change") + .expect("prepare fake command expansion"); + assert_eq!(expansion.content, "fake-provider: change"); + assert_eq!(expansion.workspace_file_references, ["src/lib.rs"]); + + let final_result = ExpandedPromptCommand { + content: expansion.content, + }; + assert_eq!( + serde_json::to_value(final_result).unwrap(), + serde_json::json!({"content": "fake-provider: change"}) + ); +} + +#[test] +fn persisted_source_preference_keys_round_trip_without_path_guessing() { + let record = source( + "provider.with.dots", + "fake.ecosystem", + "project/source:agents", + ); + assert_eq!( + ExternalSourceRecord::source_key_from_preference_key(&record.preference_key()), + Some(record.key) + ); + assert!(ExternalSourceRecord::source_key_from_preference_key("malformed").is_none()); +} + +#[test] +fn external_subagent_identity_preserves_ordered_provenance_and_separate_revisions() { + let provider = + ExternalSubagentProviderIdentity::new("fake.agents", "fake.ecosystem", "Fake Agents") + .unwrap(); + let first = ExternalSubagentContributionId::new( + SourceKey::new("fake.agents", "global-config").unwrap(), + ExternalSubagentLocalId::new("review").unwrap(), + ); + let second = ExternalSubagentContributionId::new( + SourceKey::new("fake.agents", "project-config").unwrap(), + ExternalSubagentLocalId::new("review").unwrap(), + ); + let provenance = vec![ + ExternalSubagentProvenanceRef { + contribution_id: first, + role: ExternalSubagentContributionRole::Base, + }, + ExternalSubagentProvenanceRef { + contribution_id: second, + role: ExternalSubagentContributionRole::Overlay, + }, + ]; + let candidate_id = external_subagent_candidate_id(&provider.provider_id, "review", &provenance); + let reversed = external_subagent_candidate_id( + &provider.provider_id, + "review", + &provenance.iter().cloned().rev().collect::>(), + ); + assert_ne!( + candidate_id, reversed, + "provenance order changes behavior identity" + ); + + let definition = ExternalSubagentDefinition { + candidate_id, + logical_id: "review".to_string(), + provenance, + display_name: "Review".to_string(), + description: "Reviews a change".to_string(), + prompt: SecretText::new("Review carefully"), + mode: ExternalSubagentMode::Subagent, + disabled: false, + hidden: false, + requested_model: ExternalSubagentModelRequest::Default, + requested_model_profile: None, + requested_tools: ExternalSubagentToolRequest { + selectors: vec![ExternalSubagentToolSelector { + source_name: "read".to_string(), + canonical_host_name: Some("Read".to_string()), + allowed: true, + }], + uses_conservative_default: false, + }, + permission_constraints: PermissionConstraintLayer::new(vec![PermissionRule::new( + "read", + "C:/sensitive/private/*", + PermissionEffect::Deny, + )]), + compatibility: ExternalSubagentCompatibilityState::Ready, + diagnostic_codes: Vec::new(), + behavior_version: ExternalSubagentBehaviorVersion::new("behavior-v1").unwrap(), + }; + assert_eq!(definition.prompt.expose(), "Review carefully"); + assert!(!format!("{definition:?}").contains("Review carefully")); + assert!(!format!("{definition:?}").contains("C:/sensitive/private")); + + let mut invalid_model = definition.clone(); + invalid_model.requested_model = ExternalSubagentModelRequest::Reference { + provider_hint: Some("fake\nprovider".to_string()), + model_name: "model".to_string(), + }; + assert!(invalid_model.validate().is_err()); + + let mut invalid_tool = definition.clone(); + invalid_tool.requested_tools.selectors[0].source_name = "read\nsecret".to_string(); + assert!(invalid_tool.validate().is_err()); + + let mut invalid_permission = definition.clone(); + invalid_permission.permission_constraints = + PermissionConstraintLayer::new(vec![PermissionRule::new( + "read\nsecret", + "*", + PermissionEffect::Deny, + )]); + assert!(invalid_permission.validate().is_err()); + + let mut invalid_diagnostic = definition.clone(); + invalid_diagnostic.diagnostic_codes = vec!["provider.invalid:raw-source-key".to_string()]; + assert!(invalid_diagnostic.validate().is_err()); + + let mut excessive_tools = definition.clone(); + excessive_tools.requested_tools.selectors = (0..257) + .map(|index| ExternalSubagentToolSelector { + source_name: format!("tool-{index}"), + canonical_host_name: None, + allowed: true, + }) + .collect(); + assert!(excessive_tools.validate().is_err()); + + let snapshot = ExternalSubagentProviderSnapshot { + provider, + sources: vec![ + source("fake.agents", "fake.ecosystem", "global-config"), + source("fake.agents", "fake.ecosystem", "project-config"), + ], + definitions: vec![definition], + diagnostics: Vec::new(), + }; + snapshot + .validate() + .expect("valid external subagent provider snapshot"); + + let source_key = snapshot.sources[0].key.clone(); + let mut valid_diagnostic = snapshot.clone(); + valid_diagnostic.diagnostics.push( + ExternalSourceDiagnostic::warning( + "fake.agent.degraded", + "An optional field is not supported", + Some(source_key), + ) + .with_asset_kind(ExternalSourceAssetKind::Subagent), + ); + valid_diagnostic + .validate() + .expect("bounded provider diagnostics with a known source are valid"); + + let mut valid_source_diagnostic = snapshot.clone(); + let valid_source_key = valid_source_diagnostic.sources[0].key.clone(); + valid_source_diagnostic.sources[0].diagnostics.push( + ExternalSourceDiagnostic::warning( + "fake.agent.source_degraded", + "This source has a recoverable warning", + Some(valid_source_key), + ) + .with_asset_kind(ExternalSourceAssetKind::Subagent), + ); + valid_source_diagnostic + .validate() + .expect("source-owned diagnostics use the same provider contract"); + + let mut invalid_provider_diagnostic = snapshot.clone(); + invalid_provider_diagnostic.diagnostics.push( + ExternalSourceDiagnostic::warning( + "fake.agent:raw-source", + "Invalid diagnostic code", + Some(SourceKey::new("other.agents", "project").unwrap()), + ) + .with_asset_kind(ExternalSourceAssetKind::Command), + ); + assert!(invalid_provider_diagnostic.validate().is_err()); + + let mut wrong_provider_diagnostic = snapshot.clone(); + wrong_provider_diagnostic.diagnostics.push( + ExternalSourceDiagnostic::warning( + "fake.agent.invalid_source", + "Unknown provider source", + Some(SourceKey::new("other.agents", "project").unwrap()), + ) + .with_asset_kind(ExternalSourceAssetKind::Subagent), + ); + assert!(wrong_provider_diagnostic.validate().is_err()); + + let mut unknown_source_diagnostic = snapshot.clone(); + unknown_source_diagnostic.diagnostics.push( + ExternalSourceDiagnostic::warning( + "fake.agent.unknown_source", + "Unknown source", + Some(SourceKey::new("fake.agents", "missing").unwrap()), + ) + .with_asset_kind(ExternalSourceAssetKind::Subagent), + ); + assert!(unknown_source_diagnostic.validate().is_err()); + + let mut invalid_diagnostic_message = snapshot.clone(); + invalid_diagnostic_message.diagnostics.push( + ExternalSourceDiagnostic::warning("fake.agent.invalid_message", "invalid\nmessage", None) + .with_asset_kind(ExternalSourceAssetKind::Subagent), + ); + assert!(invalid_diagnostic_message.validate().is_err()); + + let mut wrong_asset_kind = snapshot.clone(); + wrong_asset_kind.diagnostics.push( + ExternalSourceDiagnostic::warning( + "fake.agent.wrong_kind", + "Diagnostic belongs to another asset kind", + None, + ) + .with_asset_kind(ExternalSourceAssetKind::Tool), + ); + assert!(wrong_asset_kind.validate().is_err()); + + let mut excessive_sources = snapshot.clone(); + excessive_sources.sources = vec![snapshot.sources[0].clone(); 1025]; + assert!(excessive_sources.validate().is_err()); + + let mut excessive_definitions = snapshot.clone(); + excessive_definitions.definitions = vec![snapshot.definitions[0].clone(); 1025]; + assert!(excessive_definitions.validate().is_err()); + + let mut excessive_diagnostics = snapshot.clone(); + excessive_diagnostics.diagnostics = vec![ + ExternalSourceDiagnostic::warning( + "fake.agent.degraded", + "An optional field is not supported", + None, + ) + .with_asset_kind(ExternalSourceAssetKind::Subagent); + 1025 + ]; + assert!(excessive_diagnostics.validate().is_err()); + + let mut excessive_provenance = snapshot.clone(); + excessive_provenance.definitions[0].provenance = + vec![snapshot.definitions[0].provenance[0].clone(); 257]; + assert!(excessive_provenance.validate().is_err()); + + let input = ExternalSubagentDiscoveryInput { + context: context(), + suppressed_sources: [SourceKey::new("fake.agents", "suppressed").unwrap()] + .into_iter() + .collect(), + }; + assert_eq!(input.suppressed_sources.len(), 1); +} + +#[test] +fn external_subagent_model_contract_preserves_control_and_opaque_reference_semantics() { + let requests = [ + ExternalSubagentModelRequest::Default, + ExternalSubagentModelRequest::Inherit, + ExternalSubagentModelRequest::Reference { + provider_hint: Some("openrouter".to_string()), + model_name: "anthropic/claude-sonnet-4".to_string(), + }, + ExternalSubagentModelRequest::Reference { + provider_hint: None, + model_name: "gpt-5.6-codex".to_string(), + }, + ExternalSubagentModelRequest::Reference { + provider_hint: None, + model_name: "glm-5".to_string(), + }, + ExternalSubagentModelRequest::Reference { + provider_hint: None, + model_name: "deepseek-v4".to_string(), + }, + ExternalSubagentModelRequest::Reference { + provider_hint: None, + model_name: "future-model-that-does-not-exist-yet".to_string(), + }, + ]; + + for request in requests { + let encoded = serde_json::to_value(&request).unwrap(); + if let ExternalSubagentModelRequest::Reference { + provider_hint, + model_name, + } = &request + { + assert_eq!(encoded["modelName"], model_name.as_str()); + assert!(encoded.get("model_name").is_none()); + if let Some(provider_hint) = provider_hint { + assert_eq!(encoded["providerHint"], provider_hint.as_str()); + assert!(encoded.get("provider_hint").is_none()); + } + } + let decoded: ExternalSubagentModelRequest = serde_json::from_value(encoded).unwrap(); + assert_eq!(decoded, request); + } + + assert_ne!( + ExternalSubagentModelRequest::Inherit, + ExternalSubagentModelRequest::Reference { + provider_hint: None, + model_name: "inherit".to_string(), + } + ); +} + +#[test] +fn external_subagent_model_profile_contract_keeps_variant_and_effort_semantics_distinct() { + let profiles = [ + ExternalSubagentModelProfileRequest::NamedVariant { + name: "high".to_string(), + }, + ExternalSubagentModelProfileRequest::ReasoningEffort { + value: "high".to_string(), + }, + ]; + + let encoded = profiles + .iter() + .map(|profile| serde_json::to_value(profile).unwrap()) + .collect::>(); + assert_eq!( + encoded[0], + serde_json::json!({ "kind": "named_variant", "name": "high" }) + ); + assert_eq!( + encoded[1], + serde_json::json!({ "kind": "reasoning_effort", "value": "high" }) + ); + assert_ne!(profiles[0], profiles[1]); + + for (profile, encoded) in profiles.into_iter().zip(encoded) { + let decoded: ExternalSubagentModelProfileRequest = serde_json::from_value(encoded).unwrap(); + assert_eq!(decoded, profile); + } + + assert!(ExternalSubagentModelProfileRequest::NamedVariant { + name: "x".repeat(4097), + } + .validate() + .is_err()); + assert!(ExternalSubagentModelProfileRequest::ReasoningEffort { + value: "bad\u{0001}".to_string(), + } + .validate() + .is_err()); +} + +#[test] +fn external_subagent_model_binding_contract_groups_only_matching_scope_identity() { + let ecosystem = EcosystemId::new("opencode").unwrap(); + let request = ExternalSubagentModelRequest::Reference { + provider_hint: Some("openrouter".to_string()), + model_name: "vendor/model".to_string(), + }; + let global_a = external_subagent_model_binding_key( + &ecosystem, + &request, + None, + "local-user", + ExternalSourceScope::UserGlobal, + "D:/workspace/a", + ) + .unwrap(); + assert_eq!( + global_a, + "external_subagent_model_binding:408ebedb7c2644acda3b4c0c5a78e8eb83fb2ece8b3a1671a866ed0d6cc08f56", + "profile-free bindings must retain their pre-profile persisted identity" + ); + let global_b = external_subagent_model_binding_key( + &ecosystem, + &request, + None, + "local-user", + ExternalSourceScope::UserGlobal, + "D:/workspace/b", + ) + .unwrap(); + assert_eq!( + global_a, global_b, + "user bindings belong to the execution domain" + ); + + let project_a = external_subagent_model_binding_key( + &ecosystem, + &request, + None, + "local-user", + ExternalSourceScope::Project, + "D:/workspace/a", + ) + .unwrap(); + let project_b = external_subagent_model_binding_key( + &ecosystem, + &request, + None, + "local-user", + ExternalSourceScope::Project, + "D:/workspace/b", + ) + .unwrap(); + assert_ne!( + project_a, project_b, + "project bindings stay workspace-scoped" + ); + assert_ne!( + global_a, project_a, + "global and project bindings never alias" + ); + let remote_global = external_subagent_model_binding_key( + &ecosystem, + &request, + None, + "remote:user@example", + ExternalSourceScope::RemoteUser, + "D:/workspace/a", + ) + .unwrap(); + assert_ne!( + global_a, remote_global, + "remote and local execution domains never share bindings" + ); + + let option = ExternalSubagentModelBindingOption { + target: ExternalSubagentModelBindingTarget::Primary, + effective_model_label: "Provider / Model".to_string(), + configured_reasoning_effort: Some("high".to_string()), + }; + let group = ExternalSubagentModelBindingGroup { + binding_key: project_a, + request, + profile_request: Some(ExternalSubagentModelProfileRequest::ReasoningEffort { + value: "high".to_string(), + }), + scope: ExternalSourceScope::Project, + method: ExternalSubagentModelBindingMethod::Explicit, + selected_target: Some(option.target.clone()), + effective_model_label: Some(option.effective_model_label.clone()), + affected_candidate_ids: vec!["candidate-a".to_string(), "candidate-b".to_string()], + }; + let encoded = serde_json::to_value((&option, &group)).unwrap(); + let decoded: ( + ExternalSubagentModelBindingOption, + ExternalSubagentModelBindingGroup, + ) = serde_json::from_value(encoded).unwrap(); + assert_eq!(decoded, (option, group)); +} + +#[test] +fn external_subagent_profile_binding_identity_extends_existing_model_binding_scope() { + let ecosystem = EcosystemId::new("opencode").unwrap(); + let default_request = ExternalSubagentModelRequest::Default; + assert!(external_subagent_model_binding_key( + &ecosystem, + &default_request, + None, + "local-user", + ExternalSourceScope::Project, + "D:/workspace/a", + ) + .is_none()); + + let variant = ExternalSubagentModelProfileRequest::NamedVariant { + name: "high".to_string(), + }; + let effort = ExternalSubagentModelProfileRequest::ReasoningEffort { + value: "high".to_string(), + }; + let variant_key = external_subagent_model_binding_key( + &ecosystem, + &default_request, + Some(&variant), + "local-user", + ExternalSourceScope::Project, + "D:/workspace/a", + ) + .unwrap(); + let effort_key = external_subagent_model_binding_key( + &ecosystem, + &default_request, + Some(&effort), + "local-user", + ExternalSourceScope::Project, + "D:/workspace/a", + ) + .unwrap(); + assert_ne!(variant_key, effort_key); + let delimited_provider = ExternalSubagentModelRequest::Reference { + provider_hint: Some("a:b".to_string()), + model_name: "c".to_string(), + }; + let delimited_model = ExternalSubagentModelRequest::Reference { + provider_hint: Some("a".to_string()), + model_name: "b:c".to_string(), + }; + let key_for = |request| { + external_subagent_model_binding_key( + &ecosystem, + request, + Some(&effort), + "local-user", + ExternalSourceScope::Project, + "D:/workspace/a", + ) + .unwrap() + }; + assert_ne!(key_for(&delimited_provider), key_for(&delimited_model)); +} + +#[test] +fn external_subagent_decision_keys_bind_behavior_but_not_catalog_copy() { + let candidate = ExternalSubagentCandidateId::new("candidate-v1").unwrap(); + let behavior = ExternalSubagentBehaviorVersion::new("behavior-v1").unwrap(); + let approval = external_subagent_approval_key(&candidate, &behavior, "envelope-v1"); + let same = external_subagent_approval_key(&candidate, &behavior, "envelope-v1"); + let changed = external_subagent_approval_key( + &candidate, + &ExternalSubagentBehaviorVersion::new("behavior-v2").unwrap(), + "envelope-v1", + ); + assert_eq!(approval, same); + assert_ne!(approval, changed); + + let first = external_subagent_conflict_key( + "local-user", + "/workspace", + "review", + [("local", "v1"), (candidate.as_str(), behavior.as_str())], + ); + let reordered = external_subagent_conflict_key( + "local-user", + "/workspace", + "REVIEW", + [(candidate.as_str(), behavior.as_str()), ("local", "v1")], + ); + assert_eq!(first, reordered); +} + +#[test] +fn diagnostics_remain_source_qualified() { + let diagnostic = ExternalSourceDiagnostic::warning( + "fake.warning", + "A non-blocking fake diagnostic", + Some(SourceKey::new("fake", "source").unwrap()), + ); + assert_eq!(diagnostic.source.unwrap().provider_id.as_str(), "fake"); +} + +#[test] +fn provider_snapshot_rejects_duplicate_sources_and_commands() { + let provider = FakeProvider::new("fake", "fake.ecosystem", "project", 1); + let mut duplicate_source = provider.snapshot.clone(); + duplicate_source + .sources + .push(duplicate_source.sources[0].clone()); + assert!(duplicate_source.validate().is_err()); + + let mut duplicate_command = provider.snapshot; + duplicate_command + .commands + .push(duplicate_command.commands[0].clone()); + assert!(duplicate_command.validate().is_err()); +} + +#[test] +fn unavailable_command_must_be_unique_absent_and_source_qualified() { + let provider = FakeProvider::new("fake", "fake.ecosystem", "project", 1); + let mut invalid = provider.snapshot; + invalid + .unavailable_command_ids + .push(invalid.commands[0].id.clone()); + assert!(invalid.validate().is_err()); +} + +#[test] +fn standalone_tool_contract_separates_static_preview_from_executable_source() { + let target = SourceQualifiedToolTargetId::new( + SourceKey::new("opencode.tools", "project-tools").unwrap(), + "weather.js", + ) + .unwrap(); + let tool = ExternalToolDefinition { + id: SourceQualifiedToolId::new(target, "default").unwrap(), + name: "weather".to_string(), + description_preview: "Get the weather for a location".to_string(), + module_path: "/workspace/.opencode/tools/weather.js".to_string(), + working_directory: "/workspace".to_string(), + runtime_kind: ExternalToolRuntimeKind::JavaScript, + capabilities: vec![ + ExternalToolCapability::FileSystem, + ExternalToolCapability::Network, + ExternalToolCapability::Process, + ], + content_version: "sha256:v1".to_string(), + static_status: ExternalToolStaticStatus::Ready, + }; + + let encoded = serde_json::to_value(&tool).expect("serialize tool preview"); + assert_eq!(encoded["name"], "weather"); + assert_eq!(encoded["runtimeKind"], "java_script"); + assert!(encoded.get("moduleSource").is_none()); + assert!(encoded.get("payload").is_none()); + tool.validate().expect("valid standalone tool preview"); +} + +#[test] +fn legacy_public_snapshot_downprojects_new_tool_review_variants() { + let snapshot: ExternalSourcePublicSnapshot = serde_json::from_value(serde_json::json!({ + "generation": 1, + "discoveryPending": false, + "sources": [], + "commands": [{ + "candidateId": "17:opencode.commands6:global6:review", + "definition": { + "id": { + "source": { "providerId": "opencode.commands", "sourceId": "global" }, + "localId": "review" + }, + "name": "review", + "description": "Review changes", + "availability": { "state": "available" }, + "contentVersion": "v1" + } + }], + "tools": [{ + "definition": { + "id": { + "target": { + "source": { "providerId": "opencode.tools", "sourceId": "project" }, + "localId": "weather.js" + }, + "exportId": "default" + }, + "name": "weather", + "descriptionPreview": "Get weather", + "modulePath": "/.opencode/tools/weather.js", + "workingDirectory": "", + "runtimeKind": "java_script", + "capabilities": [], + "contentVersion": "sha256:v1", + "staticStatus": { "state": "ready" } + }, + "approvalKey": "approval-v1", + "decisionKey": "decision-v1", + "activation": { "state": "declined" } + }], + "subagents": [{ + "candidateId": "external-review", + "logicalId": "review", + "displayName": "External Review", + "description": "Review changes", + "providerLabel": "OpenCode", + "scope": "project", + "sourceKeys": [], + "sourceLocationLabels": [], + "sourceCount": 1, + "requestedModel": { + "kind": "reference", + "providerHint": "anthropic", + "modelName": "claude-sonnet-4" + }, + "requestedModelProfile": { + "kind": "reasoning_effort", + "value": "high" + }, + "modelBindingMethod": "binding_required", + "modelBindingKey": "external_subagent_model_binding:review", + "effectiveToolLabels": ["Read"], + "unavailableToolLabels": ["Shell"], + "supportsFollowUp": false, + "compatibilityState": "blocked", + "diagnostics": [{ + "code": "external_subagent.tool_unavailable", + "blocksActivation": true + }], + "activationState": { "state": "blocked" }, + "decisionKey": "agent-decision-v1" + }], + "subagentModelBindingGroups": [{ + "bindingKey": "external_subagent_model_binding:review", + "request": { "kind": "reference", "modelName": "claude-sonnet-4" }, + "profileRequest": { "kind": "reasoning_effort", "value": "high" }, + "scope": "project", + "method": "binding_required", + "affectedCandidateIds": ["external-review"] + }], + "subagentModelBindingOptions": [{ + "target": { "kind": "fast" }, + "effectiveModelLabel": "Fast", + "configuredReasoningEffort": "high" + }] + })) + .expect("new public snapshot"); + + let legacy = + serde_json::to_value(snapshot.into_legacy_v0_compatible()).expect("legacy public snapshot"); + assert!(legacy["commands"][0].get("candidateId").is_none()); + assert_eq!(legacy["tools"][0]["activation"]["state"], "disabled"); + assert!(legacy["subagents"][0] + .get("unavailableToolLabels") + .is_none()); + assert!(legacy["subagents"][0].get("requestedModel").is_none()); + assert!(legacy["subagents"][0] + .get("requestedModelProfile") + .is_none()); + assert!(legacy["subagents"][0].get("modelBindingMethod").is_none()); + assert!(legacy["subagents"][0].get("modelBindingKey").is_none()); + assert!(legacy.get("subagentModelBindingGroups").is_none()); + assert!(legacy.get("subagentModelBindingOptions").is_none()); +} + +#[test] +fn standalone_tool_contract_rejects_names_that_are_not_model_callable() { + let target = SourceQualifiedToolTargetId::new( + SourceKey::new("fake.tools", "project-tools").unwrap(), + "unsafe.js", + ) + .unwrap(); + let mut tool = ExternalToolDefinition { + id: SourceQualifiedToolId::new(target, "default").unwrap(), + name: "unsafe tool".to_string(), + description_preview: String::new(), + module_path: "/workspace/unsafe.js".to_string(), + working_directory: "/workspace".to_string(), + runtime_kind: ExternalToolRuntimeKind::JavaScript, + capabilities: vec![ExternalToolCapability::FileSystem], + content_version: "sha256:v1".to_string(), + static_status: ExternalToolStaticStatus::Ready, + }; + + assert!(tool.validate().is_err()); + tool.name = "safe_tool-1".to_string(); + tool.validate() + .expect("portable tool name should be accepted"); +} + +#[test] +fn tool_approval_is_stable_for_safe_updates_but_changes_with_capabilities_or_domain() { + let target = SourceQualifiedToolTargetId::new( + SourceKey::new("opencode.tools", "project-tools").unwrap(), + "weather.js", + ) + .unwrap(); + let first = external_tool_approval_key( + "local-user", + &target, + ExternalToolRuntimeKind::JavaScript, + [ + ExternalToolCapability::FileSystem, + ExternalToolCapability::Network, + ], + ); + let reordered = external_tool_approval_key( + "local-user", + &target, + ExternalToolRuntimeKind::JavaScript, + [ + ExternalToolCapability::Network, + ExternalToolCapability::FileSystem, + ], + ); + let expanded = external_tool_approval_key( + "local-user", + &target, + ExternalToolRuntimeKind::JavaScript, + [ + ExternalToolCapability::FileSystem, + ExternalToolCapability::Network, + ExternalToolCapability::Process, + ], + ); + let remote = external_tool_approval_key( + "remote-user", + &target, + ExternalToolRuntimeKind::JavaScript, + [ + ExternalToolCapability::FileSystem, + ExternalToolCapability::Network, + ], + ); + + assert_eq!(first, reordered); + assert_ne!(first, expanded); + assert_ne!(first, remote); +} + +#[test] +fn tool_conflict_choice_is_invalidated_when_name_or_candidate_changes() { + let first = external_tool_conflict_key( + "local-user", + "weather", + [ + ("builtin:weather", "builtin-v1"), + ("opencode:weather", "tool-v1"), + ], + ); + let reordered = external_tool_conflict_key( + "local-user", + "WEATHER", + [ + ("opencode:weather", "tool-v1"), + ("builtin:weather", "builtin-v1"), + ], + ); + let updated = external_tool_conflict_key( + "local-user", + "weather", + [ + ("builtin:weather", "builtin-v1"), + ("opencode:weather", "tool-v2"), + ], + ); + + assert_ne!(first, reordered); + assert_ne!(first, updated); +} + +#[test] +fn external_mcp_contract_keeps_runtime_secrets_out_of_static_snapshots() { + let source = source("opencode.mcp", "opencode", "project-config"); + let definition = ExternalMcpServerDefinition { + id: SourceQualifiedMcpServerId::new(source.key.clone(), "github").unwrap(), + provenance: vec![source.key.clone()], + name: "github".to_string(), + transport: ExternalMcpTransportKind::StreamableHttp, + command_preview: None, + argument_count: 0, + working_directory: None, + environment_keys: Vec::new(), + environment_reference_names: Vec::new(), + remote_url_preview: Some("https://mcp.example.com/mcp".to_string()), + header_names: vec!["Authorization".to_string()], + timeouts: ExternalMcpTimeouts::default(), + source_enabled: true, + behavior_version: "sha256:behavior-v1".to_string(), + static_status: ExternalMcpStaticStatus::Ready, + }; + let provider = + ExternalMcpProviderIdentity::new("opencode.mcp", "opencode", "OpenCode MCP servers") + .unwrap(); + let snapshot = ExternalMcpProviderSnapshot { + provider, + sources: vec![source], + servers: vec![definition.clone()], + diagnostics: Vec::new(), + }; + + snapshot.validate().expect("valid MCP provider snapshot"); + let encoded = serde_json::to_string(&snapshot).expect("serialize MCP snapshot"); + assert!(encoded.contains("Authorization")); + assert!(!encoded.contains("Bearer secret")); + assert!(encoded.contains("mcp.example.com")); + + let prepared = PreparedExternalMcpServer { + id: definition.id, + behavior_version: definition.behavior_version, + timeouts: ExternalMcpTimeouts::default(), + transport: PreparedExternalMcpTransport::Remote { + url: "https://mcp.example.com/mcp?token=url-secret".to_string(), + headers: [( + "Authorization".to_string(), + SecretValue::new("Bearer secret"), + )] + .into_iter() + .collect(), + oauth_enabled: true, + }, + }; + assert_eq!( + prepared.transport.remote_headers().unwrap()["Authorization"].expose(), + "Bearer secret" + ); + assert!(!format!("{prepared:?}").contains("Bearer secret")); + assert!(!format!("{prepared:?}").contains("url-secret")); +} + +#[test] +fn external_mcp_timeouts_are_positive_optional_millisecond_facts() { + let timeouts = ExternalMcpTimeouts { + startup_ms: Some(2_000), + catalog_ms: None, + execution_ms: Some(30_000), + }; + + timeouts.validate().expect("positive timeouts are valid"); + assert_eq!( + serde_json::to_value(&timeouts).unwrap(), + serde_json::json!({ + "startupMs": 2_000, + "executionMs": 30_000, + }) + ); + assert!(ExternalMcpTimeouts { + startup_ms: Some(0), + ..Default::default() + } + .validate() + .is_err()); + assert!(ExternalMcpTimeouts { + execution_ms: Some(9_007_199_254_740_991), + ..Default::default() + } + .validate() + .is_ok()); + assert!(ExternalMcpTimeouts { + execution_ms: Some(9_007_199_254_740_992), + ..Default::default() + } + .validate() + .is_err()); + assert!(ExternalMcpTimeouts::default().is_empty()); +} + +#[test] +fn external_mcp_revision_key_never_exposes_material_through_debug_output() { + let key = ExternalMcpRevisionKey::new([0x5a; 32]); + assert_eq!(format!("{key:?}"), "ExternalMcpRevisionKey([REDACTED])"); + assert!(!format!("{key:?}").contains("5a")); +} + +#[test] +fn external_mcp_revision_is_stable_secret_sensitive_and_not_an_unkeyed_oracle() { + let key = ExternalMcpRevisionKey::new([7; 32]); + let first = key.opaque_revision( + "test.mcp.behavior.v1", + [b"server".as_slice(), b"PIN=0007".as_slice()], + ); + let repeated = key.opaque_revision( + "test.mcp.behavior.v1", + [b"server".as_slice(), b"PIN=0007".as_slice()], + ); + let changed = key.opaque_revision( + "test.mcp.behavior.v1", + [b"server".as_slice(), b"PIN=0008".as_slice()], + ); + let raw_candidate = format!( + "sha256:{}", + hex::encode(Sha256::digest(b"server\0PIN=0007")) + ); + + assert_eq!(first, repeated); + assert_ne!(first, changed); + assert_ne!(first, raw_candidate); + assert!(first.starts_with("hmac-sha256:")); +} + +#[test] +fn external_mcp_snapshot_rejects_cross_provider_and_duplicate_servers() { + let provider = + ExternalMcpProviderIdentity::new("opencode.mcp", "opencode", "OpenCode MCP").unwrap(); + let source = source("opencode.mcp", "opencode", "project-config"); + let definition = ExternalMcpServerDefinition { + id: SourceQualifiedMcpServerId::new(source.key.clone(), "github").unwrap(), + provenance: vec![source.key.clone()], + name: "github".to_string(), + transport: ExternalMcpTransportKind::LocalStdio, + command_preview: Some("npx".to_string()), + argument_count: 2, + working_directory: Some("/workspace".to_string()), + environment_keys: vec!["GITHUB_TOKEN".to_string()], + environment_reference_names: Vec::new(), + remote_url_preview: None, + header_names: Vec::new(), + timeouts: ExternalMcpTimeouts::default(), + source_enabled: true, + behavior_version: "sha256:behavior-v1".to_string(), + static_status: ExternalMcpStaticStatus::Ready, + }; + let snapshot = ExternalMcpProviderSnapshot { + provider, + sources: vec![source], + servers: vec![definition.clone(), definition], + diagnostics: Vec::new(), + }; + + assert!(snapshot.validate().is_err()); + + let input = ExternalMcpDiscoveryInput { + context: context(), + suppressed_sources: [SourceKey::new("opencode.mcp", "suppressed").unwrap()] + .into_iter() + .collect(), + revision_key: ExternalMcpRevisionKey::new([7; 32]), + }; + assert_eq!(input.suppressed_sources.len(), 1); +} + +#[test] +fn external_mcp_decisions_change_only_with_behavior_domain_or_conflict_participants() { + let id = SourceQualifiedMcpServerId::new( + SourceKey::new("opencode.mcp", "project-config").unwrap(), + "github", + ) + .unwrap(); + let first = external_mcp_approval_key("local-user", "/workspace-a", &id, "behavior-v1"); + let same = external_mcp_approval_key("local-user", "/workspace-a", &id, "behavior-v1"); + let updated = external_mcp_approval_key("local-user", "/workspace-a", &id, "behavior-v2"); + let other_workspace = + external_mcp_approval_key("local-user", "/workspace-b", &id, "behavior-v1"); + let remote = external_mcp_approval_key("remote-user", "/workspace-a", &id, "behavior-v1"); + assert_eq!(first, same); + assert_ne!(first, updated); + assert_ne!(first, other_workspace); + assert_ne!(first, remote); + + let stable_id = id.stable_key(); + let conflict = external_mcp_conflict_key( + "local-user", + "/workspace-a", + "github", + [ + ("bitfun:github", "native-v1"), + (stable_id.as_str(), "behavior-v1"), + ], + ); + let reordered = external_mcp_conflict_key( + "local-user", + "/workspace-a", + "GITHUB", + [ + (stable_id.as_str(), "behavior-v1"), + ("bitfun:github", "native-v1"), + ], + ); + let participant_updated = external_mcp_conflict_key( + "local-user", + "/workspace-a", + "github", + [ + ("bitfun:github", "native-v1"), + (stable_id.as_str(), "behavior-v2"), + ], + ); + assert_eq!(conflict, reordered); + assert_ne!(conflict, participant_updated); + assert_ne!( + conflict, + external_mcp_conflict_key( + "local-user", + "/workspace-b", + "github", + [ + ("bitfun:github", "native-v1"), + (stable_id.as_str(), "behavior-v1"), + ], + ) + ); +} + +#[test] +fn external_mcp_product_view_is_version_guarded_and_contains_only_disclosed_fields() { + let source = source("opencode.mcp", "opencode", "project-config"); + let definition = ExternalMcpServerDefinition { + id: SourceQualifiedMcpServerId::new(source.key.clone(), "github").unwrap(), + provenance: vec![source.key], + name: "github".to_string(), + transport: ExternalMcpTransportKind::LocalStdio, + command_preview: Some("npx".to_string()), + argument_count: 2, + working_directory: Some("".to_string()), + environment_keys: vec!["GITHUB_TOKEN".to_string()], + environment_reference_names: Vec::new(), + remote_url_preview: None, + header_names: Vec::new(), + timeouts: ExternalMcpTimeouts::default(), + source_enabled: true, + behavior_version: "sha256:behavior-v1".to_string(), + static_status: ExternalMcpStaticStatus::Ready, + }; + let entry = ExternalMcpCatalogEntry { + candidate_id: definition.candidate_id(), + definition: definition.clone(), + approval_key: "external_mcp_approval:local-user:v1".to_string(), + decision_key: "external_mcp_approval:local-user:v1".to_string(), + runtime_id: None, + activation_state: ExternalMcpActivationState::ApprovalRequired, + }; + let request = ExternalMcpApprovalRequest { + candidate_id: entry.candidate_id.clone(), + approval_key: entry.approval_key.clone(), + decision_key: entry.decision_key.clone(), + definition, + }; + let conflict = ExternalMcpConflict { + conflict_key: "external_mcp:local-user:github:v1".to_string(), + server_name: "github".to_string(), + candidates: vec![ + ExternalMcpConflictCandidate { + candidate_id: "native_mcp:github".to_string(), + display_name: "BitFun: github".to_string(), + external: false, + source: None, + behavior_version: "native-v1".to_string(), + available: true, + unavailable_reason: None, + }, + ExternalMcpConflictCandidate { + candidate_id: entry.candidate_id.clone(), + display_name: "OpenCode: github".to_string(), + external: true, + source: Some(entry.definition.id.source.clone()), + behavior_version: entry.definition.behavior_version.clone(), + available: true, + unavailable_reason: None, + }, + ], + selected_candidate_id: None, + }; + + let encoded = serde_json::to_string(&(entry, request, conflict)).unwrap(); + assert!(encoded.contains("GITHUB_TOKEN")); + assert!(!encoded.contains("Bearer secret")); + assert!(encoded.contains("approval_required")); +} + +fn external_capability(value: &str) -> ExternalIntegrationCapabilityId { + ExternalIntegrationCapabilityId::new(value).expect("valid external capability id") +} + +const TEST_ECOSYSTEM_ID: &str = "test-ecosystem"; +const EXTERNAL_CAPABILITY_COMMAND: &str = "command"; +const EXTERNAL_CAPABILITY_TOOL: &str = "tool"; +const EXTERNAL_CAPABILITY_SUBAGENT: &str = "subagent"; +const EXTERNAL_CAPABILITY_MCP: &str = "mcp"; + +fn test_external_integration_ecosystems() -> Vec { + let capability = + |id, recommended_access, safety_ceiling| ExternalIntegrationCapabilityDescriptor { + capability_id: external_capability(id), + recommended_access, + safety_ceiling, + }; + vec![ExternalIntegrationEcosystemDescriptor { + ecosystem_id: EcosystemId::new(TEST_ECOSYSTEM_ID).unwrap(), + display_name: "Test ecosystem".to_string(), + adapter_revision: "1".to_string(), + capabilities: vec![ + capability( + EXTERNAL_CAPABILITY_COMMAND, + ExternalIntegrationAccess::Auto, + ExternalIntegrationAccess::Auto, + ), + capability( + EXTERNAL_CAPABILITY_TOOL, + ExternalIntegrationAccess::AskBeforeUse, + ExternalIntegrationAccess::AskBeforeUse, + ), + capability( + EXTERNAL_CAPABILITY_SUBAGENT, + ExternalIntegrationAccess::AskBeforeUse, + ExternalIntegrationAccess::AskBeforeUse, + ), + capability( + EXTERNAL_CAPABILITY_MCP, + ExternalIntegrationAccess::AskBeforeUse, + ExternalIntegrationAccess::AskBeforeUse, + ), + ], + }] +} + +#[test] +fn external_integration_policy_is_disabled_by_default() { + let effective = evaluate_external_integration_policy( + &ExternalIntegrationPolicyDocument::default(), + Some("workspace-a"), + &test_external_integration_ecosystems(), + ) + .expect("default policy evaluates"); + let opencode = effective + .ecosystems + .get(&EcosystemId::new(TEST_ECOSYSTEM_ID).unwrap()) + .expect("test ecosystem is registered"); + + assert!(!effective.enabled); + assert_eq!(opencode.mode, ExternalIntegrationMode::Disabled); + for capability in [ + EXTERNAL_CAPABILITY_COMMAND, + EXTERNAL_CAPABILITY_TOOL, + EXTERNAL_CAPABILITY_SUBAGENT, + EXTERNAL_CAPABILITY_MCP, + ] { + assert_eq!( + opencode.capabilities[&external_capability(capability)], + ExternalIntegrationAccess::Disabled + ); + } +} + +#[test] +fn explicitly_enabled_recommended_policy_keeps_registered_access_defaults() { + let mut document = ExternalIntegrationPolicyDocument::default(); + document.user_defaults.enabled = true; + + let effective = evaluate_external_integration_policy( + &document, + Some("workspace-a"), + &test_external_integration_ecosystems(), + ) + .expect("enabled recommended policy evaluates"); + let opencode = effective + .ecosystems + .get(&EcosystemId::new(TEST_ECOSYSTEM_ID).unwrap()) + .expect("test ecosystem is registered"); + + assert!(effective.enabled); + assert_eq!(opencode.mode, ExternalIntegrationMode::Recommended); + assert_eq!( + opencode.capabilities[&external_capability(EXTERNAL_CAPABILITY_COMMAND)], + ExternalIntegrationAccess::Auto + ); + for capability in [ + EXTERNAL_CAPABILITY_TOOL, + EXTERNAL_CAPABILITY_SUBAGENT, + EXTERNAL_CAPABILITY_MCP, + ] { + assert_eq!( + opencode.capabilities[&external_capability(capability)], + ExternalIntegrationAccess::AskBeforeUse + ); + } +} + +#[test] +fn workspace_policy_overrides_only_the_fields_the_user_changed() { + let ecosystem = EcosystemId::new(TEST_ECOSYSTEM_ID).unwrap(); + let mut document = ExternalIntegrationPolicyDocument::default(); + document.user_defaults.enabled = true; + document.user_defaults.ecosystems.insert( + ecosystem.clone(), + ExternalEcosystemPolicy { + mode: ExternalIntegrationMode::DiscoverOnly, + ..ExternalEcosystemPolicy::default() + }, + ); + document.workspace_overrides.insert( + "workspace-a".to_string(), + ExternalIntegrationPolicyOverride { + ecosystems: [( + ecosystem.clone(), + ExternalEcosystemPolicyOverride { + mode: Some(ExternalIntegrationMode::Custom), + capability_overrides: [( + external_capability(EXTERNAL_CAPABILITY_COMMAND), + ExternalIntegrationAccess::Auto, + )] + .into_iter() + .collect(), + ..ExternalEcosystemPolicyOverride::default() + }, + )] + .into_iter() + .collect(), + ..ExternalIntegrationPolicyOverride::default() + }, + ); + + let effective = evaluate_external_integration_policy( + &document, + Some("workspace-a"), + &test_external_integration_ecosystems(), + ) + .unwrap(); + let opencode = &effective.ecosystems[&ecosystem]; + assert_eq!(opencode.mode, ExternalIntegrationMode::Custom); + assert_eq!( + opencode.capabilities[&external_capability(EXTERNAL_CAPABILITY_COMMAND)], + ExternalIntegrationAccess::Auto + ); + assert_eq!( + opencode.capabilities[&external_capability(EXTERNAL_CAPABILITY_MCP)], + ExternalIntegrationAccess::DiscoverOnly + ); + + let inherited = evaluate_external_integration_policy( + &document, + Some("workspace-b"), + &test_external_integration_ecosystems(), + ) + .unwrap(); + assert_eq!( + inherited.ecosystems[&ecosystem].mode, + ExternalIntegrationMode::DiscoverOnly + ); +} + +#[test] +fn high_risk_auto_access_is_limited_by_the_capability_owner() { + let ecosystem = EcosystemId::new(TEST_ECOSYSTEM_ID).unwrap(); + let mcp = external_capability(EXTERNAL_CAPABILITY_MCP); + let mut document = ExternalIntegrationPolicyDocument::default(); + document.user_defaults.enabled = true; + document.user_defaults.ecosystems.insert( + ecosystem.clone(), + ExternalEcosystemPolicy { + mode: ExternalIntegrationMode::Custom, + capability_overrides: [(mcp.clone(), ExternalIntegrationAccess::Auto)] + .into_iter() + .collect(), + ..ExternalEcosystemPolicy::default() + }, + ); + + let effective = evaluate_external_integration_policy( + &document, + None, + &test_external_integration_ecosystems(), + ) + .unwrap(); + let opencode = &effective.ecosystems[&ecosystem]; + assert_eq!( + opencode.capabilities[&mcp], + ExternalIntegrationAccess::AskBeforeUse + ); + assert!(opencode.policy_limited_capabilities.contains(&mcp)); +} + +#[test] +fn future_policy_values_and_minor_fields_survive_read_modify_write() { + let raw = serde_json::json!({ + "schemaMajor": 1, + "userDefaults": { + "enabled": true, + "ecosystems": { + "opencode": { + "mode": "future_mode", + "capabilityOverrides": { + "future-capability": "future_access" + }, + "futureEcosystemField": { "enabled": true } + } + }, + "futureSettingsField": "preserve-me" + }, + "workspaceOverrides": {}, + "futureDocumentField": [1, 2, 3] + }); + let mut document: ExternalIntegrationPolicyDocument = + serde_json::from_value(raw.clone()).expect("future minor data remains readable"); + document.user_defaults.enabled = false; + let encoded = serde_json::to_value(&document).expect("policy remains serializable"); + + assert_eq!( + encoded["userDefaults"]["ecosystems"]["opencode"]["mode"], + "future_mode" + ); + assert_eq!( + encoded["userDefaults"]["ecosystems"]["opencode"]["capabilityOverrides"] + ["future-capability"], + "future_access" + ); + assert_eq!( + encoded["userDefaults"]["ecosystems"]["opencode"]["futureEcosystemField"], + raw["userDefaults"]["ecosystems"]["opencode"]["futureEcosystemField"] + ); + assert_eq!( + encoded["userDefaults"]["futureSettingsField"], + "preserve-me" + ); + assert_eq!(encoded["futureDocumentField"], raw["futureDocumentField"]); + + let effective = evaluate_external_integration_policy( + &document, + None, + &test_external_integration_ecosystems(), + ) + .unwrap(); + assert!(!effective.enabled); +} + +#[test] +fn incompatible_policy_schema_major_is_rejected_without_downgrade() { + let document = ExternalIntegrationPolicyDocument { + schema_major: 2, + ..ExternalIntegrationPolicyDocument::default() + }; + let error = evaluate_external_integration_policy( + &document, + None, + &test_external_integration_ecosystems(), + ) + .expect_err("future major schemas must fail closed"); + assert!(error.to_string().contains("schema major: 2")); +} + +#[test] +fn incompatible_policy_schema_has_a_safe_read_only_public_snapshot() { + let raw = serde_json::json!({ + "schemaMajor": 2, + "userDefaults": { + "enabled": true, + "futureSecretHostField": "persistence-only" + }, + "futureDocumentField": { "keep": true } + }); + let document: ExternalIntegrationPolicyDocument = serde_json::from_value(raw).unwrap(); + let snapshot = external_integration_policy_snapshot( + &document, + Some("workspace-a"), + test_external_integration_ecosystems(), + ) + .expect("incompatible schemas remain inspectable through a safe snapshot"); + + assert_eq!( + snapshot.status, + ExternalIntegrationPolicyStatus::IncompatibleSchema + ); + assert!(!snapshot.global_effective.enabled); + assert!(!snapshot.effective.enabled); + assert!(snapshot + .effective + .ecosystems + .values() + .all(|ecosystem| ecosystem + .capabilities + .values() + .all(|access| { matches!(access, ExternalIntegrationAccess::Disabled) }))); + + let public = serde_json::to_string(&snapshot).unwrap(); + assert!(!public.contains("futureSecretHostField")); + assert!(!public.contains("futureDocumentField")); + + let persisted = serde_json::to_string(&document).unwrap(); + assert!(persisted.contains("futureSecretHostField")); + assert!(persisted.contains("futureDocumentField")); +} + +#[test] +fn integration_registry_rejects_ambiguous_or_unsafe_descriptors() { + let mut duplicate_ecosystem = test_external_integration_ecosystems(); + duplicate_ecosystem.push(duplicate_ecosystem[0].clone()); + let duplicate_error = evaluate_external_integration_policy( + &ExternalIntegrationPolicyDocument::default(), + None, + &duplicate_ecosystem, + ) + .expect_err("duplicate ecosystem registrations must fail closed"); + assert!(duplicate_error.to_string().contains("duplicate ecosystem")); + + let mut unsafe_recommendation = test_external_integration_ecosystems(); + unsafe_recommendation[0].capabilities[1].recommended_access = ExternalIntegrationAccess::Auto; + let unsafe_error = evaluate_external_integration_policy( + &ExternalIntegrationPolicyDocument::default(), + None, + &unsafe_recommendation, + ) + .expect_err("registry defaults cannot exceed their safety ceiling"); + assert!(unsafe_error + .to_string() + .contains("exceeds the safety ceiling")); +} + +#[test] +fn public_snapshot_never_exposes_executable_prompt_templates() { + let snapshot = ExternalSourceCatalogSnapshot { + generation: 1, + discovery_pending: false, + sources: Vec::new(), + commands: vec![PromptCommandCatalogEntry { + definition: command("opencode", "project-commands", 1), + }], + command_conflicts: Vec::new(), + tools: Vec::new(), + tool_approval_requests: Vec::new(), + tool_conflicts: Vec::new(), + mcp_generation: 0, + mcp_servers: Vec::new(), + mcp_approval_requests: Vec::new(), + mcp_conflicts: Vec::new(), + subagent_generation: 0, + preference_revision: 0, + subagents: Vec::new(), + subagent_model_binding_groups: vec![ExternalSubagentModelBindingGroup { + binding_key: "external_subagent_model_binding:review".to_string(), + request: ExternalSubagentModelRequest::Reference { + provider_hint: Some("anthropic".to_string()), + model_name: "claude-sonnet-4".to_string(), + }, + profile_request: None, + scope: ExternalSourceScope::Project, + method: ExternalSubagentModelBindingMethod::BindingRequired, + selected_target: None, + effective_model_label: None, + affected_candidate_ids: vec!["opencode-review".to_string()], + }], + subagent_model_binding_options: vec![ExternalSubagentModelBindingOption { + target: ExternalSubagentModelBindingTarget::Fast, + effective_model_label: "GLM-4.5-Air".to_string(), + configured_reasoning_effort: None, + }], + subagent_conflicts: Vec::new(), + pending_subagent_approvals: Vec::new(), + integration_policy: Default::default(), + diagnostics: Vec::new(), + }; + + let public = ExternalSourcePublicSnapshot::from(snapshot); + let encoded = serde_json::to_value(public).expect("serialize public projection"); + + assert_eq!(encoded["commands"][0]["definition"]["name"], "review"); + assert!(encoded["commands"][0]["definition"] + .get("template") + .is_none()); + assert_eq!( + encoded["subagentModelBindingGroups"][0]["bindingKey"], + "external_subagent_model_binding:review" + ); + assert_eq!( + encoded["subagentModelBindingOptions"][0]["effectiveModelLabel"], + "GLM-4.5-Air" + ); +} + +#[test] +fn control_projection_keeps_lifecycle_facts_orthogonal() { + let catalog = ExternalSourceCatalogSnapshot { + generation: 7, + discovery_pending: false, + sources: vec![ExternalSourceCatalogEntry { + stable_key: "opencode.commands:project".to_string(), + presentation_group_id: None, + record: source("opencode.commands", "opencode", "project"), + lifecycle: ExternalSourceLifecycleState::UsingLastValidVersion, + }], + commands: vec![PromptCommandCatalogEntry { + definition: command("opencode.commands", "project", 1), + }], + command_conflicts: Vec::new(), + tools: Vec::new(), + tool_approval_requests: Vec::new(), + tool_conflicts: Vec::new(), + mcp_generation: 2, + mcp_servers: Vec::new(), + mcp_approval_requests: Vec::new(), + mcp_conflicts: Vec::new(), + subagent_generation: 3, + preference_revision: 11, + subagents: Vec::new(), + subagent_model_binding_groups: Vec::new(), + subagent_model_binding_options: Vec::new(), + subagent_conflicts: Vec::new(), + pending_subagent_approvals: Vec::new(), + integration_policy: Default::default(), + diagnostics: Vec::new(), + }; + + let control = ExternalSourceControlSnapshotV1::from_catalog( + &catalog, + ExecutionDomainId::new("local-user").unwrap(), + false, + ExternalSourceHostCapabilities::read_write(), + ); + + assert_eq!(control.schema_version, EXTERNAL_SOURCE_CONTROL_SCHEMA_V1); + assert_eq!(control.refresh_generation, 7); + assert_eq!(control.preference_revision, 11); + assert_eq!(control.sources.len(), 1); + assert_eq!( + control.sources[0].discovery, + ExternalSourceDiscoveryState::LastKnownGood + ); + assert_eq!( + control.sources[0].desired, + ExternalSourceDesiredState::Enabled + ); + assert_eq!( + control.sources[0].review, + ExternalSourceReviewState::NotRequired + ); + assert_eq!(control.capabilities.len(), 4); +} + +#[test] +fn control_projection_does_not_infer_review_facts_from_runtime_activation() { + let record = source("opencode.mcp", "opencode", "project-config"); + let definition = ExternalMcpServerDefinition { + id: SourceQualifiedMcpServerId::new(record.key.clone(), "docs").unwrap(), + provenance: vec![record.key.clone()], + name: "docs".to_string(), + transport: ExternalMcpTransportKind::StreamableHttp, + command_preview: None, + argument_count: 0, + working_directory: None, + environment_keys: Vec::new(), + environment_reference_names: Vec::new(), + remote_url_preview: Some("https://mcp.example.com".to_string()), + header_names: Vec::new(), + timeouts: ExternalMcpTimeouts::default(), + source_enabled: true, + behavior_version: "behavior-v1".to_string(), + static_status: ExternalMcpStaticStatus::Ready, + }; + let catalog = ExternalSourceCatalogSnapshot { + generation: 1, + discovery_pending: false, + sources: vec![ExternalSourceCatalogEntry { + stable_key: "opencode.mcp:project-config".to_string(), + presentation_group_id: None, + record: record.clone(), + lifecycle: ExternalSourceLifecycleState::Available, + }], + commands: Vec::new(), + command_conflicts: Vec::new(), + tools: Vec::new(), + tool_approval_requests: Vec::new(), + tool_conflicts: Vec::new(), + mcp_generation: 1, + mcp_servers: vec![ExternalMcpCatalogEntry { + candidate_id: "external_mcp:docs".to_string(), + definition, + approval_key: "approval-v1".to_string(), + decision_key: "decision-v1".to_string(), + runtime_id: None, + activation_state: ExternalMcpActivationState::Declined, + }], + mcp_approval_requests: Vec::new(), + mcp_conflicts: Vec::new(), + subagent_generation: 1, + preference_revision: 1, + subagents: Vec::new(), + subagent_model_binding_groups: Vec::new(), + subagent_model_binding_options: Vec::new(), + subagent_conflicts: Vec::new(), + pending_subagent_approvals: Vec::new(), + integration_policy: Default::default(), + diagnostics: Vec::new(), + }; + + let control = ExternalSourceControlSnapshotV1::from_catalog( + &catalog, + ExecutionDomainId::new("local-user").unwrap(), + false, + ExternalSourceHostCapabilities::read_write(), + ); + + assert_eq!( + control.sources[0].review, + ExternalSourceReviewState::NotRequired + ); +} + +#[test] +fn desktop_local_host_capability_is_additive_on_the_wire() { + let portable = serde_json::to_value(ExternalSourceHostCapabilities::read_write()).unwrap(); + let read_only = + serde_json::to_value(ExternalSourceHostCapabilities::read_only_projection()).unwrap(); + let desktop = serde_json::to_value(ExternalSourceHostCapabilities::local_desktop()).unwrap(); + + assert!(portable.get("canRevealSourceLocation").is_none()); + assert!(read_only.get("canRevealSourceLocation").is_none()); + assert_eq!(desktop["canRevealSourceLocation"], true); + + let legacy: ExternalSourceHostCapabilities = serde_json::from_value(serde_json::json!({ + "canRefresh": true, + "canMutatePolicy": true, + "canManageSources": true, + "canApproveRuntime": true, + "canExecuteExternalAssets": true, + "canSetSafeMode": true + })) + .unwrap(); + assert!(!legacy.can_reveal_source_location); +} + +#[test] +fn operation_error_round_trip_preserves_typed_recovery_without_message_parsing() { + let error = ExternalSourceOperationError::new( + ExternalSourceOperationErrorCode::StaleRevision, + "refresh required", + true, + ) + .with_stage(ExternalSourceOperationStage::ApplyPreference) + .with_causation_id("refresh-generation-7") + .with_recovery_action(ExternalSourceRecoveryActionV1::Refresh); + + let encoded = error.encode(); + assert_eq!(ExternalSourceOperationError::decode(&encoded), Some(error)); + assert!(!encoded.contains("metadata")); +} + +#[test] +fn control_action_uses_one_camel_case_dto_across_product_surfaces() { + let request = ExternalSourceControlRequestV1 { + schema_version: EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, + operation_id: "surface-operation-1".to_string(), + expected_preference_revision: Some(8), + action: ExternalSourceControlActionV1::SetSourceEnabled { + source_key: "opencode.commands:project".to_string(), + enabled: false, + }, + }; + + let encoded = serde_json::to_value(&request).expect("serialize control request"); + assert_eq!(encoded["schemaVersion"], 1); + assert_eq!(encoded["operationId"], "surface-operation-1"); + assert_eq!(encoded["expectedPreferenceRevision"], 8); + assert_eq!(encoded["action"]["type"], "set_source_enabled"); + assert_eq!(encoded["action"]["sourceKey"], "opencode.commands:project"); + assert!(encoded["action"].get("source_key").is_none()); + assert_eq!( + serde_json::from_value::(encoded) + .expect("deserialize the shared control request"), + request + ); +} + +#[test] +fn legacy_operation_errors_decode_with_empty_extension_fields() { + let decoded = ExternalSourceOperationError::decode( + r#"{"code":"unavailable","detail":"retry","retryable":true}"#, + ) + .expect("legacy operation error remains readable"); + + assert_eq!(decoded.code, ExternalSourceOperationErrorCode::Unavailable); + assert!(decoded.stage.is_none()); + assert!(decoded.causation_id.is_none()); + assert!(decoded.recovery_actions.is_empty()); +} + +#[test] +fn decoded_operation_errors_bound_untrusted_extension_fields() { + let oversized = "x".repeat(5000); + let encoded = serde_json::json!({ + "code": "stale_revision", + "detail": oversized, + "retryable": true, + "correlationId": "forged\nreference", + "recoveryActions": [ + { "type": "refresh" }, + { "type": "refresh" }, + { "type": "retry" } + ] + }) + .to_string(); + + let decoded = ExternalSourceOperationError::decode(&encoded).unwrap(); + assert_eq!(decoded.detail.chars().count(), 4096); + assert!(decoded.correlation_id.is_none()); + assert_eq!( + decoded.recovery_actions, + vec![ + ExternalSourceRecoveryActionV1::Refresh, + ExternalSourceRecoveryActionV1::Retry, + ] + ); +} diff --git a/src/crates/contracts/product-domains/tests/workspace_reference_contracts.rs b/src/crates/contracts/product-domains/tests/external_source_contracts/workspace_reference_contracts.rs similarity index 100% rename from src/crates/contracts/product-domains/tests/workspace_reference_contracts.rs rename to src/crates/contracts/product-domains/tests/external_source_contracts/workspace_reference_contracts.rs diff --git a/src/crates/contracts/product-domains/tests/plugin_source_contracts.rs b/src/crates/contracts/product-domains/tests/plugin_source_contracts.rs index 6788520f0..75fa47a87 100644 --- a/src/crates/contracts/product-domains/tests/plugin_source_contracts.rs +++ b/src/crates/contracts/product-domains/tests/plugin_source_contracts.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "plugin-source")] + use bitfun_product_domains::plugin_source::{ PluginPackageInput, PluginPackageManifest, PluginPackageSourceIdentity, PluginPackageTrustLevel, PluginTrustDecision, PluginTrustStore, @@ -428,7 +430,8 @@ fn activation_lifecycle_is_exact_independent_and_idempotent() { assert_eq!((store.epoch(), store.activation_epoch()), (trust_epoch, 9)); assert!(store .clear_activation_record(PROJECT, WORKSPACE, &package.package_id, None) - .expect("repeat deactivation").is_none()); + .expect("repeat deactivation") + .is_none()); assert_eq!((store.epoch(), store.activation_epoch()), (trust_epoch, 9)); } @@ -496,7 +499,8 @@ fn stale_residual_cleanup_cannot_clear_a_newer_activation() { assert!(store .clear_activation_record(PROJECT, WORKSPACE, &package.package_id, Some(stale_epoch),) - .expect("stale cleanup is a no-op").is_none()); + .expect("stale cleanup is a no-op") + .is_none()); assert!(store.is_activated(PROJECT, WORKSPACE, &package)); assert_eq!(store.activation_epoch(), current_epoch); } diff --git a/src/crates/contracts/product-domains/tests/product_domain_contracts.rs b/src/crates/contracts/product-domains/tests/product_domain_contracts.rs new file mode 100644 index 000000000..476faa886 --- /dev/null +++ b/src/crates/contracts/product-domains/tests/product_domain_contracts.rs @@ -0,0 +1,4 @@ +#[path = "product_domain_contracts/canvas_contracts.rs"] +mod canvas_contracts; +#[path = "product_domain_contracts/tool_permission_contracts.rs"] +mod tool_permission_contracts; diff --git a/src/crates/contracts/product-domains/tests/canvas_contracts.rs b/src/crates/contracts/product-domains/tests/product_domain_contracts/canvas_contracts.rs similarity index 100% rename from src/crates/contracts/product-domains/tests/canvas_contracts.rs rename to src/crates/contracts/product-domains/tests/product_domain_contracts/canvas_contracts.rs diff --git a/src/crates/contracts/product-domains/tests/tool_permission_contracts.rs b/src/crates/contracts/product-domains/tests/product_domain_contracts/tool_permission_contracts.rs similarity index 100% rename from src/crates/contracts/product-domains/tests/tool_permission_contracts.rs rename to src/crates/contracts/product-domains/tests/product_domain_contracts/tool_permission_contracts.rs diff --git a/src/crates/contracts/runtime-ports/Cargo.toml b/src/crates/contracts/runtime-ports/Cargo.toml index b142db5da..7cc930223 100644 --- a/src/crates/contracts/runtime-ports/Cargo.toml +++ b/src/crates/contracts/runtime-ports/Cargo.toml @@ -4,11 +4,16 @@ version.workspace = true authors.workspace = true edition.workspace = true description = "Thin runtime ports for BitFun core decomposition" +autotests = false [lib] name = "bitfun_runtime_ports" crate-type = ["rlib"] +[[test]] +name = "runtime_port_contracts" +path = "tests/runtime_port_contracts.rs" + [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } diff --git a/src/crates/contracts/runtime-ports/tests/runtime_port_contracts.rs b/src/crates/contracts/runtime-ports/tests/runtime_port_contracts.rs new file mode 100644 index 000000000..cc711cc08 --- /dev/null +++ b/src/crates/contracts/runtime-ports/tests/runtime_port_contracts.rs @@ -0,0 +1,10 @@ +#[path = "runtime_port_contracts/git_port_contracts.rs"] +mod git_port_contracts; +#[path = "runtime_port_contracts/plugin_runtime_contracts.rs"] +mod plugin_runtime_contracts; +#[path = "runtime_port_contracts/plugin_runtime_diagnostics_contracts.rs"] +mod plugin_runtime_diagnostics_contracts; +#[path = "runtime_port_contracts/script_tool_port_contracts.rs"] +mod script_tool_port_contracts; +#[path = "runtime_port_contracts/session_store_contracts.rs"] +mod session_store_contracts; diff --git a/src/crates/contracts/runtime-ports/tests/git_port_contracts.rs b/src/crates/contracts/runtime-ports/tests/runtime_port_contracts/git_port_contracts.rs similarity index 100% rename from src/crates/contracts/runtime-ports/tests/git_port_contracts.rs rename to src/crates/contracts/runtime-ports/tests/runtime_port_contracts/git_port_contracts.rs diff --git a/src/crates/contracts/runtime-ports/tests/plugin_runtime_contracts.rs b/src/crates/contracts/runtime-ports/tests/runtime_port_contracts/plugin_runtime_contracts.rs similarity index 100% rename from src/crates/contracts/runtime-ports/tests/plugin_runtime_contracts.rs rename to src/crates/contracts/runtime-ports/tests/runtime_port_contracts/plugin_runtime_contracts.rs diff --git a/src/crates/contracts/runtime-ports/tests/plugin_runtime_diagnostics_contracts.rs b/src/crates/contracts/runtime-ports/tests/runtime_port_contracts/plugin_runtime_diagnostics_contracts.rs similarity index 100% rename from src/crates/contracts/runtime-ports/tests/plugin_runtime_diagnostics_contracts.rs rename to src/crates/contracts/runtime-ports/tests/runtime_port_contracts/plugin_runtime_diagnostics_contracts.rs diff --git a/src/crates/contracts/runtime-ports/tests/script_tool_port_contracts.rs b/src/crates/contracts/runtime-ports/tests/runtime_port_contracts/script_tool_port_contracts.rs similarity index 100% rename from src/crates/contracts/runtime-ports/tests/script_tool_port_contracts.rs rename to src/crates/contracts/runtime-ports/tests/runtime_port_contracts/script_tool_port_contracts.rs diff --git a/src/crates/contracts/runtime-ports/tests/session_store_contracts.rs b/src/crates/contracts/runtime-ports/tests/runtime_port_contracts/session_store_contracts.rs similarity index 100% rename from src/crates/contracts/runtime-ports/tests/session_store_contracts.rs rename to src/crates/contracts/runtime-ports/tests/runtime_port_contracts/session_store_contracts.rs diff --git a/src/crates/services/miniapp-market-service/Cargo.toml b/src/crates/services/miniapp-market-service/Cargo.toml index 6c2163a96..c15336869 100644 --- a/src/crates/services/miniapp-market-service/Cargo.toml +++ b/src/crates/services/miniapp-market-service/Cargo.toml @@ -29,7 +29,6 @@ tokio = { workspace = true, features = ["fs", "rt", "sync", "time"] } tower-http = { version = "0.6.11", features = ["fs", "set-header", "trace"] } tracing = { workspace = true } url = { workspace = true } -urlencoding = { workspace = true } uuid = { workspace = true } zip = { workspace = true } diff --git a/src/crates/services/page-function-runtime/Cargo.toml b/src/crates/services/page-function-runtime/Cargo.toml index f399d1e4a..ab78c3f3b 100644 --- a/src/crates/services/page-function-runtime/Cargo.toml +++ b/src/crates/services/page-function-runtime/Cargo.toml @@ -15,6 +15,3 @@ rquickjs = { version = "0.9", default-features = false, features = ["classes", " serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "2" - -[dev-dependencies] -tokio = { version = "1.52", features = ["macros", "rt"] }