From 597c84b19265d38f4893cbb3c8bb33daf39dff1f Mon Sep 17 00:00:00 2001 From: limityan Date: Tue, 30 Jun 2026 18:24:52 +0800 Subject: [PATCH] refactor: validate product SDK assembly boundary --- .../agent-runtime-services-design.md | 3 + docs/architecture/core-decomposition.md | 2 +- docs/plans/core-decomposition-plan.md | 6 + scripts/core-boundaries/rules/crate-rules.mjs | 6 + .../rules/source/forbidden-rules.mjs | 52 +++++ .../rules/source/required-rules.mjs | 52 +++++ scripts/core-boundaries/self-test.mjs | 25 +++ .../core/src/agentic/tools/product_runtime.rs | 1 + .../assembly/product-capabilities/Cargo.toml | 2 + .../assembly/product-capabilities/src/lib.rs | 17 +- .../tests/product_capabilities.rs | 6 + .../tests/product_sdk_assembly.rs | 177 ++++++++++++++++++ 12 files changed, 345 insertions(+), 4 deletions(-) create mode 100644 src/crates/assembly/product-capabilities/tests/product_sdk_assembly.rs diff --git a/docs/architecture/agent-runtime-services-design.md b/docs/architecture/agent-runtime-services-design.md index 844649f737..ee13ad2464 100644 --- a/docs/architecture/agent-runtime-services-design.md +++ b/docs/architecture/agent-runtime-services-design.md @@ -717,12 +717,15 @@ product-assembly ```rust pub enum DeliveryProfile { + ProductFull, Desktop, Cli, Server, Remote, Acp, Web, + MobileWeb, + Sdk, } pub struct CapabilitySet { diff --git a/docs/architecture/core-decomposition.md b/docs/architecture/core-decomposition.md index 3ec48b6e11..cf0ed0e211 100644 --- a/docs/architecture/core-decomposition.md +++ b/docs/architecture/core-decomposition.md @@ -389,7 +389,7 @@ Platform adapter 只实现 ports 并调用边界外资源。 | 接口 / API | 定义 owner | 实现 owner | 注册者 | 消费者 | 边界 | |---|---|---|---|---|---| -| `ProductAssembler` / `ProductAssemblyPlan` | Product Assembly | 产品入口或 assembly crate | 产品入口 | Desktop / CLI / Web / SDK / ACP | 选择能力,不写 Agent 状态机 | +| `ProductAssembler` / `ProductAssemblyPlan` | Product Assembly | 产品入口或 assembly crate | 产品入口 | Desktop / CLI / Server / Remote / Web / Mobile Web / SDK / ACP | 选择能力,不写 Agent 状态机 | | `ProductFeaturePack` | Product Feature | feature owner | Product Assembly | 产品入口、Kernel API、UI host | 编排 feature,不拥有 OS concrete 或 Extension Host | | `AgentRuntimeBuilder` / Kernel API | Agent Kernel | `bitfun-agent-runtime` | Product Assembly / SDK host | Product Feature、SDK、Extension adapter | 接收 typed parts,不创建 concrete manager | | `ToolRuntimeBuilder` / Tool contracts | Execution | tool/execution owner | Product Assembly | Agent Kernel 通过 execution port、Harness | 执行 tool,不选择产品形态 | diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 819b2955c3..e560ceaaf8 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -70,6 +70,12 @@ ### PR-E:Cross-platform Adapter 与多形态 SDK 验证 +状态:已完成本轮 SDK / 产品组装验证收口。 + +- `ProductRuntimeParts` 支持所有权交接到 runtime builder 输入。 +- `bitfun-product-capabilities` 覆盖 SDK no-direct-core profile 和 product-full 兼容 profile 组装到 `AgentRuntimeBuilder` 的无 `bitfun-core` smoke。 +- agent-runtime crate 级规则禁止 product assembly、concrete tool runtime 和平台/provider concrete 依赖回流。 + 目标: - 收口 filesystem、network、process/thread/time、terminal、remote、Git、MCP transport、AI/provider protocol、browser/desktop automation 的 adapter/provider 边界。 diff --git a/scripts/core-boundaries/rules/crate-rules.mjs b/scripts/core-boundaries/rules/crate-rules.mjs index aadc9b3729..d14a13b681 100644 --- a/scripts/core-boundaries/rules/crate-rules.mjs +++ b/scripts/core-boundaries/rules/crate-rules.mjs @@ -117,9 +117,11 @@ export const lightweightBoundaryRules = [ 'bitfun-services-core', 'bitfun-services-integrations', 'bitfun-tool-packs', + 'bitfun-product-capabilities', 'bitfun-product-domains', 'bitfun-transport', 'terminal-core', + 'tool-runtime', 'tauri', 'reqwest', 'git2', @@ -143,9 +145,11 @@ export const lightweightBoundaryRules = [ 'bitfun-services-core', 'bitfun-services-integrations', 'bitfun-tool-packs', + 'bitfun-product-capabilities', 'bitfun-product-domains', 'bitfun-transport', 'terminal-core', + 'tool-runtime', 'tauri', 'reqwest', 'git2', @@ -353,9 +357,11 @@ export const dependencyProfileRules = [ 'bitfun-services-core', 'bitfun-services-integrations', 'bitfun-tool-packs', + 'bitfun-product-capabilities', 'bitfun-product-domains', 'bitfun-transport', 'terminal-core', + 'tool-runtime', 'tauri', 'reqwest', 'git2', diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index 653967e6f0..cf8618af6c 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -16,6 +16,58 @@ export const forbiddenContentRules = [ }, ], }, + { + path: 'src/crates/execution/agent-runtime/src/sdk.rs', + patterns: [ + { + regex: /\bbitfun_core\b/, + message: 'SDK facade must not expose bitfun-core', + }, + { + regex: /\bbitfun_product_capabilities\b/, + message: 'SDK facade must not expose product assembly facts', + }, + { + regex: /\btauri\b/, + message: 'SDK facade must not expose Tauri APIs', + }, + { + regex: /\bAppHandle\b/, + message: 'SDK facade must not expose desktop app handles', + }, + { + regex: /\breqwest\b/, + message: 'SDK facade must not expose concrete HTTP clients', + }, + { + regex: /\bgit2\b/, + message: 'SDK facade must not expose concrete Git providers', + }, + { + regex: /\brmcp\b/, + message: 'SDK facade must not expose concrete MCP clients', + }, + ], + }, + { + path: 'src/crates/assembly/product-capabilities/tests/product_sdk_assembly.rs', + patterns: [ + { + regex: /\bbitfun_core\b/, + message: + 'product assembly to SDK smoke must not depend on bitfun-core', + }, + { + regex: /\bCoreRuntimeServicesProvider\b/, + message: + 'product assembly to SDK smoke must not use core concrete service adapters', + }, + { + regex: /\btauri\b/, + message: 'product assembly to SDK smoke must not depend on Tauri', + }, + ], + }, { path: 'src/crates/contracts/core-types/src/ai.rs', patterns: [ diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index edd1794630..8dcf395787 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -1082,6 +1082,10 @@ export const requiredContentRules = [ regex: /\bMobileWeb\b/, message: 'missing mobile web delivery profile coverage', }, + { + regex: /\bDeliveryProfile::Sdk\b/, + message: 'missing SDK delivery profile coverage', + }, { regex: /\bProductAssembler\b/, message: 'missing typed product assembler', @@ -1094,6 +1098,10 @@ export const requiredContentRules = [ regex: /\bProductRuntimeParts\b/, message: 'missing product runtime parts output', }, + { + regex: /\binto_runtime_parts\b/, + message: 'missing owned runtime-parts handoff for SDK/product assembly consumers', + }, { regex: /\bfeature_groups_from_tool_provider_group_plan\b/, message: 'missing tool-provider feature group projection owner', @@ -1131,6 +1139,42 @@ export const requiredContentRules = [ }, ], }, + { + path: 'src/crates/assembly/product-capabilities/tests/product_sdk_assembly.rs', + reason: + 'product-capabilities must prove product runtime parts can feed the SDK runtime without bitfun-core', + patterns: [ + { + regex: /\bproduct_runtime_parts_can_build_agent_runtime_sdk_without_core\b/, + message: 'missing product assembly to SDK runtime smoke', + }, + { + regex: + /\bsdk_delivery_profile_builds_minimal_agent_runtime_without_product_full_capabilities\b/, + message: 'missing SDK delivery profile minimal runtime smoke', + }, + { + regex: /\bProductAssembler::new\(\)/, + message: 'product SDK smoke must assemble through ProductAssembler', + }, + { + regex: /\binto_runtime_parts\b/, + message: 'product SDK smoke must consume owned ProductRuntimeParts', + }, + { + regex: /\bAgentRuntimeBuilder::new\(\)/, + message: 'product SDK smoke must build through the Agent Runtime SDK facade', + }, + { + regex: /\bDeliveryProfile::Cli\b/, + message: 'product SDK smoke must cover a product-full compatibility delivery profile', + }, + { + regex: /\bDeliveryProfile::Sdk\b/, + message: 'product SDK smoke must cover the no-direct-core SDK delivery profile', + }, + ], + }, { path: 'src/crates/execution/agent-runtime/src/agents.rs', reason: @@ -6033,6 +6077,14 @@ export const requiredContentRules = [ regex: /\bproduct_tool_runtime_registry_preserves_provider_plan_order\b/, message: 'missing product tool provider plan-to-registry order regression', }, + { + regex: /\bproduct_tool_runtime_keeps_no_direct_core_profiles_empty\b/, + message: 'missing no-direct-core product tool runtime regression', + }, + { + regex: /\bDeliveryProfile::Sdk\b/, + message: 'product tool runtime no-direct-core regression must cover SDK profile', + }, ], }, { diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 78565385c0..feb1181bf5 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -674,12 +674,24 @@ export function runManifestParserSelfTest({ if (!agentRuntimeRule?.forbiddenDeps.includes('bitfun-services-integrations')) { throw new Error('agent-runtime lightweight boundary must forbid concrete service integrations'); } + if (!agentRuntimeRule?.forbiddenDeps.includes('bitfun-product-capabilities')) { + throw new Error('agent-runtime lightweight boundary must forbid product assembly facts'); + } + if (!agentRuntimeRule?.forbiddenDeps.includes('tool-runtime')) { + throw new Error('agent-runtime lightweight boundary must forbid concrete tool runtime'); + } const agentRuntimeProfile = dependencyProfileRules.find( (rule) => rule.crateName === 'agent-runtime', ); if (!agentRuntimeProfile?.forbiddenNonOptionalDeps.includes('tauri')) { throw new Error('agent-runtime dependency profile must forbid product surface dependencies'); } + if (!agentRuntimeProfile?.forbiddenNonOptionalDeps.includes('bitfun-product-capabilities')) { + throw new Error('agent-runtime dependency profile must forbid product assembly facts'); + } + if (!agentRuntimeProfile?.forbiddenNonOptionalDeps.includes('tool-runtime')) { + throw new Error('agent-runtime dependency profile must forbid concrete tool runtime'); + } const productCapabilitiesRule = lightweightBoundaryRules.find( (rule) => rule.crateName === 'product-capabilities', ); @@ -1708,6 +1720,8 @@ export function runManifestParserSelfTest({ 'ProductCapabilityAssembly', 'ProductFeatureGroup', 'ProductRuntimeAssembly', + 'DeliveryProfile::Sdk', + 'into_runtime_parts', 'feature_groups_from_tool_provider_group_plan', ], }, @@ -1719,6 +1733,15 @@ export function runManifestParserSelfTest({ 'product_harness_provider_plans_legacy_facade_without_execution', ], }, + { + path: 'src/crates/assembly/product-capabilities/tests/product_sdk_assembly.rs', + contracts: [ + 'product_runtime_parts_can_build_agent_runtime_sdk_without_core', + 'sdk_delivery_profile_builds_minimal_agent_runtime_without_product_full_capabilities', + 'DeliveryProfile::Cli', + 'DeliveryProfile::Sdk', + ], + }, { path: 'src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs', contracts: [ @@ -2232,6 +2255,8 @@ export function runManifestParserSelfTest({ 'product_assembly_plan_for_profile', 'product_tool_runtime_owner_preserves_registry_contract', 'product_tool_runtime_registry_preserves_provider_plan_order', + 'product_tool_runtime_keeps_no_direct_core_profiles_empty', + 'DeliveryProfile::Sdk', ], }, { diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime.rs index 0bac6caa48..daf2502433 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime.rs @@ -145,6 +145,7 @@ mod tests { DeliveryProfile::Remote, DeliveryProfile::Web, DeliveryProfile::MobileWeb, + DeliveryProfile::Sdk, ] { let runtime = ProductToolRuntime::for_profile(profile); let registry = runtime.create_registry(); diff --git a/src/crates/assembly/product-capabilities/Cargo.toml b/src/crates/assembly/product-capabilities/Cargo.toml index 919728a70c..1d496f0ddc 100644 --- a/src/crates/assembly/product-capabilities/Cargo.toml +++ b/src/crates/assembly/product-capabilities/Cargo.toml @@ -16,4 +16,6 @@ bitfun-runtime-services = { path = "../../execution/runtime-services" } bitfun-tool-packs = { path = "../../execution/tool-provider-groups", default-features = false } [dev-dependencies] +async-trait = { workspace = true } +bitfun-agent-runtime = { path = "../../execution/agent-runtime" } tokio = { workspace = true } diff --git a/src/crates/assembly/product-capabilities/src/lib.rs b/src/crates/assembly/product-capabilities/src/lib.rs index 60c982f76c..9f39ae35ca 100644 --- a/src/crates/assembly/product-capabilities/src/lib.rs +++ b/src/crates/assembly/product-capabilities/src/lib.rs @@ -142,6 +142,7 @@ pub enum DeliveryProfile { Acp, Web, MobileWeb, + Sdk, } impl DeliveryProfile { @@ -155,6 +156,7 @@ impl DeliveryProfile { Self::Acp => "acp", Self::Web => "web", Self::MobileWeb => "mobile-web", + Self::Sdk => "sdk", } } @@ -168,6 +170,7 @@ impl DeliveryProfile { Self::Acp, Self::Web, Self::MobileWeb, + Self::Sdk, ] } } @@ -244,6 +247,10 @@ const PRODUCT_DELIVERY_PROFILE_ENTRIES: &[ProductDeliveryProfileEntry] = &[ DeliveryProfile::MobileWeb, ProductCoreDependencyMode::NoDirectCoreDependency, ), + ProductDeliveryProfileEntry::new( + DeliveryProfile::Sdk, + ProductCoreDependencyMode::NoDirectCoreDependency, + ), ]; pub const fn product_delivery_profile_entries() -> &'static [ProductDeliveryProfileEntry] { @@ -493,6 +500,11 @@ impl ProductRuntimeParts { pub fn missing_service_requirements(&self) -> &[ProductServiceCapabilityRequirement] { &self.missing_service_requirements } + + /// Consume the assembled product output into runtime-builder inputs. + pub fn into_runtime_parts(self) -> (RuntimeServices, HarnessRegistry) { + (self.services, self.harness_registry) + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -927,8 +939,7 @@ fn product_capability_registry_for_profile(profile: DeliveryProfile) -> ProductC DeliveryProfile::Server | DeliveryProfile::Remote | DeliveryProfile::Web - | DeliveryProfile::MobileWeb => { - ProductCapabilityRegistry::new(EMPTY_PRODUCT_CAPABILITY_PACKS) - } + | DeliveryProfile::MobileWeb + | DeliveryProfile::Sdk => ProductCapabilityRegistry::new(EMPTY_PRODUCT_CAPABILITY_PACKS), } } diff --git a/src/crates/assembly/product-capabilities/tests/product_capabilities.rs b/src/crates/assembly/product-capabilities/tests/product_capabilities.rs index e8d3284629..9d0b42543f 100644 --- a/src/crates/assembly/product-capabilities/tests/product_capabilities.rs +++ b/src/crates/assembly/product-capabilities/tests/product_capabilities.rs @@ -196,6 +196,7 @@ fn no_direct_core_profiles_do_not_select_product_full_runtime_capabilities() { DeliveryProfile::Remote, DeliveryProfile::Web, DeliveryProfile::MobileWeb, + DeliveryProfile::Sdk, ] { let plan = product_assembly_plan_for_profile(profile); @@ -277,6 +278,10 @@ fn product_delivery_profile_matrix_documents_current_core_dependency_shape() { DeliveryProfile::MobileWeb, ProductCoreDependencyMode::NoDirectCoreDependency, ), + ( + DeliveryProfile::Sdk, + ProductCoreDependencyMode::NoDirectCoreDependency, + ), ] ); assert_eq!( @@ -509,6 +514,7 @@ fn product_assembler_allows_no_direct_core_profiles_without_product_services() { DeliveryProfile::Remote, DeliveryProfile::Web, DeliveryProfile::MobileWeb, + DeliveryProfile::Sdk, ] { let services = FakeRuntimeServicesProvider::with_all_required() .build_services() diff --git a/src/crates/assembly/product-capabilities/tests/product_sdk_assembly.rs b/src/crates/assembly/product-capabilities/tests/product_sdk_assembly.rs new file mode 100644 index 0000000000..780340e53a --- /dev/null +++ b/src/crates/assembly/product-capabilities/tests/product_sdk_assembly.rs @@ -0,0 +1,177 @@ +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use bitfun_agent_runtime::sdk::{ + AgentEventStream, AgentRunRequest, AgentRuntimeBuilder, AgentSessionCreateRequest, + AgentSessionCreateResult, AgentSubmissionPort, AgentSubmissionRequest, AgentSubmissionResult, + AgentSubmissionSource, PortResult, RuntimeServiceCapability, SessionSelector, +}; +use bitfun_product_capabilities::{DeliveryProfile, ProductAssembler, ProductAssemblyInput}; +use bitfun_runtime_services::test_support::FakeRuntimeServicesProvider; +use bitfun_runtime_services::{ + RuntimeServiceMarkerPort, RuntimeServices, RuntimeServicesBuilder, RuntimeServicesProvider, +}; + +#[derive(Debug, Default)] +struct ProductSdkAgentProvider { + created_sessions: Mutex>, + submitted_turns: Mutex>, +} + +#[async_trait] +impl AgentSubmissionPort for ProductSdkAgentProvider { + async fn create_session( + &self, + request: AgentSessionCreateRequest, + ) -> PortResult { + self.created_sessions.lock().unwrap().push(request.clone()); + Ok(AgentSessionCreateResult { + session_id: "product-sdk-session".to_string(), + session_name: request.session_name, + agent_type: request.agent_type, + }) + } + + async fn submit_message( + &self, + request: AgentSubmissionRequest, + ) -> PortResult { + self.submitted_turns.lock().unwrap().push(request.clone()); + Ok(AgentSubmissionResult { + turn_id: request + .turn_id + .clone() + .unwrap_or_else(|| "product-sdk-turn".to_string()), + accepted: true, + }) + } + + async fn resolve_session_agent_type(&self, _session_id: &str) -> PortResult> { + Ok(Some("agentic".to_string())) + } +} + +fn baseline_sdk_services() -> RuntimeServices { + FakeRuntimeServicesProvider::with_all_required() + .build_services() + .expect("baseline SDK services should build") +} + +fn product_full_compatible_services() -> RuntimeServices { + FakeRuntimeServicesProvider::with_all_required() + .register(RuntimeServicesBuilder::new()) + .with_optional_terminal(Some(RuntimeServiceMarkerPort::terminal_port())) + .with_optional_git(Some(RuntimeServiceMarkerPort::git_port())) + .with_optional_network(Some(RuntimeServiceMarkerPort::network_port())) + .build() + .expect("product-full compatible services should build") +} + +#[tokio::test] +async fn sdk_delivery_profile_builds_minimal_agent_runtime_without_product_full_capabilities() { + let parts = ProductAssembler::new() + .assemble(ProductAssemblyInput::new( + DeliveryProfile::Sdk, + baseline_sdk_services(), + )) + .expect("SDK delivery profile should assemble without product-full services"); + + assert_eq!(parts.plan().profile(), DeliveryProfile::Sdk); + assert!(parts.plan().capability_set().ids().is_empty()); + assert!(parts.service_availability().is_empty()); + assert!(parts.missing_service_requirements().is_empty()); + assert!(parts.harness_registry().provider_ids().is_empty()); + assert!(!parts + .services() + .has_capability(RuntimeServiceCapability::Terminal)); + assert!(!parts + .services() + .has_capability(RuntimeServiceCapability::Git)); + assert!(!parts + .services() + .has_capability(RuntimeServiceCapability::Network)); + + let (services, harness_registry) = parts.into_runtime_parts(); + let provider = Arc::new(ProductSdkAgentProvider::default()); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(provider) + .with_services(services) + .with_harness_registry(Arc::new(harness_registry)) + .build() + .expect("SDK profile parts should build a minimal runtime"); + + let handle = runtime + .run(AgentRunRequest::new( + SessionSelector::create("SDK profile smoke", "agentic", None), + "hello from sdk profile", + )) + .await + .expect("SDK delivery profile runtime should accept a minimal run"); + + assert_eq!(handle.session_id, "product-sdk-session"); + assert_eq!(handle.turn_id, "product-sdk-turn"); + assert!(handle.accepted); + assert!(runtime.harness_provider_ids().is_empty()); +} + +#[tokio::test] +async fn product_runtime_parts_can_build_agent_runtime_sdk_without_core() { + let parts = ProductAssembler::new() + .assemble(ProductAssemblyInput::new( + DeliveryProfile::Cli, + product_full_compatible_services(), + )) + .expect("CLI product-full compatibility profile should assemble"); + + assert_eq!(parts.plan().profile(), DeliveryProfile::Cli); + assert!(parts.missing_service_requirements().is_empty()); + + let (services, harness_registry) = parts.into_runtime_parts(); + let provider = Arc::new(ProductSdkAgentProvider::default()); + let events = AgentEventStream::new(); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(provider.clone()) + .with_services(services) + .with_harness_registry(Arc::new(harness_registry)) + .with_event_stream(events.clone()) + .build() + .expect("product assembly parts should build an SDK runtime"); + + let runtime_services = runtime + .services() + .expect("product assembly services should be attached to runtime"); + assert!(runtime_services.has_capability(RuntimeServiceCapability::Terminal)); + assert!(runtime_services.has_capability(RuntimeServiceCapability::Git)); + assert!(runtime_services.has_capability(RuntimeServiceCapability::Network)); + assert_eq!( + runtime.harness_provider_ids(), + vec!["core.deep_review", "core.deep_research", "core.miniapp"] + ); + + let handle = runtime + .run( + AgentRunRequest::new( + SessionSelector::create( + "Product SDK smoke", + "agentic", + Some("/workspace/project".to_string()), + ), + "hello from product assembly", + ) + .with_turn_id("product-sdk-turn") + .with_source(AgentSubmissionSource::Cli), + ) + .await + .expect("product assembly runtime should accept an SDK run"); + + assert_eq!(handle.session_id, "product-sdk-session"); + assert_eq!(handle.turn_id, "product-sdk-turn"); + assert_eq!(handle.agent_type.as_deref(), Some("agentic")); + assert!(handle.accepted); + assert_eq!( + handle.events.expect("event stream").snapshot(), + events.snapshot() + ); + assert_eq!(provider.created_sessions.lock().unwrap().len(), 1); + assert_eq!(provider.submitted_turns.lock().unwrap().len(), 1); +}